From e8fb9a41a6315ba1062913ac853ceb2512594500 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 25 Aug 2026 11:41:11 +0000 Subject: [PATCH] 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. --- docs/re/mission-phase-advance.md | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/re/mission-phase-advance.md diff --git a/docs/re/mission-phase-advance.md b/docs/re/mission-phase-advance.md new file mode 100644 index 0000000..8f66c72 --- /dev/null +++ b/docs/re/mission-phase-advance.md @@ -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*` 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.