diff --git a/docs/re/script-runtime-probe.md b/docs/re/script-runtime-probe.md new file mode 100644 index 00000000..ba5d99bb --- /dev/null +++ b/docs/re/script-runtime-probe.md @@ -0,0 +1,76 @@ +# Reading the live script state β€” the real phase counter, and per-squadron liveness + +Status: βœ… `ScriptMission` and `ScriptPhase` located in a running mission with no +debugger, validated arithmetically; βœ… the true phase ordinal read live; +🟑 the per-unit `state` encoding needs care. + +This unblocks [mission-phase-membership](mission-phase-membership.md), which was +stuck because "38 enemies died" could not say whether the *right* ones did. +Chasing craftβ†’squadron was the wrong angle: **the script VM already keeps that +table**, indexed by the `.ssb` symbol-table-2 index. + +Tool: `tools/re-capture/squadron_state.py`. + +## βœ… Locating the objects, without gdb + +1. Find the **`.ssb` header** in guest memory β€” 20 bytes of version + code offset + + the two symbol-table offsets, distinctive enough to hit once. For a Stage 02 + run it sat at **`0xAB840010`**. +2. `code base = file base + header code offset` (`0x24`) β†’ `0xAB840034`. +3. `[ScriptMission+24]` **is** that code base, so scan for a word equal to it. +4. **Validate arithmetically, not by eye:** `[ScriptMission+44]` must equal + `file base + symtab1 offset + 4`. Measured `0xAB874C94`; predicted + `0xAB840010 + 0x34C80 + 4 = 0xAB874C94`. Exact. + +That check is what makes this trustworthy β€” the candidate is confirmed against a +number taken from the file on disc, not against "it looks like a pointer". A +second candidate that also pointed at the code base failed it and was discarded. + +``` +ScriptMission 0xBC7A2A20 + +4 ScriptPhase* = 0xBE14DD80 +20 state = 1 ("phase running") + +24 code base = 0xAB840034 +28 pc = 0xAB84007C + +40 PHASE ORDINAL = 1 +44 symtab1 = 0xAB874C94 βœ“ +ScriptPhase 0xBE14DD80 + +196 finished = 0 +244 symtab1 = 0xAB874C94 +324 unit array = 0xBC43B560 +``` + +`ScriptPhase+324` β†’ `+4` β†’ an array of per-unit records. It holds **122 +records β€” exactly the size of Stage 02's symbol table 2**, which is an +independent confirmation that the index space is the one the bytecode uses. + +## βœ… The real phase counter reads 1 β€” the mirror was the wrong field + +`[ScriptMission+40]` reads **1** in a phase-1 mission. The runtime mirror at +`[*(0x828F35F8)+236]`, which three earlier runs polled, reads **0** β€” because +`ChangePhase` is only posted once the ordinal exceeds 1. + +So the mirror is not a phase readout at all in phase 1, and **`+40` is**. It is +reachable from `/dev/shm` with no debugger, which is what made three runs of +polling the wrong address avoidable in hindsight. + +## 🟑 The per-unit `state` encoding is not what the summary implies + +For the three phase-1 objective squadrons, early in a fresh mission: + +``` +ADN110 idx=1 obj=True state=1 +ADN111 idx=2 obj=True state=1 +ADN112 idx=5 obj=True state=1 +records with state==2 (active): 27-29 of 122 +``` + +The built-in table describes `+16` as *"2 = active; 1/3/4 = gone/dead/invalid"*. +But these three have a **live object pointer and state 1**, in a mission that has +barely started and where nothing has been shot. So either state 1 does not mean +"gone", or it means "not yet deployed" β€” **not settled**, and worth pinning +before any conclusion is drawn from it. Reading `state != 2` as "destroyed" +would be exactly the kind of plausible-but-wrong inference this corpus keeps +catching. + +## What this makes possible + +The decisive phase experiment is no longer blocked on attribution: sample +`[ScriptMission+40]` and the three squadrons' records together over a run, and a +phase advance becomes directly observable along with the state change that caused +it. That run has **not** been done yet. diff --git a/tools/re-capture/squadron_state.py b/tools/re-capture/squadron_state.py new file mode 100644 index 00000000..4cecbfd3 --- /dev/null +++ b/tools/re-capture/squadron_state.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Read per-SQUADRON liveness straight out of the running ScriptPhase. + +Why this exists: the phase-1 clear condition polls three named squadrons +(ADN110/111/112), but nothing mapped a live craft back to its roster squadron, +so "38 enemies died" could not say whether the *right* ones died +(mission-phase-membership.md). Chasing craft->squadron was the wrong angle -- +the script VM already keeps exactly that table. + +From the built-in disassembly (isl-builtins.md): + + [phase+324] runtime unit array + [[phase+324]+4] -> array of per-unit record pointers + record[idx*4] indexed by the .ssb SYMBOL TABLE 2 index + record +4 live object (NULL = absent) + record +16 state (2 = active; 1/3/4 = gone/dead/invalid) + record +128 / +132 HP / max HP + +So a squadron's state is one indexed read, no attribution needed. + +Finding the ScriptPhase without a debugger: + 1. the loaded .ssb code is in guest memory -- search for a distinctive run of + its bytes; + 2. `[phase+232]` is the code base, so scan for a word equal to that address; + 3. the candidate phase is that word's address - 232; + 4. validate: `[cand+244]` and `[cand+324]` must both be plausible pointers. + +Usage: squadron_state.py [names...] +""" +import struct +import sys + +sys.path.insert(0, __file__.rsplit('/', 1)[0]) +import gmem +import isl + +HDR_LEN = 20 # version, +4, code offset, symtab1, symtab2 -- distinctive + + +def _fd_extents(f, size): + # gmem.extents takes a file DESCRIPTOR, not a file object. + return gmem.extents(f.fileno(), size) + + +def _find(f, size, needle): + out = [] + for start, end in _fd_extents(f, size): + f.seek(start) + remaining, base, prev = end - start, start, b'' + while remaining > 0: + chunk = f.read(min(1 << 22, remaining)) + if not chunk: + break + buf = prev + chunk + i = buf.find(needle) + while i >= 0: + out.append(base - len(prev) + i) + i = buf.find(needle, i + 1) + prev = chunk[-len(needle):] + base += len(chunk) + remaining -= len(chunk) + return out + + +def u32(f, va): + off = gmem.va_to_off(va) + if off is None: + return None + f.seek(off) + b = f.read(4) + return int.from_bytes(b, 'big') if len(b) == 4 else None + + +def find_mission(f, size, ssb): + """Locate the live ScriptMission for this .ssb. + + 1. find the .ssb HEADER in guest memory -- 20 bytes of version + offsets, + distinctive enough that it hits once; + 2. code base = file base + the header's code offset; + 3. `[ScriptMission+24]` is that code base, so scan for a word equal to it; + 4. validate with `[+44]`, which must equal file base + symtab1 offset + 4 -- + an exact arithmetic check, not a heuristic. + """ + hdr = ssb[:HDR_LEN] + code_off = struct.unpack_from('>I', ssb, 0x08)[0] + sym1_off = struct.unpack_from('>I', ssb, 0x0C)[0] + for off in _find(f, size, hdr): + for filebase in gmem.off_to_vas(off): + code_base = filebase + code_off + want_44 = filebase + sym1_off + 4 + for poff in _find(f, size, struct.pack('>I', code_base & 0xFFFFFFFF)): + if poff % 4: + continue + for pva in gmem.off_to_vas(poff): + m = pva - 24 + if u32(f, m + 44) == want_44: + return m, filebase + return None, None + + +def read_states(f, mission, sym2, names): + phase = u32(f, mission + 4) + arr = u32(f, phase + 324) if phase else None + base = u32(f, arr + 4) if arr else None + idx = {n: i for i, (_t, n) in sym2.items()} + out = {'phase_ordinal': u32(f, mission + 40), + 'phase_obj': phase, 'finished': u32(f, phase + 196) if phase else None, + 'units': {}} + active = 0 + if base: + for i in sorted(sym2): + rec = u32(f, base + i * 4) + if not rec: + continue + if u32(f, rec + 16) == 2: + active += 1 + for n in names: + i = idx.get(n) + rec = u32(f, base + i * 4) if i is not None else None + out['units'][n] = None if not rec else { + 'idx': i, 'obj': bool(u32(f, rec + 4)), 'state': u32(f, rec + 16)} + out['active_records'] = active + return out + + +if __name__ == '__main__': + ssb = isl.load(sys.argv[1]) + sym2 = isl.symbols(ssb, 2) + names = sys.argv[2:] or ['ADN110', 'ADN111', 'ADN112'] + import os + path = gmem.mem_path() + with open(path, 'rb', buffering=0) as f: + size = os.path.getsize(path) + m, fb = find_mission(f, size, ssb) + if m is None: + print('ScriptMission not located'); sys.exit(1) + print('file base 0x%08X, ScriptMission 0x%08X' % (fb, m)) + r = read_states(f, m, sym2, names) + print(' phase ordinal = %s (the REAL counter; the +236 mirror reads 0 in phase 1)' + % r['phase_ordinal']) + print(' ScriptPhase = 0x%08X finished=%s' % (r['phase_obj'] or 0, r['finished'])) + print(' records active = %d of %d' % (r['active_records'], len(sym2))) + for n, v in r['units'].items(): + print(' %-10s %s' % (n, v))