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.
184 lines
8.5 KiB
Markdown
184 lines
8.5 KiB
Markdown
# 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 `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.
|