Instructions and generated text that still sent readers to `xenia-rs`, which is
archived and deleted locally. Provenance and dated research records are left as
they are — "lifted from xenia-rs", or a finding stating what database it was
measured against, is still true, and rewriting it would falsify the record.
Changed, because each one tells someone what to do today or writes stale text:
docs/re/README.md the RE toolchain guide queried `xenia-rs/sylpheed.db`
with `xenia-rs/zq.py`, and its "Dynamic" bullet said
to prefer xenia-rs's probe suite OVER Canary. Now:
`sylpheed.db` at the repo root (how to build it and
re-stamp names), `tools/zq.py`, dynamic = Canary, and
`.rdata` reads from the `.pe` (offset = VA - 0x82000000)
in place of `--dump-addr`.
challenge-mission-gate.md, structures/achievements.md
reproduction commands `python3 xenia-rs/zq.py dis …`
crates/sylpheed-xexdb/SCHEMA.md
titled after the retired `xenia-analysis` crate,
citing `xenia-rs dis`, pointing at a `xenia-analysis`
source path
sylpheed-xexdb/src/formatter.rs
WROTE "generated by xenia-rs" into every disassembly
it produced (no test pins the banner)
sylph-xexdb.rs, db.rs "SQLite" / "DuckDB writer for xenia-rs"
sylpheed-formats/src/hash.rs
pointed at `xenia-rs/RE_SYMBOLS.md`, now at
`docs/re/RE_SYMBOLS.md`
Verified: every rewritten reproduction command was extracted from the doc and
run as written against the regenerated database — all 5 exit 0 and return the
disassembly they describe. The README's function count (25 676) is the database's
own. `cargo fmt --all -- --check` clean.
Not changed, deliberately: the README's Oracle bullet says Canary's native Linux
ELF "crashes / does not run". That is about Canary, not xenia-rs, and it conflicts
with a July note that the native build works — unverified either way here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
673 lines
35 KiB
Markdown
673 lines
35 KiB
Markdown
# 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](../../../xenia-canary-native/src/xenia/hid/file/file_input_driver.h)
|
||
and a live guest-memory read.
|
||
**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<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, `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"**. 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](captures/ready-room-extra-mode.png)) — 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 (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 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](captures/mission-select-stage01-only.png)).
|
||
With word A = 2 the screen lists `Stage01` **selectable, with a High Score and Best
|
||
Time**, and `Stage02`–`Stage08` **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`, `24`–`31` in word A). Poking only real
|
||
story ids (`0x0001FFFE` = stages 1–16) 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 1–16) every entry `Stage01`…`Stage16` is selectable
|
||
([capture](captures/mission-select-all-story-unlocked.png)) 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 24–29
|
||
marked cleared) the list still **saturates at `Stage16`**
|
||
([capture](captures/mission-select-ends-at-stage16.png)) — 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 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.
|
||
|
||
### 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
|
||
|
||
```bash
|
||
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 tools/zq.py dis 0x82184df0 0x82184e30 # the three-way section switch
|
||
python3 tools/zq.py dis 0x82185ed0 0x82185f10 # the same switch, second site
|
||
python3 tools/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 `24`–`29` in slot order —
|
||
exactly the six challenge stages `S24`–`S29` 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, 28` — **`29` 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
|
||
`0x821C09D8`–`0x821C29F0` 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:
|
||
|
||
```asm
|
||
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
|
||
|
||
```asm
|
||
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
|
||
`(n−24)` 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 0–5 are challenge stages 24–29 — 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, `xor`s 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 64–95**, 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.
|