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,15 +1,23 @@
# Achievements — the 24-bit progress space the game gates content on
# Achievements — the 24-entry table, and where the earned state comes from
**Conf.:** ✅ for the table, the award path and the bit numbering; 🟡 for which
challenge mission consumes which bit; ❔ for where the earned bits persist.
**Conf.:** ✅ for the table and for the XAM read path; 🟡 for the requirement list's
bit numbering.
**Spec:** [`tools/xach_dump.py`](../../../tools/xach_dump.py) ·
[`examples/achievements_map.rs`](../../../crates/sylpheed-formats/examples/achievements_map.rs)
**Evidence:** [`captures/achievements-xach.txt`](../captures/achievements-xach.txt)
This is not just Xbox metadata. The title keeps its own **earned-achievement
bitmask** and gates content on it — the challenge missions read it directly (see
[challenge-mission-gate](../challenge-mission-gate.md)), so the achievement ids
*are* the bit numbers other systems reference.
> **Retracted here, and it matters:** earlier drafts of this file claimed the
> challenge missions gate on this achievement mask. **They do not.** That gate reads a
> *cleared-stage* bitmask whose sole writer is `GamePart_StageClear` — see
> [challenge-mission-gate §5.2](../challenge-mission-gate.md). The link was invented
> out of a numeric coincidence: `ACHIEVEMENTS_REQUIREMENTS` has 24 entries and the
> challenge gate splits its two words at 24, for entirely unrelated reasons (24 is
> also the first challenge stage's id). Everything below is what survived checking the
> writer.
The title reads its earned-achievement state from the console, and separately carries
a disc table of what each achievement requires. Both are useful to the
reimplementation on their own terms.
## 1. The table: 24 achievements, 1000G ✅
@@ -45,40 +53,44 @@ Strings come from one `XSTR` section per language (7 present); each is
Ids run `1…24` with no gaps.
> **Correction (same day).** This file first said "bit `n` ↔ achievement id `n+1`",
> **Correction 1 of 2 (same day).** This file first said "bit `n` ↔ achievement id `n+1`",
> i.e. 0-based bits. That was wrong-headed: the only place the image is *observed*
> turning an achievement into a bit does `1 << dwId` with `dwId` 1-based (§3.1), so
> the bit index is the **id itself** and bit 0 is unused. The 0-based reading came
> from assuming the config list's position was the bit, which §3 no longer supports.
## 2. What each bit is ✅
## 2. The 24 achievements ✅
| bit | id | G | name |
|---|---|---|---|
| 0 | 1 | 20 | Space Combat Award |
| 1 | 2 | 20 | Schlos Base Defense Award |
| 2 | 3 | 20 | Aegis of the People Medal |
| 3 | 4 | 20 | TCAF Luna Medal |
| 4 | 5 | 40 | TCAF Mars Medal |
| 5 | 6 | 50 | Soldier's Charm Amulet |
| 6 | 7 | 20 | White Griffons Patch |
| 7 | 8 | 30 | TCAF Jupiter Medal |
| 8 | 9 | 40 | Furious Pursuit Badge |
| 9 | 10 | 30 | Solo Aerospace Combat Award |
| 10 | 11 | 30 | Operation Nebula Blaze Award |
| 11 | 12 | 40 | Guilty Roses Patch |
| 12 | 13 | 30 | Super Battleship Slayer Patch |
| 13 | 14 | 40 | TCAF Terra Medal |
| 14 | 15 | 40 | Hellfires Patch |
| 15 | 16 | 50 | Night Ravens Patch |
| **16** | **17** | 40 | **Solar System Defense Award** — "great achievements during the campaign to defend the Solar System" |
| 17 | 18 | 40 | Special Operations Medal |
| 18 | 19 | 30 | 1,000 Units Destroyed Medal |
| 19 | 20 | 70 | 10,000 Units Destroyed Medal |
| 20 | 21 | 50 | Ship Hunter Award |
| 21 | 22 | 70 | Gigaton Club Patch |
| 22 | 23 | 80 | Weapon Lord Patch |
| 23 | 24 | 100 | TCAF Pilot's Commendation |
Ids run `1…24` with no gaps. Where the image turns one into a bit it uses `1 << dwId`
(§3.1), so the id is the bit and bit 0 is unused — but note that this mask is *not*
the challenge gate's (§4).
| id | G | name |
|---|---|---|
| 1 | 20 | Space Combat Award |
| 2 | 20 | Schlos Base Defense Award |
| 3 | 20 | Aegis of the People Medal |
| 4 | 20 | TCAF Luna Medal |
| 5 | 40 | TCAF Mars Medal |
| 6 | 50 | Soldier's Charm Amulet |
| 7 | 20 | White Griffons Patch |
| 8 | 30 | TCAF Jupiter Medal |
| 9 | 40 | Furious Pursuit Badge |
| 10 | 30 | Solo Aerospace Combat Award |
| 11 | 30 | Operation Nebula Blaze Award |
| 12 | 40 | Guilty Roses Patch |
| 13 | 30 | Super Battleship Slayer Patch |
| 14 | 40 | TCAF Terra Medal |
| 15 | 40 | Hellfires Patch |
| 16 | 50 | Night Ravens Patch |
| 17 | 40 | Solar System Defense Award |
| 18 | 40 | Special Operations Medal |
| 19 | 30 | 1,000 Units Destroyed Medal |
| 20 | 70 | 10,000 Units Destroyed Medal |
| 21 | 50 | Ship Hunter Award |
| 22 | 70 | Gigaton Club Patch |
| 23 | 80 | Weapon Lord Patch |
| 24 | 100 | TCAF Pilot's Commendation |
## 3. The game evaluates them itself, from a disc config ✅ (numbering 🟡)
@@ -146,53 +158,19 @@ the code shifts by is that position or the id (see the ⚠️ above).
they are requirement *types* in the achievement table. Worth stating because the
strings sit next to genuinely debug-looking ones in the image.
## 4. The gate's own words — related, but not yet joined up 🟡
## 4. What this is *not*: the challenge-mission gate ✅ (refuted)
`GamePart_ChallengeMission` reads its two words off a **different** object, the
singleton at `0x821707C0` (lazily built behind the global `0x828F48B0`):
`GamePart_ChallengeMission` gates each mission on a bit of a word at singleton `+80`.
That word has exactly one writer in the image — `0x821C1820` in `GamePart_StageClear`
— and it sets `1 << (this+84)` where `this+84` is the **stage number** (it also
indexes a 20-byte per-stage record array and the debriefing config's `STAGE` sprite
list). So that word is a **cleared-stage** mask, not this achievement mask, and the
`< 24` / `>= 24` split is the disc's stage numbering (story 116 and tutorial 1823
below 24; challenge 2429 above), not a count of achievements.
- **`+80`** — tested for requirement values `< 24`;
- **`+1956`** — tested for values `>= 24` with bit `n-24`, so a **second, different
flag space** (the challenge screen's own five later requirements land here).
The split at exactly 24, against a table of exactly 24 achievements, is why `+80`
reads as the achievement mask.
⚠️ **But the join is not proven.** The Debriefing's masks live on the *Debriefing*
object (`+208`, `+736`, `+740`); nothing observed copies them into the singleton's
`+80`, and no writer of the singleton's `+80` bitmask has been found at all. Until
that link exists, "challenge `REQUIREMENT` *n* = achievement *n*" is a **hypothesis**.
Which reading wins also moves the answer by one: with §3.1's 1-based ids, the
`TimeAttack` requirement `16` would be achievement **16, `Night Ravens Patch`**, not
17 (`Solar System Defense Award`) — an earlier draft of this note asserted 17.
Note also that `+1960` is a third bitfield on the same singleton, and a "what changed"
pass (`0x8219F3A4`) diffs it before/after and reports each newly-set bit as
**`bit + 64`** — evidence of a wider flag-id space whose base for `+1956`/`+1960` is
not yet worked out.
**Not yet known: what writes them.** No `stw` to `+1956` anywhere in the image
targets this object — the ones that exist belong to a different singleton (reached
via `0x822CEB30`, an object with a `+2652` flag that stores *string pointers* at
`+1956`/`+2024`), so an earlier guess that the save serializer region wrote it is
**refuted**. `+80` is handled by address (`addi r4, obj, 80` into a copy helper at
`0x82175110`, written back through `0x8216FF70`), so it is a small struct read and
written as a value — consistent with being serialised somewhere, but that is not yet
demonstrated. Note also that XEX imports are **by ordinal**, so the absence of
`XamUser*` name strings in the `.pe` is *not* evidence that the mask does not come
from the profile.
**Ruled out on the save side:** the singleton is *not* the object carrying the
545-byte save block. Scanning all 202 call sites of the accessor with
function-boundary stops, only `+80`, `+1956`, `+1960` and `+1964` are ever touched on
it — **none** of the known save offsets (`+304` block start, `+316` flight time,
`+320` clear ratio, `+336` Points, `+380` develop blob, `+440` SHAB) appear. An
earlier pass without those boundary stops appeared to find them and was a
false positive from register tracking bleeding into the next function.
So the persistence question now has a positive answer on the other side (§3.1: the
title reads earned achievements from **XAM**) and the remaining work is to join the
XAM-derived mask to the gate's `+80`.
Nothing observed copies the Debriefing's achievement masks (`+208`, `+736`, `+740`)
into that word, which is the check that should have been done before the claim was
made rather than after.
## Reproduce