diff --git a/docs/re/mission-phase-advance.md b/docs/re/mission-phase-advance.md new file mode 100644 index 00000000..8f66c727 --- /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.