mission-phase-advance.md still carried "🔴 Not settled: where the script bytecode
lives". It was settled, in a sibling file I had never opened:
structures/mission-script-ssb.md, which says in its own opening that this file
"recorded the bytecode as not on the disc under any obvious name. It is on the
disc."
Verified independently before correcting: name_hash resolves Stage\script.tbl (the
manifest, 838 B compressed) and 28 Stage\StageNN.ssb records -- S01-S16, S18-S29 --
all in dat/GP_MAIN_GAME_S.pak, with Stage\Stage17.ssb absent, matching the loader
guard sub_8225EC78 (n == 16 || n > 32).
Recorded WHY the last three iterations' searches could not have worked, since that
is the reusable part: MISSION1..33 and MISSION_*_PRT are manifest FIELD KEYS, not
record names. The records are named Stage\StageNN.ssb and the manifest maps
between them, so probing the record namespace with field-key names cannot hit --
2148 hashes over 41 paks and 26443 records returned zero for that reason alone.
Two side findings survive: the .embsec_ sections hold PPC code, not bytecode; and
the archive lookup keys by tag_hash (0x00FFFFDF) while the pak record index uses
name_hash -- not interchangeable.
281 lines
14 KiB
Markdown
281 lines
14 KiB
Markdown
# 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<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.
|
||
|
||
## ✅ 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.
|