re(challenge): the unlock is a bit test, and the mission table is on disc

Static only. Bounding each GamePart's code block by its factory creator thunk
(id -> creator recovered for 22 of 24 registrations at 0x8280C000-0x8280F800)
puts GamePart_ChallengeMission at 0x82187E60-0x8218CF10. Resolving every string
that block references gives the screen's config schema, and the record itself is
on disc -- tables.pak schema 54a10697, one copy per language, English entry #64.

Six missions: TimeAttack (record Time), ScoreAttack (record Points) and
Extra01..Extra04, each with MISSION_ID / REQUIREMENT / REQUIREMENT_DESC /
THUMBNAIL / STAGE_DESC / NEW_STAGE and a NORMAL_BUTTON / GRAY_BUTTON pair -- so
the screen always lists all six and greys out what is not earned.

THE GATE (0x82189970-0x821899D8), read off the code:

  REQUIREMENT absent      -> available
  REQUIREMENT == "Always" -> available
  else n = atoi(REQUIREMENT)
      n == 0              -> locked
      n <  24             -> test bit n      of the word at singleton+80
      n >= 24             -> test bit (n-24) of the word at singleton+1956

The singleton is 0x821707C0 (lazy, global 0x828F48BC). So availability is one
bit in a progress bitfield and REQUIREMENT is a bit INDEX -- not a stage number,
score or difficulty.

Values per mission are 🟡: the pool's numeric tokens are 16/25/26/27/29 and
24/28 already appear earlier as font metrics, so they would be deduped -- which
fits 24..29 but IDXD dedup makes positional pairing unsound here, so it is
recorded as a hypothesis, not a table.

Negative: the requirement TEXT is not in GP_CHALLENGE.pak (TextIndex over it =
0 entries; its only prose is embedded font copyright). Its PATH is a per-language
branch the loader does not currently reproduce.

Next: three stores to +1956 sit in 0x822C7DD0 / 0x822C8748, the same region as
the save serializer 0x822C00E8 -- if the bits are save-backed, a hand-written
save unlocks all six challenge missions and the last 42 units become one run.
This commit is contained in:
2026-08-13 19:14:48 +00:00
parent 10a96844ba
commit 8ecd70f1bc
3 changed files with 908 additions and 3 deletions

View File

@@ -109,7 +109,82 @@ trap) but it is **not proven** — proving it needs a run.
range", and the kind field is the likely gate. It has never been tested — only
116 and 2429 were.
## 5. The unlock condition ❔
## 5. The challenge screen, its six missions, and the unlock gate ✅
The factory registration sites (`0x8280C000``0x8280F800`) give **id → creator** for
22 of the 24 registered parts, and the creators bound each class's code block. So
`GamePart_ChallengeMission`'s methods are **`0x82187E60``0x8218CF10`** (between
`GP_BUNK`'s creator `0x82187E38` and its own `0x8218CF10`). Resolving every string
that range references gives the screen's whole config schema:
```
MISSIONS · MISSION_ID · RECORD_TYPE · REQUIREMENT · REQUIREMENT_DESC
NORMAL_BUTTON · GRAY_BUTTON · THUMBNAIL · STAGE_DESC · TEXT_STAGE
TEXT_RECORD · NEW_STAGE · "Always" · "Time" · BASE_INFO
```
**The record itself is on disc**, in `tables.pak` (schema `54a10697`, one copy per
language — English is entry #64), *not* in `GP_CHALLENGE.pak`. It names six missions:
| slot | `MISSION_ID` | buttons / thumbnail | record |
|---|---|---|---|
| 1 | `TimeAttack` | `Button_TimeAttack{,_Gray}`, `Thumbnail_TimeAttack` | `Time` |
| 2 | `ScoreAttack` | `Button_ScoreAttack{,_Gray}` | `Points` |
| 36 | `Extra01``Extra04` | `Button_Extra0N{,_Gray}` | — |
`GRAY_BUTTON` is the locked art, `NORMAL_BUTTON` the unlocked art — so the screen
always shows all six and greys out what you have not earned. Full dump:
[`captures/challenge-screen-config.txt`](captures/challenge-screen-config.txt).
### 5.1 The gate is a bit test against a progress bitfield ✅
`0x82189870` builds the list, and `0x82189970``0x821899D8` is the availability
decision for each mission:
```asm
lookup REQUIREMENT in the mission record ; bl 0x82448C50
absent -> AVAILABLE
== "Always" -> AVAILABLE ; strcmp, bl 0x825EDD20
else n = atoi(v) ; bl 0x825EDCD0
n == 0 -> LOCKED
n < 24 -> bit n of word A
n >= 24 -> bit (n-24) of word B
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
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 🟡
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.
**Negative worth keeping:** the requirement *text* (`TimeAttackRequirement`,
`Extra01Requirement`, …) is **not** in `GP_CHALLENGE.pak` — building a `TextIndex`
over it yields **0 entries** and its only wordy payload is embedded font copyright.
The config's `PATH` is `dat\GP_CHALLENGE.pak+eng\`, a per-language branch, so those
keys resolve through a naming scheme the current loader does not reproduce. Reading
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
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.
## 6. The unlock condition, from the strings ❔
Three strings say challenge missions are *announced*, not menu-browsed:
@@ -126,7 +201,7 @@ config lookup, exactly like the save screen's sprite keys, so there is nothing t
grep back to. Naming the flag needs the challenge-availability check disassembled
from the screen side.
## 6. What this makes actionable
## 7. What this makes actionable
`roster_target` against the harvested CSV, with the stage families now named:
@@ -151,7 +226,9 @@ rest are not in any stage roster table and need a different lever.
## Reproduce
```bash
cargo run --release -q -p sylpheed-formats --example challenge_map -- <disc-root>
cargo run --release -q -p sylpheed-formats --example challenge_map -- <disc-root>
cargo run --release -q -p sylpheed-formats --example challenge_screen -- <disc-root>
python3 xenia-rs/zq.py dis 0x82184df0 0x82184e30 # the three-way section switch
python3 xenia-rs/zq.py dis 0x82185ed0 0x82185f10 # the same switch, second site
python3 xenia-rs/zq.py dis 0x82189860 0x821899f0 # the challenge availability gate
```