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.
6.5 KiB
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. 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.