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.
9.9 KiB
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 idn+1", i.e. 0-based bits. That was wrong-headed: the only place the image is observed turning an achievement into a bit does1 << dwIdwithdwId1-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 (0x8218CF38–0x82191B18, 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
ACHIEVEMENT01 … ACHIEVEMENT24, 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
0x38E38E39plussrawi 3, which is the standard unsigned/36sequence, so the stride is confirmed by the arithmetic and not just by inspection; - field
+0is used as a shift amount (1 << id), field+32is tested for bit0x00020000; - the buffer is produced asynchronously: a handle at
this+100is 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 19–24 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.
4. The gate's own words — related, but not yet joined up 🟡
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>= 24with bitn-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