From 10a96844bab16ebbdc6db89227a2e685d86e98ff Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Thu, 13 Aug 2026 18:59:52 +0000 Subject: [PATCH] re(challenge): the EX missions are a MODE, not a stage number Static-only (no emulator, no pad input). Three findings, each with its own evidence: - The disc holds exactly 29 StageResource records in three families -- S01-S16 story, S18-S23 tutorial (all bg=Original), S24-S29 challenge, plus Test. That is 16 + 6 + 6 + 1, matching weapon.tbl's stage01..16 / tutorial01..06 / challenge01..06 key set exactly. S17 does not exist. GP_CHALLENGE.pak has 0 IDXD objects -- it is the menu screen; challenge missions reuse GP_MAIN_GAME_E.pak's records. - The GamePart id table is at 0x820A1630 (29 ids). Indices are confirmed by the image's own RegisterToFactory text, not by position: GP_CHALLENGE = 26, GP_TUTORIAL = 25, GP_BUNK = 10. - The stage loader selects its config section from a mission-KIND field at object+144: 3 -> EXTRA, 5|6 -> CHALLENGE, else FILE (two independent sites, 0x82184df0 and 0x82185ed0; two more classify {3,5,6} as one group). The constructor sets it to 0 and every write inside the class only clears it, and no immediate 3/5/6 store to it exists image-wide -- so the kind is supplied by the launching GamePart, never derived from the stage number. That last point is a mechanism (unproven) for why patching the save's stage field to 27 kills the load: the record is a challenge stage but the kind stays FILE. Names an untried, zero-cost discriminator -- try stage 18-23. Also flagged, not resolved: roster_target says S10 (a STORY stage) still fields an unharvested unit, which contradicts the "story campaign complete" claim by one unit. --- .../examples/challenge_map.rs | 81 +++++++++ docs/re/INDEX.md | 4 +- docs/re/captures/challenge-map.txt | 96 +++++++++++ docs/re/challenge-mission-gate.md | 157 ++++++++++++++++++ 4 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 crates/sylpheed-formats/examples/challenge_map.rs create mode 100644 docs/re/captures/challenge-map.txt create mode 100644 docs/re/challenge-mission-gate.md diff --git a/crates/sylpheed-formats/examples/challenge_map.rs b/crates/sylpheed-formats/examples/challenge_map.rs new file mode 100644 index 0000000..eee6fc6 --- /dev/null +++ b/crates/sylpheed-formats/examples/challenge_map.rs @@ -0,0 +1,81 @@ +//! RE probe: what does the disc say about CHALLENGE / EXTRA missions? +//! +//! The title's stage-config reader (`0x82184b98`..`0x82184e94`) selects one of three +//! config sections by a mode field at `obj+144`: +//! mode == 3 -> "EXTRA" +//! mode == 5 or 6 -> "CHALLENGE" +//! otherwise -> "FILE" +//! so challenge missions are a *mode*, not a separate stage numbering. This probe +//! asks the disc which stage records exist and what GP_CHALLENGE.pak carries. +//! +//! Run: cargo run --release -p sylpheed-formats --example challenge_map -- + +use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive}; + +fn main() { + let disc = std::env::args().nth(1).unwrap_or_else(|| { + std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC") + }); + + println!("=== 1. StageResource records in GP_MAIN_GAME_E.pak ==="); + let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let mut rows = vec![]; + for e in arc.entries() { + let Ok(b) = arc.read(e) else { continue }; + let Ok(o) = IdxdObject::parse(&b) else { continue }; + if o.schema_hash != 0x3c9ae32e { + continue; + } + let t = o.tokens(); + let stage = t + .iter() + .find_map(|s| s.strip_prefix("EnumUnit_").map(|x| x.trim_end_matches(".tbl").to_string())) + .unwrap_or("?".into()); + let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string(); + rows.push((stage, bg, t.len())); + } + rows.sort(); + println!(" {} stage records", rows.len()); + for (s, bg, n) in &rows { + println!(" {s:8} bg={bg:16} tokens={n}"); + } + + println!("\n=== 2. GP_CHALLENGE.pak contents ==="); + match PakArchive::open(format!("{disc}/dat/GP_CHALLENGE.pak")) { + Ok(ch) => { + let mut by_schema: std::collections::BTreeMap = Default::default(); + let mut all_tokens: Vec<(u32, Vec)> = vec![]; + for e in ch.entries() { + let Ok(b) = ch.read(e) else { continue }; + let Ok(o) = IdxdObject::parse(&b) else { continue }; + *by_schema.entry(o.schema_hash).or_default() += 1; + all_tokens.push((o.schema_hash, o.tokens().iter().map(|s| s.to_string()).collect())); + } + println!(" {} entries, {} IDXD objects", ch.entries().len(), all_tokens.len()); + for (h, n) in &by_schema { + println!(" schema {h:08x} x{n}"); + } + for (h, t) in all_tokens.iter().take(12) { + println!(" -- {h:08x}: {:?}", &t[..t.len().min(60)]); + } + } + Err(e) => println!(" open failed: {e}"), + } + + println!("\n=== 3. tokens mentioning Challenge / EX across the main pak ==="); + let mut hits: std::collections::BTreeSet = Default::default(); + for e in arc.entries() { + let Ok(b) = arc.read(e) else { continue }; + let Ok(o) = IdxdObject::parse(&b) else { continue }; + for t in o.tokens() { + let l = t.to_ascii_lowercase(); + if l.contains("challenge") || t.ends_with("_EX") || t.contains("_EX4") || t.contains("_EX5") { + hits.insert(format!("{:08x} {t}", o.schema_hash)); + } + } + } + for h in hits.iter().take(120) { + println!(" {h}"); + } + println!(" ({} distinct)", hits.len()); +} diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 0fd98a5..4ae5942 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -41,8 +41,6 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes ## Functions / code paths -_None documented yet — populated during the dynamic-RE phase._ - | Function | Conf. | Reimpl. | Summary | |----------|-------|---------|---------| -| — | — | — | — | +| Stage-config section switch (`0x82184df0`, `0x82185ed0`) | ✅ | [challenge-mission-gate](challenge-mission-gate.md) | The stage loader picks its config section from a **mission-kind field at `object+144`**: `3` → `EXTRA`, `5`/`6` → `CHALLENGE`, anything else → `FILE`; two further sites treat `{3,5,6}` as one class. Constructed as `0` (`sub_821783D8`) and only ever *cleared* inside the class, so the kind comes from the launching GamePart, **not** from the stage number — which is a mechanism (🟡, unproven) for why patching the save's stage field to a challenge stage kills the load. Same note carries the **GamePart id table** (`0x820A1630`, 29 ids, `GP_CHALLENGE` = 26, cross-checked against the image's own `RegisterToFactory<26, …>` text) and the disc's **three stage families** — `S01`–`S16` story, `S18`–`S23` tutorial, `S24`–`S29` challenge, plus `Test`, matching `weapon.tbl`'s 16 + 6 + 6 key set exactly. `GP_CHALLENGE.pak` holds **0 IDXD objects** — it is the menu screen; challenge missions reuse `GP_MAIN_GAME_E.pak`'s stage records | diff --git a/docs/re/captures/challenge-map.txt b/docs/re/captures/challenge-map.txt new file mode 100644 index 0000000..e90f39a --- /dev/null +++ b/docs/re/captures/challenge-map.txt @@ -0,0 +1,96 @@ +=== 1. StageResource records in GP_MAIN_GAME_E.pak === + 29 stage records + S01 bg=Lebendorf tokens=52 + S02 bg=Lebendorf tokens=51 + S03 bg=Planet_Lebendorf tokens=51 + S04 bg=Lebendorf_far tokens=54 + S05 bg=Lebendorf_far tokens=54 + S06 bg=Hargenteen tokens=51 + S07 bg=Hargenteen tokens=53 + S08 bg=Barch tokens=59 + S09 bg=Acheron tokens=53 + S10 bg=Acheron tokens=53 + S11 bg=Anastasis tokens=53 + S12 bg=Hargenteen tokens=53 + S13 bg=Hargenteen tokens=56 + S14 bg=Earth tokens=59 + S15 bg=Earth tokens=53 + S16 bg=PD tokens=53 + S18 bg=Original tokens=43 + S19 bg=Original tokens=43 + S20 bg=Original tokens=43 + S21 bg=Original tokens=43 + S22 bg=Original tokens=50 + S23 bg=Original tokens=43 + S24 bg=Anastasis tokens=53 + S25 bg=Hargenteen tokens=53 + S26 bg=Hargenteen tokens=53 + S27 bg=Planet_Lebendorf tokens=53 + S28 bg=Lebendorf tokens=54 + S29 bg=Earth tokens=53 + Test bg=Earth tokens=43 + +=== 2. GP_CHALLENGE.pak contents === + 151 entries, 0 IDXD objects + +=== 3. tokens mentioning Challenge / EX across the main pak === + 033b5b7e pgmsg_challenge1.t32 + 033b5b7e pgmsg_challenge2.t32 + 033b5b7e pgmsg_challenge3.t32 + 033b5b7e pgmsg_challenge4.t32 + 033b5b7e pgmsg_challenge5.t32 + 033b5b7e pgmsg_challenge6.t32 + 35b8dc67 UN_e001_ADAN_Elan_EX4 + 35b8dc67 UN_e007_ADAN_Turret_EX4 + 35b8dc67 UN_e008_ADAN_TurretPlus_EX4 + 35b8dc67 UN_e010_ADAN_Attacker_S_EX4 + 35b8dc67 UN_e011_ADAN_Attacker_B_EX4 + 35b8dc67 UN_e107_ADAN_AAFrigate_EX4 + 35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5 + 35b8dc67 UN_f001_TCAF_DeltaSaber_T_EX5_el + 35b8dc67 UN_f003_TCAF_ArrowHead_EX4 + 35b8dc67 UN_f003_TCAF_ArrowHead_EX5 + 35b8dc67 UN_f104_TCAF_Battleship_EX5 + 35b8dc67 UN_f105_TCAF_Cruiser_EX5 + 3c5b0549 UN_e107_ADAN_AAFrigate_EX4 + 3c5b0549 UN_f104_TCAF_Battleship_EX5 + 3c5b0549 UN_f105_TCAF_Cruiser_EX5 + 3c5b0549 UnitName_UN_e107_ADAN_AAFrigate_EX4 + 3c5b0549 UnitName_UN_f104_TCAF_Battleship_EX5 + 3c5b0549 UnitName_UN_f105_TCAF_Cruiser_EX5 + 3c5b0549 Weapon_TCAF_Ship_AAGun_EX5 + 3c9ae32e EnumWeapon_EX5.tbl + 43faa517 UN_e001_ADAN_Elan_EX4 + 43faa517 UN_e007_ADAN_Turret_EX4 + 43faa517 UN_e008_ADAN_TurretPlus_EX4 + 43faa517 UN_e010_ADAN_Attacker_S_EX4 + 43faa517 UN_e011_ADAN_Attacker_B_EX4 + 43faa517 UN_f001_TCAF_DeltaSaber_T_EX5 + 43faa517 UN_f001_TCAF_DeltaSaber_T_EX5_el + 43faa517 UN_f003_TCAF_ArrowHead_EX4 + 43faa517 UN_f003_TCAF_ArrowHead_EX5 + 43faa517 UnitName_UN_e001_ADAN_Elan_EX4 + 43faa517 UnitName_UN_e007_ADAN_Turret_EX4 + 43faa517 UnitName_UN_e008_ADAN_TurretPlus_EX4 + 43faa517 UnitName_UN_e010_ADAN_Attacker_S_EX4 + 43faa517 UnitName_UN_e011_ADAN_Attacker_B_EX4 + 43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5 + 43faa517 UnitName_UN_f001_TCAF_DeltaSaber_T_EX5_el + 43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX4 + 43faa517 UnitName_UN_f003_TCAF_ArrowHead_EX5 + 659aff47 UN_e001_ADAN_Elan_EX4 + 659aff47 UN_e007_ADAN_Turret_EX4 + 659aff47 UN_e008_ADAN_TurretPlus_EX4 + 659aff47 UN_e010_ADAN_Attacker_S_EX4 + 659aff47 UN_e011_ADAN_Attacker_B_EX4 + 659aff47 UN_e107_ADAN_AAFrigate_EX4 + 659aff47 UN_f003_TCAF_ArrowHead_EX4 + 6ab4825a WeaponCannonName_TCAF_Ship_AAGun_EX5 + 6ab4825a Weapon_TCAF_Ship_AAGun_EX5 + 6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5 + 6b9b000d UN_f001_TCAF_DeltaSaber_T_EX5_el + 6b9b000d UN_f003_TCAF_ArrowHead_EX5 + 6b9b000d UN_f104_TCAF_Battleship_EX5 + 6b9b000d UN_f105_TCAF_Cruiser_EX5 + ffbc19c2 Weapon_TCAF_Ship_AAGun_EX5 + (59 distinct) diff --git a/docs/re/challenge-mission-gate.md b/docs/re/challenge-mission-gate.md new file mode 100644 index 0000000..b8382d3 --- /dev/null +++ b/docs/re/challenge-mission-gate.md @@ -0,0 +1,157 @@ +# 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); 🟡 for the crash mechanism; ❔ for the unlock condition. +**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), +`crates/sylpheed-formats/examples/challenge_map.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 unlock condition ❔ + +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. + +## 6. 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 -- +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 +```