"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene (scenes/activities//level_/level_.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 , 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 /scene// (manifest group scene/): 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.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-_comp_light, stored RGBM: decode_rgbm), A = the key light's baked shadow (Lightmap-_comp_shadowmask, R) and the materials' albedo textures under /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 .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('