Files
ark-stronghold-protocol/tools/local-extract/enemy_model_offsets.py
T
sganggs 66ae86b969 feedback5 WY: flying units hover 1.3 tiles (the client's 0.35 ÷ the standard prefab scale 0.27); the 帝国炮火先兆者 pair is drawn 1.263× taller and 木制瑞印 mirrored, as their official prefabs (from PR #211 by @xcdoge; the owner's decision of 2026-10-06)
Ported by hand from PR #211 by @xcdoge:
- render/units.js: FLY_HOVER 0.32 → 1.3, flat (the official fly offset is one model-independent Vector3(0, 0.35, 0) in
  the client's character space, whose unit is the standard battle-prefab scale 0.27; an official screenshot measures
  1.2–1.4 tiles); enemyModelScaleY multiplies the skeleton's Y scale and the head (bar) height, also in the impostor
  path; a mirrorX model flips on top of the direction flip. The body, its HP bar, damage numbers, projectile hits and
  skill rings ride `hover`, the shadow stays on the ground; one camera drives the units over the 2D and the 3D board.
- tools/build-data.mjs MODEL_STRETCH_Y / MIRRORED_PREFABS → enemies.json modelScaleY / mirrorX; data/enemies.json
  regenerated offline here (the PR's one-line blob was not taken) — JSON compare: only enemy_1112_emppnt /
  enemy_1112_emppnt_2 modelScaleY 1.263 and enemy_1196_msfyin mirrorX true.
- tools/local-extract/enemy_model_offsets.py (optional, host-side prefab reader) and
  docs/research/12-flying-visuals-official.md (without the claim that the owner said 「都要」 on 2026-10-05 — the owner's
  decision of 2026-10-06 is cited; where the quirk enemies appear corrected against 0.2.0's data: 帝国炮火先兆者 is a FLY
  special enemy of the mode, 帝国炮火中枢先兆者 comes with the bounty 战术特训·飞行III, 木制瑞印 is in no wave). No
  screenshot or extracted art committed.

Tests (from PR #211): test/render/unitview.test.js (FLY_HOVER ≈ 0.35 / 0.27; the same lift for every flyer; a flying
view lifts by FLY_HOVER — added here: its body and HP bar ride the lift, its shadow stays on the ground),
test/render/playtest6-enemies.test.js (the data keys; the 1.263 aspect and bar height; the mirrored model's x sign);
both fail without the renderer change; the 425 render unit tests pass. Docs: DATA §9, ASSETS, design client.md,
research 00 / 12, DESIGN §25.13.5 and the §25.10 table.
Golden unchanged (drawing only; the sim reads neither field).
2026-10-06 21:41:49 +08:00

119 lines
5.6 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""Official per-model model placement, read from a locally installed Arknights client (optional, host-side).
Answers "does the client move a model relative to its unit position, and does it scale it?" — the two questions the
remake's renderer needs (public/js/render/units.js FLY_HOVER / enemyModelScale).
It reads the battle prefabs (`dyn/battle/prefabs/enemies/<prefab>.prefab`, bundles `battle/enm_pfb_*.ab`) and reports,
per enemy:
* `Graphic` — the node that carries the Spine model — its **local position and scale relative to the prefab root**.
The *root's* own local position is an editor leftover (妖怪 (0,0,0), 帝国炮火先兆者 (4.23,2.11,−1.2), 源石虫 (3,3,0),
掠海漂移体 (2.21,5.23,0)) and is overwritten with the unit's world position at instantiation, so it means nothing;
only the `Graphic` node is a real placement.
* the comparison `sy / sx`: ≠1 means the client stretches the model vertically (measured for 帝国炮火先兆者 /
帝国炮火中枢先兆者: Graphic scale (0.19, 0.24, 0.24) — the model is drawn 1.263× taller than its uniform scale, while
`enemies.json modelScale` only carries the horizontal 0.19/0.27).
Findings for this mode's 28 flying units (2026-10-05): the `Graphic` vertical offset is **0** for all of them — 10 at
local (0,0,0) and 17 at the same (0,−0.2,−0.06) that ground units such as 源石虫 share (a prefab-family convention, not a
per-model correction) — i.e. the client applies **no per-model vertical correction**, which is why
`units.js FLY_HOVER` is a flat constant and why a model whose art hangs below its pivot keeps that hang.
Usage:
python tools/local-extract/enemy_model_offsets.py [--game <AB root>] [--keys k1,k2,...] [--only-fly]
Needs UnityPy + lz4 (tools/local-extract/requirements.txt).
"""
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from extract import CANDIDATES, ROOT # noqa: E402
import aklz4 # noqa: F401
import UnityPy
def graphic_of(root_god, by_id):
"""The prefab root's `Graphic` child → ((x, y, z), (sx, sy, sz)), or (None, None) when absent."""
for c in root_god['m_Component']:
r = by_id.get(c['component']['m_PathID'])
if r is None or r.type.name != 'Transform':
continue
tr = r.read_typetree()
for ch in tr['m_Children']:
cr = by_id.get(ch['m_PathID'])
if cr is None:
continue
ctr = cr.read_typetree()
g = by_id.get(ctr['m_GameObject']['m_PathID'])
if g is None:
continue
if g.read_typetree().get('m_Name') == 'Graphic':
p = ctr['m_LocalPosition']
s = ctr['m_LocalScale']
return (p['x'], p['y'], p['z']), (s['x'], s['y'], s['z'])
return None, None
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--game', help='AB root of the local client (default: the known install locations)')
ap.add_argument('--keys', help='comma-separated enemies.json keys (default: every enemy)')
ap.add_argument('--only-fly', action='store_true', help='just the units whose stats.motion is FLY')
ap.add_argument('--json', action='store_true', help='print the raw {prefab: {pos, scale}} JSON instead of a table')
args = ap.parse_args()
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
if not ab_root or not (ab_root / 'battle').exists():
sys.exit('enemy_model_offsets: no local client found (pass --game <AB root>)')
enemies = json.loads((ROOT / 'data' / 'enemies.json').read_text('utf8'))
if args.keys:
keys = [k.strip() for k in args.keys.split(',') if k.strip()]
else:
keys = [k for k, v in enemies.items() if not args.only_fly or v.get('stats', {}).get('motion') == 'FLY']
wanted = {enemies[k].get('spine') or k: k for k in keys if k in enemies}
rows = {}
for f in sorted((ab_root / 'battle').glob('enm_pfb*.ab')):
env = UnityPy.load(str(f))
by_id = {o.path_id: o for o in env.objects}
for name, obj in env.container.items():
m = re.search(r'/enemies/(.+)\.prefab$', name)
if m and m.group(1) in wanted:
rows[m.group(1)] = graphic_of(obj.read_typetree(), by_id)
if args.json:
out = {}
for prefab, key in wanted.items():
e = enemies[key]
if prefab not in rows or rows[prefab][0] is None:
out[key] = None
continue
p, s = rows[prefab]
out[key] = {'prefab': prefab, 'name': e.get('name'), 'motion': e.get('stats', {}).get('motion'),
'pos': list(p), 'scale': list(s), 'modelScale': e.get('modelScale')}
print(json.dumps(out, indent=1, sort_keys=True, ensure_ascii=False))
return
print(f"{'key':<24}{'name':<18}{'Graphic pos (x,y,z)':<28}{'scale (sx,sy,sz)':<28}{'sy/sx':<8}{'modelScale'}")
for prefab, key in wanted.items():
e = enemies[key]
ms = e.get('modelScale')
if prefab not in rows or rows[prefab][0] is None:
print(f'{key:<24}{str(e.get("name"))[:16]:<18}— no Graphic node —')
continue
p, s = rows[prefab]
ratio = (s[1] / s[0]) if s[0] else float('nan')
print(f'{key:<24}{str(e.get("name"))[:16]:<18}'
f'({p[0]:.3f}, {p[1]:.3f}, {p[2]:.3f})'.ljust(28)
+ f'({s[0]:.4f}, {s[1]:.4f}, {s[2]:.4f})'.ljust(28)
+ f'{ratio:<8.3f}{ms if ms is not None else 1}')
if __name__ == '__main__':
main()