# 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. ## βœ… SETTLED β€” the scripts ARE on the disc: `Stage\StageNN.ssb` > ### ⚠️ 2026-08-27 β€” this section was STALE and cost three iterations > > Everything below said the bytecode had not been located. It **had**, in a > sibling file: [`structures/mission-script-ssb.md`](structures/mission-script-ssb.md), > which states plainly that this file "recorded the bytecode as *not on the disc > under any obvious name*. **It is on the disc**". > > Verified independently here before correcting: `name_hash` resolves > `Stage\script.tbl` (the manifest, 838 B compressed) and **28** `Stage\StageNN.ssb` > records β€” S01–S16 and S18–S29 β€” all in `dat/GP_MAIN_GAME_S.pak`, with > **`Stage\Stage17.ssb` absent**, matching the loader's own guard > `sub_8225EC78: if (n == 16 || n > 32) return`. > > **Why the searches below failed**, which is the reusable part: `MISSION1` … > `MISSION33` and `MISSION_*_PRT` are **manifest FIELD keys**, not record names. > The record names are `Stage\StageNN.ssb`, and the manifest maps one to the > other. Probing the record namespace with field-key names cannot hit, no matter > how many prefixes, suffixes or hash functions are tried β€” 2 148 distinct hashes > across 41 paks and 26 443 records returned zero for exactly that reason. Every > pak entry is `Z1` + zlib, so no plaintext name is greppable either. > > Two findings from that search still stand on their own: > * ❌ the seven `.embsec_` sections hold **PPC code**, not bytecode (129 472 B, > all with standard prologues) β€” so they were never the answer; > * the archive lookup keys by **`tag_hash`** (`0x00FFFFDF`, case-sensitive), not > `name_hash` β€” while the pak record index uses `name_hash`. They are not > interchangeable, and `Stage\StageNN.ssb` resolves under `name_hash`. ### (historical) 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. ### πŸ”΄ 2026-08-27 β€” the `.embsec_` candidate is REFUTED, and the pak route narrowed Both named candidates were tested statically, from the flat-VA `.pe` and the extracted paks. No emulator. **The seven `.embsec_` sections hold PPC CODE, not bytecode.** Parsed from the section table (they begin at file offset 592, forty bytes apart): | RVA | VirtSize | |---|---| | `0x84e800` | 39 340 | | `0x858200` | 42 168 | | `0x862800` | 7 436 | | `0x864600` | 23 680 | | `0x86a400` | 4 944 | | `0x86b800` | 3 272 | | `0x86c600` | 8 632 | Total **129 472 bytes** β€” matching this file's own "~130 KB" estimate, so these are the right sections. Their contents are unambiguous machine code: six of the seven begin `7d8802a6` (`mflr r12`) and every one contains the standard prologue (`9421ff??` `stwu r1,-N(r1)`, `fbc1fff0` `std r30,-16(r1)`, `4b??????` `bl`). **Script bytecode for a 147-builtin VM does not look like this.** ❌ Candidate eliminated. ⚠️ A false lead recorded so it is not chased: `strings` reports `.embsec_P`, which looks like a longer truncated name. It is not β€” the name field is exactly `.embsec_` and the `P` is byte `0x50` of the following `VirtualSize` (`0x1350` = 4 944, the fifth section's size). **The `MISSION_*_PRT` names ARE in the executable.** This file says grepping "returns nothing", but that grep was over the *disc extraction*; all five (`START`/`END`/`UPDATE`/`FAILED`/`RESTART`) are present in the image, in an `.rdata` string table that reads: ``` "SCRIPTS" "GP_SCRIPT" "script load cancel\n" "MISSION1" … "MISSION33" ``` So the loader's vocabulary is intact and includes a `GP_SCRIPT` archive name. **But the scripts are not pak records under any obvious name.** The paks are name-hash addressed (`unitgroup.name_hash`), so candidate names can be probed directly against every index rather than guessed at by eye: **616 distinct hashes** β€” `MISSION1..33` and the five `MISSION_*_PRT`, each under the prefixes `SCRIPTS\`, `GP_SCRIPT\`, `scripts\`, `script\`, `SCRIPT\` and none, with the suffixes `.prt`, `.PRT`, `.scr`, `.bin` and none β€” across **all 35 paks** in `dat/` and `hidden/`. **Zero hits.** ### The pak route is now properly closed, and the loader's own code read **All 41 paks, not 35.** The first sweep missed `hidden/resource3d/` and `dat/movie/`. Redone across every `*.pak` on the disc β€” **41 archives, 26 443 indexed records, 768 distinct name hashes** β€” still **zero hits**. The scripts are not a pak record under any of those names. **The loader compares names; it does not build a path.** The three strings live at known VAs (the `.pe` is a flat VA dump, so `VA = 0x82000000 + offset`): | string | VA | referenced from | |---|---|---| | `SCRIPTS` | `0x820a823c` | `0x8225f1b8`, `0x82262374` | | `GP_SCRIPT` | `0x820a8244` | `0x8225f168`, `0x822622bc` | | `MISSION1` | `0x820a8264` | `0x8225eed8` | All three references inside `sub_8225EE20` disassemble to the same shape: ``` 8225F160 addis r11, r0, 0x820B 8225F164 lwz r3, 80(r31) ; an object 8225F168 addi r4, r11, -32188 ; = 0x820A8244 "GP_SCRIPT" 8225F16C bl 0x82448AA0 ... 8225F1B0 addis r11, r0, 0x820B 8225F1B4 lwz r3, 0(r25) 8225F1B8 addi r4, r11, -32196 ; = 0x820A823C "SCRIPTS" 8225F1BC bl 0x82448AA0 8225F1C0 cmpi cr6, 0, r3, 0 ; result tested as a comparison ``` Both call **the same routine `0x82448AA0`** with `(object, string)` and test the result β€” so this is a *name comparison* against an already-open archive, not the construction of `dat\GP_SCRIPT.pak`. That is consistent with `GP_SCRIPT` being the name the engine expects an archive to *report*, which is why no file of that name exists to be found. ### The lookup is a HASHED directory search, and the pak route is now dead under both hashes `0x82448AA0` and `0x82448C50` are not `strcmp`. Both pass the name to **`0x82447DF0`** and use the result as a key: * `0x82448AA0` hashes, then binary-searches a table of **16-byte records** β€” `rlwinm r11,r11,4` (Γ—16) to find the end, `srawi r11,r11,4` (Γ·16) for the count, `srawi r9,r11,1` for the midpoint. * `0x82448C50` hashes, packs the result into a three-word key at `sp+80`, and calls `0x8244E338` to find it. **`0x82447DF0` is `tag_hash`** β€” decoded from the disassembly as `((Ξ£ extsb(byte)) & 0xFF) << 24 | (rolling mod 0x00FFFFDF)`, and `tools/re-capture/unitgroup.py::tag_hash` already documents itself as "a transcription of `sub_82447DF0`". So the archive keys records by the same case-sensitive `0x00FFFFDF` hash the IDXD tables use, **not** by `name_hash` (`0x00FFF9D7`, lowercased). ❌ **That was a real defect in the previous sweep** β€” it probed `name_hash` only. Re-run with **both** hashes: 1 380 candidate names β†’ **2 148 distinct hashes**, against all **41** paks and **26 443** records. **Still zero.** So the pak-record hypothesis is now refuted properly: right hash, right archive set, right record count. The scripts are not in a pak under any of `MISSION1..33` / `MISSION_*_PRT` / `SCRIPTS` / `GP_SCRIPT`, with or without the `SCRIPTS\`, `GP_SCRIPT\`, `script\`, `SCRIPT\`, `scripts\` prefixes and the `.prt/.PRT/.scr/.bin/.dat` suffixes. ❔ **What is left.** The XEX's compressed/encrypted region is now the leading candidate β€” the retail `default.xex` has never been decrypted here. The untried static thread is the *second* pair of references, `0x82262374` (SCRIPTS) and `0x822622bc` (GP_SCRIPT), which live in a different function from `sub_8225EE20`; and tracing `[r31+80]` back to whoever opened that archive would name the container directly. ## βœ… 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.