Files
Sylpheed/docs/re/challenge-mission-gate.md
Sylpheed RE agent 2a731aa8b7 re: the Time/Points record is not on the debriefing path - and a ring drainer matches the message bus
Read 0x821A0AD0, the debriefing, end to end, resolving every call and string in
code order. The word-B bit-setter sub_8219F2E8 is its FIRST call, before anything
else; the function makes 44 calls and references exactly two strings, DEBRIEFING
and BASE_INFO.

No Time and no Points anywhere on this path - not in the debriefing, not in the
bit-setter. Section 5.3's expectation that clearing a challenge stage records its
metric alongside the bit is not supported by the code that clears it. The reach of
that negative: the debriefing function and its directly-called bit-setter, both
read whole; it does not rule out a store deeper inside one of the other 43
callees.

0x8219F670 turns out to be an online-state predicate: 88 bytes, no strings, reads
the singleton, tests bit 1 of [0]+60, and if set passes [0]+64 to 0x824A9C90 and
returns whether the result is 2. A connection or sign-in check, not a record
store.

The useful find is 0x8219F460, 368 bytes and self-recursive, which drains a ring
based at r29+320 with element base +4, capacity +8, head +12 and count +16. Those
are the corpus's documented message-bus offsets exactly - BACKLOG records
sub_82175C20(bus+4, &msg) as a ring-buffer append with capacity +8, head +12,
count +16 - and sub_82175C20 is the very notifier section 5.6 found the word-B
writer calling with bit + 64. So the open question "ring-buffer PUSH, no handler"
now has a candidate drainer with a byte-for-byte matching field layout, reached
from the debriefing.

What is NOT shown is that these are the same ring instance. The push targets
bus+4, this drain targets r29+320. Matching offsets prove a shared container
shape, not a shared object, and the identity needs the two base pointers tied
together.

All seventeen artefacts byte-identical.
2026-08-28 13:07:02 +00:00

35 KiB
Raw Permalink Blame History

Challenge / EX missions — the stage set, the GamePart graph, and the kind field

Status: for the static structure (stage set, GamePart ids, the config-section switch) and for the unlock mechanism — a bit test against a cleared-stage mask, now read live off the running game and matching the profile's progress exactly (§5.6). 🟡 for the stage-field crash mechanism, for word B's writer, and for which mission consumes which bit; how the challenge menu is actually entered. Method: static analysis (.pe strings/pointers + DuckDB disassembly + disc records), then confirmed on the running title via the container-safe --hid=file pad and a live guest-memory read. Evidence: captures/challenge-map.txt, captures/challenge-screen-config.txt, structures/achievements.md; examples/challenge_map.rs, examples/challenge_screen.rs.

Why this was worth doing

The Route-B unit harvest is complete for the story campaign (68 of 110 units, 7 115 defaulted-on-disc values) and stalled there: the remaining units are *_EX4 / *_EX5 / *EX variants that only the challenge missions field. The save's stage field addresses those stage records — but setting it to 27 boots to the title and then the emulator exits during the load, while 116 all load normally. This note answers what the challenge missions are on disc and why the stage field alone is not enough, so the next runtime attempt is targeted rather than another probe sweep.

1. The disc has exactly 29 stage records, in three families

StageResource (schema 0x3c9ae32e) in dat/GP_MAIN_GAME_E.pak:

ids count BackGroundID reading
S01S16 16 Lebendorf … PD (real locations) the story campaign
S18S23 6 Original (all six) the tutorial missions
S24S29 6 Anastasis, Hargenteen ×2, Planet_Lebendorf, Lebendorf, Earth the challenge missions
Test 1 Earth developer stage

S17 does not exist. This matches weapon.tbl's sprite-key set exactlystage01…16 + tutorial01…06 + challenge01…06 (recorded in static screen config) — which is an independent confirmation of the three-way split: 16 + 6 + 6 + Test = 29.

The Original six carry ~43 tokens each while the story and challenge records carry 5159, consistent with tutorials having no squadron/route/formation tables.

2. GP_CHALLENGE.pak is a screen, not mission data

151 entries, 0 IDXD objects — sprites and layout only. So challenge missions are not a separate data set: they are ordinary StageResource records in GP_MAIN_GAME_E.pak, reached through a different menu. That is why the EX unit rosters show up in the same EnumUnit_S<NN> tables the story stages use.

3. The GamePart id table — the game's whole screen graph

.rdata pointer array at 0x820A1630, 29 entries, index = GamePart id:

 0 GP_TITLE            10 GP_BUNK             20 GP_STAGE_CLEAR
 1 GP_ADVERTISE_DEMO   11 GP_READY_ROOM       21 GP_MISSION_LOG
 2 GP_SELECT_STORAGE   12 GP_HANGAR           22 GP_GAMEOVER
 3 GP_LOAD             13 GP_ARSENAL          23 GP_DEBRIEFING
 4 GP_SAVE             14 GP_PILOT_LOG        24 GP_DIALOG
 5 GP_EXTRAS           15 GP_SYSTEM           25 GP_TUTORIAL
 6 GP_MOVIE_THEATER    16 GP_DEMO            *26 GP_CHALLENGE
 7 GP_MISSION_SELECT   17 GP_MAIN_GAME        27 GP_LEADERBOARD
 8 GP_OPTIONS          18 GP_SELECTOR         28 GP_TEST
 9 GP_MOVIE            19 GP_PAUSE_MENU

The indices are not inferred from position — the image carries the factory registration text, e.g. silph::GamePartTask::RegisterToFactory<26, class silph::GamePart_ChallengeMission>::RegisterToFactory is failed! and <10, … GamePart_Bunk>, both of which agree with this table. So GP_CHALLENGE is GamePart 26 and GP_TUTORIAL is 25.

4. A mission-kind field selects the stage config section

Immediately before the array above, 0x820A16000x820A162C holds the stage-config key list: BASE_INFO, StageResource, Background, PlayerUnit, EQUIIP_LIMITATION (sic), MODEL_PATH, NEW_ITEM, LOADING, CHALLENGE, EXTRA, FILE.

The stage loader picks one of the last three by a field at object + 144, and the same three-way switch appears at two independent sites (0x82184df0 inside the all-keys config reader, and 0x82185ed0 in sub_82185E80):

lwz  r11, 144(r30)
cmpi r11, 3          ; == 3          -> "EXTRA"     (0x820A2160)
beq  extra
addi r11, r11, -5
cmpli r11, 1         ; == 5 or 6     -> "CHALLENGE" (0x820A2154)
bgt  file            ; otherwise     -> "FILE"      (0x820A2168)

Two further sites (0x82186a60, 0x82186cb8) classify the same field as {3, 5, 6} versus everything else — i.e. EXTRA and CHALLENGE together are "not an ordinary story load". Every write to +144 inside this class (0x82184b10, 0x82185d48, 0x82186ab4) only clears it, and nothing in the image stores an immediate 3/5/6 into it, so the kind is supplied from outside the class — by whichever GamePart launches the mission — not derived from the stage number.

⚠️ Withdrawn: this section used to add "the field is initialised to 0 in the constructor sub_821783D8, alongside +148 = 0, +132 = 2, +136/+140 = -1". That linked two code regions on nothing more than both touching +144/+148, and a snapshot refutes it: sub_821783D8 initialises the static at 0x828F3EC0 (its first act is InitializeCriticalSection(obj, 256)), and in an in-flight snapshot that object's +144 holds 0x000B1C8B and +592 a float — not a kind and not flags. So the object owning the kind field is unidentified. Scanning the snapshot for it (kind ∈ {0,3,5,6} at +144, stage at +148, pointers at +0/+52/+604) returns only matches inside the executable's own static data — 10 at +148 is far too common to discriminate. The switch itself stands: it is direct disassembly at two sites. Only the constructor attribution was wrong.

Confirmed on screen instead: launching a story stage from MISSION SELECT reaches a READY ROOM carrying an "EXTRA" watermark (capture) — the EXTRA config section, i.e. kind 3, visible in the UI. That is independent evidence the kind field means what §4 says, even though its owning object is not pinned.

4.1 Why the stage-field probe crashes 🟡

That gives a mechanism for the observed failure: patching the save's stage field to 27 selects the S27 record while the kind stays 0, so the loader reads the FILE section for a stage whose config lives under CHALLENGE. A missing section then propagates into the load, and the title exits. It fits the evidence (116 fine, every 2429 the same failure, /dev/shm empty and disk free, so not the resource trap) but it is not proven — proving it needs a run.

Cheap untried discriminator, no new tooling: patch the stage field to 1823 (the tutorials). If those also die, the failure tracks "record outside the story range", and the kind field is the likely gate. It has never been tested — only 116 and 2429 were.

5. The challenge screen, its six missions, and the unlock gate

The factory registration sites (0x8280C0000x8280F800) give id → creator for 22 of the 24 registered parts, and the creators bound each class's code block. So GamePart_ChallengeMission's methods are 0x82187E600x8218CF10 (between GP_BUNK's creator 0x82187E38 and its own 0x8218CF10). Resolving every string that range references gives the screen's whole config schema:

MISSIONS · MISSION_ID · RECORD_TYPE · REQUIREMENT · REQUIREMENT_DESC
NORMAL_BUTTON · GRAY_BUTTON · THUMBNAIL · STAGE_DESC · TEXT_STAGE
TEXT_RECORD · NEW_STAGE · "Always" · "Time" · BASE_INFO

The record itself is on disc, in tables.pak (schema 54a10697, one copy per language — English is entry #64), not in GP_CHALLENGE.pak. It names six missions:

slot MISSION_ID buttons / thumbnail record
1 TimeAttack Button_TimeAttack{,_Gray}, Thumbnail_TimeAttack Time
2 ScoreAttack Button_ScoreAttack{,_Gray} Points
36 Extra01Extra04 Button_Extra0N{,_Gray}

GRAY_BUTTON is the locked art, NORMAL_BUTTON the unlocked art — so the screen always shows all six and greys out what you have not earned. Full dump: captures/challenge-screen-config.txt.

5.1 The gate is a bit test against a progress bitfield

0x82189870 builds the list, and 0x821899700x821899D8 is the availability decision for each mission:

lookup REQUIREMENT in the mission record        ; bl 0x82448C50
  absent            -> AVAILABLE
  == "Always"       -> AVAILABLE                ; strcmp, bl 0x825EDD20
  else n = atoi(v)                              ; bl 0x825EDCD0
     n == 0         -> LOCKED
     n <  24        -> bit n      of word A
     n >= 24        -> bit (n-24) of word B
     bit set ? AVAILABLE : LOCKED

Word A and word B are read from a singleton (0x821707C0; the object pointer lives at the global 0x828F48B0, with a construct-once flag at 0x828F48BC) at offsets +80 and +1956. So challenge availability is one bit in a progress bitfield, and REQUIREMENT is that bit's index — not a stage number, a score, or a difficulty.

5.2 The bit space is CLEARED STAGES

Word A has exactly one writer in the image, and finding it settles the question. Scanning all 22 callers of the +80 struct copier (0x82175110) for one that stores to the copy's word 0 gives a single hit, 0x821C1820, inside GamePart_StageClear (0x821C09D80x821C29F0):

if (this+1004 & 0x20000) skip            ; already recorded
this+80 = 1
copy   local = singleton->progress        ; bl 0x82175110, src = obj+80
x      = this+84
local.word0 |= 1 << x                     ; slw r10, r26, r10
if changed: singleton->set(local)         ; bl 0x8216FF70 (assign + async persist)

this+84 is the stage number, and two independent uses prove it:

  • 0x821C1760 indexes a 20-byte record array with it — this + (x+7)*20, whose records are written as three words plus an 8-byte timestamp, i.e. the shape of the savegame's 16×20-byte SHAB table;
  • 0x821C1EEC and 0x821C1924 pass it as the index into the config key STAGE (0x820A2540) — the debriefing config's px_deb_stage01…16 sprite list.

So word A is a "stage cleared" bitmask, and a challenge mission's REQUIREMENT n means "stage n has been cleared". The < 24 / >= 24 split then lines up with the disc's own stage numbering from §1: story 116 and tutorial 1823 sit in word A, and the challenge stages 2429 are exactly word B's bits 05.

⚠️ Third revision of this claim — the first two were wrong, and how they went wrong is worth keeping. An earlier pass read the split at 24 as "the game's 24 achievements" because ACHIEVEMENTS_REQUIREMENTS has exactly 24 entries. That is a coincidence: the achievement count and the first challenge stage id are both 24 for unrelated reasons. The achievement work itself stands — the XACH table, the 1000G self-check, and the XACHIEVEMENT_DETAILS/XAM enumeration are all solid, and are documented in structures/achievements.md — it just does not gate the challenge missions. The lesson: a numeric coincidence is not a join; find the writer.

5.3 What the six requirement values are /🟡

The record's numeric tokens are 16, 25, 26, 27, 29, with 24/28 already in the pool earlier (they double as font metrics) and so deduped away if used. IDXD dedup makes positional key/value pairing unsound in general — but 16 sits immediately before REQUIREMENT (tokens 96 → 97), the documented value-before-key adjacency, so the first mission's value is well-supported.

TimeAttack requires stage 16 cleared — the final story mission. That is the natural gate, and it is what the first reading of these numbers suggested before the achievement detour talked me out of it.

The other five values are ≥ 24, i.e. challenge stages 2429: the challenge missions chain off each other. 🟡 on the exact pairing (which mission needs which), which needs the record's binary index section rather than the string pool.

🟡 Word B has no known writer. GamePart_StageClear sets 1 << x into word A unconditionally, which for a challenge stage (x ≥ 24) would land on word A bits 2429, not word B — so clearing a challenge mission must be recorded by a different path, presumably one that also stores its Time/Points record. Not yet found.

Negative worth keeping: the requirement text (TimeAttackRequirement, Extra01Requirement, …) is not in GP_CHALLENGE.pak — building a TextIndex over it yields 0 entries and its only wordy payload is embedded font copyright. The config's PATH is dat\GP_CHALLENGE.pak+eng\, a per-language branch, so those keys resolve through a naming scheme the current loader does not reproduce. Reading them would say in plain English what each mission asks for — worth one more attempt via hash::TOC_NAME_SCHEMES.

5.4 Where the mask lives, and one lead refuted

5.5 Both gate words are one record — and it is not the savegame

0x82175110 copies the record at singleton +80 in full: two words, an 8-byte pair, a 184-byte memcpy, 8 words from +200, then 816 bytes at +232, 816 more at +1048, a sub-object at +1864, and a final word at +1876. So the record runs +0 … ~+1880, i.e. singleton +80 … +1960 — which means

  • word A is record +0 (singleton +80), and
  • word B is record +1876 (singleton +1956).

That answers §5.3's "word B has no known writer": there is no separate stw because the whole record is copied out, modified and assigned back as a unit (0x8216FF70 → compare 0x822C3708, assign 0x82170650, then spawn a worker 0x821700A8 under a lock which retries a commit 0x822C33B8 up to five times). GamePart_Debriefing uses the same get/modify/set to accumulate saturating career counters at +200+224 (the last 64-bit) — the quantities the achievement requirements test.

And the record is not what savedata holds. Two independent checks:

  • Size. Every real save on disk is a 276-byte container deflating to a 545-byte payload. The progress record is ~1 880 bytes. It does not fit.
  • Files. After many sessions the game's content tree holds only game0N/savedata + game0N/__thumbnail.png per slot and the three Headers/*.header — no second data file anywhere.

Also checked and negative: the savegame object is *(*(this+4)) + 304, and the singleton's holder address (0x828F48B0) is referenced nowhere but inside the accessor itself, so this+4 is a different holder — the save block is not a window into this record.

⚠️ So hand-editing a save cannot unlock the challenge missions. That kills the operational hope this section previously carried; the earlier savegame-editing win does not extend here.

5.6 CONFIRMED ON THE RUNNING GAME

Read live from a booted title (tools/re-capture/gpoke.py r32):

0x828F40C0 = 0x00000002      word A
0x828F4814 = 0x00000000      word B

Word A = 2 = bit 1 set. The profile's save is Stage 02, "At Standby" — i.e. stage 01 cleared — so the mask is exactly one bit, at the index of the one cleared stage, 1-based. Reproduced on two separate cold boots. That confirms, on the running game and against a known progress state:

  • the singleton really is the static object at 0x828F4070;
  • word A is a cleared-stage bitmask, not achievements, not a stage number;
  • bit index = stage id, 1-based — so TimeAttack's REQUIREMENT 16 is "clear stage 16", the last story mission;
  • word B is the challenge half and is 0 on a story-only profile, as expected.

Both words were then poked (0xFFFFFFFF / 0x3F) and read back OK.

MISSION SELECT shows the mask directly (capture). With word A = 2 the screen lists Stage01 selectable, with a High Score and Best Time, and Stage02Stage08 greyed out. One cleared stage, one selectable entry, at the bit index that names it — the semantics are visible on screen, not inferred.

And the mask drives that screen. Two runs, identical navigation, fresh boot each:

run word A MISSION SELECT
control 0x00000002 (untouched) opens; Stage01 selectable, rest greyed
poked 0xFFFFFFFF MmAllocatePhysicalMemoryEx fails on a 128 MB request, guest throws, Xenia shows "Disc Read Error"

So the earlier failure was caused by the poke, and by a careless one: 0xFFFFFFFF claims stages that do not exist (0, 17, 2431 in word A). Poking only real story ids (0x0001FFFE = stages 116) does not blow the heap. That the list screen changes behaviour with the mask is itself confirmation that word A feeds it.

Poking real stage ids unlocks the story campaign in the menu . With word A = 0x0001FFFE (stages 116) every entry Stage01Stage16 is selectable (capture) where the control had only Stage01; Stage16 reads "Lonely Blue Planet — NO RECORD". So any story stage can now be launched from the menu, with no save editing, by poking one word.

But MISSION SELECT is story-only . With word B = 0x3F (challenge stages 2429 marked cleared) the list still saturates at Stage16 (capture) — the cursor stops there and further presses do nothing. That matches the data: the debriefing config declares exactly px_deb_stage01…16, so the list length is capped by the disc, not by the mask. The challenge missions cannot be reached through this screen, and word B does not feed it.

What the poke did not do (yet): EXTRAS still shows only MISSION SELECT / MOVIE THEATER / BACK — no challenge entry — although the menu was built 26 s after the poke, so this is not staleness. Entering MISSION SELECT then failed, and the log gives the real reason: MmAllocatePhysicalMemoryEx could not satisfy a 128 MB request (parent free 30633/131072 pages), the guest threw a C++ exception, and Xenia surfaced it as its generic "Disc Read Error" dialog. It is preceded by BaseHeap::Release failed because address is not a region start, a failed release that leaks the range. So that is an emulator/heap problem on the way into the screen, not evidence about the gate. Open: repeat without the poke to see whether MISSION SELECT fails the same way regardless.

5.7 What would work — static addresses for a live write

The singleton is a static object at 0x828F4070 (0x8216F650: addis 0x828F + addi …, 16496), with the holder at 0x828F48B0 pointing at it. So the two gate words are at fixed guest addresses, no scanning required:

word guest VA meaning
A 0x828F40C0 cleared stages, bit = stage id (< 24)
B 0x828F4814 cleared stages, bit = stage id 24 (challenge 2429)

Canary maps guest RAM into /dev/shm, which the project already reads live (tools/re-capture/gmem.py). Writing 0xFFFF into word A and 0x3F into word B while the title sits on a menu should open all six challenge missions without playing the campaign — and that is the route to the last 42 *_EX4/*_EX5 units. Untested: it needs a run, and the run needs gamepad input to reach the menu.

The obvious lead is dead. The three stws to +1956 in 0x822AF278 / sub_822C8748 looked promising because they sit in the save serializer's code region — they are on a different object. That one is fetched through 0x822CEB30, has a +2652 flag the code checks first, and stores string pointers at +1956/+2024 (built by 0x822D35F8); a pointer ANDed with 1 << n would be meaningless as a gate. So nothing in the image stores to this singleton's +1956 field-wise, which means it is filled by a bulk copy or by a path not yet found.

Two candidates remain and they need different levers: the save (hand-write it) or the Xbox profile (the emulator's profile data). Note that XEX imports are resolved by ordinal, so the absence of XamUser* name strings in the .pe is not evidence against the profile route. +80 at least is handled as a small struct by address (addi r4, obj, 80 → copy helper 0x82175110, written back via 0x8216FF70), which is what a serialised value object looks like.

5.8 The part id is never persisted — differential search (negative)

sub_821749C0 creates a part from slot+12, and no literal 26 exists anywhere, so the id is computed. It can still be found by differential search, because the id is known at each screen from §3: GP_EXTRAS = 5, GP_MISSION_SELECT = 7. Snapshot both screens and intersect (tools/re-capture/diff_words.py):

scanned 171 MB of allocated guest memory
addresses reading 5 on EXTRAS and 7 on MISSION SELECT: 4
   0x708FFBEC  0x708FFCBC  0x708FFDAC  0x708FFE20

All four are guest stack (thread stacks sit at 0x709… in the same run's log). So the requested part id exists only as a stack argument in flight — there is no persistent field holding it, which is consistent with finding no literal store and means there is nothing stable to poke. Forcing a transition to GamePart 26 needs the caller's context, i.e. an emulator-side hook rather than a memory write.

Every memory-and-menu route to the challenge missions is now closed (§5.4, §5.6, §5.7, this section). What remains is the genuine in-game unlock — an in-mission attainment, per AVSCRIPT_COMMAND_ATTAINMENT_CHALLENGE_MISSION_CARGO_SCORE — or a Canary patch that forces the transition in emulator code.

6. The unlock condition, from the strings

Three strings say challenge missions are announced, not menu-browsed:

  • AVSCRIPT_COMMAND_ATTAINMENT_CHALLENGE_MISSION_CARGO_SCORE — a mission-script command that grants challenge-mission attainment from a cargo score;
  • DLG_CHALLENGE_MISSION_AVAILABLE — the "a challenge mission is now available" dialog;
  • DLG_GO_CHALLENGE_MISSION_MENU — the prompt that takes you to GamePart 26.

So the gate is plausibly an in-mission achievement, not a title-menu state — which is consistent with Game Status = GAME_CLEAR unlocking nothing (measured, refuted). Neither dialog key is reachable by xref: they are selected by index through a config lookup, exactly like the save screen's sprite keys, so there is nothing to grep back to. Naming the flag needs the challenge-availability check disassembled from the screen side.

7. What this makes actionable

roster_target against the harvested CSV, with the stage families now named:

stage family roster units not yet read
S27 challenge f003_ArrowHead_EX4, e107_AAFrigate_EX4, e010_Attacker_S_EX4, e011_Attacker_B_EX4, e007_Turret_EX4, e008_TurretPlus_EX4
S28 challenge f004_DeltaSaber_A_Player, f001_DeltaSaber_T_EX5(_el), f003_ArrowHead_EX5, f104_Battleship_EX5, f105_Cruiser_EX5
S25 challenge e101_SDBattleshipEX, e102_BattleshipEX, e105_CruiserEX, e108_ASFrigateEX
S29 challenge e101_SDBattleshipEX, e102_BattleshipEX, e105_CruiserEX
S24 challenge e105_CruiserEX, e106_DestroyerEX
S10 story e005_ADAN_ElanTypeQ_Margras

⚠️ The S10 row contradicts "the story campaign is complete." One story stage still fields a unit that has never been read. Either S10 was never flown (it is the smallest stage container on the disc — 7 XBG7 resources — so it may be a cutscene stage that is not flyable), or the completeness claim was one unit optimistic. Flagged rather than resolved: it costs one ordinary story run to settle.

Remaining coverage: 42 units missing, of which the rosters above account for ~23. The rest are not in any stage roster table and need a different lever.

Reproduce

cargo run --release -q -p sylpheed-formats --example challenge_map    -- <disc-root>
cargo run --release -q -p sylpheed-formats --example challenge_screen -- <disc-root>
python3 xenia-rs/zq.py dis 0x82184df0 0x82184e30      # the three-way section switch
python3 xenia-rs/zq.py dis 0x82185ed0 0x82185f10      # the same switch, second site
python3 xenia-rs/zq.py dis 0x82189860 0x821899f0      # the challenge availability gate

5.4 CLOSED — the exact pairing, read from the record's own fields

§5.3 left 🟡 "the exact pairing (which mission needs which), which needs the record's binary index section rather than the string pool." That is exactly the right route, and running it with the corpus's own reader settles all six at once:

slot MISSION_ID REQUIREMENT RECORD_TYPE
TimeAttack 24 16 Time
ScoreAttack 25 24 Points
Extra01 26 25 Points
Extra02 27 26 Time
Extra03 28 27 Time
Extra04 29 28 Points

Control: the table appears in 6 pak entries, all in tables.pak, and all six are byte-for-byte the same table — one per language, i.e. one user under the per-pak copy rule. Zero variants.

🔑 Three things fall out, none of them assumed

MISSION_ID is the stage number. The six IDs are 2429 in slot order — exactly the six challenge stages S24S29 from §1, bijectively. The leaderboard's six modes and the disc's six challenge stages are the same six things, so the "four Extra0n against five _EXn" worry dissolves: _EXn is a unit-variant suffix and indexes nothing here.

REQUIREMENT is a strict chain, measured not guessed. 16, 24, 25, 26, 27, 28 — each mission requires the previous one's stage cleared, with TimeAttack anchored on stage 16, the final story mission. §5.3 inferred "the challenge missions chain off each other" from the shape of the pool; the chain is now exact, and it is a pure chain with no branches.

RECORD_TYPE is the leaderboard metric, and it is per-stage, not per-name. 3 Time (S24, S27, S28) and 3 Points (S25, S26, S29). The tempting reading — that the Extra0n family shares one metric — is wrong: Extra01 and Extra04 are ranked by Points while Extra02 and Extra03 are ranked by Time. Only the two self-describing missions match their names.

⚠️ Withdrawn from §5.3: the string-pool reading listed the numeric tokens as 16, 25, 26, 27, 29 with 24/28 "deduped away". The real REQUIREMENT set is 16, 24, 25, 26, 27, 2829 is a MISSION_ID, never a requirement. A value pulled from a deduped string pool by adjacency got one member wrong; the indexed read has no such failure mode.

🟡 Still an association, not a proof: result-screens names two ranking screens, POINT_RANKING (sub_8227F6E0) and CLEAR_TIME_RANKING (sub_82282C28), and RECORD_TYPE takes exactly the two values Points and Time. The pairing is strongly supported by both vocabularies, but no code path has been shown selecting one screen from that field.

5.5 🟡 Word B's writer — the search space is now closed at 21 functions

§5.3 left word B (+1956) with no known writer. Three routes ran; one produced a population, two were refuted, and the field itself is still unwritten.

The offset route works here, unlike +184. +1956 is a large, unusual offset: 9 stores and 28 loads image-wide, against +80's 5 403 / 6 923. Control: the known reader 0x821898C4 sits in the gate's list-builder 0x82189870, exactly where §5.1 puts it.

But none of the 9 stores is a progress write. Filtering them by whether the storing function reaches the progress object gives 0 of 8 distinct functions — and the 9th (0x826AAEA4) stores through r1, the stack pointer, so it is a local. +1956 is simply an offset several unrelated structures also use.

The obvious filter is dead — it fails its own control. Testing "does the function reference the singleton global 0x828F48B0 or call 0x821707C0?" against the known word-A writer returns no contact, because that path reaches the progress object through the copier, never through the global. A filter that rejects the known-good answer proves nothing about the others.

What did work: the write population is exactly 21

Progress is only ever changed copy-modify-store, so every writer must call both the copier 0x82175110 and the setter 0x8216FF70. Measured:

functions calling the setter 21
functions calling the copier 21
calling both 21

21/21 in both directions — the two caller sets are the same set. That is the complete progress-write population, and it is small enough to read exhaustively.

Control: the word-A writer is among them as 0x821C1630 (976 B, strings BASE_INFO + DIFFICULTY) — §5.2's store 0x821C1820 lies inside it. The doc's 0x821C09D80x821C29F0 is the enclosing method; 0x821C1630 is the function.

No member of the 21 stores to +1956 directly. Their only large store offsets are into this (1004, 1980, 2040, 2100, 2199, 2436) — 1004 being §5.2's own this+1004 & 0x20000 guard. So word B is written through the stack copy, at localbase + 1876, which no offset scan can pick out.

⚠️ A false friend, recorded because it cost a query: two of the 21 (0x822A6B00, 0x822A6EB0) reference Points, which looks exactly like the challenge record-storing path §5.3 predicts. It is not — their full string sets are Dependency, MissionObjective, Points, WEAPON, WEAPONS, i.e. the arsenal development economy, and neither references Time. There are two Points vocabularies: development points and the leaderboard metric.

🔑 Next candidate, not yet checked: 0x8218EFE0 (1212 B) is the one progress writer whose strings are BASE_EXTRA + DIFFICULTY — the only member of the 21 carrying the EXTRA vocabulary.

5.6 CLOSED — word B's writer is sub_8219F2E8, and it sets bit (stage 24)

§5.5 narrowed the search to 21 functions but could not name one, because the scan assumed the copy destination was a r1-relative stack local. It is not. Reading the known word-A writer instead of guessing its shape:

bl     0x821707C0            ; singleton
addi   r3, r31, 96           ; DEST = r31+96   <- a FRAME register, not r1
addi   r4, r11, 80           ; SRC  = obj+80
bl     0x82175110            ; copy
stw    r10, 96(r31)          ; write copy word 0  == word A

⚠️ The first scan returned 0 for all 21 — including the known-good — because of that one wrong assumption. Third filter this week killed by its own control.

With the real shape (addi r3, rF, K before the copier, then stores at K + d on rF), word B is at d = 1956 80 = 1876, and exactly one of the 21 hits:

function copy dest store at copy +1876
sub_8219F2E8 (288 B) r31+560 yes — 2436(r31)
0x821C1630 (control) r31+96 no — stores at +0, i.e. word A
the other 19 various no

560 + 1876 = 2436 .

🔑 The 24 is the proof

lwz    r11, 60(r11)
addi   r11, r11, -24         ;  <-- index - 24
lwz    r10, 2436(r31)        ; read copy word B
slw    r11, r27, r11         ; 1 << (index - 24)
or     r11, r11, r10
stw    r11, 2436(r31)        ; write copy word B
bl     0x8216FF70            ; setter (dest = r31+560)

That is §5.1's documented rule reproduced on the write side: "n >= 24 → bit (n24) of word B". Word A's writer shifts by the raw stage number (slw r10, r26, r10, no bias); word B's shifts by stage 24. The two writers are exact complements, so word B's bits 05 are challenge stages 2429 — the same six the leaderboard's MISSION_ID names in §5.4.

Where it is called from: exactly one real caller, 0x821A0B1C inside 0x821A0AD0 (1108 B), whose strings are BASE_INFO + DEBRIEFING — a debriefing screen. Clearing a challenge mission is recorded from the debriefing.

A second bitfield falls out

The same function also reads the copy at +1880 (2440(r31) = obj+1960, the word after B), re-reads obj+1960 after the setter, xors old against new, and loops the 32 result bits calling 0x82175C20 with bit + 64. So +1960 is a second 32-bit progress bitfield with ids 6495, and newly-set bits are announced one at a time — an unlock-notification loop.

⚠️ Not shown, against §5.3's own prediction: neither sub_8219F2E8 nor its caller references Time or Points (0 for both). §5.3 expected word B's writer to also store the stage's Time/Points record. It does not — this is the bit-setter only. The record store is still unlocated.

5.7 The Time/Points record is NOT on the debriefing path — and a ring drainer turns up

Reading 0x821A0AD0 (the debriefing) end to end, resolving every call and string in code order:

  • The word-B bit-setter sub_8219F2E8 is its FIRST call (0x821A0B1C), before anything else.
  • 44 calls, and exactly two strings in the whole function: DEBRIEFING and BASE_INFO.

No Time, no Points, anywhere on this path — not in the debriefing, not in the bit-setter. §5.3's expectation that clearing a challenge stage records its metric alongside the bit is not supported by the code that clears it. Reach of this negative: the debriefing function and its directly-called bit-setter, read whole; it does not rule out a store further down one of the other 43 callees.

0x8219F670 is an online-state predicate. 88 bytes, no strings: reads the singleton, tests bit 1 of [0]+60, and if set passes [0]+64 to 0x824A9C90 and returns result == 2. A connection/sign-in check, not a record store.

🔑 0x8219F460 drains a ring with the message bus's exact field layout

0x8219F460 (368 B, self-recursive) pops from a ring based at r29+320:

offset from the ring base role, from the code
+4 element array base (lwzx r10, r10, r8)
+8 capacity (compared against head)
+12 head (incremented, wrapped to 0)
+16 count (addic. r10, r10, -1)

⚠️ Those are the corpus's documented message-bus offsets, exactly: BACKLOG records sub_82175C20(bus+4, &msg) as "a ring-buffer append (capacity +8, head +12, count +16)". And sub_82175C20 is the very notifier §5.6 found the word-B writer calling with bit + 64.

So the corpus's open 🟡 "ring-buffer PUSH, no handler" now has a candidate drainer with a byte-for-byte matching field layout, reached from the debriefing.

🟡 Not shown: that these are the same ring instance. The push targets bus+4; this drain targets r29+320. Matching offsets prove a shared shape — the same container template — not a shared object. Same layout ≠ same instance, and the identity needs the two base pointers tied together.