# 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, whose sole writer is `GamePart_StageClear`, in a ~1 880-byte progress record that is **not** the savegame); ๐ŸŸก for the crash mechanism, for word B's writer, and for which mission consumes which bit. **Method:** static only โ€” `.pe` string/pointer analysis + DuckDB disassembly + disc records. No emulator run, no gamepad input. **Evidence:** [`captures/challenge-map.txt`](captures/challenge-map.txt), [`captures/challenge-screen-config.txt`](captures/challenge-screen-config.txt), [`structures/achievements.md`](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 1โ€“16 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 | |---|---|---|---| | `S01`โ€“`S16` | 16 | Lebendorf โ€ฆ PD (real locations) | the **story** campaign | | `S18`โ€“`S23` | 6 | `Original` (all six) | the **tutorial** missions | | `S24`โ€“`S29` | 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 exactly** โ€” `stage01โ€ฆ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 51โ€“59, 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` 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, `0x820A1600`โ€“`0x820A162C` 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`): ```asm 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"**. The field is initialised to **0** in the constructor (`sub_821783D8`, `0x8217851c`, alongside `+148 = 0` โ€” the stage index โ€” and `+132 = 2`, `+136/+140 = -1`), and every write to it inside this class (`0x82184b10`, `0x82185d48`, `0x82186ab4`) only *clears* it. 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. ### 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 (1โ€“16 fine, every 24โ€“29 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 **18โ€“23** (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 1โ€“16 and 24โ€“29 were. ## 5. The challenge screen, its six missions, and the unlock gate โœ… The factory registration sites (`0x8280C000`โ€“`0x8280F800`) 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 **`0x82187E60`โ€“`0x8218CF10`** (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` | | 3โ€“6 | `Extra01`โ€ฆ`Extra04` | `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`](captures/challenge-screen-config.txt). ### 5.1 The gate is a bit test against a progress bitfield โœ… `0x82189870` builds the list, and `0x82189970`โ€“`0x821899D8` is the availability decision for each mission: ```asm 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`** (`0x821C09D8`โ€“`0x821C29F0`): ```asm 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 `1`โ€“`16` and tutorial `18`โ€“`23` sit in word A, and the challenge stages `24`โ€“`29` are exactly word B's bits `0`โ€“`5`. > โš ๏ธ **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](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 24โ€“29**: 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 24โ€“29, *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 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 24โ€“29) | Canary maps guest RAM into `/dev/shm`, which the project already reads live ([`tools/re-capture/gmem.py`](../../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 `stw`s 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 `AND`ed 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. ## 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 ```bash cargo run --release -q -p sylpheed-formats --example challenge_map -- cargo run --release -q -p sylpheed-formats --example challenge_screen -- 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 ```