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
pressholds) 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 (theSkey) 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.
| Key | Pad |
|---|---|
| Arrows | D-pad |
X / Z | A / B |
S / A | X / Y |
Q / W | L / R |
| Enter / Backspace | Start / Select |
- / = | Playback speed down / up |
What happens before the window opens
- 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). - The bot starts:
ap_initcaches its RAM pointers and loads the stateshomeandhpegsfrom<rom>.states/. Makehomeonce 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. - Unless
--fresh, the machine then goes back toresume, 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_bptraps 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
pressholds 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).
| Always | What |
|---|---|
state | The game state, as JSON. |
bot | What 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_history | Jev's recent goal-choice history, newest first, as JSON: what the Jev panel's history shows, with the exact request sent when it asked. |
frame | The game picture as a PNG, at the console's resolution. |
window | The whole window as drawn, panels included. |
read_wram | Bytes of work RAM by SNES address, as hex. |
states | The names of the snapshots kept beside the ROM. |
budget | Jev'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_mode | on: true adds the tools below; false takes them away. |
patch_info | This process's pid and subsecond::aslr_reference(), for tools/hotpatch/patch.sh. |
| Dev mode only | What |
|---|---|
press | Hold 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_state | Keep the machine exactly as it stands, under a name. |
load_state | Become the machine kept under a name. The bot carries on. |
power_cycle | Turn the SNES off and on again; the battery save is kept. |
hot_patch | Apply a tools/hotpatch-built patch (a path to its patch-N.json): no restart, the game keeps running. Rust host code only. |
restart | Re-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
| Path | What |
|---|---|
| src/main.rs | The executable: native mcp goes to mcp::proxy::run, anything else to app::window. |
| src/app.rs | The 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.rs | The 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/ →