diff --git a/docs/re/menu-navigation-semantics.md b/docs/re/menu-navigation-semantics.md index 9bd44b85..7b3b7046 100644 --- a/docs/re/menu-navigation-semantics.md +++ b/docs/re/menu-navigation-semantics.md @@ -23,8 +23,8 @@ with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py). | **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 | | -| **Ⓑ on the title** | **nothing** | | +| **Ⓑ 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. @@ -48,7 +48,7 @@ Measured by driving: focus the item, press Ⓐ, read the screen's own title. | `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 | | 🟡 `7 GP_MISSION_SELECT` | +| `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** — @@ -134,3 +134,109 @@ 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. diff --git a/tools/re-capture/footer_and_locked_rows.py b/tools/re-capture/footer_and_locked_rows.py new file mode 100755 index 00000000..0e138bcb --- /dev/null +++ b/tools/re-capture/footer_and_locked_rows.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Re-measure two menu facts from the COMMITTED oracle captures — no disc needed. + + 1. Which screens advertise Ⓑ in their footer legend. + The pad glyphs are saturated green (Ⓐ) and red (Ⓑ) discs on a blue field, + so a colour test finds them without knowing where the footer is. + + 2. Whether a dim MISSION SELECT row is LOCKED or merely UNFOCUSED. + Three brightness levels discriminate; the all-unlocked capture is the + control that separates them. + +Usage: python3 tools/re-capture/footer_and_locked_rows.py [repo-root] +""" +import sys +import pathlib +import numpy as np +from PIL import Image + +ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() +CAP = ROOT / "docs/re/captures" + + +def glyph_masks(rgb): + r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] + green = (g > 110) & (g > r + 45) & (g > b + 45) + red = (r > 110) & (r > g + 45) & (r > b + 45) + return green, red + + +def blobs(mask, gap=20): + ys, xs = np.nonzero(mask) + if len(xs) == 0: + return [] + o = np.argsort(xs) + xs, ys = xs[o], ys[o] + out, start = [], 0 + for i in range(1, len(xs) + 1): + if i == len(xs) or xs[i] - xs[i - 1] > gap: + s = slice(start, i) + out.append((int(xs[s].min()), int(xs[s].max()), + int(ys[s].min()), int(ys[s].max()), i - start)) + start = i + return out + + +def footers(): + print("== 1. footer legends: does the screen advertise Ⓑ? ==") + print(f"{'capture':46} {'Ⓐ px':>7} {'Ⓑ px':>7} verdict") + shots = [ + ("title-builds/live-main-menu.png", "main menu"), + ("title-builds/live-main-menu-options-focused.png", "main menu (OPTIONS focused)"), + ("title-builds/live-extras.png", "EXTRAS"), + ("difficulty-screen.png", "DIFFICULTY"), + ] + for rel, _name in shots: + p = CAP / rel + if not p.exists(): + print(f"{rel:46} MISSING") + continue + a = np.asarray(Image.open(p).convert("RGB")).astype(int) + g, r = glyph_masks(a) # WHOLE frame, not a guessed band + verdict = "no Ⓑ anywhere in frame" if r.sum() == 0 else f"Ⓑ at {blobs(r)[0][:2]}" + print(f"{rel:46} {g.sum():7d} {r.sum():7d} {verdict}") + + +ROW_Y0, ROW_PITCH, ROW_X = 201, 50, (190, 320) + + +def stage_rows(): + print("\n== 2. MISSION SELECT rows: locked, or just unfocused? ==") + shots = [ + ("mission-select-stage01-only.png", "save with only Stage01 cleared"), + ("mission-select-all-story-unlocked.png", "save with the story unlocked"), + ("mission-select-ends-at-stage16.png", "unlocked, scrolled to the end"), + ] + for rel, note in shots: + p = CAP / rel + if not p.exists(): + print(f"{rel:44} MISSING") + continue + a = np.asarray(Image.open(p).convert("L")).astype(float) + p95 = [] + for i in range(8): + y = ROW_Y0 + ROW_PITCH * i + p95.append(np.percentile(a[y - 14:y + 14, ROW_X[0]:ROW_X[1]], 95)) + print(f"{rel:44} {note}") + print(" row p95: " + " ".join(f"{v:5.0f}" for v in p95)) + + +if __name__ == "__main__": + footers() + stage_rows()