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

184 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## ✅ 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](mission-arrival-watch.md) 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](../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.