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.
205 lines
9.9 KiB
Markdown
205 lines
9.9 KiB
Markdown
# 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 "<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
|
||
```
|