#!/usr/bin/env python3 """Solve the runtime `Weapon`/`Shell` struct layouts against disc ground truth, then read the fields the disc leaves defaulted. The game parses every `weapon\\Weapon_*.tbl` IDXD record into two C++ objects, a `Weapon` and its `Shell`. Each class lives in its own contiguous array in guest RAM and is identifiable by its vtable pointer. Fields the IDXD omits (the Route-B "defaulted" list) still hold their real values in those objects — put there by the constructor before the parser overwrites what the disc supplies. Method (no guessing): 1. read every disc record's explicitly-valued fields, split per sub-record (`sylpheed-formats --example idxd_tokens`); 2. read every runtime object out of the live emulator (`gmem`), keyed by the object's own ID string; 3. for each (field, byte-offset, encoding) triple, count how many weapons the offset reproduces and how many it contradicts. An offset that agrees on many and contradicts none is a confirmed binding; 4. print the map, then the values at those offsets for the weapons whose disc record omits the field. Step 3 is the whole safeguard: a wrong offset cannot silently agree with ~100 independent records. Usage: weapon_runtime.py [--md] """ import math import os import re import struct import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 # vtable VA -> (class name, object stride) CLASSES = { 0x820AF548: ("Weapon", 0xC0), 0x820AF58C: ("Shell", 0x200), } NAME_STR_OFF = 0x10 # the name string sits +0x10 into a name record # ---------------------------------------------------------------- disc side def read_tokens(path): """({class: {ID: {field: value}}}, {(class, ID): [field...]}) from the dump. A handful of IDs are declared by more than one `.tbl`. Where two such declarations disagree on a field, that field is *ambiguous on disc* — the runtime holds whichever definition won, so it must not be scored as a contradiction. Those are collected separately, not silently merged. """ seen, ambiguous = {}, {} cur = None for line in open(path): if line.startswith("REC "): _, _, _, cls, rid = line.split() cur = (cls, rid) seen.setdefault(cur, []) seen[cur].append({}) elif line.startswith("F ") and cur is not None: _, k, v = line.rstrip("\n").split(" ", 2) seen[cur][-1][k] = v out = {} for (cls, rid), defs in seen.items(): merged = {} for d in defs: merged.update(d) if len(defs) > 1: bad = { k for k in {k for d in defs for k in d} if len({d.get(k) for d in defs}) > 1 } if bad: ambiguous[(cls, rid)] = sorted(bad) for k in bad: merged.pop(k, None) out.setdefault(cls, {})[rid] = merged return out, ambiguous # ------------------------------------------------------------- runtime side def scan_vtable(f, size, vt): pat = struct.pack(">I", vt) hits = [] for a, b in gmem.extents(f.fileno(), size): f.seek(a) blob = f.read(b - a) for m in re.finditer(re.escape(pat), blob): off = a + m.start() if off % 4 == 0: hits.append(off) return sorted(set(hits)) def runtime_objects(f, size): """{class: {ID: raw_bytes}} plus {class: [(va, ID)]} in array order.""" objs, order = {}, {} for vt, (cls, stride) in CLASSES.items(): objs[cls], order[cls] = {}, [] for off in scan_vtable(f, size, vt): f.seek(off) raw = f.read(stride) (nameptr,) = struct.unpack_from(">I", raw, 4) try: f.seek(gmem.va_to_off(nameptr + NAME_STR_OFF)) name = f.read(64).split(b"\0")[0].decode("latin-1") except (ValueError, UnicodeDecodeError): continue objs[cls][name] = raw order[cls].append((gmem.primary_va(off), name)) return objs, order # ------------------------------------------------------------------- solver ENC = { "f32": lambda raw, o, ch: struct.unpack_from(">f", raw, o)[0], "u32": lambda raw, o, ch: float(struct.unpack_from(">I", raw, o)[0]), "deg": lambda raw, o, ch: math.degrees(struct.unpack_from(">f", raw, o)[0]), # A charging weapon drains its magazine continuously, so the two counter # fields are float32 on `IsCharging = Yes` records and int32 on the rest. # Discovered from the runtime: `IsCharging` partitions the 8 float-encoded # records exactly (see docs/re/weapon-struct-runtime.md). "cnt": lambda raw, o, ch: ( struct.unpack_from(">f", raw, o)[0] if ch else float(struct.unpack_from(">I", raw, o)[0]) ), } def near(a, b): if a == b: return True return abs(a - b) <= 2e-4 * max(abs(a), abs(b), 1e-6) def solve(disc_cls, run_cls, stride, charging): """{field: (off, enc, n_agree, [mismatch...])}""" numeric = {} for rid, fields in disc_cls.items(): if rid not in run_cls: continue for k, v in fields.items(): try: numeric.setdefault(k, {})[rid] = float(v.rstrip("fF")) except ValueError: pass solved = {} for field, samples in numeric.items(): if len(samples) < 2: continue cands = [] for off in range(0, stride - 3, 4): for enc, fn in ENC.items(): agree, bad = 0, [] for rid, want in samples.items(): got = fn(run_cls[rid], off, rid in charging) if math.isfinite(got) and near(got, want): agree += 1 else: bad.append((rid, want, got)) if agree >= 2: cands.append((len(bad), -agree, off, enc, agree, bad)) if not cands: continue cands.sort() nbad, _, off, enc, agree, bad = cands[0] # A binding that contradicts a large slice of the evidence is not a # binding at all -- it is a coincidence on the agreeing subset. if len(bad) > 0.2 * len(samples): continue # Discriminating power: a field whose on-disc samples are all the same # value matches any offset holding that constant, so such a binding is # a coincidence waiting to happen. Count the distinct values that the # *agreeing* records pin down. distinct = len({round(v, 6) for rid, v in samples.items() if rid not in {b[0] for b in bad}}) solved[field] = (off, enc, agree, bad, distinct) # Two fields cannot share one byte offset. Where the solver lands two on the # same (offset, enc), keep the better-evidenced one and drop the other. best_at = {} for field, (off, enc, agree, bad, distinct) in solved.items(): key = (off, enc) score = (distinct, agree, -len(bad)) if key not in best_at or score > best_at[key][1]: best_at[key] = (field, score) winners = {f for f, _ in best_at.values()} return {f: v for f, v in solved.items() if f in winners}, numeric def report(cls, disc_cls, run_cls, stride, out, charging): solved, numeric = solve(disc_cls, run_cls, stride, charging) p = out.append p(f"\n### `{cls}` — runtime struct ({stride:#x} bytes/object)\n") p("Confidence: ✅ = ≥10 disc records agree on ≥3 distinct values, none contradict.") p("🟡 = consistent but thin evidence. ⚠️ = the runtime contradicts the disc " "(listed below the table).\n") p("| offset | enc | field | agree | distinct values | contradict | conf |") p("|--------|-----|-------|------:|----------------:|-----------:|------|") contradictions = [] for field, (off, enc, agree, bad, distinct) in sorted(solved.items(), key=lambda kv: kv[1][0]): if bad: conf = "⚠️" contradictions.append((field, off, enc, bad)) elif agree >= 10 and distinct >= 3: conf = "✅" else: conf = "🟡" p(f"| `+{off:#05x}` | {enc} | `{field}` | {agree} | {distinct} | {len(bad)} | {conf} |") unsolved = sorted(set(numeric) - set(solved)) if unsolved: p(f"\nNumeric fields with no consistent offset (unsolved): " f"{', '.join('`'+u+'`' for u in unsolved)}") for field, off, enc, bad in contradictions: p(f"\n**`{field}` (`+{off:#05x}`) — runtime disagrees with disc on " f"{len(bad)} record(s):**\n") for rid, want, got in sorted(bad)[:24]: p(f"- `{rid}`: disc `{want:g}` → runtime `{got:g}`") p(f"\n#### `{cls}` values the disc defaults, read from the live objects\n") for field, (off, enc, agree, bad, distinct) in sorted(solved.items(), key=lambda kv: kv[1][0]): if bad or agree < 10 or distinct < 3: continue # only report from ✅ bindings missing = [ (rid, ENC[enc](run_cls[rid], off, rid in charging)) for rid in sorted(disc_cls) if rid in run_cls and field not in disc_cls[rid] ] if not missing: continue vals = sorted({round(v, 6) for _, v in missing}) p(f"\n**`{field}`** (`+{off:#05x}`, {enc}) — defaulted by {len(missing)} of " f"{len(run_cls)} records") if len(vals) == 1: p(f"\n> all = **{vals[0]:g}**") else: p("") for rid, v in missing: p(f"- `{rid}` = **{v:g}**") return solved def main(): tokens = sys.argv[1] if len(sys.argv) > 1 else "/tmp/wep_tokens.txt" disc, ambiguous = read_tokens(tokens) path = gmem.mem_path() size = os.path.getsize(path) with open(path, "rb") as f: objs, order = runtime_objects(f, size) # `IsCharging = Yes` switches the two counter fields to float32; the Shell # inherits the flag from its Weapon (same ID suffix). charging = { rid for rid, f in disc.get("Weapon", {}).items() if f.get("IsCharging") == "Yes" } charging |= {"Shell" + rid[len("Weapon"):] for rid in set(charging)} out = [] for cls, stride in [(c, s) for c, s in CLASSES.values()]: d, r = disc.get(cls, {}), objs.get(cls, {}) matched = set(d) & set(r) out.append(f"\n") report(cls, d, r, stride, out, charging) if "--csv" in sys.argv: import csv w = csv.writer(sys.stdout) w.writerow(["class", "id", "field", "offset", "enc", "value", "source", "conf"]) for cls, stride in [(c, st) for c, st in CLASSES.values()]: d, r = disc.get(cls, {}), objs.get(cls, {}) solved, _ = solve(d, r, stride, charging) for field, (off, enc, agree, bad, distinct) in sorted( solved.items(), key=lambda kv: kv[1][0] ): conf = "confirmed" if (not bad and agree >= 10 and distinct >= 3) else "tentative" for rid in sorted(r): val = ENC[enc](r[rid], off, rid in charging) src = "disc" if field in d.get(rid, {}) else "defaulted-on-disc" w.writerow([cls, rid, field, f"{off:#05x}", enc, f"{val:g}", src, conf]) return if ambiguous: out.append("\n### Disc records declared twice, with conflicting values\n") out.append("These fields are ambiguous *on disc*; the runtime shows which " "declaration won. They are excluded from the scoring above.\n") for (cls, rid), fields in sorted(ambiguous.items()): out.append(f"- `{cls}` `{rid}`: {', '.join('`'+f+'`' for f in fields)}") print("\n".join(out)) if __name__ == "__main__": main()