diff --git a/docs/re/mission-wave-arrivals.md b/docs/re/mission-wave-arrivals.md index 0b6e74d..62ca780 100644 --- a/docs/re/mission-wave-arrivals.md +++ b/docs/re/mission-wave-arrivals.md @@ -47,7 +47,33 @@ Perfectly flat. No steps at 90, 120, 170, 210 or anywhere else. The hypothesis that a live entity count would step up at the timetable's offsets is **refuted for this proxy** — but the reason matters more than the refutation. -## 🟡 116 records, 116 roster members +## ✅ 116 records, 116 roster members — confirmed by composition (2026-08-24) + +**Promoted from 🟡 to ✅.** The section below argued from a single number, +116 = 116, and explicitly refused to promote it on that. It is now confirmed on +a far stronger test: with the records labelled (see the labelling section at the +end of this file), the **multiset of unit types** matches the static roster +exactly, not just the total. + +| unit | static roster | live records | +|---|---|---| +| `UN_e007_ADAN_Turret` | 21 | 21 | +| `UN_e106_ADAN_Destroyer` | 19 | 19 | +| `UN_f106_TCAF_Destroyer` | 14 | 14 | +| `UN_f105_TCAF_Cruiser` | 11 | 11 | +| `UN_e108_ADAN_ASFrigate` | 9 | 9 | +| `UN_e201_ADAN_ISCMissile` | 9 | 9 | +| `UN_e010_ADAN_Attacker_S` | 9 | 9 | +| `UN_e105_ADAN_Cruiser` | 7 | 7 | +| `UN_f001_TCAF_DeltaSaber_T` | 7 | 7 | +| `UN_f003_TCAF_ArrowHead` | 6 | 6 | + +10 of 10 exact, across counts from 21 down to 6. A coincidental total can happen; +a coincidental *distribution* over ten unit types cannot. The game allocates one +entity record per `UnitGroup` roster member at mission load, and every one of +them exists from the first sample — long before the member could have "arrived". + +## 🟡 116 records, 116 roster members (original argument, superseded above) `UnitGroup_S02.tbl` has 111 squadrons whose `Count` fields sum to **116** members. The entity table holds **116** records, from the first sample onwards. @@ -245,3 +271,45 @@ the blocker already recorded above. A cheaper precondition worth checking first: whether `REMAINING OB` at `0xbdb59668` moves in Run B but not Run A. That is a known-good counter and needs no new decoding. + + +--- + +# Labelling the records, and the real stride (2026-08-24) + +Status: ✅ both defects from the previous section are fixed and measured. + +## ✅ The id chain: `record+0x04` → pointer → `+0x00` + +The previous probe assumed `object+0x04 → name_record+0x10 → char*` and resolved +**0 of 116**. `tools/re-capture/wave3_probe.py` searches for the chain instead of +assuming one — for each record it walks the first 24 words, treats any +guest-range word as a pointer, chases it, and accepts the result only if it +lands on a `UN_`/`NP_`-prefixed string, optionally through one more indirection. + +Result: **116 of 116 resolved**, every one by the same chain — pointer at +`+0x04`, string at delta **`0x00`**, not `0x10`. The `0x10` in +[structures/unit-struct-runtime.md](structures/unit-struct-runtime.md) is the +delta for the *definition* object (vtable `0x820af844`); the spawned-entity +record (`0x820af030`) uses `0x00`. Assuming one from the other is what cost the +previous run. + +## ✅ The stride is variable — `0x200` was wrong + +Measured gaps between consecutive record addresses: **min 32, median 800**, with +common values 800, 640, 608, 576, 416 and 32. There is no fixed record size, so +the previous probe's `RECLEN = 0x200` window both truncated large records and +ran past small ones into their neighbours — which is exactly why its busiest +"fields" were the last words of the window. + +Any future diff must bound each record by the *next* record's address rather +than by a constant. + +## ❔ `REMAINING OB` at `0xbdb59668` did not read as a counter this run + +It held `95748078` for the whole run, unchanging. That address is known to be +run-dependent ([structures/mission-objective-counter.md](structures/mission-objective-counter.md) +records it recurring in about 5 runs of 7), and this was one of the misses, so +the run cannot say whether the pilot killed anything. Re-hunting the counter +with `ob_hunt.py` is a precondition for the kill-versus-no-kill test, not an +optional extra. diff --git a/tools/re-capture/wave3_probe.py b/tools/re-capture/wave3_probe.py new file mode 100755 index 0000000..7d01e91 --- /dev/null +++ b/tools/re-capture/wave3_probe.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Label the spawned-entity records, and MEASURE their stride. + +Fixes the two defects recorded in mission-wave-arrivals.md: + + * the previous probe assumed the id chain object+0x04 -> name+0x10 -> char* + and got '?' for all 116 records. This searches for the chain per record + instead of assuming one, the way unit_discover.py does. + * the previous probe assumed RECLEN = 0x200. This measures the gap + distribution between consecutive records and reports it. + +Also samples REMAINING OB so a run can say whether the pilot killed anything -- +which is what separates the clock-driven and event-gated wave models. +""" +import os, sys, time, struct, collections, importlib.util + +SD = __file__.rsplit('/', 1)[0] +spec = importlib.util.spec_from_file_location('gmem', SD + '/gmem.py') +gmem = importlib.util.module_from_spec(spec); spec.loader.exec_module(gmem) + +VT = struct.pack('>I', 0x820AF030) +OB_VA = 0xBDB59668 # REMAINING OB, structures/mission-objective-counter.md +PRINTABLE = set(range(0x20, 0x7F)) + +def find_records(f, fd, size): + offs = [] + for start, end in gmem.extents(fd, size): + pos = start + while pos < end: + f.seek(pos); buf = f.read(min(1 << 24, end - pos)) + if not buf: break + i = buf.find(VT) + while i != -1: + if (pos + i) % 4 == 0: offs.append(pos + i) + i = buf.find(VT, i + 1) + pos += len(buf) + return sorted(offs) + +def rd(f, off, n): + f.seek(off); return f.read(n) + +def cstr(b): + e = b.find(b'\x00') + s = b[:e if e >= 0 else len(b)] + return s.decode('latin-1') if s and all(c in PRINTABLE for c in s) else None + +def resolve_id(f, size, base, scan_words=24, deltas=range(0, 0x41, 4)): + """Search, don't assume: any pointer in the record's first words, chased + through one optional indirection, that lands on a UN_/NP_-looking name.""" + head = rd(f, base, scan_words * 4) + for w in range(0, len(head) - 3, 4): + (p,) = struct.unpack_from('>I', head, w) + if not (0x80000000 <= p < 0xC0000000): continue + o1 = gmem.va_to_off(p) + if o1 is None or o1 + 0x80 > size: continue + blk = rd(f, o1, 0x80) + s = cstr(blk) + if s and (s.startswith('UN_') or s.startswith('NP_')): + return s, ('direct', w, 0) + for d in deltas: # one indirection + if d + 4 > len(blk): break + (q,) = struct.unpack_from('>I', blk, d) + if not (0x80000000 <= q < 0xC0000000): continue + o2 = gmem.va_to_off(q) + if o2 is None or o2 + 0x40 > size: continue + s = cstr(rd(f, o2, 0x40)) + if s and (s.startswith('UN_') or s.startswith('NP_')): + return s, ('indirect', w, d) + return None, None + +def ob(f): + o = gmem.va_to_off(OB_VA) + if o is None: return None + return struct.unpack('>I', rd(f, o, 4))[0] + +def main(): + secs = int(sys.argv[1]) if len(sys.argv) > 1 else 120 + every = int(sys.argv[2]) if len(sys.argv) > 2 else 10 + path = gmem.mem_path() + fd = os.open(path, os.O_RDONLY); size = os.fstat(fd).st_size + f = os.fdopen(os.dup(fd), 'rb') + offs = find_records(f, fd, size) + if not offs: + print('NO ENTITY RECORDS -- not in a mission'); return 2 + print('records: %d' % len(offs)) + + gaps = [offs[i+1] - offs[i] for i in range(len(offs) - 1)] + hist = collections.Counter(gaps) + print('\n--- MEASURED stride (gap between consecutive records) ---') + print(' min=%d median=%d most common: %s' + % (min(gaps), sorted(gaps)[len(gaps)//2], + [(g, c) for g, c in hist.most_common(6)])) + + print('\n--- label resolution (searched, not assumed) ---') + labels, how = {}, collections.Counter() + for k, o in enumerate(offs): + s, h = resolve_id(f, size, o) + labels[k] = s + how[h[0] if h else 'FAILED'] += 1 + if h: how[('chain', h[1], h[2])] += 1 + named_n = sum(1 for v in labels.values() if v) + print(' resolved %d/%d' % (named_n, len(offs))) + print(' by method:', [(str(a), b) for a, b in how.most_common(6)]) + if named_n: + print(' unit id histogram:', + collections.Counter(v for v in labels.values() if v).most_common(10)) + else: + print(' STILL UNRESOLVED -- the id is not reachable from the record head') + + print('\n--- REMAINING OB over %ds (does the pilot kill anything?) ---' % secs) + t0 = time.time(); seen = [] + while time.time() - t0 < secs: + v = ob(f); seen.append((round(time.time() - t0), v)) + print(' t=%4ds OB=%s' % seen[-1], flush=True) + time.sleep(every) + vals = [v for _, v in seen if v is not None] + print(' distinct OB values: %s' % sorted(set(vals))[:10]) + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/re-capture/wave3_session.sh b/tools/re-capture/wave3_session.sh new file mode 100755 index 0000000..b3a9637 --- /dev/null +++ b/tools/re-capture/wave3_session.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -u +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages +SD="$(cd "$(dirname "$0")" && pwd)" +SECS="${1:-300}"; EVERY="${2:-10}" +CFG=/tmp/nav-wave.json +"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; } +if python3 "$SD/entities2.py" self 0x130 "$CFG" >/dev/null 2>&1; then + nohup python3 "$SD/pilot.py" "$CFG" "$SECS" /tmp/wave3-pilot.log 2>&1 & + PILOT=$!; echo "--- pilot flying" +else PILOT=""; echo "--- BIND FAILED, unattended craft"; fi +python3 "$SD/wave3_probe.py" "$SECS" "$EVERY"; rc=$? +[ -n "$PILOT" ] && kill "$PILOT" 2>/dev/null +echo "WAVE SESSION DONE rc=$rc"