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

@@ -0,0 +1,143 @@
# Achievements — the 24-bit progress space the game gates content on
**Conf.:** ✅ for the table, the award path and the bit numbering; 🟡 for which
challenge mission consumes which bit; ❔ for where the earned bits persist.
**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.
## 1. The table: 24 achievements, 1000G ✅
The XEX embeds an SPA/XDBF resource. `XACH` sits at `.pe` offset **`0x8FBCBC`**:
| field | type | notes |
|---|---|---|
| magic | `char[4]` | `XACH` |
| version | `u32` | 1 |
| size | `u32` | 874 |
| count | `u16` | **24** |
then `count` records of **36 bytes**:
| off | type | field |
|---|---|---|
| 0 | `u16` | achievement id (1…24) |
| 2 | `u16` | name string id |
| 4 | `u16` | unlocked-description string id |
| 6 | `u16` | locked-description string id |
| 8 | `u32` | image id |
| 12 | `u16` | gamerscore |
| 14 | `u16` | pad (0) |
| 16 | `u32` | flags (`0x0C` throughout) |
| 20 | — | 16 bytes of zeroes |
Strings come from one `XSTR` section per language (7 present); each is
`magic[4] "XSTR"`, `version u32`, `size u32`, `count u16`, then entries of
`id u16, len u16, len bytes ASCII`. English is table **#5**.
**The stride and field offsets are self-checked**: the 24 gamerscores sum to
**exactly 1000**, the retail total. A wrong stride does not add up to a round 1000.
Ids run `1…24` with no gaps, so **bit `n` ↔ achievement id `n+1`**.
## 2. What each bit is ✅
| 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 |
## 3. The game awards them itself, from a disc config ✅
`GamePart_Debriefing` (`0x8218CF38``0x82191B18`, bounded by the factory creator
thunks either side) runs `sub_8218F9A8` after a mission:
```asm
for each entry of the ACHIEVEMENTS_REQUIREMENTS config list, index n:
bit = 1 << n
if (this+208 & bit) continue ; already awarded
if (evaluate(entry)) ; bl 0x8218FAB0
this+208 |= bit
```
so **the list index is the bit index**. The list is on disc — `tables.pak` entry
**#16**, schema `744c0519`, the `GP_DEBRIEFING_PILOTLOG.pak+eng` config — and its
entries are literally `ACHIEVEMENT01``ACHIEVEMENT24`, in order.
The record also carries each requirement's **type and parameters**:
`StageClear`(`Stage`), `MissionObjective`, `Item`, `Rank` (`S`),
`ShootDownAircrafts`(`Count` 1000 / 10000), `ShootDownShips`(`Count` 100),
`ShootDownWeight`(`MegaTons`), `GetAllWeapons`, `GetAllAchievements`.
**That set independently confirms the ordering.** The last five types line up with
ids 1924 exactly as the XACH table names them: 1 000 units, 10 000 units, 100
warships, one gigaton, all Delta Saber equipment, and finally the meta
"`GetAllAchievements`" → id 24, `TCAF Pilot's Commendation`, the 100G one. Two
independent sources agreeing on the order is what makes the bit mapping ✅ rather
than an inference.
⚠️ `GetAllAchievements` and `GetAllWeapons` **look like debug cheats and are not**
they are requirement *types* in the achievement table. Worth stating because the
strings sit next to genuinely debug-looking ones in the image.
## 4. Where the earned bits live ❔
`GamePart_ChallengeMission` reads them off a singleton (`0x821707C0`, lazily built
behind the global at `0x828F48B0`):
- **`+80`** — tested for requirement values `< 24`, i.e. **the 24 achievements above**;
- **`+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 matching a table of exactly 24 achievements is the reason to
read `+80` as the achievement mask.
**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.
Settling this is worth doing: if the mask is save-backed, a hand-written save
unlocks the challenge missions (the savegame round-trip is already solved); if it is
profile-backed, the emulator's profile is the lever instead.
## Reproduce
```bash
python3 tools/xach_dump.py "<disc>/…/Project Sylpheed ….pe"
cargo run --release -q -p sylpheed-formats --example achievements_map -- <disc-root>
python3 xenia-rs/zq.py dis 0x8218f9a8 0x8218fa60 # the award pass
```