Files

458 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene
(scenes/activities/<season>/level_<stage>/level_<stage>.ab, with …/lightingdata.ab beside it) whose tile models are
baked into "Combined Mesh (root: scene)" objects, plus loose meshes for the parts the game shows and hides.
The scene bundles ship only while the season runs (the hot-update list of an off-season client has none), so they are
read from the client's AB root when it has them and else from a directory of bundles kept from an in-season version
(extract.py --scenes <dir>, default .cache/official-scenes/*; tools/local-extract/fetch_scenes.py fills it). Materials
and their textures come from the theme bundles of the local client (SCENES lists each scene's, as the official asset
index records them: the scene bundle's dependencies).
Output per stage, under <out>/scene/<stageId>/ (manifest group scene/<stageId>):
scene.json { version, stage, light, lightmaps, materials, groups, chunks: [{ material, lightmap, group, verts,
tris, offset, index32, bounds }] }
scene.bin the chunks' vertex data in board space (x = column, y = row, z = height: Unity's anchor-local
(x, y, −z)); per chunk: position f32×3, uv f32×2, lightmap uv f32×2 (scale / offset applied),
normal i8×4, colour u8×4, indices u16|u32 (padded to 4 bytes)
lm<i>.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-<i>_comp_light, stored RGBM: decode_rgbm),
A = the key light's baked shadow (Lightmap-<i>_comp_shadowmask, R)
and the materials' albedo textures under <out>/scene/tex/ (manifest group scene/tex, shared by the scenes).
A chunk's `group` is '' for what the scene shows at rest and the sub-graphic name (HIDDEN@1 …, MapSubGraphicHolder)
for the meshes the game switches on later.
"""
import json
import re
import struct
from pathlib import Path
SAND = ['arts/maps/map_autochesssand/res.ab', 'arts/maps/map_survivaloasis_city/res.ab', 'arts/maps/map_yumen/res.ab']
SHORE = ['arts/maps/map_doss_shore/res.ab']
# (stage id, theme bundles of the local client that hold the scene's materials / textures / shared meshes): the
# dependencies of each scene bundle in the official asset index (Android 25-11-21-15-21-44_ee1197 for 盟约·上半,
# 26-03-13-06-11-47_14fb99 and 26-04-13-12-31-42_e9b560 for 盟约·下半 — the same in all three).
SCENES = [
('act1autochess_m01', SAND),
('act1autochess_m02', SAND),
('act1autochess_m03', SAND),
('act1autochess_m04', SAND),
('act1autochess_m05', SAND + SHORE),
('act1autochess_m06', SAND),
('act1autochess_m07', SAND),
('act2autochess_m01', ['arts/maps/map_lakegarden/res.ab', 'arts/maps/map_syracuse_metro/res.ab',
'arts/maps/common/meshes/s_background_common.ab']),
('act2autochess_m02', SAND),
('act2autochess_m03', ['arts/maps/map_kxmr/res.ab', 'arts/maps/map_kxmr/background_mesh/bg_kxmr.ab',
'arts/maps/map_lm_center/res.ab']),
('act2autochess_m04', SAND + SHORE),
]
# bundles every scene may reference besides its theme (shared water / noise textures)
COMMON_DEPS = ['arts/maps/effect.ab']
SCENE_SUB = 'scene'
TEX_SUB = 'scene/tex'
SCENE_VERSION = 1
# linear light value that a lightmap page's 1.0 stands for (decode_rgbm; scene.json lightRange)
LIGHT_RANGE = 8.0
HIDDEN_NAME = re.compile(r'^[A-Z_]+@\d+$')
WATER_SHADERS = ('StylizedWater', 'StandardWater', 'DynamicWaterTile')
def scene_bundle(stage_id):
"""Bundle path of a stage's scene, relative to an AB root (act1autochess_m01 → scenes/activities/act1autochess/…)."""
season = stage_id.rsplit('_', 1)[0]
return f'scenes/activities/{season}/level_{stage_id}/level_{stage_id}.ab'
def find_scene(stage_id, roots):
"""The first existing scene bundle of `stage_id` under `roots` (AB roots), or None."""
rel = scene_bundle(stage_id)
for root in roots:
p = Path(root) / rel
if p.is_file():
return p
return None
# ---- small pure-Python linear algebra (row-major 3×4 affine matrices: [r0, r1, r2], each [a, b, c, t]) -----------
def trs(pos, rot, scale):
"""Affine matrix of a Unity local transform (position, quaternion xyzw, scale)."""
x, y, z, w = rot
r = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
return [[r[i][0] * scale[0], r[i][1] * scale[1], r[i][2] * scale[2], pos[i]] for i in range(3)]
def mul(a, b):
"""a · b of two affine matrices."""
return [[a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + (a[i][3] if j == 3 else 0.0) for j in range(4)]
for i in range(3)]
def det3(m):
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
IDENTITY = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
def to_board(p, anchor):
"""Unity world position → board space (x = column, y = row, z = height above the low ground)."""
return (p[0] - anchor[0], p[1] - anchor[1], -(p[2] - anchor[2]))
def pack_chunk(ch):
"""Binary record of a chunk (see the module docstring); returns (bytes, index32)."""
n = len(ch['pos'])
flat = lambda rows: [v for row in rows for v in row] # noqa: E731
out = bytearray()
out += struct.pack(f'<{n * 3}f', *flat(ch['pos']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv1']))
out += struct.pack(f'<{n * 4}b', *flat([(*(max(-127, min(127, int(round(c * 127)))) for c in nr), 0) for nr in ch['nrm']]))
out += struct.pack(f'<{n * 4}B', *flat([tuple(max(0, min(255, int(round(c * 255)))) for c in col) for col in ch['col']]))
idx32 = n > 65535
out += struct.pack(f'<{len(ch["idx"])}{"I" if idx32 else "H"}', *ch['idx'])
while len(out) % 4:
out.append(0)
return bytes(out), idx32
def decode_rgbm(img):
"""RGB image of a baked lightmap page stored RGBM (Unity, linear colour space: light = 5^2.2 · A^2.2 · RGB):
the linear light L as (L / LIGHT_RANGE)^(1/2.2) per channel — = (5^2.2 / LIGHT_RANGE)^(1/2.2) · A · RGB^(1/2.2),
clamped to 1 (the renderer reads L = value^2.2 · LIGHT_RANGE). A page without alpha is taken as plain RGB."""
from PIL import Image, ImageChops
if img.mode != 'RGBA':
return img.convert('RGB')
k = (5.0 ** 2.2 / LIGHT_RANGE) ** (1 / 2.2)
r, g, b, a = img.split()
m = a.point([min(255, int(round(v * k))) for v in range(256)])
gamma = [int(round(255 * (v / 255) ** (1 / 2.2))) for v in range(256)]
return Image.merge('RGB', tuple(ImageChops.multiply(ch.point(gamma), m) for ch in (r, g, b)))
def save_image(img, out_dir, stem, sub):
"""Write `img` as <stem>.png and a WebP copy beside it (lossy at quality 95 with the alpha lossless and the RGB
under transparent texels kept, like extract.py WEBP: every player downloads these with the scene); returns the
manifest entry, which lists the copy (the PNG with a Pillow that has no WebP)."""
img.save(Path(out_dir) / f'{stem}.png')
entry = {'path': f'/assets/local/{sub}/{stem}.png', 'w': img.width, 'h': img.height}
try:
img.save(Path(out_dir) / f'{stem}.webp', 'WEBP', quality=95, method=6, alpha_quality=100, exact=True)
entry.update(path=f'/assets/local/{sub}/{stem}.webp', webp='lossy')
except Exception:
pass
return entry
def hidden_groups(raw, objects):
"""{GameObject path id: sub-graphic name} of a MapSubGraphicHolder's raw data: after each name (HIDDEN@n) come the
GameObjects and MeshTileGraphics of that group as PPtrs of this file. The script has no type tree in the bundle,
so the fields are walked as 4-byte words: a length-prefixed name, or a (file 0, path id) pointer to an object."""
out, group, i = {}, None, 0
body = raw
while i + 4 <= len(body):
v = struct.unpack_from('<i', body, i)[0]
if 0 < v < 64 and i + 4 + v <= len(body):
text = body[i + 4:i + 4 + v]
if all(32 <= b < 127 for b in text):
s = text.decode('ascii')
if HIDDEN_NAME.match(s):
group = s
i += 4 + ((v + 3) & ~3)
continue
if v == 0 and i + 12 <= len(body) and group:
pid = struct.unpack_from('<q', body, i + 4)[0]
target = objects.get(pid)
if target is not None:
out[pid] = group
i += 12
continue
i += 4
return out
def export_scene(stage_id, deps, scene_path, ab_root, out_root, manifest, log, material_info, shader_deps=()):
"""Write one stage's scene (see the module docstring). Returns the number of files written (0 on failure)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
from UnityPy.helpers.MeshHelper import MeshHandler
from PIL import Image
try:
env = UnityPy.load(str(scene_path))
except Exception as e: # corrupt or unsupported bundle
log(f'FAIL load {scene_path}: {e}')
return 0
objects = {o.path_id: o for o in env.objects}
scene_objs = list(objects.values())
light_bundle = scene_path.with_suffix('') / 'lightingdata.ab'
extra = [light_bundle] if light_bundle.is_file() else []
for dep in [*(Path(ab_root) / d for d in [*deps, *COMMON_DEPS]), *shader_deps]:
if Path(dep).is_file():
extra.append(Path(dep))
else:
log(f' warn {stage_id}: dependency {dep} missing (its materials are skipped)')
for dep in extra:
try:
env.load_file(str(dep))
except Exception as e:
log(f' warn {stage_id}: dependency {Path(dep).name}: {e}')
# ---- hierarchy: world matrices, active flags, hidden groups ---------------------------------------------------
tf = {} # transform path id → (local matrix, father id, game object)
for o in scene_objs:
if o.type.name != 'Transform':
continue
t = o.read()
p, r, s = t.m_LocalPosition, t.m_LocalRotation, t.m_LocalScale
tf[o.path_id] = (trs((p.x, p.y, p.z), (r.x, r.y, r.z, r.w), (s.x, s.y, s.z)), t.m_Father.m_PathID, t.m_GameObject)
world_cache = {}
def world(tid):
if tid not in tf:
return IDENTITY
if tid not in world_cache:
local, father, _ = tf[tid]
world_cache[tid] = mul(world(father), local) if father else local
return world_cache[tid]
go_of_tf = {tid: rec[2].m_PathID for tid, rec in tf.items()}
tf_of_go = {gid: tid for tid, gid in go_of_tf.items()}
go_info = {} # game object path id → (name, active)
for o in scene_objs:
if o.type.name == 'GameObject':
g = o.read()
go_info[o.path_id] = (g.m_Name, bool(g.m_IsActive))
hidden = {}
for o in scene_objs:
if o.type.name != 'MonoBehaviour':
continue
raw = o.get_raw_data()
if b'@' not in raw:
continue
for pid, group in hidden_groups(raw, objects).items():
target = objects[pid]
if target.type.name == 'GameObject':
hidden[pid] = group
elif target.type.name == 'MonoBehaviour':
try:
hidden[target.read().m_GameObject.m_PathID] = group
except Exception:
pass
def state(gid):
"""(active in the hierarchy, hidden group of the object or its nearest grouped ancestor)."""
active, group, tid = True, None, tf_of_go.get(gid)
while tid in tf:
g = go_of_tf[tid]
active = active and go_info.get(g, ('', True))[1]
if group is None and g in hidden:
group = hidden[g]
tid = tf[tid][1]
return active, group
anchor = (0.0, 0.0, 0.0)
for gid, (name, _) in go_info.items():
if name == 'Anchor' and gid in tf_of_go:
m = world(tf_of_go[gid])
anchor = (m[0][3], m[1][3], m[2][3])
break
# ---- light ----------------------------------------------------------------------------------------------------
light = None
for o in scene_objs:
if o.type.name != 'Light':
continue
d = o.read_typetree()
if d.get('m_Type') != 1: # directional
continue
m = world(tf_of_go.get(d['m_GameObject']['m_PathID']))
fwd = (m[0][2], m[1][2], m[2][2]) # the light shines along its +z
n = (fwd[0] ** 2 + fwd[1] ** 2 + fwd[2] ** 2) ** 0.5 or 1.0
c = d.get('m_Color', {})
light = {'dir': [round(-fwd[0] / n, 5), round(-fwd[1] / n, 5), round(fwd[2] / n, 5)],
'color': [round(c.get(k, 1.0), 5) for k in 'rgb'], 'intensity': round(d.get('m_Intensity', 1.0), 5)}
break
# ---- renderers → chunks -----------------------------------------------------------------------------------------
mesh_cache, materials, mat_tex = {}, {}, {}
chunks = {} # (material, lightmap, group) → {'pos': [], …}
def mesh_data(pptr):
key = (getattr(pptr, 'm_FileID', 0), pptr.m_PathID)
if key not in mesh_cache:
try:
h = MeshHandler(pptr.read())
h.process()
mesh_cache[key] = (h.m_Vertices, h.m_Normals, h.m_UV0, h.m_UV1, h.m_Colors, h.get_triangles())
except Exception as e:
log(f' warn {stage_id}: mesh #{pptr.m_PathID}: {e}')
mesh_cache[key] = None
return mesh_cache[key]
def material_name(pptr):
if not pptr.m_PathID:
return None
try:
mat = pptr.read()
except Exception: # its bundle is not in the client
return None
name = mat.m_Name
if name not in materials:
info = material_info(mat)
shader = info.get('shader') or ''
info['kind'] = 'water' if any(w in shader for w in WATER_SHADERS) else 'lit'
materials[name] = info
try:
for slot, env_ in mat.m_SavedProperties.m_TexEnvs:
if slot == '_MainTex' and env_.m_Texture.m_PathID:
mat_tex[name] = env_.m_Texture
except Exception:
pass
return name
skipped = 0
for o in scene_objs:
if o.type.name != 'MeshRenderer':
continue
r = o.read()
gid = r.m_GameObject.m_PathID
mf = None
for c in r.m_GameObject.read().m_Component:
if c.component.type.name == 'MeshFilter':
mf = c.component.read()
if mf is None or not mf.m_Mesh.m_PathID:
continue
data = mesh_data(mf.m_Mesh)
if not data:
continue
verts, normals, uv0, uv1, colors, tris = data
active, group = state(gid)
if not active and group is None:
group = 'inactive'
sb = r.m_StaticBatchInfo
static = sb.subMeshCount > 0
first = sb.firstSubMesh if static else 0
m = IDENTITY if static else world(tf_of_go.get(gid))
# board space mirrors Unity's z: the triangle order is reversed to stay counter-clockwise (three.js front
# faces), unless the object's own transform already mirrors it
flip = det3(m) > 0
so = r.m_LightmapTilingOffset
st = (1.0, 1.0, 0.0, 0.0) if static else (so.x, so.y, so.z, so.w)
lm = r.m_LightmapIndex if r.m_LightmapIndex < 65534 else -1
luv = uv1 if uv1 else uv0
for k, mref in enumerate(r.m_Materials):
sub = first + k
if sub >= len(tris):
break
name = material_name(mref)
if not name:
skipped += 1
continue
ch = chunks.setdefault((name, lm, group or ''), {'pos': [], 'uv': [], 'uv1': [], 'nrm': [], 'col': [], 'idx': [], 'map': None})
remap = {}
for tri in tris[sub]:
tri = (tri[0], tri[2], tri[1]) if flip else tri
for vi in tri:
j = remap.get(vi)
if j is None:
j = remap[vi] = len(ch['pos'])
v = verts[vi]
p = (m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2] + m[0][3],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2] + m[1][3],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + m[2][3])
ch['pos'].append(to_board(p, anchor))
nv = normals[vi] if normals else (0.0, 0.0, -1.0)
nw = (m[0][0] * nv[0] + m[0][1] * nv[1] + m[0][2] * nv[2],
m[1][0] * nv[0] + m[1][1] * nv[1] + m[1][2] * nv[2],
m[2][0] * nv[0] + m[2][1] * nv[1] + m[2][2] * nv[2])
ln = (nw[0] ** 2 + nw[1] ** 2 + nw[2] ** 2) ** 0.5 or 1.0
ch['nrm'].append((nw[0] / ln, nw[1] / ln, -nw[2] / ln))
t0 = uv0[vi] if uv0 else (0.0, 0.0)
ch['uv'].append((t0[0], t0[1]))
t1 = luv[vi] if luv else (0.0, 0.0)
ch['uv1'].append((t1[0] * st[0] + st[2], t1[1] * st[1] + st[3]))
col = colors[vi] if colors else (1.0, 1.0, 1.0, 1.0)
ch['col'].append(tuple(col[:4]) if len(col) >= 4 else (*col[:3], 1.0))
ch['idx'].append(j)
if not chunks:
log(f' warn {stage_id}: no drawable mesh in {scene_path.name}')
return 0
# ---- write ------------------------------------------------------------------------------------------------------
out_dir = Path(out_root) / SCENE_SUB / stage_id
out_dir.mkdir(parents=True, exist_ok=True)
sub = f'{SCENE_SUB}/{stage_id}'
blob, records = bytearray(), []
for (name, lm, group), ch in sorted(chunks.items(), key=lambda kv: (kv[0][2], kv[0][0], kv[0][1])):
data, idx32 = pack_chunk(ch)
xs, ys, zs = zip(*ch['pos'])
records.append({'material': name, 'lightmap': lm, 'group': group, 'verts': len(ch['pos']), 'tris': len(ch['idx']) // 3,
'offset': len(blob), 'index32': idx32,
'bounds': [round(v, 3) for v in (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))]})
blob += data
n = 0
(out_dir / 'scene.bin').write_bytes(bytes(blob))
manifest.setdefault(sub, {})['bin'] = {'path': f'/assets/local/{sub}/scene.bin', 'kind': 'SceneData', 'bytes': len(blob)}
n += 1
# lightmap pages: light RGB + the shadow mask's R as alpha
pages = {}
for o in env.objects:
if o.type.name != 'Texture2D':
continue
try:
t = o.read()
except Exception:
continue
mm = re.match(r'^Lightmap-(\d+)_comp_(light|shadowmask)$', t.m_Name or '')
if mm:
pages.setdefault(int(mm.group(1)), {})[mm.group(2)] = t
used_pages = sorted({rec['lightmap'] for rec in records if rec['lightmap'] >= 0})
lightmaps = 0
for i in used_pages:
page = pages.get(i, {})
if 'light' not in page:
log(f' warn {stage_id}: lightmap {i} missing (drawn unlit)')
continue
try:
rgb = decode_rgbm(page['light'].image)
mask = page['shadowmask'].image.convert('RGB').getchannel('R').resize(rgb.size) if 'shadowmask' in page else Image.new('L', rgb.size, 255)
img = Image.merge('RGBA', (*rgb.split(), mask))
entry = save_image(img, out_dir, f'lm{i}', sub)
except Exception as e:
log(f' warn {stage_id}: lightmap {i}: {e}')
continue
manifest[sub][f'lm{i}'] = {**entry, 'kind': 'Lightmap'}
lightmaps = max(lightmaps, i + 1)
n += 1
# albedo textures of the materials (shared by the scenes)
tex_dir = Path(out_root) / TEX_SUB
tex_dir.mkdir(parents=True, exist_ok=True)
for name, pptr in sorted(mat_tex.items()):
try:
tex = pptr.read()
tname = re.sub(r'[^A-Za-z0-9_.\-\[\]]+', '_', tex.m_Name or f'tex_{pptr.m_PathID}')
materials[name]['map'] = tname
if tname in manifest.get(TEX_SUB, {}):
continue
manifest.setdefault(TEX_SUB, {})[tname] = {**save_image(tex.image, tex_dir, tname, TEX_SUB), 'kind': 'Texture2D'}
n += 1
except Exception as e:
log(f' warn {stage_id}: texture of {name}: {e}')
doc = {'version': SCENE_VERSION, 'stage': stage_id, 'bundle': scene_bundle(stage_id), 'light': light, 'lightmaps': lightmaps,
'lightRange': LIGHT_RANGE, 'materials': dict(sorted(materials.items())), 'groups': sorted({rec['group'] for rec in records}),
'chunks': records, 'binBytes': len(blob)}
(out_dir / 'scene.json').write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
manifest[sub]['scene'] = {'path': f'/assets/local/{sub}/scene.json', 'kind': 'Scene', 'chunks': len(records)}
n += 1
verts = sum(rec['verts'] for rec in records)
log(f'{scene_path.name}: {len(records)} chunks, {verts} vertices, {lightmaps} lightmaps -> {sub}'
+ (f' ({skipped} sub-meshes without a material)' if skipped else ''))
return n