re(challenge): the gate's bit space is the game's 24 ACHIEVEMENTS

Static only. Last commit left "REQUIREMENT is a bit index into a progress
bitfield" with the space unidentified. It is the achievement space, and both
halves are now readable off the disc and the executable.

- GamePart_Debriefing (0x8218CF38-0x82191B18) awards them: sub_8218F9A8 walks
  the on-disc ACHIEVEMENTS_REQUIREMENTS list (tables.pak #16, schema 744c0519),
  and for entry index n tests bit n, evaluates the entry when clear, and sets
  the bit when satisfied. The list is literally ACHIEVEMENT01..ACHIEVEMENT24 --
  24 entries, which is exactly where the challenge gate splits word A from
  word B.

- The XEX carries the definitions: XACH at .pe 0x8FBCBC, 36-byte records
  {id, name_id, unlocked_desc_id, locked_desc_id, image_id u32, gamerscore u16,
  pad, flags u32, 16 zero bytes}, strings from one XSTR per language (English is
  table #5). tools/xach_dump.py parses it. SELF-CHECK: the 24 gamerscores sum to
  exactly 1000, the retail total -- a wrong stride does not land on a round 1000.

- The two sources agree on ORDER independently: the requirement types
  ShootDownAircrafts 1000/10000, ShootDownShips 100, ShootDownWeight MegaTons,
  GetAllWeapons and GetAllAchievements line up with ids 19-24 exactly as XACH
  names them. So bit n <-> achievement n+1 is evidence, not inference. (Those
  last two are requirement TYPES, not debug cheats, despite how they read.)

- Corollary: TimeAttack's REQUIREMENT 16 -- the one value that sits in direct
  value-before-key adjacency, so it survives IDXD dedup -- is bit 16 =
  achievement 17, "Solar System Defense Award", i.e. finish the story campaign.
  The other five values (25-29) are >= 24 and so index word B, a second flag
  space, plausibly a challenge-clear chain. Still 🟡.

REFUTED, from the last commit: the stores to +1956 in 0x822AF278 / sub_822C8748
are NOT this singleton. That object comes from 0x822CEB30, checks a +2652 flag
and stores string POINTERS at +1956/+2024 -- and a pointer ANDed with 1<<n is
meaningless as a gate. So nothing in the image writes this singleton's +1956
field-wise, and where the mask persists (save vs Xbox profile) is open. XEX
imports are by ordinal, so absent XamUser* strings are not evidence either way.
This commit is contained in:
2026-08-13 19:32:43 +00:00
parent 8ecd70f1bc
commit 33ae20896e
6 changed files with 427 additions and 14 deletions

View File

@@ -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 1924 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 |

View File

@@ -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]

View File

@@ -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 ❔

View File

@@ -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 1924 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 "<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
```