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.
6.2 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, 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:
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 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. 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>= 24with bitn-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
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