# 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`](../../../tools/xach_dump.py) ยท [`examples/achievements_map.rs`](../../../crates/sylpheed-formats/examples/achievements_map.rs) **Evidence:** [`captures/achievements-xach.txt`](../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](../challenge-mission-gate.md). The link was invented > out of a numeric coincidence: `ACHIEVEMENTS_REQUIREMENTS` has 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 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. 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: ```asm 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 **`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 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 ```bash python3 tools/xach_dump.py "/โ€ฆ/Project Sylpheed โ€ฆ.pe" cargo run --release -q -p sylpheed-formats --example achievements_map -- python3 xenia-rs/zq.py dis 0x8218f9a8 0x8218fa60 # the requirement walk python3 xenia-rs/zq.py dis 0x8218f888 0x8218f990 # the XACHIEVEMENT_DETAILS scan ```