re(challenge): the gate is CLEARED STAGES -- my achievement reading was wrong

Third revision of this claim, and this one has the writer.

Word A (singleton +80) has exactly ONE writer in the image. 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:

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

and this+84 is the STAGE NUMBER, by two independent uses: 0x821C1760 indexes a
20-byte per-stage record array with it (this + (x+7)*20, three words plus an
8-byte timestamp -- the savegame's 16x20 SHAB shape), and 0x821C1EEC/0x821C1924
pass it as the index into the config key "STAGE" (0x820A2540), the debriefing's
px_deb_stage01..16 sprite list.

So a challenge mission's REQUIREMENT n means "stage n has been cleared", and the
<24 / >=24 split is the disc's own stage numbering: story 1-16 and tutorial 18-23
in word A, challenge 24-29 as word B bits 0-5. TimeAttack needs stage 16 -- the
last story mission -- and the other five chain off challenge stages 25-29. That
is what the raw numbers suggested in the first place.

REFUTED: "the bit space is the game's 24 achievements". ACHIEVEMENTS_REQUIREMENTS
having 24 entries and the gate splitting at 24 is a COINCIDENCE -- 24 is also the
first challenge stage's id. Nothing copies the Debriefing's masks (+208/+736/+740)
into +80, which is the check that should have preceded the claim.

The achievement work itself stands and is kept, retitled and rescoped in
structures/achievements.md: the XACH table (.pe 0x8FBCBC, 36-byte records,
1000G self-check), the 24 names/descriptions, the on-disc ACHIEVEMENTS_REQUIREMENTS
list, and the XACHIEVEMENT_DETAILS/XAM read path. It just does not gate the
challenge missions.

Still open: word B (+1956) has no known writer -- GamePart_StageClear's
unconditional 1 << x would land a challenge stage on word A bits 24-29, not word
B, so clearing a challenge mission must be recorded by another path (presumably
the one that also stores its Time/Points record).
This commit is contained in:
2026-08-13 19:54:39 +00:00
parent 90e0e66c7b
commit 20299c05a1
3 changed files with 128 additions and 127 deletions

View File

@@ -1,9 +1,9 @@
# 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 the achievement mask);
🟡 for the crash mechanism and for which mission consumes which bit; ❔ for where the
mask persists.
switch) and for the **unlock mechanism** (a bit test against a *cleared-stage* mask,
whose sole writer is `GamePart_StageClear`); 🟡 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),
@@ -162,50 +162,65 @@ 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 the game's 24 ACHIEVEMENTS ✅
### 5.2 The bit space is CLEARED STAGES ✅
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).
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`):
So **word A (`+80`) reads as the earned-achievement mask** and **word B (`+1956`) as a
second, different flag space** that requirement values `≥ 24` index as `bit n-24`.
```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)
```
⚠️ **Corrected the same day:** an earlier draft said "bit `n` = achievement `n+1`" and
called the join ✅. Both were over-claimed. The Debriefing's masks live on the
*Debriefing* object (`+208`/`+736`/`+740`), nothing observed copies them into the
singleton's `+80`, and the one place the image is *seen* turning an achievement into a
bit does `1 << dwId` with **1-based** ids. So the mapping is 🟡 and, if it holds, is
`REQUIREMENT n` = achievement **`n`** — see
[achievements §3.1 and §4](structures/achievements.md).
**`this+84` is the stage number**, and two independent uses prove it:
### 5.3 What the six requirement values are 🟡
- `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.
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. **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.
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`.
Under the 1-based reading that §5.2 now prefers, bit 16 is achievement **16,
"Night Ravens Patch"** — *"for challenging and eradicating the Night Raven
Squadron"*, a stage-15-era award. (The withdrawn 0-based reading gave achievement 17,
"Solar System Defense Award"; both are plausible gates for a first challenge mission,
which is precisely why the numbering has to be settled by evidence rather than by
which story reads better.)
> ⚠️ **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.
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.
### 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`
@@ -215,12 +230,20 @@ 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.4 Why this matters operationally — and one lead already refuted
### 5.4 Where the mask lives, and one lead 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.
Word A is **word 0 of a 232-byte progress struct at singleton `+80`** (`0x82175110`
copies it out: two words, an 8-byte pair, a 184-byte `memcpy`, then 8 words from
`+200`). `0x8216FF70` compares, assigns, and then spawns a worker (`0x82170168`) under
a lock — i.e. the struct is **persisted asynchronously**. `GamePart_Debriefing` uses
the same get/modify/set on it to accumulate saturating career counters
(`+200``+224`, the last a 64-bit total), which are exactly the quantities the
achievement requirements test.
So a **hand-written progress record with the stage bits set would unlock the challenge
missions**, and the last 42 units of the Route-B harvest become one run instead of an
unreachable menu — *if* this struct is what the savegame carries. That is the open
question.
**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