jevsnes.git / apps / native

Chapter 12: native, the window

Everything so far comes together here: the SNES in a window, played by the zbanks/alttp C bot, with Jev at its goal choice if you ask, and panels around the picture saying what is going on. The picture is never drawn over, so nothing covers the game's own HUD.

nix develop -c buck2 run //apps/native:native -- \
  "roms/Legend of Zelda, The - A Link to the Past (USA).sfc"

With --jev (or ZB_JEV=1) Jev makes the bot's goal choice (chapter 5), which needs the key in the window's environment:

op-env-run -- nix develop -c buck2 run //apps/native:native -- --jev \
  "roms/Legend of Zelda, The - A Link to the Past (USA).sfc"

Without --jev nothing in the window asks Jev and the bot plays exactly as upstream's. With it and no key, it plays the same and the Jev panel says why Jev is off. Each question blocks the frame loop for its round trip (about 150 ms measured), so the game pauses for a blink while Jev thinks.

Aside: you can take the controller. Every frame the window hands the bot the human's pad (your keyboard, plus anything an MCP press holds) and holds whatever pad the bot returns, exactly the hook upstream's Snes9x used. The bot zeroes the pad and plays, except that holding X (the S key) makes it step aside and pass your pad through: upstream's own manual override (alttp.c:57). The Bot panel says when it has stepped aside.

KeyPad
ArrowsD-pad
X / ZA / B
S / AX / Y
Q / WL / R
Enter / BackspaceStart / Select
- / =Playback speed down / up

What happens before the window opens

  1. The ROM is patched the way the Randomizer the bot was written for differs from vanilla where the bot can tell (zbanks::rando::patch_rom).
  2. The bot starts: ap_init caches its RAM pointers and loads the states home and hpegs from <rom>.states/. Make home once with chapter 11's --make-home home --states "roms/<rom>.states". Without it the bot starts at power-on, where it presses nothing, and the log says so.
  3. Unless --fresh, the machine then goes back to resume, the state the last window kept, as a Snes9x user loading their own state after the bot was up.

The bot's printf trace goes to run/zbanks-window/bot.log (this process's stdout is a pipe that a thread copies into that file, rotated at 50 MB with three old files kept). Its debug files (goals.txt, map, map.pbm, full_map.dot, ...) go beside it. The window's own messages are stderr.

The panels

  • Bot: the bot's own line (ap_info_string, what upstream's Snes9x drew over the picture), the frame, where it puts Link, its task list (current first, green) and its goals with the score the planner last gave each (dim: unsatisfiable or over the limit; green: complete), assert_bp traps it has stepped over and reads of unmapped memory (red). A red "STALLED" line when the bot has stopped playing, and under it what the bounded recovery is doing about it: amber while it is still trying, red once it has given up for good.
  • MCP: the server's address, a light (amber: listening; green: a client has been heard from; red: the port would not bind, with the reason), and a log of every tool call with its arguments and outcome.
  • State: the game in words, from chapter 3.
  • Jev: whether it is on; the shared spend guards in one line (questions in the last minute against the bucket of 30, every process's; dollars in the last hour; spent so far, with no cap); in amber, whatever is stopping questions right now ("throttled: bucket empty, next question in 12s" clears by itself, "out of credit (API)" does not); the totals; a usage graph by game frame across the current recording, with a playhead that follows the scrubber; and a newest-first history of every choice this window made, plus the last window's (loaded from run/zbanks-window/jev.jsonl). Click a row to see every option in the words Jev was given, its probability, the pick (>) and upstream's own (u), and for a choice that asked, the exact request, tokens, latency and cost.
  • Pad: the pad the console held on the last frame (the bot's), lit; a button an MCP press holds carries its confidence as a percentage.
  • Scrubber: directly under the picture: play/pause, skip to end, a speed control (0.25x to 8x), "run the bot from here", and a timeline bar to click or drag. Marks show where other branches forked off.

Bot and MCP share the left column; State and Jev share the right. The layout is one function of the window's size (chapter 7).

The replay timeline

Every emulated frame of live play is recorded (chapter 6) to run/zbanks-window/replay/<recording-id>/, and the scrubber turns that into VCR controls:

  • Click or drag the bar to jump to any recorded frame and pause there: the nearest keyframe, then the log replayed forward, headless. The bot is never asked anything while scrubbing.
  • Play replays the log from a paused frame; running off the end hands back to the live bot at exactly the frame it had reached, byte for byte, so nothing is reconstructed and nothing stutters.
  • Speed scales both replay and live play; sound mutes at anything but 1x, and the choice survives a restart.
  • Run the bot from here restarts the window with --branch-from, which replays the whole history up to that frame through a fresh bot (a second one cannot exist in this process) before going live as a new branch. The scrubber shows "rebuilding bot: N/M frames" meanwhile. The old future is kept.

Try it. Let the bot play for a minute, drag the scrubber back thirty seconds, and press play. Then try "run the bot from here" and watch the new branch's mark appear on the bar.

Restarting without losing the game

A snapshot is the whole machine (1.3 MB), kept as <rom>.states/<name>.state beside the ROM (gitignored). The window keeps one for itself, resume: every 30 seconds, on exit, and before a restart. It resumes from it unless given --fresh, and a restart drops --fresh from the arguments it re-runs with, so it carries on the game.

The bot's state is not in a snapshot: it is the C bot's globals, and a restart starts a new bot. What it learned of the map is carried the way upstream carried it, as a map file: whenever the window keeps resume it also exports the bot's map to run/zbanks-window/map_export.txt (written to a .part name and renamed, so a killed window leaves the last whole one), and a window starting copies that to map.19.txt, which the new bot's first tick imports. Each goal's outcome is carried too (chapter 4's goal_outcomes.jsonl); the goal list itself is rebuilt from the map.

A snapshot names the core version and the cartridge it was taken from, and is refused by any other; the window then plays from home and says why. The cartridge here is the ROM as patched for the bot, so snapshots of the unpatched image are refused.

The cartridge save is read from and written to a .sav beside the ROM (gitignored), every few seconds and on exit. --sound turns audio on; it is off by default because under WSLg it cuts in and out (heard 2026-09-20).

MCP: the window answers questions

The window serves MCP (chapter 8) over streamable HTTP at http://127.0.0.1:7637/mcp. A client does not connect to that. It starts native mcp, the same binary with no window, which serves MCP on stdio and passes everything on to whichever window is running; ../../.mcp.json starts it through ../../tools/mcp/launch.sh (chapter 25).

AlwaysWhat
stateThe game state, as JSON.
botWhat the bot says about itself, as JSON: info line, tasks, the first goals with their scores, goal count, where it puts Link, traps, stray reads, and the savestate calls ap_init made.
jev_historyJev's recent goal-choice history, newest first, as JSON: what the Jev panel's history shows, with the exact request sent when it asked.
frameThe game picture as a PNG, at the console's resolution.
windowThe whole window as drawn, panels included.
read_wramBytes of work RAM by SNES address, as hex.
statesThe names of the snapshots kept beside the ROM.
budgetJev's spend guards from the shared ledger: spent so far, with no cap; the question bucket (and seconds until it refills); the hour breaker (and seconds until it reopens); holds in flight; whether the vendor says the account is out of credit. Nothing in it needs clearing by hand.
dev_modeon: true adds the tools below; false takes them away.
patch_infoThis process's pid and subsecond::aslr_reference(), for tools/hotpatch/patch.sh.
Dev mode onlyWhat
pressHold buttons for a number of emulated frames, then return the state. The buttons go to the bot as the human's pad, so they reach the game only while X is among them (the bot steps aside) or the bot is in a mode it does not play. Optional confidence, 0 to 1.
save_stateKeep the machine exactly as it stands, under a name.
load_stateBecome the machine kept under a name. The bot carries on.
power_cycleTurn the SNES off and on again; the battery save is kept.
hot_patchApply a tools/hotpatch-built patch (a path to its patch-N.json): no restart, the game keeps running. Rust host code only.
restartRe-run the binary this window was started from, to pick up a new build.

Changing the code while it runs

nix develop
tools/hotpatch/patch.sh //apps/native:native

rebuilds the window's own code (//apps/native:app: src/app.rs, src/activity.rs) and packages/panels, builds a patch from the fresh objects (chapter 27), and delivers it over MCP: dev mode on, hot_patch, dev mode off. The picture and the panels are redrawn on the very next frame, same process, same game. The executable itself is never rebuilt for this: src/main.rs is a three-line dispatcher with no patch points, so a panels edit never pays for relinking the whole eframe/wgpu binary.

Most edits to logic/ui's bodies and to packages/panels patch. Changing the shape of App, or of a struct crossing the patch boundary, does not, and needs a restart. The C bot is not hot-patchable at all: a change there is a buck2 build and a restart. The window closes and reopens about a second later on the same frame of the same game, and the client is still connected, because what it is connected to is native mcp.

For the people who maintain it

Left running in the background, start it so that its end is written down whatever the cause (a window once vanished with no trace, 2026-09-21):

nohup nix develop -c sh -c 'buck2 run //apps/native:native -- \
  "roms/Legend of Zelda, The - A Link to the Past (USA).sfc"; \
  echo "native exited with status $? at $(date -Is)"' > run/native.log 2>&1 &

In this folder

PathWhat
src/main.rsThe executable: native mcp goes to mcp::proxy::run, anything else to app::window.
src/app.rsThe window, as its own rust_library (//apps/native:app): arguments, the bot and its stall/recovery, Jev, sound, saves, resume, the map carry-over, the replay recorder and scrubber, App::answer for every MCP Request, the patch points logic_impl/ui_impl, and the bot.log rotation.
src/activity.rsThe MCP panel: where the server listens, whether a client has been heard from, and every tool call that came in.
BUCK:app (with the hot-patch flags), :test (the bot.log rotation tests), and :native, the executable, which keeps -Csave-temps/-Clink-dead-code for the "main" sentinel object a patch needs.

Measured 2026-09-20: the window opens as a native Wayland surface, wgpu runs on Vulkan (Dozen, under WSL), and the process sits at about half of one core. The hot-patch build flags cost nothing measurable (50.6% CPU before and after loading a build with them); the emulator core is in packages/console, which does not carry them.

← Previous: Chapter 11, zbanks/ · Up: apps · Next: Chapter 13, jevprobe/ →