re: phases are advanced by a compiled script VM, not by data

Closes the open question from mission-phase-membership.md. The static sweep
found no trigger because there is none in the data: each phase ends when its own
per-mission script says so.

[ScriptMission+40] is the 1/2/3 phase ordinal -- initialised at 0x822606B0 and
incremented at exactly one site, 0x822609F8-0x82260A00 (checked: only one stw to
40(rN) in the whole state machine). Its guard is [ScriptPhase+196] != 0, and
that flag has only two writers, vtable slots 0 and 1, reached only from built-ins
6 and 62 of the phase-script VM's 147-entry command table. Built-in 39 sets
[phase+300]=2, which ends the mission instead of advancing.

Refutes all four candidate triggers: no kill counter, timer, trigger volume or
message event appears on the path. Any such condition lives inside the script,
which is why three phases of one stage can have three different clear conditions.

CScriptInterpreter::ChangePhase (sub_822FF330, opcode 995) is the consumer, not
the cause; it writes a second phase mirror at [*(0x828F35F8)+236] -- a probe
target.

Not settled: the script bytecode is not on the disc under any obvious name.

Tooling correction: the .pe is not stale, it is a flat VA image (offset = VA -
0x82000000), verified 7/7 against the DB. Also: instructions.raw is an INTEGER,
and decoding it as a hex string silently compares nothing -- that nearly got
this correction recorded backwards.
This commit is contained in:
Sylpheed RE agent
2026-08-25 11:41:11 +00:00
parent 72a80d0d2f
commit d93917bed4

View File

@@ -0,0 +1,129 @@
# What advances a mission phase: a compiled script VM, not data
Status: ✅ the mechanism and the phase counter, by direct disassembly;
🟡 class names inferred from destructor traces; 🔴 the script bytecode itself
has not been located on disc.
This closes the ❔ left by [mission-phase-membership](mission-phase-membership.md).
The exhaustive table sweep there found no trigger because **there is none in the
data**: each phase ends when its own per-mission **script** says so.
Everything below is from `/work/xenia-rs/sylpheed.db` (1,865,751 decoded PPC
instructions). The retail `default.xex` is compressed/encrypted and was **not**
disassembled directly and **not** decrypted.
## ✅ The phase counter and its single writer
`ScriptMission` is the mission-level bytecode object. Layout: `+4` ScriptPhase,
`+8` ScriptDemo, `+20` state, `+24/+28` code base / pc, **`+40` the phase
ordinal**.
| what | address | instruction |
|---|---|---|
| init to 1 | `0x822606B0` | `stw r11, 40(r30)` (after `li r11,1`) |
| **the only increment** | `0x822609F8``0x82260A00` | `lwz r11,40(r30)` / `addi r11,r11,1` / `stw r11,40(r30)` |
Checked rather than assumed: across the whole state machine `sub_82260710`
(`0x82260710``0x82260C18`) there is **exactly one** `stw` to `40(rN)`. So
`[ScriptMission+40]` is the 1/2/3 field, and it has a single writer.
The guard on that increment, in state 1 ("phase running"):
```
r3 = [mission+4] ; the ScriptPhase
if ([phase+196] == 0) { phase->vtbl[111]() ; return } ; not finished -> keep running
if ([mission+52] != 0) return
if ([phase+300] == 2) post 994 ; state = 0 ; [phase+260] = 1 ; post 1014(0) ; MISSION OVER
else post 1014(1) ; state = 5 ; [mission+40] += 1 ; NEXT PHASE
```
So the immediate cause of a phase advance is **`[ScriptPhase+196] != 0`**.
## ✅ Who sets the "phase finished" flag — only the script can
`[phase+196]` has just two writers besides its initialisers: `ScriptPhase`
vtable slot 0 (`0x82264058`, "finish", fires the phase's end-event first if
`[phase+236] != -1`) and slot 1 (`0x822640F8`, immediate finish). The
`ScriptPhase` vtable is at `0x820A84BC`, 113 slots.
Both are reached from exactly one place: the **built-in command table** of the
phase-script VM, `sub_82272220`, jump table at `0x8227226C`, 147 entries.
Verified at the call sites:
| built-in | site | dispatch | effect |
|---|---|---|---|
| **6** | `0x82272540` | `lwz r11,0(r11)` → vtbl slot 0 | **end this phase**`+196 = 1` |
| **62** | `0x82272558` | `lwz r11,4(r11)` → vtbl slot 1 | force-end, skipping the end event |
| **39** | `0x82272ABC` | `lwz r11,180(r11)` → slot 45 | `[phase+300] = 2`*last* phase, so the mission ends instead of advancing |
| 40 | `0x82272AD4` | slot 44 | `[phase+300] = 1` (semantics 🟡 unpinned) |
`ScriptPhase::Update` (`sub_82263408`, vtable slot 111) is a coroutine
scheduler: frame-wait counter at `+160`, thread list at `+220`, per-thread
`double` countdown and instruction pointer, 25 opcodes (jump table
`0x822635FC`). The engine→script edge is `sub_8226D740`, called every frame,
which pops trigger records from the queue at `+272` and starts a script
coroutine at the code offset the trigger carries.
## 🔴 Refuted: all four candidate triggers
There is **no kill counter, no timer, no trigger volume and no message event**
compared against anything on the path to `[mission+40]++`. The single immediate
cause is `[phase+196]`, which only built-ins 6/62 set, which only the phase's own
script invokes. Any kill-count or positional or timed condition is expressed
*inside the script*, via the trigger queue and the 32-entry float/flag register
files at `[phase+88]` and `[phase+120]`.
**That is why the static sweep found nothing — and why the three Stage 02 phases
can each have a completely different clear condition.**
## ✅ `CScriptInterpreter::ChangePhase` is the consumer, not the cause
`sub_822FF330`, the sole reference to `' CScriptInterpreter::ChangePhase( %d )'`
(`0x820AE623`). It is opcode **995** in a 1024-slot command table built by
`sub_822FE040`; the dispatcher `sub_822FE660` takes the opcode from bits 8..17
of `[cmd+4]` — which is why the posted word is `0xAB03E3BA` (`0x3E3` = 995).
```
r29 = *(0x828F35F8) ; mission-manager singleton
r31 = [cmd+16] ; the new phase number
if ([r29+236] != r31) { ... "--- CHANGE ASTEROIDS %d ---" ; "Clear all sounds" }
[r29+236] = r31 ; the runtime current-phase mirror
```
So `[*(0x828F35F8) + 236]` is a **second** 1/2/3 field — a mirror for the
renderer/audio, written after the fact. Useful as a probe target.
## ✅ Confirms the existing `Phase_N` finding
`sub_8230D1F8` is the stage-config parser; its loop at `0x8230D4B8` sprintf's
`"Phase_%1d"` (`0x8209F0F0`) and looks the sub-record up — reading only the
map/background fields. **The executable never consults `Phase_N` for a trigger.**
Nothing anywhere parses `Route_*_p<N>*` names either; the only three such
literals in the image (`0x820AEA38`) are debug defaults, not a parser. So the
route-name phase map is a *convention of the data*, read by us, not by the game.
## 🔴 Not settled: where the script bytecode lives
The loader `sub_8225EE20` matches section names `MISSION1`..`MISSION33` and the
five `MISSION_{START,END,UPDATE,FAILED,RESTART}_PRT`, and `sub_8225EC78` gates
on `if (n == 16 || n > 32) return`. But **there is no `GP_SCRIPT.pak` on the
disc** and grepping the whole extraction for `MISSION_START_PRT` returns
nothing — so the payload is hashed or compressed. Candidate homes, unchecked:
the seven executable `.embsec_` sections in the XEX (~130 KB), or a hashed record
in `hidden/MiscBin.pak`.
**Finding it would give the actual per-phase clear condition for every stage**
which is the thing the port needs.
## ✅ Tooling correction: the `.pe` is NOT stale
A standing note said "`.pe` STALE vs `sylpheed.db` — DB only". Wrong, and the
reason is mundane: `/work/Project Sylpheed … .pe` is a **flat VA image dump**, so
**file offset = VA 0x82000000**, *not* the PE section `PointerToRawData`.
Reading it through the section headers gives garbage for `.text`, which is what
"stale" was. Verified 7 functions byte-for-byte against the DB under the flat
rule: **7/7**. It is usable for `.rdata` tables the DB does not decode.
⚠️ In `sylpheed.db`, `instructions.raw` is an **integer**, not a hex string —
decoding it as hex silently compares nothing and makes every check "fail". That
is how this correction nearly got recorded backwards.