Files
Sylpheed/docs/re/mission-phase-advance.md
Sylpheed RE agent d9032af5be re: refute the .embsec_ script candidate; narrow the pak route with 616 hash probes
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.
2026-08-27 04:01:34 +00:00

8.9 KiB
Raw Blame History

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 0x822609F80x82260A00 lwz r11,40(r30) / addi r11,r11,1 / stw r11,40(r30)

Checked rather than assumed: across the whole state machine sub_82260710 (0x822607100x82260C18) 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] = 2last 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 hashesMISSION1..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.