This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/tools/re-capture/squadron_state.py
Sylpheed RE agent 47af78d668 re: bounded scan fixes the freeze; mission-over branch confirmed on the oracle
Bounding the pointer scan to 0xBC000000-0xBD000000 (with a full-sweep fallback)
drops find_mission from a ~371MB walk to 0.7s. The run then went 694s with the
probe attached and NO freeze, against 3-of-3 frozen inside ~4 minutes with the
unbounded version. n=1, but the first probe-attached run to survive.

State encoding pinned to three points: 1 = not yet deployed, 2 = active,
4 = destroyed. ADN111 caught going 2 -> 4 at 433s while the active count fell
36 -> 27.

The phase ended at 694.9s WITHOUT the ordinal advancing, and every field matches
the branch read statically from sub_82260710: [phase+300]=2 (last-phase flag),
[mission+20]=0 (mission-over state), [phase+196]=1 (finished), [mission+40]=1
(unchanged). The static state machine is confirmed on the live oracle for the
mission-over half.

But this was a LOSS, not a clear: GAME OVER on screen, escort at 35.7%, pilot
DEAD at 676s, and two of the three objective squadrons still at state 2. So the
'destroy all three clears phase 1' prediction remains untested. What is
established is that the else-branch is the only route to phase 2 and needs
[phase+300] != 2 when the phase ends.

Five attempts, still no phase advance observed -- the obstacle is now keeping the
escort alive, not the freeze or the instrument.
2026-08-25 16:07:26 +00:00

179 lines
6.9 KiB
Python

#!/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
# Bound for the pointer scan; observed ScriptMission addresses: 0xBC7A2A20 (x3),
# 0xBC79C960. Widened generously either side -- a miss falls back to the full
# sweep rather than failing.
PTR_WINDOW = (0xBC000000, 0xBD000000)
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, window=None):
"""Search allocated extents for `needle`.
`window` is an optional (lo_va, hi_va) guest-address bound. The full sweep
covers ~371 MB and is the leading suspect for the in-mission freeze
(mission-freeze-resume-spin.md); every ScriptMission observed so far has sat
in 0xBC79xxxx-0xBC7Axxxx, so bounding the POINTER scan cuts it by ~10x while
still finding the object. The header scan stays unbounded -- the .ssb has
been loaded at two different addresses across runs, so it cannot be bounded
on this evidence.
"""
out = []
for start, end in _fd_extents(f, size):
if window is not None:
vs = gmem.va_to_off(window[0]), gmem.va_to_off(window[1])
if vs[0] is not None and vs[1] is not None:
if end <= vs[0] or start >= vs[1]:
continue
start = max(start, vs[0]); end = min(end, vs[1])
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),
window=PTR_WINDOW):
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
# nothing in the window -- fall back to the full sweep rather than fail
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))