re(units): read definitions with the code-derived layout — defaulted values 1 059 -> 2 351
unit_runtime.py can only place fields the disc values (it scores triples against disc records): 58 of 153 from one snapshot. data/unit_definition_layout.txt came from the title's loader instead, so it places all 159 — including the fields no disc record sets, which is the Route-B target. tools/re-capture/unit_dump_layout.py reads every field of every live definition object with that layout and keeps the discipline: a field the disc DOES value is a check, not a new value. Over two snapshots (19 objects): 700 cross-checks agree, 0 disagree. fields placed per object 58 of 153 -> 159 rows over those 19 units 609 -> 2 736 defaulted-on-disc values (whole file) 1 059 -> 2 351 Two traps recorded: the layout table's offsets are DECIMAL while the solver CSV prints hex (parsing as hex fails the cross-check on everything — which is how it announced itself), and angle fields can carry a prefix (AB_AA_PitchPlus is still an angle, so the AV_/AA_ test must match anywhere in the name). Flagged: UN_f201_TCAF_Tanker's object is not byte-identical between the two missions — per-mission override or a runtime-mutated field; needs a third snapshot to separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -414,3 +414,39 @@ craft!) and `UN_mn040_Asteroid_Big` (47 defaulted values, `Size_Z = 2000`).
|
||||
Regenerating the solver's token file: `idxd_tokens <GP_MAIN_GAME_E.pak> Generic`
|
||||
emits the `REC`/`F` records `unit_runtime.py` wants — **110** of them are `UN_*`,
|
||||
which is the disc's full unit count.
|
||||
|
||||
### Reading the objects with the code-derived layout, not the solver (2026-08-13)
|
||||
|
||||
`unit_runtime.py` places a field only if the **disc** values it somewhere — that is
|
||||
what lets it score `(field, offset, encoding)` triples — so it resolved 58 of 153
|
||||
fields from a single-mission snapshot. The layout in
|
||||
[`data/unit_definition_layout.txt`](../../../crates/sylpheed-formats/data/unit_definition_layout.txt)
|
||||
came from the title's own loader instead (`sub_82341A20`, where the field *name* of
|
||||
every store is a string in the image), so it places **all 159**, including the ones
|
||||
no disc record ever sets — which is precisely the Route-B target.
|
||||
|
||||
[`tools/re-capture/unit_dump_layout.py`](../../../tools/re-capture/unit_dump_layout.py)
|
||||
reads every field of every live definition object with that layout, and keeps the
|
||||
project's discipline: a field the disc **does** value is a **check**, not a new
|
||||
value. Over two snapshots (19 objects): **700 disc cross-checks agree, 0 disagree.**
|
||||
|
||||
| | solver-derived | layout-derived |
|
||||
|---|---|---|
|
||||
| fields placed per object | 58 of 153 | **159** |
|
||||
| rows over these 19 units | 609 | **2 736** |
|
||||
| defaulted-on-disc values, disc-wide file | 1 059 | **2 351** |
|
||||
|
||||
Two things the run pinned down, both cheap to re-learn the hard way:
|
||||
|
||||
* **The layout table's offsets are DECIMAL** (`48 f32 Size_X`), while the solver's
|
||||
CSV prints hex. Parsing it as hex puts every field `0x18` bytes late — and the
|
||||
disc cross-check then fails on *everything*, which is exactly how the mistake
|
||||
announced itself.
|
||||
* **Angle fields can carry a prefix.** `AB_AA_PitchPlus` (afterburner) is still an
|
||||
angle, so anchoring the `AV_`/`AA_` test at the start of the name reported two
|
||||
player-craft fields as contradictions when they were 15°/16° in radians. With the
|
||||
token matched anywhere, the cross-check is clean.
|
||||
|
||||
⚠️ One unit's object is **not** byte-identical between the two missions —
|
||||
`UN_f201_TCAF_Tanker`. Either a per-mission override or a field the runtime mutates;
|
||||
the merged CSV holds the later reading, and separating them needs a third snapshot.
|
||||
|
||||
119
tools/re-capture/unit_dump_layout.py
Normal file
119
tools/re-capture/unit_dump_layout.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read EVERY field of the live unit-definition objects, using the code-derived
|
||||
layout instead of solving offsets from the disc.
|
||||
|
||||
`unit_runtime.py` scores `(field, offset, encoding)` triples against the disc
|
||||
records, so it can only place fields the disc actually values — 58 of 153 on a
|
||||
single-mission snapshot. The layout in `data/unit_definition_layout.txt` was read
|
||||
out of the title's own loader (`sub_82341A20`: the field NAME of every store is a
|
||||
string in the image), so it places **all** of them, including the ones no disc
|
||||
record ever sets. That is exactly the Route-B target: the defaulted fields.
|
||||
|
||||
Discipline is unchanged — every field the disc DOES value is cross-checked against
|
||||
the object, and a mismatch is reported rather than written. Angle fields are
|
||||
degrees on disc and radians in the object (see the layout header), so those are
|
||||
compared after conversion.
|
||||
|
||||
Usage:
|
||||
unit_dump_layout.py <layout.txt> <tokens.txt> <snap.bin>... [--csv out.csv]
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
||||
import unit_runtime as ur # object locator + disc token reader
|
||||
|
||||
# Angle fields can carry a prefix (`AB_AA_PitchPlus` = afterburner), so match
|
||||
# the AV_/AA_ token anywhere, not just at the start — anchoring it left two
|
||||
# player-craft fields looking like contradictions when they were 15°/16° in
|
||||
# radians.
|
||||
ANGLE = re.compile(r"(^|_)(AV|AA)_|Bank|Turn_AngularVelocity")
|
||||
|
||||
|
||||
def read_layout(path):
|
||||
"""[(offset, kind, name)] from the '# offset kind field' table."""
|
||||
out = []
|
||||
for line in open(path):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
off, kind, name = parts[0], parts[1].lower(), parts[2]
|
||||
try:
|
||||
# The layout table writes offsets in DECIMAL (`48 f32 Size_X`), while
|
||||
# the solver's CSV prints them in hex — parsing this file as hex puts
|
||||
# every field 0x18 bytes late and makes the disc cross-check fail on
|
||||
# everything, which is how the mistake announces itself.
|
||||
out.append((int(off, 10), kind, name))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def value_at(raw, off, kind):
|
||||
if off + 4 > len(raw):
|
||||
return None
|
||||
if kind in ("f32", "float"):
|
||||
return struct.unpack_from(">f", raw, off)[0]
|
||||
if kind in ("word", "u32", "int", "bool"):
|
||||
return struct.unpack_from(">I", raw, off)[0]
|
||||
return None # str: a guest pointer, not a value
|
||||
|
||||
|
||||
def as_float(s):
|
||||
try:
|
||||
return float(str(s).rstrip("fF"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
layout_path, tokens_path = sys.argv[1], sys.argv[2]
|
||||
snaps = [a for a in sys.argv[3:] if not a.startswith("--")]
|
||||
out_csv = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--csv=")), None)
|
||||
|
||||
layout = read_layout(layout_path)
|
||||
disc, _ambiguous = ur.read_tokens(tokens_path)
|
||||
objs, conflicts = ur.runtime_objects(snaps)
|
||||
print(f"# layout fields: {len(layout)} runtime objects: {len(objs)} "
|
||||
f"disc units: {len(disc)} matched: {len(set(objs) & set(disc))}")
|
||||
if conflicts:
|
||||
print(f"# !! objects differing between snapshots: {conflicts}")
|
||||
|
||||
rows, agree, disagree = [], 0, 0
|
||||
for unit in sorted(objs):
|
||||
raw = objs[unit]
|
||||
drec = disc.get(unit, {})
|
||||
for off, kind, name in layout:
|
||||
v = value_at(raw, off, kind)
|
||||
if v is None:
|
||||
continue
|
||||
dv = as_float(drec.get(name)) if name in drec else None
|
||||
if dv is not None:
|
||||
# The disc values this field: it is a CHECK, not a new value.
|
||||
cmpv = math.degrees(v) if ANGLE.search(name) and isinstance(v, float) else v
|
||||
ok = abs(cmpv - dv) <= max(1e-3, abs(dv) * 1e-3)
|
||||
agree, disagree = (agree + 1, disagree) if ok else (agree, disagree + 1)
|
||||
if not ok:
|
||||
print(f"# MISMATCH {unit} {name} @{off:#05x}: object {cmpv!r} vs disc {dv!r}")
|
||||
rows.append([unit, name, f"{off:#05x}", kind, repr(v), "disc",
|
||||
"verified" if ok else "CONTRADICTED"])
|
||||
else:
|
||||
rows.append([unit, name, f"{off:#05x}", kind, repr(v),
|
||||
"defaulted-on-disc", "layout-derived"])
|
||||
print(f"# cross-check against the disc: {agree} agree, {disagree} disagree")
|
||||
print(f"# rows: {len(rows)} ({sum(1 for r in rows if r[5] == 'defaulted-on-disc')} defaulted)")
|
||||
if out_csv:
|
||||
w = csv.writer(open(out_csv, "w", newline=""))
|
||||
w.writerow(["unit", "field", "offset", "enc", "value", "source", "conf"])
|
||||
w.writerows(rows)
|
||||
print(f"# wrote {out_csv}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user