0.2.5-dev: official battlefield scenes on the 3D board — a local fetch / extract step for the season scene bundles, per-field scene rendering with the metal board as fallback, the full bundle can include the extracted scenes and setup can fetch them (--scenes); nothing official is stored in the repository (PR #533 by @andrew05060414, ported; the owner's decision of 2026-10-12); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 14:59:20 +08:00
1 parent 7fd0478dc0
commit d482fbb8ba
19 files changed
+1659 -22

No files matched your search

+50 -2
View File
@@ -34,6 +34,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
.cache/official-scenes/*, filled by tools/local-extract/fetch_scenes.py)
Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
@@ -41,6 +45,7 @@ Usage:
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow)
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
@@ -55,6 +60,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402 (the battlefield scene table and exporter; imports UnityPy only when it runs)
ROOT = Path(__file__).resolve().parents[2]
HOME = Path.home()
@@ -722,6 +728,39 @@ def write_spine(objects, sda, sub, name, out_root, manifest, log):
return len(files) + len(pages)
def scene_ids(only):
"""The stage ids of scenes.SCENES that run under --only (all with no prefix, `scene` or a parent of it)."""
return spine_ids(only, scenes.SCENE_SUB, [sid for sid, _deps in scenes.SCENES])
def scene_roots(ab_root, extra):
"""AB roots searched for the scene bundles, in order: the client, --scenes dirs, then every version kept under
.cache/official-scenes (newest name first)."""
kept = ROOT / '.cache' / 'official-scenes'
cached = sorted((p for p in kept.iterdir() if p.is_dir()), reverse=True) if kept.is_dir() else []
return [Path(ab_root), *[Path(p) for p in extra], *cached]
def export_scenes(ab_root, out_root, manifest, log, ids, roots):
"""scenes.SCENES (the ids in `ids`) → scene/<stageId>/ (scenes.export_scene); a stage without a scene bundle in
any root is skipped."""
root = Path(ab_root)
shader_deps = dep_bundles(root, {'Material'})
n = 0
for sid, deps in scenes.SCENES:
if sid not in ids:
continue
src = scenes.find_scene(sid, roots)
if src is None:
log(f'skip (no scene bundle: off-season client, see --scenes) {scenes.scene_bundle(sid)}')
continue
try:
n += scenes.export_scene(sid, deps, src, root, out_root, manifest, log, material_info, shader_deps)
except Exception as e: # one broken scene must not stop the others
log(f'FAIL scene {sid}: {e}')
return n
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was
found)."""
@@ -802,6 +841,9 @@ def main():
ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json'))
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
ap.add_argument('--scenes', action='append', default=[], metavar='DIR',
help='an AB root holding the battlefield scene bundles of an in-season version (repeatable; '
'searched after --game and before .cache/official-scenes/*)')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
@@ -816,7 +858,10 @@ def main():
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp,
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,
'stages': [{'id': sid, 'bundle': scenes.scene_bundle(sid), 'deps': deps}
for sid, deps in scenes.SCENES]}},
ensure_ascii=False))
return 0
if args.webp:
@@ -829,7 +874,8 @@ def main():
jobs = select_jobs(args.only)
enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES)
token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES)
if not jobs and not enemy_ids and not token_ids:
stage_ids = scene_ids(args.only)
if not jobs and not enemy_ids and not token_ids and not stage_ids:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
@@ -842,6 +888,8 @@ def main():
total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids)
if token_ids:
total += export_token_spines(ab_root, out_root, manifest, print, token_ids)
if stage_ids:
total += export_scenes(ab_root, out_root, manifest, print, stage_ids, scene_roots(ab_root, args.scenes))
old = {}
if args.only and Path(args.manifest).exists():
try:
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Download the 卫戍协议 battlefield scene bundles of an in-season version from the official asset CDN (optional).
The scene bundles (scenes.py) ship only while the season runs: an off-season client has none, and the current
hot-update list does not name them. The CDN still serves the versions that had them; this fetches the bundles of
scenes.SCENES (scene, lighting data, lighting settings) into .cache/official-scenes/<version>/<bundle path>, where
extract.py --only scene finds them. Every file is checked against the md5 of that version's hot-update list.
Usage:
python3 tools/local-extract/fetch_scenes.py [--version 26-04-13-12-31-42_e9b560] [--platform Android] [--out <dir>]
python3 tools/local-extract/fetch_scenes.py --list (what would be downloaded; reads the hot-update list only)
VERSION is the last version of 盟约·下半 that carried all eleven scenes (the `Data:` version of the game-data commits
of that season). Needs no dependencies. Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
import hashlib
import io
import json
import sys
import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
VERSION = '26-04-13-12-31-42_e9b560'
PLATFORM = 'Android'
CDN = 'https://ak.hycdn.cn/assetbundle/official/{platform}/assets/{version}/'
def dat_name(bundle):
"""File name of a bundle on the CDN: the path without its extension, '/' → '_', '#' → '__', as .dat (a zip)."""
return bundle.rsplit('.', 1)[0].replace('/', '_').replace('#', '__') + '.dat'
def wanted(names, stage_ids):
"""The bundles of `names` (a hot-update list) that belong to the scenes of `stage_ids`, in list order."""
dirs = tuple(scenes.scene_bundle(sid).rsplit('/', 1)[0] + '/' for sid in stage_ids)
return [n for n in names if n.startswith(dirs)]
def get(url):
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=180) as r:
return r.read()
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--version', default=VERSION)
ap.add_argument('--platform', default=PLATFORM)
ap.add_argument('--out', help='output root (default .cache/official-scenes/<version>)')
ap.add_argument('--list', action='store_true', help='print the bundles and sizes, download nothing')
args = ap.parse_args()
base = CDN.format(platform=args.platform, version=args.version)
out = Path(args.out) if args.out else ROOT / '.cache' / 'official-scenes' / args.version
try:
infos = json.loads(get(base + 'hot_update_list.json'))['abInfos']
except Exception as e:
print(f'cannot read the hot-update list of {args.version} ({args.platform}): {e}', file=sys.stderr)
return 2
by_name = {x['name']: x for x in infos}
names = wanted(list(by_name), [sid for sid, _ in scenes.SCENES])
if not names:
print(f'version {args.version} lists no 卫戍协议 scene bundle (an off-season version?)', file=sys.stderr)
return 1
if args.list:
for n in names:
print(f'{by_name[n].get("totalSize", 0):>9} {n}')
print(f'{sum(by_name[n].get("totalSize", 0) for n in names):>9} total ({len(names)} bundles)')
return 0
bad = 0
for n in names:
dst = out / n
md5 = by_name[n].get('md5')
if dst.is_file() and hashlib.md5(dst.read_bytes()).hexdigest() == md5:
print(f'have {n}')
continue
try:
with zipfile.ZipFile(io.BytesIO(get(base + dat_name(n)))) as z:
blob = z.read(z.namelist()[0])
except Exception as e:
print(f'FAIL {n}: {e}', file=sys.stderr)
bad += 1
continue
if md5 and hashlib.md5(blob).hexdigest() != md5:
print(f'FAIL {n}: md5 mismatch', file=sys.stderr)
bad += 1
continue
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(blob)
print(f'got {n} ({len(blob)} bytes)')
print(f'done: {len(names) - bad}/{len(names)} bundles under {out}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())
+457
View File
@@ -0,0 +1,457 @@
"""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