re: locate the live ScriptMission/ScriptPhase without a debugger
Unblocks the phase experiment, 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 keeps that table itself, indexed by the .ssb symbol-table-2 index. Route: find the .ssb header in guest memory (0xAB840010 for a Stage 02 run), code base = filebase + 0x24, scan for a word equal to it, then VALIDATE arithmetically -- [ScriptMission+44] must equal filebase + symtab1 offset + 4. Measured 0xAB874C94, predicted 0xAB874C94, exact. A second candidate that also pointed at the code base failed that check and was discarded; without it either would have looked plausible. ScriptPhase+324 -> +4 is an array of 122 per-unit records -- exactly the size of Stage 02's symbol table 2, an independent confirmation of the index space. [ScriptMission+40] reads 1 in a phase-1 mission. The mirror at [*(0x828F35F8)+236] that three earlier runs polled reads 0, because ChangePhase only posts once the ordinal exceeds 1 -- so +40 is the real counter and is reachable from /dev/shm with no debugger. Flagged rather than asserted: the three objective squadrons read state=1 with a LIVE object pointer in a mission where nothing has been shot, which does not fit the built-in table's '1 = gone'. Reading state != 2 as destroyed would be a plausible-but-wrong inference; the encoding needs pinning first. New tool tools/re-capture/squadron_state.py, verified end to end against the manual reading.
This commit is contained in:
76
docs/re/script-runtime-probe.md
Normal file
76
docs/re/script-runtime-probe.md
Normal file
@@ -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.
|
||||||
144
tools/re-capture/squadron_state.py
Normal file
144
tools/re-capture/squadron_state.py
Normal file
@@ -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 <Stage02.ssb> [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))
|
||||||
Reference in New Issue
Block a user