Files
Sylpheed/docs/re/mission-phase-advance.md
Sylpheed RE agent d93917bed4 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.
2026-08-25 11:41:11 +00:00

6.5 KiB
Raw Blame History

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 0x822609F80x82260A00 lwz r11,40(r30) / addi r11,r11,1 / stw r11,40(r30)

Checked rather than assumed: across the whole state machine sub_82260710 (0x822607100x82260C18) 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] = 2last 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.