Files
Syplheed-Reborn/docs/re/structures/achievements.md
Claude (auto-RE) 90e0e66c7b re(achievements): earned state comes from XAM, and my bit numbering was wrong
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.
2026-08-13 19:43:35 +00:00

205 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1924 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
```