`menu-navigation-semantics.md` advertised "the cheap way to finish this": read 0x828A690C as a live screen id and 0x828F38AC as the cursor, under --gpu=null with no screenshots. `menu-state-in-memory.md` withdrew exactly that identity ON THE SAME DAY it was published -- three back presses send the "cursor" 36 -> 38 -> 40 -> 41, and a cursor returns when you go back. They are monotonic counters; the cross-run agreement is the same key sequence producing the same count. The recommendation stood for three days after the page it cited had killed it, and either document would have been believed on its own. Marked withdrawn where it was recommended, with what the words ARE still good for (did the game react?) and the consequence: a measured button->GamePart-id binding stays unfinished because no screen enum has been located. Also delivers Q4 to HANDOFF in the shape the port asked for -- exactly one main-menu button opens a GP_TITLE entry (EXTRAS -> entry 6/9); the other four leave the archive. That was measured on 2026-08-28 and was reachable only from docs/re/, which the protocol counts as undelivered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
261 lines
14 KiB
Markdown
261 lines
14 KiB
Markdown
# The title menu — how it moves, and where each button goes
|
||
|
||
**Status:** ✅ `CONFIRMED` (**measured**, by driving the running game) for the
|
||
movement rules and for four of the five main-menu destinations. 🟡 the GamePart
|
||
*id* behind each destination is a **name match onto the decoded id table**, not a
|
||
measurement. ❔ `NEW GAME` deliberately untested.
|
||
|
||
Answers [MISSION Q5](../port/MISSION.md) and most of Q4. Nothing here is on the
|
||
disc in any form found so far — the port is **authoring** these rules from this
|
||
page, not transcribing a field.
|
||
|
||
## Q5 — movement
|
||
|
||
Two boots via `tools/re-capture/boot_menu.sh`, cursor read off the focus ring
|
||
with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
|
||
|
||
| | behaviour | evidence |
|
||
|---|---|---|
|
||
| **initial focus, main menu** | **`TUTORIAL`** — the *middle* item, not the top | 2/2 boots, the first frame after the menu appears |
|
||
| **initial focus, `EXTRAS`** | `MISSION SELECT` — the top item | [`extras-wrap.png`](captures/menu-nav/extras-wrap.png) |
|
||
| **up / down** | one item per press, no auto-repeat at the durations tried | |
|
||
| **wrap at the top** | ⬆ from the first item goes to the **last** | [`wrap-montage.png`](captures/menu-nav/wrap-montage.png), panels 1→2 |
|
||
| **wrap at the bottom** | ⬇ from the last item goes to the **first** | same, panels 3→4, and 4 presses from `EXTRAS` landing on `OPTIONS` — i.e. wrapping — is what makes the count come out |
|
||
| **left / right** | **nothing**, on the main menu | cursor unmoved across one ⬅ and one ➡ |
|
||
| **Ⓑ on a submenu** | returns to the parent **with focus restored to the item you entered from** — `LOAD GAME`→`LOAD GAME`, `TUTORIAL`→`TUTORIAL`, `OPTIONS`→`OPTIONS`, `EXTRAS`→`EXTRAS` | 4/4 |
|
||
| **Ⓑ on the main menu** | 🟡 goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | **none** — and the main menu's own footer does not advertise Ⓑ; [downgraded below](#-refutation-attempt-2026-08-29--the-main-menus-own-footer-does-not-advertise-ⓑ) |
|
||
| **Ⓑ on the title** | 🟡 **nothing** | **none** |
|
||
|
||
Wrap holds on both screens tested — the 5-item main menu and the 3-item `EXTRAS`
|
||
submenu — so it is a menu rule, not a per-screen table.
|
||
|
||
**🟡 Initial focus is reproducible but not established as invariant.** Both of my
|
||
boots opened on `TUTORIAL`, and both used `boot_menu.sh`. The run recorded in
|
||
[`menu-state-in-memory.md`](menu-state-in-memory.md) reached `EXTRAS` with *four*
|
||
downs from the main menu, which only works from `NEW GAME`. Either the harness
|
||
path matters or something persists. **Do not hardcode `TUTORIAL` without
|
||
re-testing it**; what is solid is that the menu does *not* open on the top item
|
||
in this harness.
|
||
|
||
## Q4 — where each button goes
|
||
|
||
Measured by driving: focus the item, press Ⓐ, read the screen's own title.
|
||
|
||
| button | screen it opens | evidence | GamePart id |
|
||
|---|---|---|---|
|
||
| `NEW GAME` | ❔ **not tested** | — | — |
|
||
| `LOAD GAME` | the save-slot list, `LOAD GAME` / `Current Storage` | [`q4-destinations.png`](captures/menu-nav/q4-destinations.png) left | 🟡 `3 GP_LOAD` |
|
||
| `TUTORIAL` | the lesson list, `TUTORIAL`, Level 1 / Level 2 | same, middle | 🟡 `25 GP_TUTORIAL` |
|
||
| `OPTIONS` | `OPTIONS` — GAME / CONTROL / SOUND / SCREEN SETTINGS / BACK | same, right | 🟡 `8 GP_OPTIONS` |
|
||
| `EXTRAS` | **`GP_TITLE.pak` build 6** — MISSION SELECT / MOVIE THEATER / BACK | [`ui-title-build-map.md`](ui-title-build-map.md) | 🟡 `5 GP_EXTRAS` |
|
||
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map — **8 rows visible of 16**, and rows below the first are **locked** on a fresh save ([below](#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked)) | [`mission-select-stage01-only.png`](captures/mission-select-stage01-only.png) | 🟡 `7 GP_MISSION_SELECT` |
|
||
| `EXTRAS ▸ MOVIE THEATER` | ❔ not tested | | 🟡 `6 GP_MOVIE_THEATER` |
|
||
|
||
**Say which, as the gate asks.** The *screen* each button opens is **measured** —
|
||
I pressed the button and read the title off the framebuffer. The **GamePart id is
|
||
not measured**: it is the entry of the decoded 29-id table at `.rdata 0x820A1630`
|
||
([`challenge-mission-gate.md`](challenge-mission-gate.md) §3) whose *name* matches
|
||
the screen I saw. The table is decoded; the *binding* of a button to an entry in
|
||
it is a name match I made by eye. The port should treat these ids as authored.
|
||
|
||
Worth noting that the ids and the paks are not one-to-one: `GP_EXTRAS` is id 5
|
||
with **no pak of its own** — its artwork is a build inside `GP_TITLE.pak`.
|
||
|
||
**❔ `NEW GAME` was deliberately not pressed.** Ⓐ on it leads to a standing
|
||
black-screen hang that ends the run
|
||
([`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md)),
|
||
and this iteration needed the session. It is the one destination still unmeasured.
|
||
|
||
### 🔴 The "cheap way to finish this" was a dead route — WITHDRAWN 2026-08-29
|
||
|
||
This section used to say: *`0x828A690C` holds a live screen id (1 title, 3 main
|
||
menu, 4 extras) and `0x828F38AC` the cursor; read them while pressing Ⓐ and the
|
||
transition becomes a measurement, under `--gpu=null` with no screenshots.*
|
||
|
||
**Do not do that. Those words are monotonic counters, not state.**
|
||
[`menu-state-in-memory.md`](menu-state-in-memory.md) withdrew that identity **on
|
||
the same day it was published** — driving deep and then pressing Ⓑ three times
|
||
sends the "cursor" `36 → 38 → 40 → 41`, and a cursor returns when you go back.
|
||
The values match across runs because the same key sequence produces the same
|
||
count, not because `3` *means* main menu. With the sign-in fix the sequence runs
|
||
`3 → 5`, skipping `4` entirely, so "4 = extras" was never a screen at all.
|
||
|
||
This page kept recommending the route for three days after the page it cited had
|
||
killed it. ⚠️ **A cross-reference is not a citation unless you re-read the target**
|
||
— the two documents disagreed in the corpus and either would have been believed
|
||
on its own.
|
||
|
||
✅ **What those words are still good for**: all three advance if and only if the
|
||
game responds to input, and are stable when it does not. Read them as *"did the
|
||
game react?"*, never as *"which screen is this?"*. Screen identity still has to
|
||
come off the framebuffer.
|
||
|
||
❔ **So a measured button→GamePart-id binding remains unfinished**, and for the
|
||
reason it always was: no screen/state enum has been located in guest memory. The
|
||
ids in the table below stay a name match.
|
||
|
||
## ✅ `NEW GAME` — measured 2026-08-28, and it is not a hang
|
||
|
||
The one destination this page could not test has been driven. **Ⓐ on `NEW GAME`
|
||
does not hang.** It opens two more menus first:
|
||
|
||
```
|
||
NEW GAME -> DIFFICULTY -> SELECT DATA -> guest crash
|
||
```
|
||
|
||
* **`DIFFICULTY`** — `EASY` / `NORMAL` / `HARD` / `BACK`, footer
|
||
`Ⓐ : OK Ⓑ : Back`, opening focused on **`NORMAL`**
|
||
([capture](captures/newgame-path/newgame-difficulty.png)). It sat unchanged for
|
||
90 s with the emulator healthy — a menu waiting for input, which is exactly
|
||
what "a standing hang" looks like to a screenshot loop that never presses
|
||
anything.
|
||
* Ⓐ on `NORMAL` → **`SELECT DATA`**, a save-slot picker headed
|
||
`Current Storage: Dummy HDD`, prompting to pick a file for the auto-save.
|
||
* Then the guest throws, and Xenia pauses with `PC: 0x82307128`
|
||
([capture](captures/newgame-path/newgame-selectdata-crash.png)).
|
||
|
||
**That crash is already in the corpus and is not new**: `0x82307128` is inside
|
||
`sub_823070B0`, the cache-manager STL erase documented in
|
||
[`title-crash-stl-tree.md`](title-crash-stl-tree.md), whose trigger is an
|
||
incomplete on-disc shader/code cache — not the menu path. The corpus also already
|
||
holds `captures/select-data-crash.png`.
|
||
|
||
So Q4's last row closes as **measured**:
|
||
|
||
| button | screen it opens |
|
||
|---|---|
|
||
| `NEW GAME` | **`DIFFICULTY`** (then `SELECT DATA`) |
|
||
|
||
🟡 The GamePart ids for these two are unmeasured, like the rest — `24 GP_DIALOG`
|
||
and `3 GP_LOAD` / `2 GP_SELECT_STORAGE` are name-match candidates and nothing
|
||
more.
|
||
|
||
### Initial focus — a fourth data point, and it still varies
|
||
|
||
This boot opened the main menu on **`NEW GAME`**. Running tally across four
|
||
boots of the same harness: `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`.
|
||
Unchanged conclusion: **do not hardcode it.**
|
||
|
||
### The `NEW GAME` path completes — the `SELECT DATA` crash is state, not path
|
||
|
||
A second run of the same path, 2026-08-28, **did not crash**:
|
||
|
||
```
|
||
NEW GAME → DIFFICULTY → (Ⓐ on NORMAL) → SELECT DATA → (Ⓐ on a slot) → a movie plays
|
||
```
|
||
|
||
The previous run's throw at `PC 0x82307128` is therefore **not inherent to this
|
||
menu path** — the same six presses got through it. That is consistent with the
|
||
trigger `title-crash-stl-tree.md` already names (an incomplete on-disc cache) and
|
||
with nothing about `NEW GAME`. 🟡 n = 1 either way; do not read it as "fixed".
|
||
|
||
Worth recording because the first observation could easily have hardened into
|
||
"the new-game path crashes", which is what "A on NEW GAME hangs" had already
|
||
become once.
|
||
|
||
---
|
||
|
||
## 🟡 Refutation attempt 2026-08-29 — the main menu's own footer does **not** advertise Ⓑ
|
||
|
||
**Attempted claim:** this page's row *"Ⓑ on the main menu goes to the title,
|
||
which re-draws `PRESS Ⓐ BUTTON` after a beat"*.
|
||
|
||
**Why this row and not another.** It is one of only **two** rows in the Q5 table
|
||
with an **empty evidence cell** (the other is "Ⓑ on the title → nothing"); every
|
||
row that cites a capture cites one. And it is a rule the port will build on
|
||
directly — it is the only way out of the main menu.
|
||
|
||
**The measurement** — whole-frame colour test for the pad-glyph discs, run by
|
||
[`tools/re-capture/footer_and_locked_rows.py`](../../tools/re-capture/footer_and_locked_rows.py)
|
||
against the committed captures:
|
||
|
||
| capture | Ⓐ glyph px | Ⓑ glyph px |
|
||
|---|---|---|
|
||
| `live-main-menu.png` | 438 | **0** |
|
||
| `live-main-menu-options-focused.png` | 438 | **0** |
|
||
| `live-extras.png` | 440 | 514 |
|
||
| `difficulty-screen.png` | 438 | 518 |
|
||
|
||
**The control passes twice over.** The same detector, unchanged, finds the red Ⓑ
|
||
on the two screens that visibly have one; and the **Ⓐ** count is 438/438/440/438
|
||
across all four, i.e. the same glyph asset at the same size on every screen — so
|
||
a Ⓑ of that family would have been ~450–520 px and cannot have fallen under a
|
||
threshold. The negative is over the **whole frame**, not a guessed footer band:
|
||
`live-main-menu.png` contains **zero** red-glyph pixels anywhere.
|
||
|
||
So the main menu's legend reads `⊙ : Select Ⓐ : OK` where every submenu reads
|
||
`⊙ : Select Ⓐ : OK Ⓑ : Back`.
|
||
|
||
**Verdict: the claim SURVIVES, at reduced confidence, and the row is downgraded
|
||
to 🟡.** A legend is not behaviour — a game may accept an unadvertised Ⓑ — so an
|
||
absent glyph cannot refute a press that was actually observed. But:
|
||
|
||
* the observation has **no capture behind it**, and it is now the only Q5 row
|
||
contradicted by the game's own on-screen text;
|
||
* there is a **named confound**: the title-side screens auto-return on idle, and
|
||
"I pressed Ⓑ and ended up at the title, which drew `PRESS Ⓐ BUTTON` after a
|
||
beat" is also exactly what an idle timeout looks like to an observer who does
|
||
not hold the two apart. The corpus documents that timeout at ~8–10 s
|
||
([`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)).
|
||
|
||
**What would settle it:** press Ⓑ on the main menu and read `0x828A690C`, the
|
||
live screen id (`1` title, `3` main menu, `4` extras) — a transition inside a
|
||
second is a Ⓑ, one at ~8–10 s regardless of the press is the timeout. Cheap, and
|
||
it needs no screenshots.
|
||
|
||
🔴 **Not runnable here.** This container has no disc and no ISO, so there is no
|
||
oracle at all — see
|
||
[`capture-harness-status.md`](capture-harness-status.md#-2026-08-29--the-disc-is-not-in-the-decoder-container-at-all).
|
||
|
||
**For the port:** Ⓑ from the main menu to the title is **measured, single
|
||
observation, uncited, and unadvertised by the game**. Implement it — it is the
|
||
only exit — but treat it as authored rather than transcribed, and do not also
|
||
build the idle-return on the assumption that the two are distinct until someone
|
||
has separated them.
|
||
|
||
---
|
||
|
||
## ✅ MISSION SELECT: the cursor was stuck because the stages were **locked**
|
||
|
||
**Measured 2026-08-29 from committed captures**, no disc needed. Settles the
|
||
⚠️ open in [`../game/navigation.md`](../game/navigation.md): *"sixteen d-pad
|
||
presses never left Stage 01 — whether that is because only one stage was
|
||
unlocked, or because the list is driven some other way, is unknown"*.
|
||
|
||
The stage list has **three** label brightnesses, not two, and that is what
|
||
discriminates. Sampling the label strip (x 190…320) of each of the 8 visible
|
||
rows, 95th percentile of luminance:
|
||
|
||
| capture | row 1 | rows 2–8 |
|
||
|---|---|---|
|
||
| `mission-select-stage01-only.png` | **254** | **104** |
|
||
| `mission-select-all-story-unlocked.png` | **254** | **183** |
|
||
| `mission-select-ends-at-stage16.png` | 183 | 183 ×6, then **254** on row 8 |
|
||
|
||
* **254** = focused (the row carrying the spinning focus ring)
|
||
* **183** = unlocked, not focused
|
||
* **104** = **locked**
|
||
|
||
The all-unlocked capture is the control: it holds row 1 focused at the identical
|
||
254 while rows 2–8 move 104 → 183 as one uniform step. So the dim rows in the
|
||
Stage01-only capture are **not** "unfocused"; unfocused is 183, and they are
|
||
79 levels below it.
|
||
|
||
**And the cursor does move when they are unlocked.** In
|
||
`mission-select-ends-at-stage16.png` the list has scrolled to show Stage09…16,
|
||
the scrollbar thumb is at the bottom, and the focus ring is on **Stage16** — the
|
||
last row. Locked list: 16 presses, no movement. Unlocked list: the cursor reaches
|
||
the end.
|
||
|
||
| | |
|
||
|---|---|
|
||
| **rows visible at once** | **8** |
|
||
| **list length** | **16** (`Stage01`…`Stage16`; the scrollbar bottoms out at 16) |
|
||
| **why 16 presses did nothing** | every row below the first was locked |
|
||
|
||
⚠️ **Reach.** This is a still image, so it says the cursor *reached* Stage16, not
|
||
how it got there and not whether the list wraps — the scroll thumb bottoming out
|
||
at row 16 is consistent with either. Whether a *locked* row is skipped or simply
|
||
unreachable is likewise not separated: with only row 1 unlocked the two are the
|
||
same observation.
|