Files
Sylpheed/docs/re/script-runtime-probe.md
Sylpheed RE agent 189fcede5b 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

8.5 KiB
Raw Blame History

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, 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.

PINNED: state 1 = not yet deployed, state 2 = active — and arrivals are real

Watching [ScriptMission+40] and the three objective squadrons together across a live run (data/phase-watch-s02.txt):

[   1.8s] phase=1 finished=0 active= 24  ADN110:1  ADN111:1  ADN112:1
[  58.2s] phase=1 finished=0 active= 27  ADN110:1  ADN111:1  ADN112:1
[  90.7s] phase=1 finished=0 active= 30  ADN110:1  ADN111:1  ADN112:1
[ 120.8s] phase=1 finished=0 active= 29  ADN110:1  ADN111:1  ADN112:1
[ 143.4s] phase=1 finished=0 active= 32  ADN110:2  ADN111:2  ADN112:2   <-- arrive
[ 223.3s] phase=1 finished=0 active= 35  ADN110:2  ADN111:2  ADN112:2

All three flip 1 → 2 at ~143 s, and the count of records in state 2 climbs 24 → 35 over the same window. So for these squadrons state 1 is "not yet deployed", not "gone" — the built-in table's shorthand "1/3/4 = gone/dead/invalid" is incomplete, and reading state != 2 as "destroyed" would have been wrong in exactly the way flagged last iteration. Good that it was flagged rather than assumed.

This also answers a much older question: arrivals DO happen

mission-arrival-watch and the wave work recorded "0 confirmed arrivals" after many runs, measured by watching the craft population. The script's own unit table shows arrivals plainly: eleven more records enter state 2 within four minutes, three of them the phase-1 objective squadrons at a distinct moment.

The old negative was not wrong about what it measured — it was measuring the wrong structure. Craft counts conflate deployment with attrition; the per-unit state field does not.

⚠️ Both runs of this experiment froze — at ~70 s and ~253 s — so the window above is all that was observed, and no phase advance was reached. The freeze witness caught both immediately, which is the only reason the truncation is visible rather than silently producing a flat line.

🟡 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 — and it has now partly run: the arrival of the three objective squadrons is directly observed. What is still missing is a run that survives long enough (no freeze) for them to be destroyed, which is when [ScriptMission+40] should step to 2. Two attempts froze first.

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 0xBC0000000xBD000000 (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 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.