This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/mission-phase-advance.md
Sylpheed RE agent d1640548f9 re: the mission scripts were already found -- correct a stale 🔴 that cost three iterations
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.
2026-08-27 04:22:25 +00:00

281 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.