Files
Sylpheed/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

14 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.

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, 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 — S01S16 and S18S29 — 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: MISSION1MISSION33 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 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.

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 recordsrlwinm 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.