jevsnes.git / apps / native / src / app.rs
app.rsannotatedapp.rssource1776 lines · 80.3 KB · raw

The SNES in a window: picture, sound and a keyboard for a pad, played by the zbanks/alttp C bot (packages/zbanks), which gets every frame's pad.

The picture sits in the middle and everything we want to watch beside it goes in panels AROUND it, never over it, so nothing covers the game's own HUD. The same console library compiles for the browser; this file is only the parts that differ on a desktop — a window, a sound device, a save file.

Split out of main.rs into its own rust_library (2026-09-20) so that a hot-patch cycle never has to relink the executable: main.rs is now a three-line dispatcher with no patch points of its own, and tools/hotpatch gets this crate's fresh objects the same way it gets packages/panels's - see ../CLAUDE.md and ../../tools/hotpatch/CLAUDE.md for the mechanism and the measurement that motivated it.

16mod activity;
18use std::os::fd::FromRawFd;
19use std::os::unix::process::CommandExt;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex, mpsc};
22use std::thread;
23use std::time::{Duration, Instant};
24use std::{env, fs, process};
25
26use console::{Console, Saves, SnesButton};
27use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
28use eframe::egui;
29use file_rotate::compression::Compression;
30use file_rotate::suffix::AppendCount;
31use file_rotate::{ContentLimit, FileRotate};
32use mcp::states::{Name, States};
33use panels::layout::Layout;
34use panels::pad::{Confidence, Press};
35use replay::format::{FrameEntry, apply_wram_diff, diff_wram};
36use replay::speed::Speed;
37use ringbuf::HeapRb;
38use ringbuf::traits::{Consumer, Observer, Producer, Split};
39use tokio::sync::oneshot;
40use zbanks::{recovery, stall};

Keyboard for controller one. Select is Backspace because egui reports Shift as a modifier and not as a key, and Tab already moves focus.

44const KEYS: [(egui::Key, SnesButton); 12] = [
45    (egui::Key::ArrowUp, SnesButton::Up),
46    (egui::Key::ArrowDown, SnesButton::Down),
47    (egui::Key::ArrowLeft, SnesButton::Left),
48    (egui::Key::ArrowRight, SnesButton::Right),
49    (egui::Key::X, SnesButton::A),
50    (egui::Key::Z, SnesButton::B),
51    (egui::Key::S, SnesButton::X),
52    (egui::Key::A, SnesButton::Y),
53    (egui::Key::Q, SnesButton::L),
54    (egui::Key::W, SnesButton::R),
55    (egui::Key::Enter, SnesButton::Start),
56    (egui::Key::Backspace, SnesButton::Select),
57];

How much sound to keep queued ahead of the device, as a fraction of a second. Deep enough to ride out a late UI frame, shallow enough that a sword swing is heard when it is seen.

62const QUEUE_SECONDS: f64 = 0.05;

A host that fell behind catches up by at most this many frames in one pass, then forgets the rest: after a stall the game resumes, it does not sprint. This is the 1x figure; Speed::max_catch_up scales it for the current playback speed, so a sprint at 8x is still one sprint per pass.

68const MAX_CATCH_UP: u32 = 4;

How tall the replay scrubber's own strip is, directly under the picture.

71const SCRUBBER_HEIGHT: f32 = 40.0;

Frames of a rebuild ("run the bot from here") to run per UI pass while catching a fresh bot up to a fork frame - enough to finish in a reasonable number of passes without freezing the window for however long the whole rebuild takes.

77const REBUILD_BUDGET: u32 = 2000;

The open sound device. Dropping the stream stops it, so it lives here.

80struct Sound {
81    _stream: cpal::Stream,
82    queue: ringbuf::HeapProd<f32>,
83}
85impl Sound {

Open the default device for stereo f32 and point the console at it.

87    fn open(console: &mut Console) -> Result<Self, String> {
88        let device = cpal::default_host()
89            .default_output_device()
90            .ok_or("no sound output device")?;
91        let config: cpal::StreamConfig = device
92            .supported_output_configs()
93            .map_err(|e| e.to_string())?
94            .filter(|c| c.channels() == 2 && c.sample_format() == cpal::SampleFormat::F32)
95            .find_map(|c| c.try_with_sample_rate(48_000))
96            .ok_or("device offers no 48 kHz stereo f32 stream")?
97            .into();
98
99        let rate = config.sample_rate;
100        let target = (f64::from(rate) * QUEUE_SECONDS) as u32;
101        // Room for four times the target, in samples (two per stereo frame).
102        let (queue, mut device_side) = HeapRb::<f32>::new(target as usize * 2 * 4).split();
103
104        let stream = device
105            .build_output_stream(
106                config,
107                move |out: &mut [f32], _: &cpal::OutputCallbackInfo| {
108                    // Never block here: play what is queued, and silence for
109                    // whatever is not.
110                    let played = device_side.pop_slice(out);
111                    out[played..].fill(0.0);
112                },
113                |e| eprintln!("sound: {e}"),
114                None,
115            )
116            .map_err(|e| e.to_string())?;
117        stream.play().map_err(|e| e.to_string())?;
118
119        console.audio_output(rate, target);
120        Ok(Self { _stream: stream, queue })
121    }

Queue what the console produced this frame and tell it how deep the queue now is, which is what keeps the two clocks together.

125    fn play(&mut self, console: &mut Console) {
126        for (l, r) in console.samples.0.drain(..) {
127            // A full queue drops the sample; `audio_queued` is already
128            // slowing the console's output to stop that recurring.
129            let _ = self.queue.try_push(l as f32);
130            let _ = self.queue.try_push(r as f32);
131        }
132        console.audio_queued((self.queue.occupied_len() / 2) as u32);
133    }
134}

Cartridge saves as files beside the ROM: zelda.sfc keeps zelda.sav.

137struct SaveFiles {
138    rom: PathBuf,
139    written: Saves,
140}
142impl SaveFiles {
143    fn load(rom: PathBuf) -> (Self, Saves) {
144        let mut saves = Saves::default();
145        if let Ok(bytes) = fs::read(rom.with_extension("sav")) {
146            saves.0.insert("sav".into(), bytes);
147        }
148        (Self { rom, written: saves.clone() }, saves)
149    }

Write whatever changed since the last call.

152    fn flush(&mut self, saves: &Saves) {
153        for (extension, bytes) in &saves.0 {
154            if self.written.0.get(extension) == Some(bytes) {
155                continue;
156            }
157            let path = self.rom.with_extension(extension);
158            match fs::write(&path, bytes) {
159                Ok(()) => {
160                    self.written.0.insert(extension.clone(), bytes.clone());
161                }
162                Err(e) => eprintln!("saving {}: {e}", path.display()),
163            }
164        }
165    }
166}

Buttons the MCP server is holding down, and for how much longer.

169struct Hold {
170    buttons: Vec<SnesButton>,
171    frames_left: u32,
172    confidence: Option<Confidence>,
173    reply: oneshot::Sender<alttp::State>,
174}

Where the picture is relative to this run's own recording. Only Live ever calls bot.tick; the other two drive console from the log instead (apply_wram_diff, zbanks::apply_pad), never the bot - research/replay-timeline.md's replay design: "Replay... never ticks the bot". The bot's own C state sits untouched the whole time a scrub is under way, which is what lets returning to Live at the tip just resume ticking it: the console is byte-identical to wherever the live bot itself left it (proven by apps/replay-probe), so nothing needs reconstructing.

185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum Playback {
187    Live,
188    Paused(u64),
189    Playing(u64),
190}

A recording id: a zero-padded Unix timestamp, so lexical order is chronological - replay::storage::enforce_cap's own assumption about how to find the oldest one.

195fn new_recording_id() -> String {
196    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
197    format!("run-{secs:010}")
198}

dir:branch:frame, as --branch-from gives it: the recording, the branch, and the frame a "run the bot from here" click was made at.

202fn parse_branch_from(s: &str) -> Option<(PathBuf, String, u64)> {
203    let mut parts = s.rsplitn(3, ':');
204    let frame = parts.next()?.parse().ok()?;
205    let branch = parts.next()?.to_owned();
206    let dir = parts.next()?;
207    Some((PathBuf::from(dir), branch, frame))
208}
210struct App {
211    console: Console,
212    sound: Option<Sound>,
213    saves: SaveFiles,
214    picture: egui::TextureHandle,

When the next emulated frame is due.

216    due: Instant,
217    last_flush: Instant,
218    requests: mpsc::Receiver<mcp::Request>,
219    served: Arc<Mutex<mcp::Activity>>,
220    holds: Vec<Hold>,

What was held on the pad for the last emulated frame, in KEYS order.

222    pad: [Option<Press>; 12],

Callers waiting on a picture of the window, which egui delivers as an event on a later pass.

225    awaiting_window: Vec<oneshot::Sender<mcp::Picture>>,
226    states: States,
227    last_resume: Instant,

Set once a restart has been asked for and everything is kept: when to go. Not at once, because the caller is still being told it will happen, and nothing says when that reply has left; it is a loopback socket, so this is long. A reply that loses the race costs the caller one failed call, and the restart happens regardless.

233    restart_at: Option<Instant>,

The --branch-from value to carry into the restart restart_at schedules, when it is a "run the bot from here" rather than an ordinary restart. None for an ordinary restart.

237    branch_from_arg: Option<String>,

Who is playing: the zbanks/alttp C bot, handed the human's pad and returning the pad for every emulated frame. The MCP client watches.

240    bot: zbanks::Bot,

Whether the human held X on the last frame, which makes the bot step aside and pass the human's pad through (upstream alttp.c:57).

243    paused_by_x: bool,

Jev making the bot's goal choice (--jev or ZB_JEV=1), if on.

245    jev: Option<decisions::goal_choice::Shared>,

"on", "off", or why it is not on, for the Bot panel.

247    jev_status: String,

The Jev panel's history toggle: false (the default) shows only asked rows, since reused/one-choice/throttled/fell-back rows are most of a long run's history and bury the real questions (user, 2026-09-22). Ordinary widget state, like speed.

252    jev_show_all_history: bool,

How much of app.jev's history (chooser.history(), chronological) predates the CURRENT recording, so the usage graph does not sum decisions left over from an earlier bot lifetime: jev.jsonl is one running log across every recording this bot_dir has ever had (window()'s own setup always mints a fresh, timestamped recording_dir on anything but a --branch-from restart), while a decision's own frame resets to 0 with every fresh bot - so an old recording's frame 500 and this one's frame 500 are unrelated moments unless this boundary tells them apart. Set once, from Chooser::load_history's own return value, at window start.

263    jev_recording_history_start: usize,

Whether the bot has stopped playing (stall.rs), and since when. None until proven otherwise; recomputed once a pass in logic_impl, read (never recomputed) by the panel and the bot tool so both agree with the log and stalled.json.

268    stall: stall::Detector,
269    stalled: Option<stall::Stall>,

Bounded, self-driven recovery from a stall (zbanks::recovery): retries every goal the bot has given up on, unconditionally rather than only on a possession change, with escalating backoff and a cap of tries that make no progress. A fresh one on every restart, same reasoning as stall above.

275    recovery: recovery::Recovery,

The exact patched cartridge bytes the console was booted from - a seek or a rebuild boots a fresh Console on these to reconstruct a past frame, without re-reading or re-patching the ROM file.

279    rom: Vec<u8>,

This recording's own directory, run/zbanks-window/replay/<id>/.

281    recording_dir: PathBuf,
282    replay_tree: replay::tree::Tree,
283    replay_header: replay::header::RunHeader,

The branch the window is currently showing and (while Live) recording to.

286    active_branch: String,

None only while rebuilding is catching a fresh bot up to a fork frame - there is nowhere yet to record TO until that finishes.

289    recorder: Option<replay::recorder::Recorder>,
290    playback: Playback,

active_branch's own chain, frame 1 up to its tip - loaded once per seek or "play" rather than re-read from disk every frame.

293    branch_entries: Vec<FrameEntry>,
294    speed: Speed,
295    speed_path: PathBuf,

Catching a fresh bot up to a fork frame before handing it control as a new branch ("run the bot from here", via a restart - a second zbanks::Bot cannot exist in this process, packages/zbanks/CLAUDE.md's "one bot per process").

300    rebuilding: Option<replay::rebuild::Rebuilder>,

The fork frame rebuilding is catching up to - once it finishes, this is where its new branch's own keyframe goes (replay::recorder::Recorder::store_fork_keyframe).

304    rebuild_fork_frame: u64,

rebuilding's own last-reported progress, for the scrubber panel - replay::rebuild::Progress is not Clone/kept, so this is the window's own copy of the two numbers that matter.

308    rebuild_progress: Option<(u64, u64)>,
309}

The Bot panel's and the bot tool's view of the last goal choice.

312fn jev_how(how: &decisions::How) -> String {
313    match how {
314        decisions::How::Asked { dollars, input_tokens, millis, .. } => {
315            format!("asked Jev ({millis} ms, {input_tokens} tokens, ${dollars:.6})")
316        }
317        decisions::How::NoQuestion { why } => format!("not asked: {why}"),
318        decisions::How::Reused { frame } => format!("sampled again from Jev's answer at frame {frame}"),
319        decisions::How::Throttled { why, retry_in_s } => {
320            format!("upstream's pick, not asked: throttled ({why}), clears in {retry_in_s:.0}s")
321        }
322        decisions::How::Default { why } => format!("upstream's pick: {why}"),
323    }
324}

The short word a history row shows for how a choice was made - the long-form reason (jev_how, above) goes in the row's expanded view.

328fn jev_how_kind(how: &decisions::How) -> &'static str {
329    match how {
330        decisions::How::Asked { .. } => "asked",
331        decisions::How::NoQuestion { .. } => "one-choice",
332        decisions::How::Reused { .. } => "reused",
333        decisions::How::Throttled { .. } => "throttled",
334        decisions::How::Default { .. } => "fell back",
335    }
336}
338fn jev_totals(t: &decisions::Totals, overrides: u32) -> String {
339    format!(
340        "{} choices, {} questions, {} reused, {} one-choice, {} throttled, {} fell back; {} differ from upstream's pick; {} tokens, ${:.6} spent",
341        t.choices, t.questions, t.reused, t.no_question, t.throttled, t.defaults, overrides, t.input_tokens, t.dollars
342    )
343}

How many buckets the usage graph divides the current recording into.

346const USAGE_BUCKETS: u64 = 24;

The Jev panel's usage graph: app.jev's own history (decisions::Record, already loaded/kept in memory for the History section below - no separate read, no ledger involved), bucketed by game frame across [0, tip_frame] and summed only up to playhead_frame - scrubbing the picture back shows usage only up to wherever it is paused, per the user's own request (2026-09-22). history_start is app.jev_recording_history_start: decisions loaded from a PRIOR recording (its own doc comment on that field explains why jev.jsonl can hold them) are skipped, not just out-of-range ones.

A decision's own frame is the bot's tick count, which is exactly the recording's own absolute frame numbering for as long as the invariant in packages/replay/CLAUDE.md holds ("a recording always starts exactly when a fresh Bot does") - true for this window's own single, unbranched recording; a rebuilt branch re-ticks its bot from frame 0 too, so the numbers line up there as well.

364fn usage_points(
365    jev: Option<&decisions::goal_choice::Shared>,
366    history_start: usize,
367    tip_frame: u64,
368    playhead_frame: u64,
369) -> Vec<panels::jev::UsagePoint> {
370    let bucket_width = (tip_frame.max(1) as f64 / USAGE_BUCKETS as f64).max(1.0);
371    let mut points: Vec<panels::jev::UsagePoint> = (0..USAGE_BUCKETS)
372        .map(|i| panels::jev::UsagePoint {
373            start_frame: (i as f64 * bucket_width).round() as u64,
374            spend_usd: 0.0,
375            tokens: 0,
376            requests: 0,
377        })
378        .collect();
379    let Some(jev) = jev else { return points };
380    let chooser = jev.0.borrow();
381    for (i, record) in chooser.history().enumerate() {
382        if i < history_start {
383            continue;
384        }
385        let decisions::How::Asked { dollars, input_tokens, .. } = &record.how else { continue };
386        let frame = u64::from(record.frame);
387        if frame > playhead_frame {
388            continue;
389        }
390        let index = ((frame as f64 / bucket_width) as usize).min(points.len() - 1);
391        points[index].spend_usd += dollars;
392        points[index].tokens += input_tokens;
393        points[index].requests += 1;
394    }
395    points
396}

The shared guards (every process's questions, from the ledger), as the Bot panel's line and, when something is stopping questions, why.

400fn jev_guards(jev: Option<&decisions::goal_choice::Shared>) -> (String, Option<String>) {
401    match jev.map(|j| j.0.borrow_mut().guards()) {
402        None => (String::new(), None),
403        Some(Ok(status)) => (status.line(), status.stopped()),
404        Some(Err(e)) => (String::new(), Some(format!("the spend ledger: {e}"))),
405    }
406}

The bot's named savestates (ap_init asks for "home" and "hpegs") are snapshots beside the ROM, the same directory the MCP tools use.

410struct BotStates<'a>(&'a States);
412impl zbanks::States for BotStates<'_> {
413    fn load(&mut self, name: &str) -> Option<Vec<u8>> {
414        self.0.read(&Name::new(name)?).ok()
415    }
416    fn save(&mut self, name: &str, snapshot: &[u8]) -> bool {
417        Name::new(name).is_some_and(|name| self.0.write(&name, snapshot).is_ok())
418    }
419}

How wide the controller is drawn under the picture, in points.

422const PAD_WIDTH: f32 = 380.0;

Room left around what is in a side panel and around the pad, in points.

425const MARGIN: f32 = 8.0;

How much of the left column the Bot panel takes; the MCP log has the rest.

428const BOT_SHARE: f32 = 0.66;

How much of the right column the State panel takes; Jev's choice history has the rest. State is a handful of short, fixed lines and Jev's history is a growing scrollable list, so it gets the minority share here where Bot (the taller content) got the majority on the left.

434const STATE_SHARE: f32 = 0.28;

How many of the bot's goals the panels list.

437const GOALS_SHOWN: usize = 40;

Where the bot's own files go: its debug output (goals.txt, map, ...) and its stdout, bot.log.

441const BOT_DIR: &str = "run/zbanks-window";

The bot's map as the window last exported it, and the name it is written under first.

445const MAP_EXPORT: &str = "map_export.txt";
446const MAP_EXPORT_PART: &str = "map_export.txt.part";

What the bot's first tick imports (alttp.c:31-38).

449const MAP_IMPORT: &str = "map.19.txt";

Written beside the bot's own files while [stall] says the bot is stalled, removed the moment it clears - so an external watchdog can check for a stall without going through MCP at all.

454const STALLED_FILE: &str = "stalled.json";

How often where the game is gets kept while it runs, for the window that is killed rather than closed. A snapshot is 1.3 MB, so not every second.

458const RESUME_EVERY: Duration = Duration::from_secs(30);
460const BEFORE_RESTART: Duration = Duration::from_millis(300);
461
462impl App {

Keep where the game is, for the next window to start from.

464    fn keep_resume(&mut self) -> Result<(), String> {
465        self.keep(&Name::resume())?;
466        self.last_resume = Instant::now();
467        // And what the bot has learned of the map, in its own export format,
468        // beside it in run/zbanks-window/; the next window starts by copying
469        // it to map.19.txt, which the bot's first tick imports (alttp.c:31-38)
470        // - upstream's own way of carrying a map from run to run. The bot
471        // writes the file in place, so it goes to a .part name and is renamed
472        // whole: a window killed mid-export leaves the last complete one.
473        self.bot.export_map(MAP_EXPORT_PART);
474        let dir = Path::new(BOT_DIR);
475        if let Err(e) = fs::rename(dir.join(MAP_EXPORT_PART), dir.join(MAP_EXPORT)) {
476            eprintln!("keeping the bot's map: {e}");
477        }
478        Ok(())
479    }
481    fn keep(&mut self, name: &Name) -> Result<(), String> {
482        let snapshot = self.console.snapshot().map_err(|e| e.to_string())?;
483        self.states.write(name, &snapshot).map_err(|e| e.to_string())
484    }

Go back to a snapshot. The bot carries on with what it knows, as it would in Snes9x when its user loads a state.

488    fn go_back(&mut self, name: &Name) -> Result<(), String> {
489        let snapshot = self.states.read(name).map_err(|e| format!("reading state {name}: {e}"))?;
490        self.console.restore(&snapshot).map_err(|e| e.to_string())?;
491        self.bot.console_replaced();
492        Ok(())
493    }

What the bot says about itself, as JSON for the bot tool.

496    fn bot_json(&self) -> String {
497        let r = self.bot.report(GOALS_SHOWN);
498        serde_json::json!({
499            "frame": r.frame,
500            "info": r.info,
501            "tasks": r.tasks,
502            "goals": r.goals.iter().map(|g| serde_json::json!({
503                "type": g.kind,
504                "node": g.node,
505                "screen": g.screen,
506                "attempts": g.attempts,
507                "last_score": zbanks::score_words(g.last_score).map_or_else(|| serde_json::json!(g.last_score), |w| serde_json::json!(w)),
508            })).collect::<Vec<_>>(),
509            "goal_count": r.goal_count,
510            "link": [r.link.0, r.link.1],
511            "traps": r.traps,
512            "last_trap_frame": r.last_trap_frame,
513            "last_trap_offset_from_ap_tick": format!("{:#x}", r.last_trap_ip),
514            "stray_reads": r.stray_reads,
515            "manual_mode": r.manual_mode,
516            "given_up_goals_waiting": r.given_up,
517            "given_up_goals_put_back": r.retried,
518            "stalled": self.stalled.map(|s| serde_json::json!({
519                "since": s.since,
520                "reason": s.reason.words(),
521                "minutes": stall::minutes(r.frame.saturating_sub(s.since), self.console.target_fps()),
522            })),
523            "recovery": {
524                "cap": recovery::CAP,
525                "gave_up": self.recovery.status().gave_up,
526                "last": self.recovery.status().last.map(|e| serde_json::json!({
527                    "frame": e.frame,
528                    "attempt": e.attempt,
529                    "restored": e.restored,
530                    "gave_up": e.gave_up,
531                })),
532            },
533            "paused_by_x": self.paused_by_x,
534            "jev": {
535                "status": self.jev_status,
536                "last": self.jev.as_ref().and_then(|j| j.0.borrow().engine.last.clone()).map(|d| serde_json::json!({
537                    "frame": d.frame,
538                    "how": jev_how(&d.how),
539                    "options": d.answer.sentences,
540                    "probabilities": d.answer.probabilities,
541                    "picked": d.answer.picked,
542                    "goals": d.answer.goals,
543                })),
544                "totals": self.jev.as_ref().map(|j| {
545                    let chooser = j.0.borrow();
546                    jev_totals(&chooser.engine.totals, chooser.overrides)
547                }),
548                "guards": self.jev.as_ref().map(|j| match j.0.borrow_mut().guards() {
549                    Ok(status) => serde_json::json!({"line": status.line(), "stopped": status.stopped(), "status": status}),
550                    Err(e) => serde_json::json!({"error": e}),
551                }),
552            },
553            "state_calls": self.bot.state_log().iter().map(|(verb, name, ok)| format!("{verb} {name}: {}", if *ok { "ok" } else { "failed" })).collect::<Vec<_>>(),
554        })
555        .to_string()
556    }

Jev's recent goal-choice history, newest first, as JSON for the jev_history tool - the same decisions the Jev panel's history list shows, serialized straight from decisions::Record<decisions::goal_choice::Answer>.

561    fn jev_history_json(&self) -> String {
562        let history: Vec<serde_json::Value> = self.jev.as_ref().map_or_else(Vec::new, |j| {
563            j.0.borrow()
564                .history()
565                .rev()
566                .map(|d| serde_json::to_value(d).unwrap_or_else(|e| serde_json::json!({"error": e.to_string()})))
567                .collect()
568        });
569        serde_json::json!({"status": self.jev_status, "history": history}).to_string()
570    }

The machine changed under the holds: whoever was holding buttons was holding them on a machine that is gone. Dropping the reply tells them.

574    fn machine_replaced(&mut self) {
575        self.holds.clear();
576        self.console.samples.0.clear();
577    }
580    fn frame_period(&self) -> Duration {
581        self.speed.frame_period(Duration::from_secs_f64(1.0 / self.console.target_fps()))
582    }

The active branch's own tip: the last frame actually recorded to it. Scrubbing or playing past this hands off to the live bot.

586    fn tip_frame(&self) -> u64 {
587        self.replay_tree.get(&self.active_branch).map_or(0, |b| b.last_frame)
588    }

Where the picture is right now, regardless of playback mode.

591    fn current_frame(&self) -> u64 {
592        match self.playback {
593            Playback::Live => self.tip_frame(),
594            Playback::Paused(f) | Playback::Playing(f) => f,
595        }
596    }

Keep the branch tree on disk in step with recorder's own tally, so a seek elsewhere (this session or a later one) sees the real tip rather than whatever it was when the branch was forked.

601    fn save_replay_tree(&mut self) {
602        if let Some(recorder) = &self.recorder
603            && let Some(branch) = self.replay_tree.get_mut(&self.active_branch)
604        {
605            branch.last_frame = recorder.last_frame;
606        }
607        if let Err(e) = self.replay_tree.save(&self.recording_dir.join("tree.json")) {
608            eprintln!("saving the replay tree: {e}");
609        }
610    }

Jump to frame in the active branch's history and pause there - replay::seek::seek_to restores the nearest keyframe and replays the logged pad and WRAM diff forward, headless, never the bot. frame past the tip clamps to it and returns to Live instead (nothing recorded there yet to seek into).

617    fn seek(&mut self, frame: u64) {
618        let tip = self.tip_frame();
619        if frame >= tip {
620            self.playback = Playback::Live;
621            return;
622        }
623        match replay::seek::seek_to(&self.recording_dir, &self.replay_tree, &self.replay_header, self.rom.clone(), &self.active_branch, frame) {
624            Ok((console, elapsed)) => {
625                eprintln!("seek to frame {frame}: {:.3}s", elapsed.as_secs_f64());
626                self.console = console;
627                self.playback = Playback::Paused(frame);
628                self.load_branch_entries();
629            }
630            Err(e) => eprintln!("seeking to frame {frame}: {e}"),
631        }
632    }

active_branch's own history, frame 1 up to its tip, for Playing to step through without re-reading the log every frame.

636    fn load_branch_entries(&mut self) {
637        let tip = self.tip_frame();
638        match replay::seek::entries_up_to(&self.recording_dir, &self.replay_tree, &self.active_branch, tip) {
639            Ok(entries) => self.branch_entries = entries,
640            Err(e) => eprintln!("loading the branch's history: {e}"),
641        }
642    }

A scrubber [panels::replay::Action], acted on.

645    fn handle_replay_action(&mut self, action: panels::replay::Action) {
646        match action {
647            panels::replay::Action::SeekTo(frame) => self.seek(frame),
648            panels::replay::Action::TogglePlay => {
649                self.playback = match self.playback {
650                    Playback::Live => Playback::Live, // nothing to toggle: already playing, live
651                    Playback::Paused(f) => {
652                        self.load_branch_entries();
653                        Playback::Playing(f)
654                    }
655                    Playback::Playing(f) => Playback::Paused(f),
656                };
657            }
658            panels::replay::Action::SkipToEnd => self.playback = Playback::Live,
659            panels::replay::Action::SlowDown => {
660                self.speed = self.speed.slower();
661                let _ = self.speed.save(&self.speed_path);
662            }
663            panels::replay::Action::SpeedUp => {
664                self.speed = self.speed.faster();
665                let _ = self.speed.save(&self.speed_path);
666            }
667            panels::replay::Action::RunBotFromHere => {
668                let frame = self.current_frame();
669                self.save_replay_tree();
670                self.branch_from_arg = Some(format!("{}:{}:{}", self.recording_dir.display(), self.active_branch, frame));
671                self.restart_at = Some(Instant::now() + BEFORE_RESTART);
672            }
673        }
674    }

Answer what the MCP server has asked since the last pass. A reply whose caller has gone away is dropped: nobody is left to tell.

678    fn answer(&mut self, ctx: &egui::Context) {
679        // Taken off the channel first: answering some of them changes the app.
680        let requests: Vec<mcp::Request> = self.requests.try_iter().collect();
681        for request in requests {
682            match request {
683                mcp::Request::State(reply) => {
684                    let _ = reply.send(alttp::State::read(self.console.wram()));
685                }
686                mcp::Request::Bot(reply) => {
687                    let _ = reply.send(self.bot_json());
688                }
689                mcp::Request::JevHistory(reply) => {
690                    let _ = reply.send(self.jev_history_json());
691                }
692                mcp::Request::Frame(reply) => {
693                    let (width, height) = self.console.frame.size;
694                    let rgba = self.console.frame.rgba().to_vec();
695                    let _ = reply.send(mcp::Picture { width, height, rgba });
696                }
697                mcp::Request::Window(reply) => {
698                    self.awaiting_window.push(reply);
699                    ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
700                }
701                mcp::Request::Wram { at, len, reply } => {
702                    let _ = reply.send(self.console.wram()[at..at + len].to_vec());
703                }
704                mcp::Request::States(reply) => {
705                    let _ = reply.send(self.states.names());
706                }
707                mcp::Request::SaveState { name, reply } => {
708                    let kept = self.keep(&name);
709                    let _ = reply.send(kept);
710                }
711                mcp::Request::LoadState { name, reply } => {
712                    let loaded = self.go_back(&name);
713                    if loaded.is_ok() {
714                        self.machine_replaced();
715                    }
716                    let _ = reply.send(loaded.map(|()| alttp::State::read(self.console.wram())));
717                }
718                mcp::Request::PowerCycle(reply) => {
719                    self.console.power_cycle();
720                    self.bot.console_replaced();
721                    self.machine_replaced();
722                    let _ = reply.send(alttp::State::read(self.console.wram()));
723                }
724                mcp::Request::Restart(reply) => {
725                    self.saves.flush(&self.console.saves);
726                    let kept = self.keep_resume();
727                    if kept.is_ok() {
728                        self.restart_at = Some(Instant::now() + BEFORE_RESTART);
729                    }
730                    let _ = reply.send(kept);
731                }
732                mcp::Request::Press { buttons, frames, confidence, reply } => {
733                    self.holds.push(Hold {
734                        buttons,
735                        frames_left: frames.get(),
736                        confidence,
737                        reply,
738                    });
739                }
740                mcp::Request::HotPatch { table, reply } => {
741                    let mapped = table.map.len();
742                    // Safety: the table came from tools/hotpatch, built against
743                    // this same running process's own aslr_reference (§ patch.sh
744                    // and mcp::patch_info) - see apps/native/CLAUDE.md for what
745                    // this cannot safely change (struct layout) versus what it
746                    // can (function bodies behind a subsecond::call/HotFn
747                    // boundary).
748                    let result = unsafe { subsecond::apply_patch(table) }
749                        .map_err(|e| e.to_string())
750                        .map(|()| mapped);
751                    ctx.request_repaint();
752                    let _ = reply.send(result);
753                }
754            }
755        }
756
757        let taken = ctx.input(|input| {
758            input.events.iter().find_map(|event| match event {
759                egui::Event::Screenshot { image, .. } => Some(image.clone()),
760                _ => None,
761            })
762        });
763        if let Some(image) = taken {
764            for reply in self.awaiting_window.drain(..) {
765                let _ = reply.send(mcp::Picture {
766                    width: image.width() as u32,
767                    height: image.height() as u32,
768                    rgba: image.as_raw().to_vec(),
769                });
770            }
771        }
772    }

One emulated frame has run with the holds applied: count it, and answer the holds that are now over.

776    fn count_frame(&mut self) {
777        for hold in &mut self.holds {
778            hold.frames_left -= 1;
779        }
780        let (over, holds) = self.holds.drain(..).partition(|hold| hold.frames_left == 0);
781        self.holds = holds;
782        if !Vec::is_empty(&over) {
783            let state = alttp::State::read(self.console.wram());
784            for hold in over {
785                let _ = hold.reply.send(state);
786            }
787        }
788    }
789}
791impl eframe::App for App {
792    fn logic(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
793        // `logic_impl` is passed by NAME, not wrapped in a closure, so it stays
794        // a zero-sized function-item type - a closure over even one
795        // pointer-sized capture would silently take the wrong dispatch branch
796        // inside subsecond (research/subsecond-patch-build.md §8.2.1). The
797        // assert makes that a loud failure instead of a patch that "applies"
798        // and does nothing; see apps/native/CLAUDE.md.
799        debug_assert_eq!(std::mem::size_of_val(&logic_impl), 0, "logic_impl must stay a bare fn item");
800        subsecond::HotFn::current(logic_impl).call((self, ctx));
801    }
802
803    fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
804        debug_assert_eq!(std::mem::size_of_val(&ui_impl), 0, "ui_impl must stay a bare fn item");
805        subsecond::HotFn::current(ui_impl).call((self, ui));
806    }
807
808    fn on_exit(&mut self) {
809        // Said, so that a window that vanishes is not a mystery: this line
810        // means the event loop ended normally - the window was closed (its
811        // close button, or the compositor dropping it). A panic prints its
812        // own message; a signal prints nothing here, and the launch line in
813        // README.md writes the exit status after the process for that case.
814        eprintln!("{}: window closing: the event loop ended (close requested)", unix_now());
815        self.saves.flush(&self.console.saves);
816        if let Err(e) = self.keep_resume() {
817            eprintln!("keeping where the game is: {e}");
818        }
819    }
820}

The per-frame update: run the emulator, then flush saves/resume. A subsecond::call-family patch point (research/subsecond-patch-build.md §6/§8): #[inline(never)] so it survives -Copt-level=3's cross-CGU inlining as an addressable symbol (§8.2.2), called through subsecond::HotFn rather than the subsecond::call sugar because it takes arguments (call's signature is FnMut() -> O, no arguments). App itself is never patched - only which code runs against it - so state on app survives a patch that a static (like apps/hotdemo's TICKS) would not.

831#[inline(never)]
832fn logic_impl(app: &mut App, ctx: &egui::Context) {
833    if app.restart_at.is_some_and(|at| at <= Instant::now()) {
834        let mut args = env::args_os();
835        let binary = args.next().expect("a process has a name");
836        // `--fresh` was honoured when this window started; a restart carries
837        // on from `resume`, the game this window has been playing, so it is
838        // not passed on (a restart used to replay the opening from `home`).
839        // An existing `--branch-from` is dropped too: it names the OLD fork
840        // point, and this restart's own `branch_from_arg` (if any) replaces
841        // it below rather than stacking.
842        let args: Vec<_> = args.filter(|arg| arg != "--fresh" && arg != "--branch-from").collect();
843        let mut command = process::Command::new(binary);
844        command.args(&args);
845        if let Some(branch_from) = &app.branch_from_arg {
846            command.arg("--branch-from").arg(branch_from);
847        }
848        // Returns only if it failed; the window then carries on as it was.
849        let failed = command.exec();
850        eprintln!("restarting: {failed}");
851        app.restart_at = None;
852        app.branch_from_arg = None;
853    }
854    app.answer(ctx);
855
856    // Catching a fresh bot up to a fork frame ("run the bot from here")
857    // takes over the whole pass: no ordinary emulation, no recording, until
858    // it finishes - `replay::rebuild::Rebuilder` is exact regardless of how
859    // many frames land in one pass, so a bigger budget only changes how
860    // often the progress line updates, never the result.
861    if app.rebuilding.is_some() {
862        let progress = {
863            let App { rebuilding, bot, console, states, .. } = &mut *app;
864            let rebuilder = rebuilding.as_mut().expect("checked by the is_some() above");
865            rebuilder.step(bot, console, &mut BotStates(states), REBUILD_BUDGET)
866        };
867        app.rebuild_progress = Some((progress.done, progress.total));
868        let (w, h) = app.console.frame.size;
869        app.picture.set(
870            egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], app.console.frame.rgba()),
871            egui::TextureOptions::NEAREST,
872        );
873        if progress.finished {
874            app.rebuilding = None;
875            app.rebuild_progress = None;
876            let branch_dir = app.recording_dir.join("branches").join(&app.active_branch);
877            match replay::recorder::Recorder::open(&branch_dir, app.rebuild_fork_frame) {
878                Ok(mut recorder) => {
879                    // This branch's own keyframe at ITS fork point, so a
880                    // later seek into it never has to consult its parent
881                    // (`replay::tree::Tree::owner_of`'s own contract).
882                    if let Err(e) = recorder.store_fork_keyframe(app.rebuild_fork_frame, &app.console) {
883                        eprintln!("storing the new branch's fork keyframe: {e}");
884                    }
885                    app.recorder = Some(recorder);
886                }
887                Err(e) => eprintln!("opening the new branch's recorder: {e}"),
888            }
889            // The rebuild installed a `ReplayChooser` to reproduce logged
890            // goal-choice picks exactly; live play from here needs the
891            // REAL chooser back, or Jev silently stops being asked.
892            let margin = decisions::goal_choice::Config::default().margin;
893            match &app.jev {
894                Some(jev) => app.bot.set_goal_chooser(Some(Box::new(jev.clone())), margin),
895                None => app.bot.set_goal_chooser(None, margin),
896            }
897            app.playback = Playback::Live;
898            app.due = Instant::now();
899            eprintln!("rebuild finished: bot caught up to frame {}, now playing live as branch {}", app.rebuild_fork_frame, app.active_branch);
900        }
901        ctx.request_repaint();
902        return;
903    }
904
905    let keys = ctx.input(|input| KEYS.map(|(key, _)| input.key_down(key)));
906    if ctx.input(|i| i.key_pressed(egui::Key::Minus)) {
907        app.speed = app.speed.slower();
908        let _ = app.speed.save(&app.speed_path);
909    }
910    if ctx.input(|i| i.key_pressed(egui::Key::Equals) || i.key_pressed(egui::Key::Plus)) {
911        app.speed = app.speed.faster();
912        let _ = app.speed.save(&app.speed_path);
913    }
914
915    let period = app.frame_period();
916    let now = Instant::now();
917    let mut ran = 0;
918    let max_catch_up = app.speed.max_catch_up(MAX_CATCH_UP);
919    match app.playback {
920        Playback::Live => {
921            while app.due <= now && ran < max_catch_up {
922                // The human's pad is the keyboard and the holds together, set
923                // for each emulated frame, not each pass: a pass that is
924                // catching up runs several, and a hold of one frame is one
925                // frame. It goes to the bot, as Snes9x hands `ap_tick` the
926                // player's pad (snes9x.patch); the bot's answer is what the
927                // console holds.
928                let human = zbanks::pad_word(KEYS.into_iter().zip(keys).filter_map(|((_, button), key_down)| {
929                    (key_down || app.holds.iter().any(|hold| hold.buttons.contains(&button))).then_some(button)
930                }));
931                app.paused_by_x = human & zbanks::pad_word([SnesButton::X]) != 0;
932                let before_wram = *app.console.wram();
933                // Before the tick, since the C shim's goal-choice hook can
934                // fire synchronously inside it - whatever was read last is
935                // what a choice made THIS frame sees (`Chooser::set_snapshot`'s
936                // own doc comment).
937                if let Some(jev) = &app.jev {
938                    jev.0.borrow_mut().set_snapshot(decisions::goal_choice::Snapshot::read(app.console.wram()));
939                }
940                let pad = {
941                    let App { bot, console, states, .. } = &mut *app;
942                    bot.tick(console, &mut BotStates(states), human)
943                };
944                let wram_diff = diff_wram(&before_wram, app.console.wram());
945                zbanks::apply_pad(&mut app.console, pad);
946                for ((_, button), press) in KEYS.into_iter().zip(&mut app.pad) {
947                    let on = zbanks::pad_buttons(pad).any(|b| b == button);
948                    let confidence = app
949                        .holds
950                        .iter()
951                        .find(|hold| hold.buttons.contains(&button))
952                        .and_then(|hold| hold.confidence);
953                    *press = on.then_some(Press { confidence });
954                }
955                app.console.run_frame().expect("running frame");
956                app.count_frame();
957                match &mut app.sound {
958                    // Muted at any speed other than 1x, per the user's own
959                    // ruling (2026-09-22) - the pitch would be wrong anyway.
960                    Some(sound) if app.speed.is_normal() => sound.play(&mut app.console),
961                    _ => app.console.samples.0.clear(),
962                }
963                let wrote_keyframe = {
964                    let App { recorder, console, .. } = &mut *app;
965                    match recorder {
966                        Some(recorder) => {
967                            let entry = FrameEntry { console_pad: pad, human_pad: human, wram_diff, decision: None };
968                            match recorder.record(entry, console) {
969                                Ok(wrote) => wrote,
970                                Err(e) => {
971                                    eprintln!("recording: {e}");
972                                    false
973                                }
974                            }
975                        }
976                        None => false,
977                    }
978                };
979                if wrote_keyframe {
980                    let recording_root = Path::new(BOT_DIR).join("replay");
981                    if let Err(e) = replay::storage::enforce_cap(&recording_root, replay::storage::DEFAULT_CAP_BYTES, &app.recording_dir) {
982                        eprintln!("enforcing the replay disk cap: {e}");
983                    }
984                }
985                app.due += period;
986                ran += 1;
987            }
988            if app.due <= now {
989                app.due = now + period;
990            }
991            if ran > 0 {
992                let (w, h) = app.console.frame.size;
993                app.picture.set(
994                    egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], app.console.frame.rgba()),
995                    egui::TextureOptions::NEAREST,
996                );
997                check_stall(app);
998            }
999        }
1000        Playback::Paused(_) => {
1001            // Nothing emulates; the picture and every panel already show
1002            // exactly this frame, from the seek that got here.
1003        }
1004        Playback::Playing(frame) => {
1005            let mut f = frame;
1006            while app.due <= now && ran < max_catch_up {
1007                let Some(entry) = app.branch_entries.get(f as usize) else {
1008                    // Ran off the end of what is recorded: hand off to the
1009                    // live bot, exactly where it already is (byte-identical
1010                    // to this console, per apps/replay-probe's own proof).
1011                    app.playback = Playback::Live;
1012                    app.due = now;
1013                    break;
1014                };
1015                apply_wram_diff(app.console.wram_mut(), &entry.wram_diff);
1016                zbanks::apply_pad(&mut app.console, entry.console_pad);
1017                app.console.run_frame().expect("replaying a logged frame");
1018                f += 1;
1019                app.due += period;
1020                ran += 1;
1021            }
1022            if let Playback::Playing(_) = app.playback {
1023                app.playback = Playback::Playing(f);
1024            }
1025            if app.due <= now {
1026                app.due = now + period;
1027            }
1028            if ran > 0 {
1029                let (w, h) = app.console.frame.size;
1030                app.picture.set(
1031                    egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], app.console.frame.rgba()),
1032                    egui::TextureOptions::NEAREST,
1033                );
1034            }
1035        }
1036    }
1037
1038    if now.duration_since(app.last_flush) >= Duration::from_secs(1) {
1039        app.saves.flush(&app.console.saves);
1040        app.last_flush = now;
1041    }
1042    if now.duration_since(app.last_resume) >= RESUME_EVERY {
1043        // Both are skipped while not Live: `keep_resume` would otherwise
1044        // save a moment in the PAST as the point a restart resumes from.
1045        if matches!(app.playback, Playback::Live) {
1046            if let Err(e) = app.keep_resume() {
1047                eprintln!("keeping where the game is: {e}");
1048            }
1049            app.save_replay_tree();
1050        } else {
1051            app.last_resume = now;
1052        }
1053    }
1054    // Wake for the next emulated frame whether or not the display's own
1055    // refresh would have: the SNES runs at 60.0988 Hz and the monitor does
1056    // not.
1057    ctx.request_repaint_after(app.due.saturating_duration_since(Instant::now()));
1058}

Once a pass, right after logic_impl ran at least one emulated frame: is the bot still playing (stall.rs)? max_goals: 0 skips formatting the goal list this caller does not need - the panel's own report() call (ui_impl, GOALS_SHOWN) still gets the full one. Then, whether or not the stall itself just changed, gives recovery::Recovery a chance to act on it - it is its own no-op most frames (still in backoff, already given up, or not stalled at all) and only actually retries anything when its own schedule says to. Logs one line and writes/removes STALLED_FILE on every STALL transition, never on a frame where nothing changed - app.stalled == was is the whole guard, because stall::Stall carries only (since, reason), not the elapsed time, so it stays equal for as long as the SAME stall continues.

1072fn check_stall(app: &mut App) {
1073    let report = app.bot.report(0);
1074    let state = alttp::State::read(app.console.wram());
1075    let signal = stall::Signal {
1076        frame: report.frame,
1077        manual_mode: report.manual_mode,
1078        no_task: report.tasks.is_empty(),
1079        eligible: state.control && state.mode.is_play() && !app.paused_by_x,
1080        position: (state.place, state.link_x, state.link_y),
1081    };
1082    let was = app.stalled;
1083    app.stalled = app.stall.update(signal);
1084
1085    if let Some(event) =
1086        app.recovery.consider(&mut app.bot, &app.console, app.stalled.is_some(), report.frame, signal.position)
1087    {
1088        log_recovery(&event);
1089    }
1090
1091    if app.stalled == was {
1092        return;
1093    }
1094    let path = Path::new(BOT_DIR).join(STALLED_FILE);
1095    match app.stalled {
1096        Some(stall) => {
1097            let minutes = stall::minutes(report.frame.saturating_sub(stall.since), app.console.target_fps());
1098            eprintln!(
1099                "{}: STALLED since frame {} ({}), {minutes:.1} minutes ago",
1100                unix_now(),
1101                stall.since,
1102                stall.reason.words()
1103            );
1104            let json = serde_json::json!({
1105                "since": stall.since,
1106                "frame": report.frame,
1107                "minutes": minutes,
1108                "reason": stall.reason.words(),
1109            })
1110            .to_string();
1111            if let Err(e) = fs::write(&path, json) {
1112                eprintln!("writing {}: {e}", path.display());
1113            }
1114        }
1115        None => {
1116            eprintln!("{}: stall cleared (frame {})", unix_now(), report.frame);
1117            if let Err(e) = fs::remove_file(&path)
1118                && e.kind() != std::io::ErrorKind::NotFound
1119            {
1120                eprintln!("removing {}: {e}", path.display());
1121            }
1122        }
1123    }
1124}

A recovery::Recovery attempt, to run/native.log (this process's stderr - stdout_to points fd 1 at the bot's own bot.log instead, so eprintln! is the only thing that reaches it).

1129fn log_recovery(event: &recovery::Event) {
1130    if event.gave_up {
1131        eprintln!(
1132            "{}: RECOVERY gave up after {} recoveries with no progress (frame {}, restored {} goal(s) on the last try)",
1133            unix_now(),
1134            event.attempt,
1135            event.frame,
1136            event.restored
1137        );
1138    } else {
1139        eprintln!(
1140            "{}: RECOVERY attempt {} of {}: restored {} goal(s) at frame {}",
1141            unix_now(),
1142            event.attempt,
1143            recovery::CAP,
1144            event.restored,
1145            event.frame
1146        );
1147    }
1148}

The whole picture-plus-panels draw. Same patch-point shape as logic_impl; see its doc comment.

1152#[inline(never)]
1153fn ui_impl(app: &mut App, ui: &mut egui::Ui) {
1154    // One frameless panel over the whole window, cut up by `Layout`, so
1155    // that no container adds a margin of its own to any cell.
1156    egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
1157        let pad_size = PAD_WIDTH * egui::vec2(1.0, panels::pad::ASPECT);
1158        let layout = Layout::of(ui.max_rect(), SCRUBBER_HEIGHT, pad_size.y + 2.0 * MARGIN);
1159        let cell = |ui: &mut egui::Ui, rect: egui::Rect, add: &mut dyn FnMut(&mut egui::Ui)| {
1160            ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
1161                ui.set_clip_rect(rect);
1162                add(ui);
1163            });
1164        };
1165
1166        cell(ui, layout.picture, &mut |ui| {
1167            ui.put(layout.picture, egui::Image::new((app.picture.id(), layout.picture.size())));
1168        });
1169        // The left column is the bot above and MCP below. Beside the
1170        // picture, never over it: the game's own HUD stays uncovered.
1171        let report = app.bot.report(GOALS_SHOWN);
1172        let goals: Vec<panels::bot::Goal> = report
1173            .goals
1174            .iter()
1175            .map(|g| {
1176                let words = zbanks::score_words(g.last_score);
1177                panels::bot::Goal {
1178                    kind: &g.kind,
1179                    node: &g.node,
1180                    screen: &g.screen,
1181                    attempts: g.attempts,
1182                    score: words.map_or_else(|| g.last_score.to_string(), str::to_owned),
1183                    out_of_reach: matches!(words, Some("unsatisfiable" | "over limit")),
1184                    complete: words == Some("complete"),
1185                }
1186            })
1187            .collect();
1188        // `jev_guards` takes its own `borrow_mut()` (it may refresh the
1189        // cached guards) and `jev_totals_line`'s `borrow()` is dropped at the
1190        // end of its statement, so neither overlaps the `history()` read
1191        // below - the chooser is behind a `RefCell`, not a `Mutex`, and two
1192        // live borrows of different mutability would panic, not merely race.
1193        let (jev_guards_line, jev_stopped) = jev_guards(app.jev.as_ref());
1194        let jev_totals_line = app
1195            .jev
1196            .as_ref()
1197            .map(|j| {
1198                let chooser = j.0.borrow();
1199                jev_totals(&chooser.engine.totals, chooser.overrides)
1200            })
1201            .unwrap_or_default();
1202        // Cloned out (`Record` is small) rather than held as a `Ref` across
1203        // the rest of this function, for the same reason: nothing here must
1204        // still be borrowing the `RefCell` by the time anything else touches
1205        // `app.jev` this frame.
1206        let jev_history_raw: Vec<decisions::Record<decisions::goal_choice::Answer>> =
1207            app.jev.as_ref().map_or_else(Vec::new, |j| j.0.borrow().history().rev().cloned().collect());
1208        let jev_how_lines: Vec<String> = jev_history_raw.iter().map(|d| jev_how(&d.how)).collect();
1209        let jev_panel_history: Vec<panels::jev::Decision> = jev_history_raw
1210            .iter()
1211            .zip(&jev_how_lines)
1212            .map(|(d, how_line)| panels::jev::Decision {
1213                frame: d.frame,
1214                kind: &d.kind,
1215                options: &d.answer.sentences,
1216                probabilities: d.answer.probabilities.as_deref(),
1217                picked: d.answer.picked,
1218                how_kind: jev_how_kind(&d.how),
1219                how: how_line.as_str(),
1220                asked: match &d.how {
1221                    decisions::How::Asked { dollars, input_tokens, millis, prompt } => {
1222                        Some(panels::jev::Asked { prompt, dollars: *dollars, input_tokens: *input_tokens, millis: *millis })
1223                    }
1224                    _ => None,
1225                },
1226            })
1227            .collect();
1228        let stalled_line = app.stalled.map(|s| {
1229            let minutes = stall::minutes(report.frame.saturating_sub(s.since), app.console.target_fps());
1230            format!("STALLED since frame {} ({}), {minutes:.1} minutes", s.since, s.reason.words())
1231        });
1232        let recovery_status = app.recovery.status();
1233        let recovery_line = recovery_status.last.map(|e| {
1234            if e.gave_up {
1235                format!(
1236                    "RECOVERY gave up after {} recoveries with no progress (last at frame {}, restored {})",
1237                    e.attempt, e.frame, e.restored
1238                )
1239            } else {
1240                format!("recovery attempt {} of {} at frame {}: restored {} goal(s)", e.attempt, recovery::CAP, e.frame, e.restored)
1241            }
1242        });
1243        let left = layout.left.shrink(MARGIN);
1244        let (playing, serving) = left.split_top_bottom_at_fraction(BOT_SHARE);
1245        cell(ui, playing, &mut |ui| {
1246            panels::bot::show(
1247                ui,
1248                &panels::bot::Bot {
1249                    frame: report.frame,
1250                    info: &report.info,
1251                    tasks: &report.tasks,
1252                    goals: &goals,
1253                    goal_count: report.goal_count,
1254                    link: report.link,
1255                    traps: report.traps,
1256                    last_trap_frame: report.last_trap_frame,
1257                    stray_reads: report.stray_reads,
1258                    given_up: report.given_up,
1259                    retried: report.retried,
1260                    stalled: stalled_line.as_deref(),
1261                    recovery: recovery_line.as_deref().map(|line| (line, recovery_status.gave_up)),
1262                    paused_by_x: app.paused_by_x,
1263                },
1264            );
1265        });
1266        cell(ui, serving.shrink2(egui::vec2(0.0, MARGIN)), &mut |ui| {
1267            ui.heading("MCP");
1268            activity::show(ui, &mcp::record(&app.served));
1269        });
1270        // The right column is State above and Jev below. State is a handful
1271        // of short, fixed lines; Jev's scrollable choice history is the one
1272        // that wants room, so it gets most of the column - same reasoning as
1273        // `BOT_SHARE` on the left, just the other panel needing the space.
1274        let right = layout.right.shrink(MARGIN);
1275        let (showing_state, showing_jev) = right.split_top_bottom_at_fraction(STATE_SHARE);
1276        cell(ui, showing_state, &mut |ui| {
1277            ui.heading("State");
1278            panels::state::show(ui, &alttp::State::read(app.console.wram()));
1279        });
1280        let jev_usage_tip_frame = app.tip_frame();
1281        let jev_usage_playhead_frame = app.current_frame();
1282        let jev_usage = usage_points(
1283            app.jev.as_ref(),
1284            app.jev_recording_history_start,
1285            jev_usage_tip_frame,
1286            jev_usage_playhead_frame,
1287        );
1288        cell(ui, showing_jev.shrink2(egui::vec2(0.0, MARGIN)), &mut |ui| {
1289            panels::jev::show(
1290                ui,
1291                &mut panels::jev::Jev {
1292                    status: &app.jev_status,
1293                    guards: &jev_guards_line,
1294                    stopped: jev_stopped.as_deref(),
1295                    totals: &jev_totals_line,
1296                    history: &jev_panel_history,
1297                    show_all: &mut app.jev_show_all_history,
1298                    usage: &jev_usage,
1299                    usage_tip_frame: jev_usage_tip_frame,
1300                    usage_playhead_frame: jev_usage_playhead_frame,
1301                },
1302            );
1303        });
1304        let pad = egui::Rect::from_center_size(layout.pad.center(), pad_size);
1305        cell(ui, pad.intersect(layout.pad), &mut |ui| {
1306            panels::pad::show(ui, |button| {
1307                let at = KEYS.iter().position(|&(_, mapped)| mapped == button)?;
1308                app.pad[at]
1309            });
1310        });
1311
1312        let current_frame = app.current_frame();
1313        let tip_frame = app.tip_frame();
1314        let is_live = matches!(app.playback, Playback::Live);
1315        let playing = matches!(app.playback, Playback::Playing(_));
1316        let speed_label = app.speed.label();
1317        let rebuilding = app.rebuild_progress;
1318        let other_branches: Vec<panels::replay::Branch> = app
1319            .replay_tree
1320            .branches
1321            .iter()
1322            .filter(|b| b.id != app.active_branch)
1323            .map(|b| panels::replay::Branch { id: &b.id, fork_frame: b.fork_frame })
1324            .collect();
1325        let mut replay_action = None;
1326        cell(ui, layout.scrubber, &mut |ui| {
1327            replay_action = panels::replay::show(
1328                ui,
1329                &panels::replay::ReplayBar {
1330                    current_frame,
1331                    tip_frame,
1332                    playing,
1333                    speed_label,
1334                    is_live,
1335                    rebuilding,
1336                    other_branches: &other_branches,
1337                },
1338            );
1339        });
1340        if let Some(action) = replay_action {
1341            app.handle_replay_action(action);
1342        }
1343    });
1344}
1346const USAGE: &str = "usage: native [--sound] [--fresh] [--jev] <rom.sfc>
1347       native mcp
1348  --sound        play sound
1349  --jev          Jev makes the bot's goal choice (also ZB_JEV=1); needs TYPESAFE_API_KEY, so start under op-env-run
1350  --fresh        play from the bot's own \"home\" state instead of from where the last window left off
1351  --branch-from RECORDING_DIR:BRANCH:FRAME
1352                 internal - set by the scrubber's \"run the bot from here\": re-run a fresh bot over
1353                 that recording's log up to FRAME, then play on as a new branch of BRANCH
1354  mcp            no window: serve MCP on stdio, passing it on to whichever window is running";

Seconds since the epoch, for the exit lines in run/native.log.

1357fn unix_now() -> String {
1358    std::time::SystemTime::now()
1359        .duration_since(std::time::UNIX_EPOCH)
1360        .map_or_else(|_| "?".to_owned(), |since| format!("{:.0}", since.as_secs_f64()))
1361}

bot.log's printf trace has no natural end - left unbounded it reached 407 MB with nothing to stop it (incident 2026-09-22). [stdout_to] rotates it past this many bytes rather than truncating in place: the user rejected an earlier truncate-and-keep-tail version of this fix.

1367const BOT_LOG_CAP: usize = 50 * 1024 * 1024;

How many rotated files (bot.log.1 .. bot.log.N) to keep alongside the live bot.log.

1370const BOT_LOG_KEEP_FILES: usize = 3;

A size-based, rename-and-cascade rotating writer at path: at cap bytes, path becomes path.1 (path.1 becomes path.2, and so on up to [BOT_LOG_KEEP_FILES]) and a fresh path starts. cap is a parameter (rather than always [BOT_LOG_CAP]) so a test can cross it in bytes, not megabytes.

1377fn rotating_writer(path: PathBuf, cap: usize) -> FileRotate<AppendCount> {
1378    FileRotate::new(
1379        path,
1380        AppendCount::new(BOT_LOG_KEEP_FILES),
1381        ContentLimit::Bytes(cap),
1382        Compression::None,
1383        #[cfg(unix)]
1384        None,
1385    )
1386}
1388#[cfg(test)]
1389mod bot_log_rotation_tests {
1390    use super::*;
1391    use std::io::Write as _;

A scratch directory under the OS temp dir, unique to this process and test name, removed on drop - no new crate needed just for this.

1395    struct ScratchDir(PathBuf);
1396    impl ScratchDir {
1397        fn new(test_name: &str) -> Self {
1398            let dir = env::temp_dir().join(format!("jev-bot-log-rotate-test-{}-{test_name}", process::id()));
1399            let _ = fs::remove_dir_all(&dir);
1400            fs::create_dir_all(&dir).expect("creating the scratch dir");
1401            Self(dir)
1402        }
1403        fn join(&self, name: &str) -> PathBuf {
1404            self.0.join(name)
1405        }
1406    }
1407    impl Drop for ScratchDir {
1408        fn drop(&mut self) {
1409            let _ = fs::remove_dir_all(&self.0);
1410        }
1411    }
1413    #[test]
1414    fn nothing_rotates_before_the_cap() {
1415        let dir = ScratchDir::new("under-cap");
1416        let path = dir.join("bot.log");
1417        let mut w = rotating_writer(path.clone(), 10);
1418        w.write_all(b"123456789").unwrap(); // 9 bytes, cap is 10
1419        w.flush().unwrap();
1420        assert_eq!(fs::read_to_string(&path).unwrap(), "123456789");
1421        assert!(!dir.join("bot.log.1").exists());
1422    }
1423
1424    #[test]
1425    fn crossing_the_cap_rotates_the_live_file_to_dot_1() {
1426        let dir = ScratchDir::new("crosses-once");
1427        let path = dir.join("bot.log");
1428        let mut w = rotating_writer(path.clone(), 10);
1429        w.write_all(b"123456789").unwrap(); // 9 bytes: still under 10
1430        w.write_all(b"A").unwrap(); // 10th byte: at the cap, not yet over
1431        w.write_all(b"B").unwrap(); // 11th byte: over - rotates
1432        w.flush().unwrap();
1433        assert_eq!(fs::read_to_string(dir.join("bot.log.1")).unwrap(), "123456789A");
1434        assert_eq!(fs::read_to_string(&path).unwrap(), "B");
1435    }
1436
1437    #[test]
1438    fn old_rotations_cascade_and_the_oldest_past_the_keep_count_is_dropped() {
1439        let dir = ScratchDir::new("cascades");
1440        let path = dir.join("bot.log");
1441        let mut w = rotating_writer(path.clone(), 1);
1442        // BOT_LOG_KEEP_FILES is 3: after four full rotations, .1/.2/.3 hold
1443        // the three most recent, and the very first byte written ('A') is
1444        // gone rather than kept as a fourth file.
1445        for byte in [b'A', b'B', b'C', b'D', b'E'] {
1446            w.write_all(&[byte]).unwrap();
1447        }
1448        w.flush().unwrap();
1449        assert_eq!(fs::read_to_string(&path).unwrap(), "E");
1450        assert_eq!(fs::read_to_string(dir.join("bot.log.1")).unwrap(), "D");
1451        assert_eq!(fs::read_to_string(dir.join("bot.log.2")).unwrap(), "C");
1452        assert_eq!(fs::read_to_string(dir.join("bot.log.3")).unwrap(), "B");
1453        assert!(!dir.join("bot.log.4").exists());
1454    }
1455}

Point this process's stdout at a PIPE, and give the pipe's read end to a dedicated thread that copies it through a rotating writer (file_rotate, size-based, AppendCount - rename-and-cascade, the same scheme its own docs call "basic count") into bot.log in dir. The C bot prints its whole trace with printf; stderr stays the app's own log. Only ever in window mode: in native mcp stdout is the MCP stream.

The pipe is what makes rotation safe: the C bot's printf and the rotating writer never touch the same file descriptor, so a rename cascading bot.log -> bot.log.1 -> ... can never race a write the way truncating the live file out from under a still-open fd would (a truncate-in-place version of this WAS tried and rejected for exactly this shape of risk - see this function's own git history). The writer thread is the file's only owner from the moment this returns.

1471fn stdout_to(dir: &Path) -> Result<(), String> {
1472    let mut fds: [libc::c_int; 2] = [0; 2];
1473    // SAFETY: `fds` is a valid, correctly-sized out-param for pipe(2).
1474    if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
1475        return Err(format!("pipe: {}", std::io::Error::last_os_error()));
1476    }
1477    let (read_fd, write_fd) = (fds[0], fds[1]);
1478    // SAFETY: dup2 onto fd 1, which this process owns.
1479    if unsafe { libc::dup2(write_fd, 1) } < 0 {
1480        return Err(format!("dup2: {}", std::io::Error::last_os_error()));
1481    }
1482    // fd 1 now holds an equivalent reference to the pipe's write end; this
1483    // process's own copy of it is no longer needed.
1484    // SAFETY: write_fd is open and owned by this process up to this call.
1485    unsafe {
1486        libc::close(write_fd);
1487    }
1488    let rotate = rotating_writer(dir.join("bot.log"), BOT_LOG_CAP);
1489    // SAFETY: read_fd is a valid, open, uniquely-owned fd from the pipe(2)
1490    // call above; `File` takes ownership and closes it on drop.
1491    let reader = unsafe { fs::File::from_raw_fd(read_fd) };
1492    thread::Builder::new()
1493        .name("bot-log-rotate".to_owned())
1494        .spawn(move || {
1495            let mut reader = reader;
1496            let mut rotate = rotate;
1497            // The pipe's write end (fd 1, held by this whole process and
1498            // the C bot's printf) closing is what ends this normally, at
1499            // process exit - not an error to report.
1500            if let Err(e) = std::io::copy(&mut reader, &mut rotate) {
1501                eprintln!("bot.log rotation thread ended: {e}");
1502            }
1503        })
1504        .map_err(|e| format!("spawning the bot.log rotation thread: {e}"))?;
1505    Ok(())
1506}

Open the window and run until it closes. main.rs's whole job is calling this (or, for native mcp, mcp::proxy::run instead).

1510pub fn window() -> eframe::Result {
1511    let mut rom = None;
1512    let mut with_sound = false;
1513    let mut fresh = false;
1514    let mut jev_on = env::var("ZB_JEV").is_ok_and(|v| v == "1");
1515    let mut branch_from_spec: Option<String> = None;
1516    let mut args = env::args().skip(1);
1517    while let Some(arg) = args.next() {
1518        match arg.as_str() {
1519            "--sound" => with_sound = true,
1520            "--jev" => jev_on = true,
1521            "--fresh" => fresh = true,
1522            "--branch-from" => branch_from_spec = args.next(),
1523            _ if rom.is_none() && !arg.starts_with("--") => rom = Some(PathBuf::from(arg)),
1524            _ => {
1525                eprintln!("{USAGE}");
1526                process::exit(2);
1527            }
1528        }
1529    }
1530    let branch_from = branch_from_spec.as_deref().map(|s| {
1531        parse_branch_from(s).unwrap_or_else(|| {
1532            eprintln!("--branch-from wants RECORDING_DIR:BRANCH:FRAME, got {s:?}");
1533            process::exit(2);
1534        })
1535    });
1536    let Some(rom) = rom else {
1537        eprintln!("{USAGE}");
1538        process::exit(2);
1539    };
1540    let mut image = fs::read(&rom).expect("reading rom");
1541    // The window plays the cartridge the bot was written for, as near as a
1542    // vanilla one can be made (packages/zbanks, `rando`). Snapshots name the
1543    // cartridge they were taken of, so ones kept from the unpatched image are
1544    // refused by name below; that is expected once, after this change.
1545    if let Err(e) = zbanks::rando::patch_rom(&mut image) {
1546        eprintln!("not patching the ROM for the bot: {e}");
1547    }
1548    let states = States::beside(&rom);
1549    let (saves, loaded) = SaveFiles::load(rom.clone());
1550    let mut console = Console::boot(image.clone(), loaded).expect("booting");
1551
1552    let bot_dir = env::current_dir().expect("a working directory").join(BOT_DIR);
1553    fs::create_dir_all(&bot_dir).expect("creating run/zbanks-window");
1554    if let Err(e) = stdout_to(&bot_dir) {
1555        eprintln!("the bot's log stays on stdout: {e}");
1556    }
1557    // A fresh Bot means a fresh stall::Detector (check_stall's own doc
1558    // comment), which starts at "not stalled" - so any STALLED_FILE already
1559    // here describes a process that no longer exists. Left in place, it
1560    // reads as a live stall (Incident 2026-09-21 night: a `load_state` back
1561    // to a working save, mid-session, left the file from an old stall on
1562    // disk with nothing false about the check that would have caught it -
1563    // `check_stall` only writes on a TRANSITION, and this fresh process's
1564    // own state never left `None` to trigger one). Removed unconditionally,
1565    // not merged with any transition logic, because it is stale by
1566    // construction on every path into this function, restart included.
1567    let stalled_path = bot_dir.join(STALLED_FILE);
1568    if let Err(e) = fs::remove_file(&stalled_path)
1569        && e.kind() != std::io::ErrorKind::NotFound
1570    {
1571        eprintln!("removing stale {}: {e}", stalled_path.display());
1572    }
1573    if Name::new("home").is_some_and(|home| states.read(&home).is_err()) {
1574        eprintln!(
1575            "no home.state beside the ROM: the bot's ap_init has nothing to load, and plays from power-on, \
1576             where it presses nothing. Make it with: apps/zbanks <rom> 0 run/zbanks-c/home --make-home home --states {}",
1577            rom.with_extension("states").display()
1578        );
1579    }
1580    // What the last window learned of the map becomes what this one's bot
1581    // imports on its first tick, as upstream promoted its exports by hand.
1582    let export = bot_dir.join(MAP_EXPORT);
1583    if export.exists() {
1584        match fs::copy(&export, bot_dir.join(MAP_IMPORT)) {
1585            Ok(_) => eprintln!("the bot imports the last window's map ({})", export.display()),
1586            Err(e) => eprintln!("the bot starts without the last window's map: {e}"),
1587        }
1588    }
1589    // ap_init: every RAM pointer cached, then load("home"), load("hpegs").
1590    let mut bot = zbanks::Bot::start(&mut console, &image, &mut BotStates(&states), &bot_dir);
1591    eprintln!("zbanks bot started; its savestate calls: {:?}", bot.state_log());
1592    // Jev at the goal choice. Off unless asked for; asked for and unable, the
1593    // bot plays as upstream's does and the Bot panel says why.
1594    // `jev_recording_history_start` is how much of the loaded history
1595    // predates THIS bot lifetime / recording - the usage graph's own
1596    // boundary (`usage_points`'s doc comment) - 0 when nothing was loaded,
1597    // Jev is off, or it never started.
1598    let (jev, jev_status, jev_recording_history_start) = if jev_on {
1599        match jev_http::Jev::from_env("native", decisions::model()) {
1600            None => (None, "off: no TYPESAFE_API_KEY (start under op-env-run)".to_owned(), 0),
1601            Some(Err(e)) => (None, format!("off: {e}"), 0),
1602            Some(Ok(client)) => {
1603                let config = decisions::goal_choice::Config::default();
1604                match decisions::goal_choice::Chooser::new(client, config) {
1605                    Err(e) => (None, format!("off: {e}"), 0),
1606                    Ok(mut chooser) => {
1607                        let log_path = bot_dir.join("jev.jsonl");
1608                        // Before opening for append, so a `restart` (which
1609                        // rebuilds this chooser from nothing) does not lose
1610                        // the panel's history: it is seeded from what the
1611                        // last window already wrote.
1612                        let loaded = match chooser.load_history(&log_path) {
1613                            Ok(loaded) if loaded > 0 => {
1614                                eprintln!("jev: loaded {loaded} decision(s) of history from {}", log_path.display());
1615                                loaded
1616                            }
1617                            Ok(loaded) => loaded,
1618                            Err(e) => {
1619                                eprintln!("jev: no history loaded: {e}");
1620                                0
1621                            }
1622                        };
1623                        if let Err(e) = chooser.log_to(&log_path) {
1624                            eprintln!("jev: no decision log: {e}");
1625                        }
1626                        let shared = decisions::goal_choice::Shared::new(chooser);
1627                        bot.set_goal_chooser(Some(Box::new(shared.clone())), config.margin);
1628                        (Some(shared), "on".to_owned(), loaded)
1629                    }
1630                }
1631            }
1632        }
1633    } else {
1634        (None, "off".to_owned(), 0)
1635    };
1636    eprintln!("jev at the goal choice: {jev_status}");
1637    // Then, as a Snes9x user would load their own state after the bot is up,
1638    // the game goes back to where the last window left off.
1639    if branch_from.is_none() && !fresh && let Ok(snapshot) = states.read(&Name::resume()) {
1640        // A snapshot from another build of the core or another cartridge
1641        // image is refused and the console left as the bot's home left it.
1642        // Skipped for a `--branch-from` restart: that one restores the
1643        // RECORDING's own header below instead, so the rebuild starts from
1644        // exactly what the recording started from, not wherever `resume`
1645        // happens to be now.
1646        if let Err(e) = console.restore(&snapshot) {
1647            eprintln!("not resuming, playing from home: {e}");
1648        }
1649    }
1650
1651    // The replay recording: either a fresh one, starting from wherever the
1652    // console now stands (home or resume), or - for a "run the bot from
1653    // here" restart - catching this fresh bot up to a fork frame over an
1654    // EXISTING recording's log before a new branch of it goes live
1655    // (`logic_impl`'s own rebuild-finished handling opens the recorder once
1656    // that catch-up is done; `recorder` is `None` here for exactly that
1657    // window).
1658    let replay_root = bot_dir.join("replay");
1659    let (recording_dir, replay_tree, replay_header, active_branch, rebuilding, rebuild_fork_frame, recorder) =
1660        if let Some((from_dir, from_branch, from_frame)) = branch_from {
1661            let mut tree = replay::tree::Tree::load(&from_dir.join("tree.json")).expect("loading the recording's tree");
1662            let header = replay::header::RunHeader::load(&from_dir.join("header.bin")).expect("loading the recording's header");
1663            // Replay the WHOLE history up to from_frame through this fresh
1664            // bot, feeding back exactly the logged human pad and
1665            // goal-choice picks - never the real Jev, never a fresh choice
1666            // (research/replay-timeline.md: "re-running the bot over the
1667            // log is exact").
1668            console.restore(&header.starting_snapshot).expect("restoring the recording's header for the rebuild");
1669            bot.console_replaced();
1670            let entries =
1671                replay::seek::entries_up_to(&from_dir, &tree, &from_branch, from_frame).expect("reading the recording's log for the rebuild");
1672            eprintln!("rebuilding: replaying {} logged frame(s) through a fresh bot before branching from {from_branch} at frame {from_frame}", entries.len());
1673            let (rebuilder, chooser) = replay::rebuild::Rebuilder::new(entries);
1674            bot.set_goal_chooser(Some(chooser), decisions::goal_choice::Config::default().margin);
1675            let new_branch = tree.fork(&from_branch, from_frame);
1676            (from_dir, tree, header, new_branch.id, Some(rebuilder), from_frame, None)
1677        } else {
1678            // Frame 0 of a fresh recording is wherever the console stands
1679            // once Bot::start and the ordinary resume-restore above have
1680            // both run - exactly what the live bot is about to tick
1681            // forward from.
1682            let starting_snapshot = console.snapshot().expect("the recording's own starting snapshot");
1683            let map_import = fs::read(bot_dir.join(MAP_IMPORT)).unwrap_or_default();
1684            let header = replay::header::RunHeader::new(starting_snapshot, map_import, bot.retry_given_up, jev_on);
1685            let recording_dir = replay_root.join(new_recording_id());
1686            let branch_dir = recording_dir.join("branches").join(replay::tree::Tree::ROOT);
1687            let recorder = match replay::recorder::Recorder::open(&branch_dir, 0) {
1688                Ok(recorder) => Some(recorder),
1689                Err(e) => {
1690                    eprintln!("opening the recorder: {e}");
1691                    None
1692                }
1693            };
1694            if let Err(e) = header.save(&recording_dir.join("header.bin")) {
1695                eprintln!("starting a recording: {e}");
1696            }
1697            (recording_dir, replay::tree::Tree::new(), header, replay::tree::Tree::ROOT.to_owned(), None, 0, recorder)
1698        };
1699    if let Err(e) = replay_tree.save(&recording_dir.join("tree.json")) {
1700        eprintln!("saving the replay tree: {e}");
1701    }
1702    // Recordings of an older replay format cannot be seeked by this build
1703    // (their keyframes may be encoded differently), so they go first.
1704    match replay::storage::remove_other_formats(&replay_root, &recording_dir) {
1705        Ok(removed) if !removed.is_empty() => eprintln!("removed {} recording(s) of an older replay format", removed.len()),
1706        Ok(_) => {}
1707        Err(e) => eprintln!("removing old-format recordings: {e}"),
1708    }
1709    if let Err(e) = replay::storage::enforce_cap(&replay_root, replay::storage::DEFAULT_CAP_BYTES, &recording_dir) {
1710        eprintln!("enforcing the replay disk cap at startup: {e}");
1711    }
1712    let speed_path = bot_dir.join("speed.txt");
1713    let speed = Speed::load(&speed_path);
1714
1715    // Silent unless asked. Under WSLg the sound drops in and out (heard
1716    // 2026-09-20; cause not yet measured), and the game
1717    // plays identically without it, so it is opt-in until that is fixed.
1718    let sound = with_sound
1719        .then(|| Sound::open(&mut console).inspect_err(|e| eprintln!("no sound: {e}")).ok())
1720        .flatten();
1721
1722    eframe::run_native(
1723        "jev",
1724        eframe::NativeOptions {
1725            viewport: egui::ViewportBuilder::default().with_inner_size([1300.0, 780.0]),
1726            ..Default::default()
1727        },
1728        Box::new(move |cc| {
1729            let picture = cc.egui_ctx.load_texture(
1730                "picture",
1731                egui::ColorImage::filled([256, 224], egui::Color32::BLACK),
1732                egui::TextureOptions::NEAREST,
1733            );
1734            let (requests, served) = mcp::serve(cc.egui_ctx.clone());
1735            Ok(Box::new(App {
1736                console,
1737                sound,
1738                saves,
1739                picture,
1740                due: Instant::now(),
1741                last_flush: Instant::now(),
1742                requests,
1743                served,
1744                holds: Vec::new(),
1745                pad: [None; 12],
1746                awaiting_window: Vec::new(),
1747                states,
1748                last_resume: Instant::now(),
1749                restart_at: None,
1750                bot,
1751                paused_by_x: false,
1752                jev,
1753                jev_status,
1754                jev_show_all_history: false,
1755                jev_recording_history_start,
1756                stall: stall::Detector::new(),
1757                stalled: None,
1758                recovery: recovery::Recovery::new(),
1759                rom: image.clone(),
1760                recording_dir,
1761                replay_tree,
1762                replay_header,
1763                active_branch,
1764                recorder,
1765                playback: Playback::Live,
1766                branch_entries: Vec::new(),
1767                speed,
1768                speed_path,
1769                rebuilding,
1770                rebuild_fork_frame,
1771                rebuild_progress: None,
1772                branch_from_arg: None,
1773            }))
1774        }),
1775    )
1776}