diff --git a/docs/re/menu-state-in-memory.md b/docs/re/menu-state-in-memory.md new file mode 100644 index 0000000..25fb458 --- /dev/null +++ b/docs/re/menu-state-in-memory.md @@ -0,0 +1,55 @@ +# The menu's screen id and cursor, in guest memory + +**✅ Found and verified 2026-08-26.** Menu navigation in this corpus has always +been driven by screenshots. That is unusable under `--gpu=null`, which is the +only configuration where the game does not hit the software-rasterizer freeze +([`mission-freeze-heap-exhaustion.md`](mission-freeze-heap-exhaustion.md)) — so +without a memory signal, the one backend that runs is the one that cannot be +steered. + +## The words + +| address | meaning | +|---|---| +| **`0x828A690C`** | **screen id** — `1` title, `3` main menu, `4` extras | +| **`0x828F38AC`** | **menu cursor** (a second copy at `0x828F38BC`) | +| `0x828F37B4` | tracks the screen, distinct value per menu | + +## How they were found + +Snapshot `0x82800000` + 3 MB at each menu of a **rendered** run, then keep the +4-byte words that (a) differ between screens and (b) hold small integer values. +Of 786 432 words, exactly **four** qualified — and one of them, +`0x828A690C`, has the property that matters: it changes on a screen *transition* +and stays put when only the highlight moves. + + screen: title -> main menu -> (4x down) -> extras + 828a690c: 1 -> 3 -> 3 -> 4 <- screen id + 828f38ac: 2 -> 4 -> 12 -> 14 <- cursor moves with the highlight + 828f37b4: 12 -> 45 -> 45 -> 49 + +## ✅ Verified against a second run on a different GPU backend + +The values were then read on a fresh `--gpu=null` run — **no display at all** — +while driving the same key sequence blind: + +| step | rendered (lavapipe) | blind (`--gpu=null`) | +|---|---|---| +| title | 1 / 2 / 12 | **1 / 2 / 12** | +| main menu | 3 / 4 / 45 | **3 / 4 / 45** | +| after 4× down | 3 / 12 / 45 | **3 / 12 / 45** | +| extras | 4 / 14 / 49 | **4 / 14 / 49** | + +**4 / 4 exact**, across two runs and two backends. That is the check that matters: +the words are not run-dependent, unlike the OB counter address this corpus had to +mark run-dependent earlier. + +`tools/re-capture/menu_state.py` reads them (`menu_state.py watch` polls and +prints on change). + +❔ Still open: the rest of the sequence into a **mission**. Blind driving reached +`extras` (screen 4) and a further Ⓐ did not move it — `4 / 14 / 49` twice — so +MISSION SELECT needs a cursor move first. The screen ids beyond 4 are unmapped; +the same snapshot-and-diff method extends to them, but it must be done on a +rendered run, and the rendered run **freezes on entering that screen**. The way +through is to map the ids up to the freeze and step blind past it. diff --git a/tools/re-capture/menu_state.py b/tools/re-capture/menu_state.py new file mode 100755 index 0000000..e20e6c4 --- /dev/null +++ b/tools/re-capture/menu_state.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Read the guest's menu state — screen id and cursor — from memory. + +Menu navigation in this corpus has always been driven by screenshots. That +breaks under `--gpu=null`, which is the only configuration where the game does +not hit the software-rasterizer freeze +(`docs/re/mission-freeze-heap-exhaustion.md`), so a memory-based signal is what +makes that backend usable as an oracle. + + ./menu_state.py # print screen/cursor once + ./menu_state.py watch [n] # poll n times, one line per change + +Found by snapshotting `0x82800000`+3 MB at each menu of a *rendered* run and +keeping the words that change on a screen transition but hold steady when only +the highlight moves. +""" + +import os +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import gworld # noqa: E402 + +SCREEN = 0x828A690C # 1 title, 3 main menu, 4 extras +CURSOR = 0x828F38AC # menu highlight; a second copy lives at 0x828F38BC +MISC = 0x828F37B4 # tracks the screen, distinct values per menu + +SCREEN_NAMES = {0: "booting", 1: "title", 3: "main-menu", 4: "extras"} + + +def read(w, va): + return struct.unpack(">I", os.pread(w.fd, 4, gmem.va_to_off(va)))[0] + + +def state(w): + s, c, m = read(w, SCREEN), read(w, CURSOR), read(w, MISC) + return s, c, m, SCREEN_NAMES.get(s, f"unknown({s})") + + +def main(): + w = gworld.World() + if len(sys.argv) > 1 and sys.argv[1] == "watch": + n = int(sys.argv[2]) if len(sys.argv) > 2 else 60 + last = None + for _ in range(n): + cur = state(w) + if cur != last: + print(f"screen={cur[0]} ({cur[3]}) cursor={cur[1]} misc={cur[2]}", + flush=True) + last = cur + time.sleep(1) + else: + s, c, m, name = state(w) + print(f"screen={s} ({name}) cursor={c} misc={m}") + + +if __name__ == "__main__": + main()