Both candidates this file named for the missing phase-script bytecode were tested statically, from the flat-VA .pe and the extracted paks. REFUTED: the seven .embsec_ sections hold PPC CODE. Parsed from the section table (offset 592, 40 bytes apart) they total 129472 bytes, matching this file's own "~130 KB" estimate, so they are the right sections -- but six of seven begin 7d8802a6 (mflr r12) and all carry the standard prologue (stwu r1,-N(r1), std r30,-16(r1), bl). That is not bytecode for a 147-builtin VM. Also recorded: ".embsec_P" from `strings` is a false lead -- the name field is exactly ".embsec_" and the P is byte 0x50 of the following VirtualSize (0x1350 = 4944, the fifth section's size). CORRECTION: this file says grepping for MISSION_START_PRT "returns nothing". That grep was over the DISC EXTRACTION; all five MISSION_*_PRT names are present in the executable image, in an .rdata table reading "SCRIPTS" "GP_SCRIPT" "script load cancel\n" "MISSION1".."MISSION33". NARROWED: the paks are name-hash addressed, so names can be probed rather than eyeballed. 616 distinct hashes -- MISSION1..33 and the five MISSION_*_PRT under prefixes SCRIPTS\, GP_SCRIPT\, scripts\, script\, SCRIPT\ and none, with suffixes .prt/.PRT/.scr/.bin and none -- across all 35 paks: ZERO hits. Left open: the XEX's compressed/encrypted region, or a name outside those guesses. GP_SCRIPT is the strongest remaining thread -- referenced by code, absent from disc.
8.9 KiB
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. 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.
🔴 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.
❔ What is left: the payload is in the XEX's compressed/encrypted region (this
file notes the retail default.xex was never decrypted), or under a name none of
those 616 guesses covered. The GP_SCRIPT string is the strongest remaining
thread — a pak of that name is referenced by the code but absent from the disc.
✅ 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.