Third revision of this claim, and this one has the writer.
Word A (singleton +80) has exactly ONE writer in the image. Scanning all 22
callers of the +80 struct copier (0x82175110) for one that stores to the copy's
word 0 gives a single hit, 0x821C1820, inside GamePart_StageClear:
if (this+1004 & 0x20000) skip ; already recorded
copy local = singleton->progress ; src = obj+80
local.word0 |= 1 << (this+84)
if changed: singleton->set(local) ; assign + async persist
and this+84 is the STAGE NUMBER, by two independent uses: 0x821C1760 indexes a
20-byte per-stage record array with it (this + (x+7)*20, three words plus an
8-byte timestamp -- the savegame's 16x20 SHAB shape), and 0x821C1EEC/0x821C1924
pass it as the index into the config key "STAGE" (0x820A2540), the debriefing's
px_deb_stage01..16 sprite list.
So a challenge mission's REQUIREMENT n means "stage n has been cleared", and the
<24 / >=24 split is the disc's own stage numbering: story 1-16 and tutorial 18-23
in word A, challenge 24-29 as word B bits 0-5. TimeAttack needs stage 16 -- the
last story mission -- and the other five chain off challenge stages 25-29. That
is what the raw numbers suggested in the first place.
REFUTED: "the bit space is the game's 24 achievements". ACHIEVEMENTS_REQUIREMENTS
having 24 entries and the gate splitting at 24 is a COINCIDENCE -- 24 is also the
first challenge stage's id. Nothing copies the Debriefing's masks (+208/+736/+740)
into +80, which is the check that should have preceded the claim.
The achievement work itself stands and is kept, retitled and rescoped in
structures/achievements.md: the XACH table (.pe 0x8FBCBC, 36-byte records,
1000G self-check), the 24 names/descriptions, the on-disc ACHIEVEMENTS_REQUIREMENTS
list, and the XACHIEVEMENT_DETAILS/XAM read path. It just does not gate the
challenge missions.
Still open: word B (+1956) has no known writer -- GamePart_StageClear's
unconditional 1 << x would land a challenge stage on word A bits 24-29, not word
B, so clearing a challenge mission must be recorded by another path (presumably
the one that also stores its Time/Points record).
183 lines
8.3 KiB
Markdown
183 lines
8.3 KiB
Markdown
# Achievements — the 24-entry table, and where the earned state comes from
|
||
|
||
**Conf.:** ✅ for the table and for the XAM read path; 🟡 for the requirement list's
|
||
bit numbering.
|
||
**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)
|
||
|
||
> **Retracted here, and it matters:** earlier drafts of this file claimed the
|
||
> challenge missions gate on this achievement mask. **They do not.** That gate reads a
|
||
> *cleared-stage* bitmask whose sole writer is `GamePart_StageClear` — see
|
||
> [challenge-mission-gate §5.2](../challenge-mission-gate.md). The link was invented
|
||
> out of a numeric coincidence: `ACHIEVEMENTS_REQUIREMENTS` has 24 entries and the
|
||
> challenge gate splits its two words at 24, for entirely unrelated reasons (24 is
|
||
> also the first challenge stage's id). Everything below is what survived checking the
|
||
> writer.
|
||
|
||
The title reads its earned-achievement state from the console, and separately carries
|
||
a disc table of what each achievement requires. Both are useful to the
|
||
reimplementation on their own terms.
|
||
|
||
## 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 1 of 2 (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. The 24 achievements ✅
|
||
|
||
Ids run `1…24` with no gaps. Where the image turns one into a bit it uses `1 << dwId`
|
||
(§3.1), so the id is the bit and bit 0 is unused — but note that this mask is *not*
|
||
the challenge gate's (§4).
|
||
|
||
| id | G | name |
|
||
|---|---|---|
|
||
| 1 | 20 | Space Combat Award |
|
||
| 2 | 20 | Schlos Base Defense Award |
|
||
| 3 | 20 | Aegis of the People Medal |
|
||
| 4 | 20 | TCAF Luna Medal |
|
||
| 5 | 40 | TCAF Mars Medal |
|
||
| 6 | 50 | Soldier's Charm Amulet |
|
||
| 7 | 20 | White Griffons Patch |
|
||
| 8 | 30 | TCAF Jupiter Medal |
|
||
| 9 | 40 | Furious Pursuit Badge |
|
||
| 10 | 30 | Solo Aerospace Combat Award |
|
||
| 11 | 30 | Operation Nebula Blaze Award |
|
||
| 12 | 40 | Guilty Roses Patch |
|
||
| 13 | 30 | Super Battleship Slayer Patch |
|
||
| 14 | 40 | TCAF Terra Medal |
|
||
| 15 | 40 | Hellfires Patch |
|
||
| 16 | 50 | Night Ravens Patch |
|
||
| 17 | 40 | Solar System Defense Award |
|
||
| 18 | 40 | Special Operations Medal |
|
||
| 19 | 30 | 1,000 Units Destroyed Medal |
|
||
| 20 | 70 | 10,000 Units Destroyed Medal |
|
||
| 21 | 50 | Ship Hunter Award |
|
||
| 22 | 70 | Gigaton Club Patch |
|
||
| 23 | 80 | Weapon Lord Patch |
|
||
| 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. What this is *not*: the challenge-mission gate ✅ (refuted)
|
||
|
||
`GamePart_ChallengeMission` gates each mission on a bit of a word at singleton `+80`.
|
||
That word has exactly one writer in the image — `0x821C1820` in `GamePart_StageClear`
|
||
— and it sets `1 << (this+84)` where `this+84` is the **stage number** (it also
|
||
indexes a 20-byte per-stage record array and the debriefing config's `STAGE` sprite
|
||
list). So that word is a **cleared-stage** mask, not this achievement mask, and the
|
||
`< 24` / `>= 24` split is the disc's stage numbering (story 1–16 and tutorial 18–23
|
||
below 24; challenge 24–29 above), not a count of achievements.
|
||
|
||
Nothing observed copies the Debriefing's achievement masks (`+208`, `+736`, `+740`)
|
||
into that word, which is the check that should have been done before the claim was
|
||
made rather than after.
|
||
|
||
## 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
|
||
```
|