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.
This commit is contained in:
@@ -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 "<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 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user