diff --git a/docs/re/data/phase-watch-s02-full.txt b/docs/re/data/phase-watch-s02-full.txt new file mode 100644 index 00000000..4d1b13c2 --- /dev/null +++ b/docs/re/data/phase-watch-s02-full.txt @@ -0,0 +1,16 @@ +file base 0xAB840010 ScriptMission 0xBC79C960 + [ 0.7s] phase=1 finished=0 active= 24 ADN110:1 ADN111:1 ADN112:1 + [ 19.9s] phase=1 finished=0 active= 27 ADN110:1 ADN111:1 ADN112:1 + [ 54.9s] phase=1 finished=0 active= 30 ADN110:1 ADN111:1 ADN112:1 + [ 113.8s] phase=1 finished=0 active= 33 ADN110:2 ADN111:2 ADN112:2 + [ 161.0s] phase=1 finished=0 active= 35 ADN110:2 ADN111:2 ADN112:2 + [ 191.0s] phase=1 finished=0 active= 36 ADN110:2 ADN111:2 ADN112:2 + [ 220.1s] phase=1 finished=0 active= 34 ADN110:2 ADN111:2 ADN112:2 + [ 245.1s] phase=1 finished=0 active= 33 ADN110:2 ADN111:2 ADN112:2 + [ 255.2s] phase=1 finished=0 active= 32 ADN110:2 ADN111:2 ADN112:2 + [ 303.3s] phase=1 finished=0 active= 31 ADN110:2 ADN111:2 ADN112:2 + [ 433.0s] phase=1 finished=0 active= 30 ADN110:2 ADN111:4 ADN112:2 + [ 438.0s] phase=1 finished=0 active= 29 ADN110:2 ADN111:4 ADN112:2 + [ 616.7s] phase=1 finished=0 active= 28 ADN110:2 ADN111:4 ADN112:2 + [ 631.7s] phase=1 finished=0 active= 27 ADN110:2 ADN111:4 ADN112:2 + [ 694.9s] phase=1 finished=1 active= 27 ADN110:2 ADN111:4 ADN112:2 diff --git a/docs/re/script-runtime-probe.md b/docs/re/script-runtime-probe.md index fac166f2..acf74fe8 100644 --- a/docs/re/script-runtime-probe.md +++ b/docs/re/script-runtime-probe.md @@ -117,3 +117,67 @@ for them to be **destroyed**, which is when `[ScriptMission+40]` should step to `tools/re-capture/phase_watch.py` is the harness: it samples the real counter and the watched squadrons together, witnesses the freeze every 60 s, and prints only on change. + +## ✅ 2026-08-25 — the bounded scan fixes the freeze, and the state machine is confirmed live + +**The sweeps were the cost.** Bounding the pointer scan to `0xBC000000–0xBD000000` +(with a full-sweep fallback) drops `find_mission` from a full ~371 MB walk to +**0.7 s**. The run then went **694 s with the probe attached and no freeze**, +against **3 of 3 frozen inside ~4 minutes** with the unbounded version. n=1, but +it is the first probe-attached run to survive past four minutes. + +Full trace in `data/phase-watch-s02-full.txt`: + +``` +[ 0.7s] phase=1 finished=0 active=24 ADN110:1 ADN111:1 ADN112:1 +[ 113.8s] phase=1 finished=0 active=33 ADN110:2 ADN111:2 ADN112:2 <- arrive +[ 191.0s] phase=1 finished=0 active=36 +[ 433.0s] phase=1 finished=0 active=30 ADN110:2 ADN111:4 ADN112:2 <- ADN111 destroyed +[ 631.7s] phase=1 finished=0 active=27 +[ 694.9s] phase=1 finished=1 active=27 <- phase ends +``` + +### ✅ State 4 = destroyed — a squadron death caught in the act + +`ADN111` goes **2 → 4** at 433 s while the active count falls 36 → 27 over the +same window. Together with the earlier 1 → 2 arrival this pins three points of +the encoding: **1 = not yet deployed, 2 = active, 4 = destroyed**. + +### ✅ The mission-over branch, observed exactly as disassembled + +The phase ended at 694.9 s, but **the ordinal did not advance** — and the reason +is the branch [mission-phase-advance](../mission-phase-advance.md) read out of +`sub_82260710`: + +``` +if ([phase+300] == 2) post 994 ; state = 0 ; MISSION OVER +else state = 5 ; [mission+40] += 1 NEXT PHASE +``` + +Measured at the end of the run: + +| field | value | meaning | +|---|---|---| +| `[phase+300]` | **2** | last-phase flag set (built-in 39) | +| `[mission+20]` | **0** | the mission-over state | +| `[phase+196]` | **1** | phase finished | +| `[mission+40]` | **1** | ordinal unchanged — correct for this branch | + +Every field matches the disassembled branch, on the live oracle. **The static +reading of the phase state machine is confirmed** — for the mission-over half. + +### 🔴 This was a LOSS, not a phase clear + +`screen_id` shows the `GAME OVER` frame, the escort was down to **35.7 %**, and +the pilot logged `DEAD` at 676 s. So a lose path ran built-in 39 +(`MARK_LAST_PHASE`) and then `END_PHASE`, which is why the mission ended instead +of advancing. + +**Two of the three objective squadrons were still alive** (`ADN110` and `ADN112` +at state 2), so this says nothing about whether destroying all three clears +phase 1 — that prediction is **still untested**. What it does establish is that +the `else` branch is the only way to reach phase 2, and it requires +`[phase+300] != 2` at the moment the phase ends. + +**Still not observed: a phase ADVANCE.** Five attempts. The obstacle is no longer +the freeze or the instrument — it is keeping the escort alive long enough to win. diff --git a/tools/re-capture/squadron_state.py b/tools/re-capture/squadron_state.py index 4cecbfd3..2e1c5401 100644 --- a/tools/re-capture/squadron_state.py +++ b/tools/re-capture/squadron_state.py @@ -34,6 +34,11 @@ 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 @@ -42,9 +47,25 @@ def _fd_extents(f, size): return gmem.extents(f.fileno(), size) -def _find(f, size, needle): +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: @@ -84,6 +105,19 @@ def find_mission(f, size, ssb): 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