Instructions and generated text that still sent readers to `xenia-rs`, which is
archived and deleted locally. Provenance and dated research records are left as
they are — "lifted from xenia-rs", or a finding stating what database it was
measured against, is still true, and rewriting it would falsify the record.
Changed, because each one tells someone what to do today or writes stale text:
docs/re/README.md the RE toolchain guide queried `xenia-rs/sylpheed.db`
with `xenia-rs/zq.py`, and its "Dynamic" bullet said
to prefer xenia-rs's probe suite OVER Canary. Now:
`sylpheed.db` at the repo root (how to build it and
re-stamp names), `tools/zq.py`, dynamic = Canary, and
`.rdata` reads from the `.pe` (offset = VA - 0x82000000)
in place of `--dump-addr`.
challenge-mission-gate.md, structures/achievements.md
reproduction commands `python3 xenia-rs/zq.py dis …`
crates/sylpheed-xexdb/SCHEMA.md
titled after the retired `xenia-analysis` crate,
citing `xenia-rs dis`, pointing at a `xenia-analysis`
source path
sylpheed-xexdb/src/formatter.rs
WROTE "generated by xenia-rs" into every disassembly
it produced (no test pins the banner)
sylph-xexdb.rs, db.rs "SQLite" / "DuckDB writer for xenia-rs"
sylpheed-formats/src/hash.rs
pointed at `xenia-rs/RE_SYMBOLS.md`, now at
`docs/re/RE_SYMBOLS.md`
Verified: every rewritten reproduction command was extracted from the doc and
run as written against the regenerated database — all 5 exit 0 and return the
disassembly they describe. The README's function count (25 676) is the database's
own. `cargo fmt --all -- --check` clean.
Not changed, deliberately: the README's Oracle bullet says Canary's native Linux
ELF "crashes / does not run". That is about Canary, not xenia-rs, and it conflicts
with a July note that the native build works — unverified either way here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 tools/zq.py dis 0x8218f9a8 0x8218fa60 # the requirement walk
|
||
python3 tools/zq.py dis 0x8218f888 0x8218f990 # the XACHIEVEMENT_DETAILS scan
|
||
```
|