# 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`](../../../tools/xach_dump.py) ยท [`examples/achievements_map.rs`](../../../crates/sylpheed-formats/examples/achievements_map.rs) **Evidence:** [`captures/achievements-xach.txt`](../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](../challenge-mission-gate.md)), 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` (`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. 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 `>= 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 ```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 ```