Two more attempts. Recording three things rather than another flat negative. The pilot's gun-fire rate is 1.6% (81 of 4986 frames), but that is not the blocker: the nose gun is Power 15 unguided while the main mount is Power 200 guided, and ~70 missiles went out in ~500s. The log's fire= field tracks only the gun and invites the wrong conclusion. SYLPH_KILL_TURRETS=1 was tried to align DEFEND with the objective and is REFUTED as an improvement: 3387 of 11112 samples (30%) chased targets over 20,000 units away, because turrets are static and spread out, so the pilot commits to distant ones and defends nothing. Escort still fell to 48.5% and no additional objective squadron died. The bounded scan delays freezes but does not remove them -- one run clean to 694s, one frozen at ~682s, against 3-of-3 inside 4 minutes unbounded. So 'the sweeps were the cause' is too strong; they were a cost. Six attempts, no phase advance. Names the cheaper experiment: guest memory is writable, so set the two surviving squadrons' +16 to 4 with gpoke and watch whether finished goes to 1 and the ordinal steps to 2. That tests the condition directly, and a null result is equally informative.
228 lines
11 KiB
Markdown
228 lines
11 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.
|
||
|
||
## 🔴 2026-08-25 — the "win the mission" route is not converging
|
||
|
||
Two more attempts, and the honest summary is that flying to a phase *clear* is
|
||
the expensive way to test the prediction.
|
||
|
||
**The pilot's gun-fire rate is 1.6 %** — 81 fire frames in 4986 samples — but
|
||
that is *not* the blocker it looks like. The nose gun is Power 15 unguided; the
|
||
main mount is Power 200 **guided**, and the pilot fired ~70 missiles in ~500 s.
|
||
The damage is coming from missiles, and `fire=` in the log only tracks the gun.
|
||
Worth writing down because the log invites the wrong conclusion.
|
||
|
||
**`SYLPH_KILL_TURRETS=1` made things worse, not better.** The idea was to align
|
||
DEFEND with the objective by letting it kill turrets attacking the escort.
|
||
Measured: **3387 of 11112 samples (30 %) chased a target more than 20 000 units
|
||
away** — turrets are static and spread across the map, so the pilot commits to
|
||
distant ones and stops defending anything. The escort still fell to 48.5 %, and
|
||
**no additional objective squadron died**. Refuted as an improvement.
|
||
|
||
**Both runs ended the same way as before:** `ADN111` destroyed (again — it is
|
||
evidently the one closest to the action), `ADN110` and `ADN112` untouched at
|
||
state 2, no phase advance. **Six attempts now.**
|
||
|
||
### 🟡 The bounded scan delays freezes but does not remove them
|
||
|
||
| bounded-scan run | outcome |
|
||
|---|---|
|
||
| 1 | clean to 694 s, ended by the mission's own lose branch |
|
||
| 2 | **froze at ~682 s** |
|
||
|
||
Against 3-of-3 frozen inside ~4 minutes with the unbounded sweep, that is still a
|
||
large improvement — but "the sweeps were the cause" is too strong. They were *a*
|
||
cost; something else also freezes runs at ~11 minutes.
|
||
|
||
### The cheaper experiment to run instead
|
||
|
||
Stop trying to win. The prediction is that `finished` goes to 1 with
|
||
`[phase+300] != 2` when ADN110/111/112 all reach state 4. Guest memory is
|
||
writable (`tools/re-capture/gpoke.py`), so **set the two surviving squadrons'
|
||
`+16` to 4 directly and watch whether the phase ends and the ordinal steps to
|
||
2.** That tests the condition in seconds rather than fighting a mission the
|
||
autopilot is not good enough to win, and a wrong answer is as informative as a
|
||
right one — if nothing happens, the condition is not what the bytecode reading
|
||
says.
|