re: solve the runtime Unit (craft/vessel) struct from live guest memory

Craft stats were the last parked Route-B target: the Hangar exposes only a
weight class, and in menus the flight-model object is not instantiated at all.
It is instantiated in-mission, so this reads it there.

The definition class is discovered rather than assumed (unit_discover.py):
scanning for pointers to the disc ID strings and tallying the word behind each
pointer site picks out vtable 0x820af844 -- one object per unit ID, name-record
pointer at +0x04, string at +0x10, exactly the Weapon shape. The neighbouring
class 0x820af030 is the spawned entity, not the definition; the two are told
apart by one-object-per-ID and by 14/14 objects being byte-identical across two
snapshots 12 minutes and one firefight apart.

22 fields bind with zero contradictions on >=3 distinct values. Beyond that,
the Maneuver sub-record turns out to be laid out in schema declaration order,
4 bytes per field, base 0x9c with a two-slot gap after AA_Roll_Min -- 29
independently solved anchors fit two exact bases with no conflicts. That rule
pins five fields NO unit table ever values (PitchDragFactor, RollDragFactor,
DragFactorThreshold, ArterBurner_Acc, DecPitchFactor); PitchDrag == RollDrag ==
the disc's YawDrag on 18/18 units corroborates the interpolation.

Angles are float32 radians at runtime and degrees on disc.

Coverage is 18 of 110 units: unlike weapons, unit definitions are instantiated
per stage, so it grows by visiting missions. The AI-behaviour tail of Maneuver
is explicitly NOT resolved and marked tentative in the CSV.

Captured from Xenia Canary (BASIC CONTROLS tutorial + Stage 02 from save 01).
This commit is contained in:
2026-07-29 17:27:53 +00:00
parent 6a41525c41
commit 2fa4c33d1a
8 changed files with 2326 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Test the hypothesis that a unit sub-record's runtime fields are laid out in
*schema declaration order*, one 4-byte slot each.
The IDXD string pool lists a sub-record's field names in declaration order,
including the ones this .tbl leaves at their default (they appear as a bare key
with no preceding value). If the runtime struct is `base + 4*index` over that
list, then the offsets the solver found independently must all satisfy it.
That is a much stronger check than any single field's agreement count: one base
offset has to explain dozens of independently-derived anchors at once.
Usage: order_check.py <raw-token-dump> <tbl-hash> <SubRecord> <solved.txt>
"""
import re
import sys
NUMERIC = re.compile(r"^[-+]?[0-9]*\.?[0-9]+[fF]?$|^0x[0-9a-fA-F]+$")
VALUE_WORDS = {"Yes", "No", "YES", "NO", "On", "Off", "ON", "OFF"}
def section(dump, tbl, sub, subs):
"""Ordered token list of one sub-record of one .tbl."""
toks, on = [], False
for line in open(dump):
if line.startswith("RAW "):
on = line.split()[1] == tbl
continue
if on and line.startswith("T "):
toks.append(line[2:].rstrip("\n"))
starts = [i for i, t in enumerate(toks) if t in subs or t.lstrip("(") in subs]
for n, i in enumerate(starts):
name = toks[i].lstrip("(")
if name != sub:
continue
end = starts[n + 1] if n + 1 < len(starts) else len(toks)
return toks[i + 1 : end]
return []
def keys_in_order(toks):
"""Field names, in declaration order: every token that is not a value.
In the numeric sub-records every value is a number literal, so a token that
is not numeric and not a Yes/No word is a key.
"""
out = []
for t in toks:
if NUMERIC.match(t) or t in VALUE_WORDS:
continue
if t not in out:
out.append(t)
return out
def solved_offsets(path):
out = {}
for line in open(path):
m = re.match(r"\s*(0x[0-9a-f]+)\s+(\w+)\s+(\w+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$", line)
if m:
off, enc, field, agree, dist, bad = m.groups()
out[field] = (int(off, 16), enc, int(agree), int(dist), int(bad))
return out
def main():
dump, tbl, sub, solvedf = sys.argv[1:5]
subs = {"Generic", "Maneuver", "Shield", "Explosion", "Mass", "Effect", "SE"}
keys = keys_in_order(section(dump, tbl, sub, subs))
solved = solved_offsets(solvedf)
anchors = [(i, k, solved[k]) for i, k in enumerate(keys) if k in solved]
if not anchors:
sys.exit(f"no solved field lands in {sub}")
# base that the most anchors agree on
votes = {}
for i, k, (off, *_) in anchors:
votes.setdefault(off - 4 * i, []).append(k)
base, winners = max(votes.items(), key=lambda kv: len(kv[1]))
print(f"# {sub}: {len(keys)} declared fields, {len(anchors)} solved anchors")
print(f"# best base = {base:#x} -> {len(winners)}/{len(anchors)} anchors fit "
f"offset = base + 4*index\n")
for i, k in enumerate(keys):
off = base + 4 * i
if k in solved:
so, enc, agree, dist, bad = solved[k]
mark = "fits" if so == off else f"CONFLICT solver={so:#x}"
print(f" +{off:#05x} [{i:3d}] {k:<34} {enc} agree={agree:<3} "
f"dist={dist:<3} {mark}")
else:
print(f" +{off:#05x} [{i:3d}] {k:<34} — (never valued on disc)")
if __name__ == "__main__":
main()