From 90e0e66c7be0d1622fbee81eafaddfc10d44a099 Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Thu, 13 Aug 2026 19:43:35 +0000 Subject: [PATCH] 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. --- docs/re/INDEX.md | 2 +- docs/re/challenge-mission-gate.md | 23 ++++-- docs/re/structures/achievements.md | 111 ++++++++++++++++++++++------- 3 files changed, 103 insertions(+), 33 deletions(-) diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 6c6f7ba..da35274 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -43,5 +43,5 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes | Function | Conf. | Reimpl. | Summary | |----------|-------|---------|---------| -| Achievement award + content gating | ✅/❔ | [achievements](structures/achievements.md) | The title keeps its **own earned-achievement bitmask** and gates content on it. The XEX's `XACH` resource (`.pe` `0x8FBCBC`, 36-byte records) defines **24 achievements summing to 1000G** — the retail total, which self-checks the stride. `GamePart_Debriefing`'s `sub_8218F9A8` walks the on-disc `ACHIEVEMENTS_REQUIREMENTS` list (`tables.pak` #16, entries `ACHIEVEMENT01…24`) and sets **bit = list index** when the requirement evaluates true, so bit `n` ↔ achievement `n+1`. The list's requirement *types* (`ShootDownAircrafts` 1000/10000, `ShootDownShips` 100, `ShootDownWeight` MegaTons, `GetAllWeapons`, `GetAllAchievements`) line up with ids 19–24 exactly as `XACH` names them — two independent sources agreeing on the order. `GetAllAchievements`/`GetAllWeapons` are requirement **types**, not debug cheats. ❔ **where the mask persists** (save vs Xbox profile) is open; the obvious "save serializer writes `+1956`" lead is **refuted** (those stores are a different singleton's string pointers) | +| Achievement evaluation + content gating | ✅/🟡 | [achievements](structures/achievements.md) | The XEX's `XACH` resource (`.pe` `0x8FBCBC`, 36-byte records) defines **24 achievements summing to 1000G** — the retail total, which self-checks the stride and field offsets. `GamePart_Debriefing` (`0x8218CF38`–`0x82191B18`) does two things: it walks the on-disc `ACHIEVEMENTS_REQUIREMENTS` list (`tables.pak` #16, entries `ACHIEVEMENT01…24`, in achievement-id order — its `ShootDownAircrafts`/`ShootDownShips`/`ShootDownWeight`/`GetAllWeapons`/`GetAllAchievements` types line up with ids 19–24 exactly as `XACH` names them), evaluating each and setting a bit; **and it enumerates `XACHIEVEMENT_DETAILS` from XAM** — 36-byte records confirmed by the `0x38E38E39`+`srawi 3` divide-by-36, `dwId` at `+0`, `dwFlags & 0x00020000` (`…_ACHIEVED`) at `+32`, behind a waited-then-closed async handle. **So earned state comes from the console profile, not the 545-byte save** — and the masks it builds are `1 << dwId`, i.e. **bit = the 1-based id**. 🟡 the join to the challenge gate's word at singleton `+80` is *not* proven (the Debriefing's masks are its own `+208`/`+736`/`+740`, and no writer of `+80`'s bitmask has been found). `GetAllAchievements`/`GetAllWeapons` are requirement **types**, not debug cheats | | Stage-config section switch (`0x82184df0`, `0x82185ed0`) | ✅ | [challenge-mission-gate](challenge-mission-gate.md) | The stage loader picks its config section from a **mission-kind field at `object+144`**: `3` → `EXTRA`, `5`/`6` → `CHALLENGE`, anything else → `FILE`; two further sites treat `{3,5,6}` as one class. Constructed as `0` (`sub_821783D8`) and only ever *cleared* inside the class, so the kind comes from the launching GamePart, **not** from the stage number — which is a mechanism (🟡, unproven) for why patching the save's stage field to a challenge stage kills the load. Same note carries the **GamePart id table** (`0x820A1630`, 29 ids, `GP_CHALLENGE` = 26, cross-checked against the image's own `RegisterToFactory<26, …>` text) and the disc's **three stage families** — `S01`–`S16` story, `S18`–`S23` tutorial, `S24`–`S29` challenge, plus `Test`, matching `weapon.tbl`'s 16 + 6 + 6 key set exactly. `GP_CHALLENGE.pak` holds **0 IDXD objects** — it is the menu screen; challenge missions reuse `GP_MAIN_GAME_E.pak`'s stage records | diff --git a/docs/re/challenge-mission-gate.md b/docs/re/challenge-mission-gate.md index 9bc31ae..d74457f 100644 --- a/docs/re/challenge-mission-gate.md +++ b/docs/re/challenge-mission-gate.md @@ -173,9 +173,16 @@ resource holds the matching 24 achievement definitions, summing to **1000G**, th retail total. Full table and record layout: [`structures/achievements.md`](structures/achievements.md). -So **word A (`+80`) is the earned-achievement mask** (bit `n` = achievement `n+1`), -and **word B (`+1956`) is a second, different flag space** that requirement values -`≥ 24` index as `bit n-24`. +So **word A (`+80`) reads as the earned-achievement mask** and **word B (`+1956`) as a +second, different flag space** that requirement values `≥ 24` index as `bit n-24`. + +⚠️ **Corrected the same day:** an earlier draft said "bit `n` = achievement `n+1`" and +called the join ✅. Both were over-claimed. The Debriefing's masks live on the +*Debriefing* object (`+208`/`+736`/`+740`), nothing observed copies them into the +singleton's `+80`, and the one place the image is *seen* turning an achievement into a +bit does `1 << dwId` with **1-based** ids. So the mapping is 🟡 and, if it holds, is +`REQUIREMENT n` = achievement **`n`** — see +[achievements §3.1 and §4](structures/achievements.md). ### 5.3 What the six requirement values are 🟡 @@ -187,10 +194,12 @@ sound here** (the same trap the movie/subtitle map hit) — with one exception: value-before-key adjacency, so the **first** mission (`TimeAttack`) requiring **bit 16** is well-supported. -Bit 16 is achievement **17, "Solar System Defense Award"** — *"great achievements -during the campaign to defend the Solar System"*, i.e. **finish the story campaign**. -That is exactly the shape of gate you would expect on the first challenge mission, and -it is independent corroboration that the bit space is the achievement space. +Under the 1-based reading that §5.2 now prefers, bit 16 is achievement **16, +"Night Ravens Patch"** — *"for challenging and eradicating the Night Raven +Squadron"*, a stage-15-era award. (The withdrawn 0-based reading gave achievement 17, +"Solar System Defense Award"; both are plausible gates for a first challenge mission, +which is precisely why the numbering has to be settled by evidence rather than by +which story reads better.) The remaining five values (`25`–`29`, all `≥ 24`) therefore index **word B**, not achievements — most plausibly a challenge-clear chain, since there are six challenge diff --git a/docs/re/structures/achievements.md b/docs/re/structures/achievements.md index 3ef9993..7bd663c 100644 --- a/docs/re/structures/achievements.md +++ b/docs/re/structures/achievements.md @@ -43,7 +43,13 @@ Strings come from one `XSTR` section per language (7 present); each is **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`**. +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 ✅ @@ -74,50 +80,96 @@ Ids run `1…24` with no gaps, so **bit `n` ↔ achievement id `n+1`**. | 22 | 23 | 80 | Weapon Lord Patch | | 23 | 24 | 100 | TCAF Pilot's Commendation | -## 3. The game awards them itself, from a disc config ✅ +## 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 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 +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 ``` -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 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 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. +**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. Where the earned bits live ❔ +## 4. The gate's own words — related, but not yet joined up 🟡 -`GamePart_ChallengeMission` reads them off a singleton (`0x821707C0`, lazily built -behind the global at `0x828F48B0`): +`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`, i.e. **the 24 achievements above**; +- **`+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 matching a table of exactly 24 achievements is the reason to -read `+80` as the achievement mask. +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 @@ -130,14 +182,23 @@ 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. +**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 award pass +python3 xenia-rs/zq.py dis 0x8218f9a8 0x8218fa60 # the requirement walk +python3 xenia-rs/zq.py dis 0x8218f888 0x8218f990 # the XACHIEVEMENT_DETAILS scan ```