feedback1d WN: special Originium slugs drawn with their own official models (D3)

灼热源石虫 / 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs) were drawn with
the plain 源石虫 skeleton: no community dump carries their models, so the asset plan aliased
them to enemy_1007_slime. tools/local-extract/extract.py now extracts their official skeletons
from the client's enemy art bundles (ENEMY_SPINES, pages merged with their alpha textures) into
public/assets/local/spine/enemy/, and fetch-assets plans such a model before any alias
(spine.mjs findLocalEnemyModels, plan.mjs localEnemyModels). public/assets/local is never
pruned. An audit of all 249 enemies against the client's prefabs found no other enemy drawn
with another enemy's model.
This commit is contained in:
sganggs committed 2026-10-03 04:10:13 +08:00
1 parent 7f134737ce
commit 46ccfd55f6
7 files changed
+325 -18

No files matched your search

+127 -2
View File
@@ -8,6 +8,9 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
merged in (premultiplied RGB + A, the Ark-Models format); tools/fetch-assets.mjs plans them in place of an alias
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader
names resolved through the shaders/*.ab bundles), the background / device meshes as Wavefront OBJ
(mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
@@ -90,6 +93,17 @@ JOBS = [
('arts/maps/effect.ab', 'map/water', {'Texture2D'}, r'water_normal|Caustics|WaterNoise|noise_clouds|Water_Foam|SmoothWaves'),
]
# Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so
# tools/fetch-assets.mjs used to alias them to another enemy's skeleton): every enemy of data/enemies.json whose official
# battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user feedback
# after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫). Read from
# the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton TextAsset, the
# atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its _AlphaTex merged
# in as A). Output: spine/enemy/<id>/ (manifest group spine/enemy/<id>).
ENEMY_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
ENEMY_ART = 'refs/arts/enm_art_*.ab'
ENEMY_SPINE_SUB = 'spine/enemy'
# Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel
# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in
# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture.
@@ -120,6 +134,14 @@ def job_parts(job):
return rel, sub, kinds, keep
def wants_sub(only, sub):
"""Whether output subdir `sub` runs under --only: no prefixes, or one prefix is `sub`, a parent of it or below it."""
if not only:
return True
return any(sub == p.rstrip('/') or sub.startswith(p.rstrip('/') + '/') or p.rstrip('/').startswith(sub + '/')
for p in only)
def select_jobs(only):
"""The jobs whose output subdir starts with one of the `only` prefixes (all jobs when `only` is empty)."""
if not only:
@@ -127,6 +149,13 @@ def select_jobs(only):
return [j for j in JOBS if any(j[1] == p.rstrip('/') or j[1].startswith(p.rstrip('/') + '/') for p in only)]
def enemy_spine_id(container, wanted):
"""The enemy id of an enemy SkeletonData container path (Assets/Torappu/Arts/Enemies/Spines/<dir>[/<n>]/<id>_
SkeletonData.asset) when it is one of `wanted`, else None."""
m = re.search(r'/Enemies/Spines/(?:[^/]+/)+([^/]+)_SkeletonData\.asset$', container or '', re.I)
return m.group(1) if m and m.group(1) in wanted else None
def _num(v, nd=6):
"""JSON-safe rounded float (NaN / inf → 0)."""
try:
@@ -383,6 +412,98 @@ def export_bundle(ab_root, job, out_root, manifest, log):
return n
def _text_bytes(text_asset):
raw = text_asset.m_Script
return raw.encode('utf-8', 'surrogateescape') if isinstance(raw, str) else bytes(raw)
def merge_alpha(rgb, alpha):
"""RGBA page of a split texture: RGB of the main texture (premultiplied: the client's Spine material has
_StraightAlphaInput 0) and A from its [alpha] texture; RGB is clamped to A (block-compression bleed)."""
from PIL import Image, ImageChops
r, g, b = rgb.convert('RGB').split()
a = alpha.convert('L').resize(rgb.size) if alpha is not None else Image.new('L', rgb.size, 255)
return Image.merge('RGBA', tuple(ImageChops.darker(ch, a) for ch in (r, g, b)) + (a,))
def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
"""Write one enemy model (skeleton, atlas, merged pages) of a SkeletonDataAsset typetree; returns the file count."""
get = lambda pptr: objects.get(pptr['m_PathID']) if pptr and not pptr.get('m_FileID') else None # noqa: E731
sub = f'{ENEMY_SPINE_SUB}/{eid}'
out_dir = Path(out_root) / sub
out_dir.mkdir(parents=True, exist_ok=True)
files = {}
skel = get(sda.get('skeletonJSON'))
if skel is None:
log(f' warn {eid}: skeleton TextAsset not in this bundle')
return 0
ta = skel.read()
stem = safe_name(getattr(ta, 'm_Name', '') or eid).removesuffix('.skel')
files[f'{stem}.skel'] = _text_bytes(ta)
pages = {}
for aref in sda.get('atlasAssets') or []:
aa = get(aref)
if aa is None:
continue
at = aa.read_typetree()
af = get(at.get('atlasFile'))
if af is not None:
files[f'{stem}.atlas'] = _text_bytes(af.read())
for mref in at.get('materials') or []:
mo = get(mref)
if mo is None:
continue
texs = dict(mo.read_typetree()['m_SavedProperties']['m_TexEnvs'])
main, alpha = get(texs.get('_MainTex', {}).get('m_Texture')), get(texs.get('_AlphaTex', {}).get('m_Texture'))
if main is None:
continue
mt = main.read()
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None)
if f'{stem}.atlas' not in files or not pages:
log(f' warn {eid}: atlas or page textures missing')
return 0
for fname, blob in files.items():
(out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
for fname, img in pages.items():
img.save(out_dir / fname)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'w': img.width, 'h': img.height,
'kind': 'Texture2D'}
return len(files) + len(pages)
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES from the enemy art bundles (each bundle loaded on its own; stops once every model was found)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
wanted, n = set(ids or ENEMY_SPINES), 0
for f in sorted(Path(ab_root).glob(ENEMY_ART)):
if not wanted:
break
try:
env = UnityPy.load(str(f))
except Exception as e: # corrupt or unsupported bundle: report and continue
log(f'FAIL load {f.name}: {e}')
continue
objects = {o.path_id: o for o in env.objects}
for name, ref in env.container.items():
eid = enemy_spine_id(name, wanted)
if not eid:
continue
try:
k = write_enemy_spine(objects, ref.read_typetree(), eid, out_root, manifest, log)
except Exception as e:
log(f' warn {eid}: {e}')
continue
if k:
wanted.discard(eid)
n += k
log(f'{f.relative_to(ab_root).as_posix()}: {eid} -> {ENEMY_SPINE_SUB}/{eid} ({k} files)')
for eid in sorted(wanted):
log(f'skip (not found) enemy Spine {eid}')
return n
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--game', help='AssetBundle root (…/StreamingAssets/AB/Windows or …/Documents/Bundles)')
@@ -398,7 +519,8 @@ def main():
for rel, sub, kinds, keep in map(job_parts, JOBS)]
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived}, ensure_ascii=False))
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
return 0
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
@@ -406,7 +528,8 @@ def main():
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
return 2
jobs = select_jobs(args.only)
if not jobs:
enemy_spines = wants_sub(args.only, ENEMY_SPINE_SUB)
if not jobs and not enemy_spines:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
@@ -415,6 +538,8 @@ def main():
print(f'AB root: {ab_root}')
for job in jobs:
total += export_bundle(ab_root, job, out_root, manifest, print)
if enemy_spines:
total += export_enemy_spines(ab_root, out_root, manifest, print)
old = {}
if args.only and Path(args.manifest).exists():
try: