Files
Syplheed-Reborn/docs/re/structures/achievements.md
Claude (auto-RE) 90e0e66c7b re(achievements): earned state comes from XAM, and my bit numbering was wrong
Two findings, one of them a correction to the previous commit.

FOUND: GamePart_Debriefing enumerates XACHIEVEMENT_DETAILS from XAM
(0x8218F888). The records are 36 bytes -- confirmed by the arithmetic, not by
eye: the count is a byte count divided by 36 through the 0x38E38E39 multiply-high
magic plus srawi 3. Field +0 is used as a shift amount, field +32 is tested for
0x00020000, and the buffer sits behind a handle that is waited on (0x824AA330
with -1) then closed (0x824AA3E0). That is the XDK struct exactly, with
XACHIEVEMENT_DETAILS_ACHIEVED == 0x20000, via the
XamUserCreateAchievementEnumerator / XEnumerate pattern.

So the title does NOT persist earned achievements itself -- it asks the console.
The lever for achievement-gated content is the emulator's PROFILE data, not the
savegame. Independently, the singleton the challenge gate reads is not the object
holding the save block: re-scanning all 202 accessor call sites WITH function-
boundary stops touches only +80/+1956/+1960/+1964 and none of the known save
offsets (+304/+316/+320/+336/+380/+440). The earlier scan that seemed to find
them had register tracking bleeding into the next function -- a false positive I
am recording rather than quietly dropping.

CORRECTED: the previous commit claimed "bit n <-> achievement n+1" and treated
the Debriefing award pass and the challenge gate as the same bit space, both as
confirmed. Neither holds up:

- the only place the image is observed turning an achievement into a bit does
  1 << dwId with 1-based ids, so bit = the id and bit 0 is unused;
- the "list index is the bit index" step assumed 0x82448338's out-parameter is
  the loop ordinal. It is the child entry's first word out of a 12-byte array,
  and whether that is an ordinal, an id or a name hash is not pinned;
- the Debriefing's masks are its own fields (+208/+736/+740). Nothing observed
  copies them into the singleton's +80, and no writer of that bitmask has been
  found at all.

The requirement-type/id agreement still stands, but it confirms the LIST ORDER,
not the bit numbering. Consequence: if the join holds, TimeAttack's REQUIREMENT
16 is achievement 16 (Night Ravens Patch), not 17 (Solar System Defense Award).
Both read plausibly as a first-challenge gate, which is exactly why it needs
evidence and not the better story.

Also noted: +1960 is a third bitfield on the same singleton, and a what-changed
pass at 0x8219F3A4 diffs it and reports each newly set bit as bit + 64 -- so
there is a wider flag-id space whose bases are not yet worked out.
2026-08-13 19:43:35 +00:00

9.9 KiB
Raw Blame History

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 · examples/achievements_map.rs Evidence: 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), 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.

Correction (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

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 evaluates them itself, from a disc config (numbering 🟡)

GamePart_Debriefing (0x8218CF380x82191B18, bounded by the factory creator thunks either side) runs sub_8218F9A8 after a mission:

for i = 0, 1, 2, :
    node = child(ACHIEVEMENTS_REQUIREMENTS, i, &x)     ; bl 0x82448338
    bit  = 1 << x
    if (this+208 & bit) continue                      ; already awarded
    if (evaluate(this, node))                         ; bl 0x8218FAB0
        this+208 |= bit

The list is on disc — tables.pak entry #16, schema 744c0519, the GP_DEBRIEFING_PILOTLOG.pak+eng config — and its entries are literally ACHIEVEMENT01ACHIEVEMENT24, in order.

⚠️ x is not proven to be the loop index. 0x82448338 walks a 12-byte child array and writes the child entry's first word to the out-parameter; whether that word is the ordinal, an explicit id, or a name hash is not pinned. Since §3.1 shows the image elsewhere shifting by a 1-based achievement id, x is most likely the id too — but this file previously stated "the list index is the bit index" as fact, and that is withdrawn.

3.1 The earned state comes from XAM — the console profile, not the save

The same class enumerates a buffer of 36-byte records (0x8218F888):

  • the record count is a byte count divided by 36 — via the multiply-high magic 0x38E38E39 plus srawi 3, which is the standard unsigned /36 sequence, so the stride is confirmed by the arithmetic and not just by inspection;
  • field +0 is used as a shift amount (1 << id), field +32 is tested for bit 0x00020000;
  • the buffer is produced asynchronously: a handle at this+100 is waited on (0x824AA330(h, -1)) then closed (0x824AA3E0).

That is exactly the XDK's XACHIEVEMENT_DETAILS{ DWORD dwId; PWSTR pwszLabel, pwszDescription, pwszUnachieved; DWORD dwImageId, dwCred; FILETIME ftAchieved; DWORD dwFlags; } = 36 bytes, with XACHIEVEMENT_DETAILS_ACHIEVED == 0x00020000 — fetched through the XamUserCreateAchievementEnumerator / XEnumerate pattern.

So the title does not persist earned achievements itself: it asks the console. The masks it builds (this+208, +736, +740) are 1 << dwId, i.e. bit = the 1-based achievement id, bit 0 unused.

For anything that wants to unlock achievement-gated content, the lever is therefore the emulator's profile achievement data, not the 545-byte savegame.

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 list's ORDER (not the bit numbering): 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. So the config list is in achievement-id order; what it does not settle is whether the bit the code shifts by is that position or the id (see the ⚠️ above).

⚠️ 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.

GamePart_ChallengeMission reads its two words off a different object, the singleton at 0x821707C0 (lazily built behind the global 0x828F48B0):

  • +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.

Reproduce

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 requirement walk
python3 xenia-rs/zq.py dis 0x8218f888 0x8218f990     # the XACHIEVEMENT_DETAILS scan