Files
Syplheed-Reborn/docs/re/structures/achievements.md
Claude (auto-RE) 33ae20896e 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.
2026-08-13 19:32:43 +00:00

144 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```