re(challenge): the gate's bit space is the game's 24 ACHIEVEMENTS

Static only. Last commit left "REQUIREMENT is a bit index into a progress
bitfield" with the space unidentified. It is the achievement space, and both
halves are now readable off the disc and the executable.

- GamePart_Debriefing (0x8218CF38-0x82191B18) awards them: sub_8218F9A8 walks
  the on-disc ACHIEVEMENTS_REQUIREMENTS list (tables.pak #16, schema 744c0519),
  and for entry index n tests bit n, evaluates the entry when clear, and sets
  the bit when satisfied. The list is literally ACHIEVEMENT01..ACHIEVEMENT24 --
  24 entries, which is exactly where the challenge gate splits word A from
  word B.

- The XEX carries the definitions: XACH at .pe 0x8FBCBC, 36-byte records
  {id, name_id, unlocked_desc_id, locked_desc_id, image_id u32, gamerscore u16,
  pad, flags u32, 16 zero bytes}, strings from one XSTR per language (English is
  table #5). tools/xach_dump.py parses it. SELF-CHECK: the 24 gamerscores sum to
  exactly 1000, the retail total -- a wrong stride does not land on a round 1000.

- The two sources agree on ORDER independently: the requirement types
  ShootDownAircrafts 1000/10000, ShootDownShips 100, ShootDownWeight MegaTons,
  GetAllWeapons and GetAllAchievements line up with ids 19-24 exactly as XACH
  names them. So bit n <-> achievement n+1 is evidence, not inference. (Those
  last two are requirement TYPES, not debug cheats, despite how they read.)

- Corollary: TimeAttack's REQUIREMENT 16 -- the one value that sits in direct
  value-before-key adjacency, so it survives IDXD dedup -- is bit 16 =
  achievement 17, "Solar System Defense Award", i.e. finish the story campaign.
  The other five values (25-29) are >= 24 and so index word B, a second flag
  space, plausibly a challenge-clear chain. Still 🟡.

REFUTED, from the last commit: the stores to +1956 in 0x822AF278 / sub_822C8748
are NOT this singleton. That object comes from 0x822CEB30, checks a +2652 flag
and stores string POINTERS at +1956/+2024 -- and a pointer ANDed with 1<<n is
meaningless as a gate. So nothing in the image writes this singleton's +1956
field-wise, and where the mask persists (save vs Xbox profile) is open. XEX
imports are by ordinal, so absent XamUser* strings are not evidence either way.
This commit is contained in:
2026-08-13 19:32:43 +00:00
parent 8ecd70f1bc
commit 33ae20896e
6 changed files with 427 additions and 14 deletions

View File

@@ -1,11 +1,15 @@
# 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.
switch) and for the **unlock mechanism** (a bit test against the achievement mask);
🟡 for the crash mechanism and for which mission consumes which bit; ❔ for where the
mask persists.
**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`.
[`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
@@ -152,20 +156,47 @@ lookup REQUIREMENT in the mission record ; bl 0x82448C50
bit set ? AVAILABLE : LOCKED
```
Word A and word B are read from a **singleton** (`0x821707C0`, lazily constructed
behind the global at `0x828F48BC`) at offsets **`+80`** and **`+1956`**. So challenge
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 What the six requirement values are 🟡
### 5.2 The bit space is the game's 24 ACHIEVEMENTS ✅
The `< 24` / `>= 24` split is not arbitrary. `GamePart_Debriefing`
(`0x8218CF38``0x82191B18`) walks a disc config list called
**`ACHIEVEMENTS_REQUIREMENTS`** (`tables.pak` entry #16, schema `744c0519`) and, for
entry index `n`, tests and sets **bit `n`** of an awarded-mask — and that list is
exactly `ACHIEVEMENT01``ACHIEVEMENT24`, **24 entries**. The XEX's own `XACH`
resource holds the matching 24 achievement definitions, summing to **1000G**, the
retail total. Full table and record layout:
[`structures/achievements.md`](structures/achievements.md).
So **word A (`+80`) is the earned-achievement mask** (bit `n` = achievement `n+1`),
and **word B (`+1956`) is a second, different flag space** that requirement values
`≥ 24` index as `bit n-24`.
### 5.3 What the six requirement values are 🟡
The record's numeric tokens are `16`, `25`, `26`, `27`, `29`, and `24`/`28` are
already in the pool earlier (they double as font metrics), so they would be **deduped
away** if used. That is consistent with the six values being `24``29` — the same
range as the challenge stage records — but **IDXD dedupes the string pool, so
positional key/value pairing is not sound here** (the same trap the movie/subtitle map
hit). Recorded as a hypothesis: reading the values properly needs the record's binary
index section, not the pool.
away** if used. **IDXD dedupes the string pool, so positional key/value pairing is not
sound here** (the same trap the movie/subtitle map hit) — with one exception: `16` sits
*immediately* before `REQUIREMENT` (tokens 96 → 97), which is the documented
value-before-key adjacency, so the **first** mission (`TimeAttack`) requiring **bit 16**
is well-supported.
Bit 16 is achievement **17, "Solar System Defense Award"** — *"great achievements
during the campaign to defend the Solar System"*, i.e. **finish the story campaign**.
That is exactly the shape of gate you would expect on the first challenge mission, and
it is independent corroboration that the bit space is the achievement space.
The remaining five values (`25``29`, all `≥ 24`) therefore index **word B**, not
achievements — most plausibly a challenge-clear chain, since there are six challenge
missions and word B's bits `0``5` would be `24``29`. Recorded as a hypothesis;
resolving the per-mission pairing needs the record's binary index section, not the
pool.
**Negative worth keeping:** the requirement *text* (`TimeAttackRequirement`,
`Extra01Requirement`, …) is **not** in `GP_CHALLENGE.pak` — building a `TextIndex`
@@ -175,14 +206,27 @@ keys resolve through a naming scheme the current loader does not reproduce. Read
them would say in plain English what each mission asks for — worth one more attempt
via `hash::TOC_NAME_SCHEMES`.
### 5.3 Why this matters operationally
### 5.4 Why this matters operationally — and one lead already refuted
If word A / word B are restored from the save, a **hand-written save with those bits
set unlocks all six challenge missions** — and the savegame round-trip is already
solved. That would turn the last 42 units of the Route-B harvest into one run instead
of an unreachable menu. Three `stw`s to `+1956` sit in `0x822C7DD0` / `0x822C8748`,
i.e. the same code region as the save serializer (`0x822C00E8`) and deserializer
(`0x822C0380`) — suggestive, **not yet checked**. That is the next step.
of an unreachable 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 ❔