From c791b3c374f4a12aeafc6e26802ed23ef38ca6eb Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Wed, 26 Aug 2026 18:21:04 +0000 Subject: [PATCH] re: find the menu screen id and cursor in guest memory Menu navigation here has always been screenshot-driven, which is unusable under --gpu=null -- the only configuration where the game does not hit the software-rasterizer freeze. Without a memory signal, the one backend that runs is the one that cannot be steered. Snapshotting 0x82800000+3 MB at each menu of a rendered run and keeping the 4-byte words that differ between screens and hold small integers leaves exactly four of 786 432. One has the property that matters -- it changes on a screen transition and holds steady when only the highlight moves: 0x828A690C screen id 1 title, 3 main menu, 4 extras 0x828F38AC menu cursor (second copy at 0x828F38BC) 0x828F37B4 per-menu value Verified on a fresh --gpu=null run with no display at all, driving the same keys blind: title 1/2/12, main menu 3/4/45, after 4x down 3/12/45, extras 4/14/49 -- 4/4 exact against the rendered run, across two runs and two GPU backends. That is the check that matters, since this corpus has already had to mark one runtime address run-dependent. tools/re-capture/menu_state.py reads them; `menu_state.py watch` prints on change. Open: the rest of the sequence into a mission. Blind driving reached extras (screen 4) and a further A did not move it, so MISSION SELECT needs a cursor move first. Screen ids beyond 4 are unmapped, and the rendered run freezes on entering that screen -- so map ids up to the freeze, then step blind past it. --- docs/re/menu-state-in-memory.md | 55 +++++++++++++++++++++++++++++ tools/re-capture/menu_state.py | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docs/re/menu-state-in-memory.md create mode 100755 tools/re-capture/menu_state.py diff --git a/docs/re/menu-state-in-memory.md b/docs/re/menu-state-in-memory.md new file mode 100644 index 00000000..25fb458a --- /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 00000000..e20e6c40 --- /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()