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).
8.3 KiB
Achievements — the 24-entry table, and where the earned state comes from
Conf.: ✅ for the table and for the XAM read path; 🟡 for the requirement list's
bit numbering.
Spec: tools/xach_dump.py ·
examples/achievements_map.rs
Evidence: captures/achievements-xach.txt
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. The link was invented out of a numeric coincidence:ACHIEVEMENTS_REQUIREMENTShas 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 ✅
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 1 of 2 (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. The 24 achievements ✅
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 🟡)
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. What this is not: the challenge-mission gate ✅ (refuted)
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 1–16 and tutorial 18–23
below 24; challenge 24–29 above), not a count of achievements.
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
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