diff --git a/crates/sylpheed-formats/examples/achievements_map.rs b/crates/sylpheed-formats/examples/achievements_map.rs new file mode 100644 index 0000000..3b6e42c --- /dev/null +++ b/crates/sylpheed-formats/examples/achievements_map.rs @@ -0,0 +1,55 @@ +//! RE probe: the `ACHIEVEMENTS_REQUIREMENTS` config list. +//! +//! `GamePart_Debriefing` (`0x8218CF38`..`0x82191B18`) walks this list after a +//! mission (`sub_8218F9A8`): for each entry it takes the entry's **index** `n`, +//! tests bit `n` of an awarded-mask, and if the bit is clear it evaluates the +//! entry (`0x8218FAB0`) and sets the bit when satisfied. `GamePart_ChallengeMission` +//! then gates each challenge mission on a bit of the same space via its own +//! `REQUIREMENT` key. So this list *is* the achievement/bit numbering. +//! +//! Run: cargo run --release -p sylpheed-formats --example achievements_map -- + +use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive}; + +fn find(h: &[u8], n: &[u8]) -> bool { + h.windows(n.len()).any(|w| w == n) +} + +fn main() { + let disc = std::env::args().nth(1).unwrap_or_else(|| { + std::env::var("SYLPHEED_DISC").expect("pass disc root or set SYLPHEED_DISC") + }); + let dat = format!("{disc}/dat"); + let mut paks: Vec<_> = std::fs::read_dir(&dat) + .expect("dat dir") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "pak")) + .collect(); + paks.sort(); + + for p in &paks { + let Ok(arc) = PakArchive::open(p) else { continue }; + for (i, e) in arc.entries().iter().enumerate() { + let Ok(b) = arc.read(e) else { continue }; + if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") { + continue; + } + let name = p.file_name().unwrap().to_string_lossy().to_string(); + match IdxdObject::parse(&b) { + Ok(o) => { + let t = o.tokens(); + println!( + "\n===== {name} entry #{i} schema {:08x} {} tokens =====", + o.schema_hash, + t.len() + ); + for (j, tok) in t.iter().enumerate() { + println!(" {j:3} {tok}"); + } + } + Err(err) => println!("\n===== {name} entry #{i}: not IDXD ({err}) ====="), + } + } + } +} diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 4ae5942..6c6f7ba 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -43,4 +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) | | 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/captures/achievements-xach.txt b/docs/re/captures/achievements-xach.txt new file mode 100644 index 0000000..0682ebc --- /dev/null +++ b/docs/re/captures/achievements-xach.txt @@ -0,0 +1,76 @@ +XACH @0x8FBCBC 24 achievements (string table #5 of 7) + +id 1 | bit 0 | 20G | Space Combat Award + unlocked: Received after your first space battle in the Glasner Training Area. + locked : Awarded for participating in fighter combat in outer space. +id 2 | bit 1 | 20G | Schlos Base Defense Award + unlocked: Received for stopping the missile attack on Schlos Base. + locked : Awarded for stopping enemy attacks on Schlos Base. +id 3 | bit 2 | 20G | Aegis of the People Medal + unlocked: Received for escorting all 7 refugee ships to safety. + locked : Awarded for escorting the 7 refugee ships to safety. +id 4 | bit 3 | 20G | TCAF Luna Medal + unlocked: Received for bravely helping the fleet escape during the invasion of the Matisse System. + locked : Awarded for bravery beyond the call of duty to escort allied vessels to safety. +id 5 | bit 4 | 40G | TCAF Mars Medal + unlocked: Received for bravery beyond the call of duty during the escape from the Matisse System. + locked : Awarded for bravery beyond the call of duty under fierce enemy attacks. +id 6 | bit 5 | 50G | Soldier's Charm Amulet + unlocked: Given to you by Raymond as a token of his trust. + locked : Given to you by Raymond as a token of his trust. +id 7 | bit 6 | 20G | White Griffons Patch + unlocked: Received by the commander and pilots of the White Griffon Squadron when it is formed. + locked : Awarded to the commander and pilots of the White Griffon Squadron when it is formed. +id 8 | bit 7 | 30G | TCAF Jupiter Medal + unlocked: Received for bravery during the enemy's attack on the Alberti System. + locked : Awarded for bravery beyond the call of duty during withdrawal of the allied fleet. +id 9 | bit 8 | 40G | Furious Pursuit Badge + unlocked: Received for continuing attacks on the enemy and shooting down a large number of ships. + locked : Awarded for diligently shooting down large numbers of enemy ships. +id 10 | bit 9 | 30G | Solo Aerospace Combat Award + unlocked: Received for descending into Acheron's atmosphere and engaging in combat alone. + locked : Awarded for descending into the atmosphere and engaging in combat alone. +id 11 | bit 10 | 30G | Operation Nebula Blaze Award + unlocked: Received for completing the extremely hazardous Operation Nebula Blaze. + locked : Awarded for fulfilling duty and fighting bravely in this difficult operation. +id 12 | bit 11 | 40G | Guilty Roses Patch + unlocked: Received for repelling the Guilty Roses Squadron. + locked : Awarded for repelling the enemy Guilty Roses Squadron. +id 13 | bit 12 | 30G | Super Battleship Slayer Patch + unlocked: Received for shooting down the second S battleship. + locked : Awarded for shooting down the second S battleship supporting the enemy fleet. +id 14 | bit 13 | 40G | TCAF Terra Medal + unlocked: Received for shielding the fleet and seeing that all ships safely fled the Ingres System. + locked : Awarded for shielding the fleet to allow its safe escape. +id 15 | bit 14 | 40G | Hellfires Patch + unlocked: Received for repelling the enemy Hellfire Squadron. + locked : Awarded for repelling the enemy Hellfire Squadron. +id 16 | bit 15 | 50G | Night Ravens Patch + unlocked: Received for challenging and eradicating the Night Raven Squadron. + locked : Awarded for challenging and eradicating the enemy Night Raven Squadron. +id 17 | bit 16 | 40G | Solar System Defense Award + unlocked: Received for great achievements during the campaign to defend the Solar System. + locked : Awarded for great achievement during the campaign to defend the Solar System. +id 18 | bit 17 | 40G | Special Operations Medal + unlocked: Received for heroically destroying the Prometheus Driver. + locked : Awarded for heroism in destroying the enemy's main weapon. +id 19 | bit 18 | 30G | 1,000 Units Destroyed Medal + unlocked: Received for shooting down 1,000 enemy fighters and attackers in combat. + locked : Awarded for shooting down 1,000 enemy fighters and attackers in space combat. +id 20 | bit 19 | 70G | 10,000 Units Destroyed Medal + unlocked: Received for shooting down 10,000 enemy fighters and attackers in combat. + locked : Awarded for shooting down 10,000 enemy fighters and attackers in space combat. +id 21 | bit 20 | 50G | Ship Hunter Award + unlocked: Received for shooting down 100 enemy warships in combat. + locked : Awarded for shooting down 100 warships in space combat. +id 22 | bit 21 | 70G | Gigaton Club Patch + unlocked: Received for shooting down enemy vessels with a total weight of one gigaton. + locked : Awarded for downing several enemy vessels with a combined weight of one gigaton. +id 23 | bit 22 | 80G | Weapon Lord Patch + unlocked: Received after you collect all usable equipment for the Delta Saber. + locked : Awarded for collecting all usable equipment for the Delta Saber. +id 24 | bit 23 | 100G | TCAF Pilot's Commendation + unlocked: Commemmorates you as one of the greatest pilots in history. + locked : Awarded to the greatest pilots in the TCAF. + +total gamerscore = 1000 [OK: retail total] diff --git a/docs/re/challenge-mission-gate.md b/docs/re/challenge-mission-gate.md index a621487..9bc31ae 100644 --- a/docs/re/challenge-mission-gate.md +++ b/docs/re/challenge-mission-gate.md @@ -1,11 +1,15 @@ # Challenge / EX missions — the stage set, the GamePart graph, and the kind field **Status:** ✅ for the static structure (stage set, GamePart ids, the config-section -switch); 🟡 for the crash mechanism; ❔ for the unlock condition. +switch) and for the **unlock mechanism** (a bit test against the achievement mask); +🟡 for the crash mechanism and for which mission consumes which bit; ❔ for where the +mask persists. **Method:** static only — `.pe` string/pointer analysis + DuckDB disassembly + disc records. No emulator run, no gamepad input. **Evidence:** [`captures/challenge-map.txt`](captures/challenge-map.txt), -`crates/sylpheed-formats/examples/challenge_map.rs`. +[`captures/challenge-screen-config.txt`](captures/challenge-screen-config.txt), +[`structures/achievements.md`](structures/achievements.md); +`examples/challenge_map.rs`, `examples/challenge_screen.rs`. ## Why this was worth doing @@ -152,20 +156,47 @@ lookup REQUIREMENT in the mission record ; bl 0x82448C50 bit set ? AVAILABLE : LOCKED ``` -Word A and word B are read from a **singleton** (`0x821707C0`, lazily constructed -behind the global at `0x828F48BC`) at offsets **`+80`** and **`+1956`**. So challenge +Word A and word B are read from a **singleton** (`0x821707C0`; the object pointer +lives at the global `0x828F48B0`, with a construct-once flag at `0x828F48BC`) at +offsets **`+80`** and **`+1956`**. So challenge availability is one bit in a progress bitfield, and `REQUIREMENT` is that bit's index — **not** a stage number, a score, or a difficulty. -### 5.2 What the six requirement values are 🟡 +### 5.2 The bit space is the game's 24 ACHIEVEMENTS ✅ + +The `< 24` / `>= 24` split is not arbitrary. `GamePart_Debriefing` +(`0x8218CF38`–`0x82191B18`) walks a disc config list called +**`ACHIEVEMENTS_REQUIREMENTS`** (`tables.pak` entry #16, schema `744c0519`) and, for +entry index `n`, tests and sets **bit `n`** of an awarded-mask — and that list is +exactly `ACHIEVEMENT01` … `ACHIEVEMENT24`, **24 entries**. The XEX's own `XACH` +resource holds the matching 24 achievement definitions, summing to **1000G**, the +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`. + +### 5.3 What the six requirement values are 🟡 The record's numeric tokens are `16`, `25`, `26`, `27`, `29`, and `24`/`28` are already in the pool earlier (they double as font metrics), so they would be **deduped -away** if used. That is consistent with the six values being `24`–`29` — the same -range as the challenge stage records — but **IDXD dedupes the string pool, so -positional key/value pairing is not sound here** (the same trap the movie/subtitle map -hit). Recorded as a hypothesis: reading the values properly needs the record's binary -index section, not the pool. +away** if used. **IDXD dedupes the string pool, so positional key/value pairing is not +sound here** (the same trap the movie/subtitle map hit) — with one exception: `16` sits +*immediately* before `REQUIREMENT` (tokens 96 → 97), which is the documented +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. + +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 +missions and word B's bits `0`–`5` would be `24`–`29`. Recorded as a hypothesis; +resolving the per-mission pairing needs the record's binary index section, not the +pool. **Negative worth keeping:** the requirement *text* (`TimeAttackRequirement`, `Extra01Requirement`, …) is **not** in `GP_CHALLENGE.pak` — building a `TextIndex` @@ -175,14 +206,27 @@ keys resolve through a naming scheme the current loader does not reproduce. Read them would say in plain English what each mission asks for — worth one more attempt via `hash::TOC_NAME_SCHEMES`. -### 5.3 Why this matters operationally +### 5.4 Why this matters operationally — and one lead already refuted If word A / word B are restored from the save, a **hand-written save with those bits set unlocks all six challenge missions** — and the savegame round-trip is already solved. That would turn the last 42 units of the Route-B harvest into one run instead -of an unreachable menu. Three `stw`s to `+1956` sit in `0x822C7DD0` / `0x822C8748`, -i.e. the same code region as the save serializer (`0x822C00E8`) and deserializer -(`0x822C0380`) — suggestive, **not yet checked**. That is the next step. +of an unreachable menu. + +**The obvious lead is dead.** The three `stw`s to `+1956` in `0x822AF278` / +`sub_822C8748` looked promising because they sit in the save serializer's code region +— they are on a **different object**. That one is fetched through `0x822CEB30`, has a +`+2652` flag the code checks first, and stores **string pointers** at `+1956`/`+2024` +(built by `0x822D35F8`); a pointer `AND`ed with `1 << n` would be meaningless as a +gate. So **nothing in the image stores to this singleton's `+1956` field-wise**, which +means it is filled by a bulk copy or by a path not yet found. + +Two candidates remain and they need different levers: the **save** (hand-write it) or +the **Xbox profile** (the emulator's profile data). Note that XEX imports are resolved +**by ordinal**, so the absence of `XamUser*` name strings in the `.pe` is not evidence +against the profile route. `+80` at least is handled as a small struct by address +(`addi r4, obj, 80` → copy helper `0x82175110`, written back via `0x8216FF70`), which +is what a serialised value object looks like. ## 6. The unlock condition, from the strings ❔ diff --git a/docs/re/structures/achievements.md b/docs/re/structures/achievements.md new file mode 100644 index 0000000..3ef9993 --- /dev/null +++ b/docs/re/structures/achievements.md @@ -0,0 +1,143 @@ +# 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, so **bit `n` ↔ achievement id `n+1`**. + +## 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 awards them itself, from a disc config ✅ + +`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 + 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 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. + +⚠️ `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 ❔ + +`GamePart_ChallengeMission` reads them off a singleton (`0x821707C0`, lazily built +behind the global at `0x828F48B0`): + +- **`+80`** — tested for requirement values `< 24`, i.e. **the 24 achievements above**; +- **`+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. + +**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. + +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. + +## 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 +``` diff --git a/tools/xach_dump.py b/tools/xach_dump.py new file mode 100755 index 0000000..62f6fba --- /dev/null +++ b/tools/xach_dump.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Dump the title's Xbox 360 achievement table (XACH) out of the decrypted `.pe`. + +The XEX embeds an SPA/XDBF resource holding the achievement definitions and one +string table per language. `GamePart_Debriefing` awards these (it walks the +`ACHIEVEMENTS_REQUIREMENTS` config list and sets bit *i* for entry *i*), and +`GamePart_ChallengeMission` gates each challenge mission on a bit of the same +space — so this table names the bits. + +Layout, derived here and self-checked (see below): + XDBF section header : magic[4] "XACH", version u32, size u32, count u16 + record, 36 bytes : id u16, name_id u16, unlocked_desc_id u16, + locked_desc_id u16, image_id u32, gamerscore u16, + pad u16, flags u32, then 16 bytes of zeroes + XSTR section header : magic[4] "XSTR", version u32, size u32, count u16 + string entry : id u16, len u16, `len` bytes of ASCII + +Self-check: the 24 records' gamerscore sums to **1000**, the retail total — a +wrong stride or field offset does not add up to a round 1000. + +Usage: xach_dump.py [--lang-index N] +""" +import struct +import sys + + +def find_all(buf, needle): + out, i = [], 0 + while True: + i = buf.find(needle, i) + if i < 0: + return out + out.append(i) + i += 1 + + +def parse_xstr(d, off): + count = struct.unpack_from(">H", d, off + 12)[0] + o, table = off + 14, {} + for _ in range(count): + sid, ln = struct.unpack_from(">HH", d, o) + table[sid] = d[o + 4 : o + 4 + ln].decode("ascii", "replace") + o += 4 + ln + return table + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 1 + d = open(sys.argv[1], "rb").read() + + # The real XACH section is the one whose header count is small (the other + # "XACH" hit is the XDBF entry table, which merely *names* it). + xach = None + for off in find_all(d, b"XACH"): + ver, size, count = struct.unpack_from(">IIH", d, off + 4) + if ver == 1 and 0 < count < 256 and size >= count * 36: + xach = (off, count) + break + if xach is None: + print("no XACH section found", file=sys.stderr) + return 2 + off, count = xach + + # One XSTR per language. Default to English: the only table whose achievement + # strings are pure 7-bit ASCII (every other localisation carries accents or + # multi-byte text, which our ASCII decode turns into replacement chars). + strs = [parse_xstr(d, o) for o in find_all(d, b"XSTR") if len(d) - o > 16] + ids = [struct.unpack_from(">HHHH", d, off + 14 + i * 36)[1:] for i in range(count)] + lang = int(sys.argv[sys.argv.index("--lang-index") + 1]) if "--lang-index" in sys.argv else None + if lang is None: + def ascii_score(t): + txt = "".join(t.get(s, "") for rec in ids for s in rec) + return (txt.count("�") == 0 and len(txt) > 0, len(txt)) + lang = max(range(len(strs)), key=lambda i: ascii_score(strs[i])) + S = strs[lang] + + print(f"XACH @0x{off:X} {count} achievements (string table #{lang} of {len(strs)})\n") + total = 0 + for i in range(count): + b = off + 14 + i * 36 + aid, nid, did, lid = struct.unpack_from(">HHHH", d, b) + gs = struct.unpack_from(">H", d, b + 12)[0] + total += gs + print(f"id {aid:2d} | bit {aid - 1:2d} | {gs:3d}G | {S.get(nid, '?')}") + print(f" unlocked: {S.get(did, '?')}") + print(f" locked : {S.get(lid, '?')}") + print(f"\ntotal gamerscore = {total}" + (" [OK: retail total]" if total == 1000 else " [!! expected 1000]")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())