# 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*` 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.