jevsnes.git / apps / native / src / app.rs
app.rsannotatedapp.rssource1776 lines · 80.3 KB · raw
1//! The SNES in a window: picture, sound and a keyboard for a pad, played by
2//! the zbanks/alttp C bot (`packages/zbanks`), which gets every frame's pad.
3//!
4//! The picture sits in the middle and everything we want to watch beside it
5//! goes in panels AROUND it, never over it, so nothing covers the game's own
6//! HUD. The same `console` library compiles for the browser; this file is only
7//! the parts that differ on a desktop — a window, a sound device, a save file.
8//!
9//! Split out of `main.rs` into its own `rust_library` (2026-09-20) so that a
10//! hot-patch cycle never has to relink the executable: `main.rs` is now a
11//! three-line dispatcher with no patch points of its own, and `tools/hotpatch`
12//! gets this crate's fresh objects the same way it gets `packages/panels`'s -
13//! see `../CLAUDE.md` and `../../tools/hotpatch/CLAUDE.md` for the mechanism
14//! and the measurement that motivated it.
15
16mod activity;
17
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};
41
42/// Keyboard for controller one. Select is Backspace because egui reports Shift
43/// 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];
58
59/// How much sound to keep queued ahead of the device, as a fraction of a
60/// second. Deep enough to ride out a late UI frame, shallow enough that a
61/// sword swing is heard when it is seen.
62const QUEUE_SECONDS: f64 = 0.05;
63
64/// A host that fell behind catches up by at most this many frames in one pass,
65/// then forgets the rest: after a stall the game resumes, it does not sprint.
66/// This is the 1x figure; `Speed::max_catch_up` scales it for the current
67/// playback speed, so a sprint at 8x is still one sprint per pass.
68const MAX_CATCH_UP: u32 = 4;
69
70/// How tall the replay scrubber's own strip is, directly under the picture.
71const SCRUBBER_HEIGHT: f32 = 40.0;
72
73/// Frames of a rebuild ("run the bot from here") to run per UI pass while
74/// catching a fresh bot up to a fork frame - enough to finish in a
75/// reasonable number of passes without freezing the window for however long
76/// the whole rebuild takes.
77const REBUILD_BUDGET: u32 = 2000;
78
79/// 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}
84
85impl Sound {
86    /// 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    }
122
123    /// Queue what the console produced this frame and tell it how deep the
124    /// 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}
135
136/// Cartridge saves as files beside the ROM: `zelda.sfc` keeps `zelda.sav`.
137struct SaveFiles {
138    rom: PathBuf,
139    written: Saves,
140}
141
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    }
150
151    /// 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}
167
168/// 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}
175
176/// Where the picture is relative to this run's own recording. Only `Live`
177/// ever calls `bot.tick`; the other two drive `console` from the log
178/// instead (`apply_wram_diff`, `zbanks::apply_pad`), never the bot -
179/// `research/replay-timeline.md`'s replay design: "Replay... never ticks
180/// the bot". The bot's own C state sits untouched the whole time a scrub is
181/// under way, which is what lets returning to `Live` at the tip just resume
182/// ticking it: the console is byte-identical to wherever the live bot
183/// itself left it (proven by `apps/replay-probe`), so nothing needs
184/// reconstructing.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum Playback {
187    Live,
188    Paused(u64),
189    Playing(u64),
190}
191
192/// A recording id: a zero-padded Unix timestamp, so lexical order is
193/// chronological - `replay::storage::enforce_cap`'s own assumption about
194/// 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}
199
200/// `dir:branch:frame`, as `--branch-from` gives it: the recording, the
201/// 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}
209
210struct App {
211    console: Console,
212    sound: Option<Sound>,
213    saves: SaveFiles,
214    picture: egui::TextureHandle,
215    /// 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>,
221    /// What was held on the pad for the last emulated frame, in `KEYS` order.
222    pad: [Option<Press>; 12],
223    /// Callers waiting on a picture of the window, which egui delivers as an
224    /// event on a later pass.
225    awaiting_window: Vec<oneshot::Sender<mcp::Picture>>,
226    states: States,
227    last_resume: Instant,
228    /// Set once a restart has been asked for and everything is kept: when to
229    /// go. Not at once, because the caller is still being told it will happen,
230    /// and nothing says when that reply has left; it is a loopback socket, so
231    /// this is long. A reply that loses the race costs the caller one failed
232    /// call, and the restart happens regardless.
233    restart_at: Option<Instant>,
234    /// The `--branch-from` value to carry into the restart `restart_at`
235    /// schedules, when it is a "run the bot from here" rather than an
236    /// ordinary restart. `None` for an ordinary restart.
237    branch_from_arg: Option<String>,
238    /// Who is playing: the zbanks/alttp C bot, handed the human's pad and
239    /// returning the pad for every emulated frame. The MCP client watches.
240    bot: zbanks::Bot,
241    /// Whether the human held X on the last frame, which makes the bot step
242    /// aside and pass the human's pad through (upstream alttp.c:57).
243    paused_by_x: bool,
244    /// Jev making the bot's goal choice (`--jev` or `ZB_JEV=1`), if on.
245    jev: Option<decisions::goal_choice::Shared>,
246    /// "on", "off", or why it is not on, for the Bot panel.
247    jev_status: String,
248    /// The Jev panel's history toggle: `false` (the default) shows only
249    /// asked rows, since reused/one-choice/throttled/fell-back rows are
250    /// most of a long run's history and bury the real questions (user,
251    /// 2026-09-22). Ordinary widget state, like `speed`.
252    jev_show_all_history: bool,
253    /// How much of `app.jev`'s history (`chooser.history()`, chronological)
254    /// predates the CURRENT recording, so the usage graph does not sum
255    /// decisions left over from an earlier bot lifetime: `jev.jsonl` is one
256    /// running log across every recording this `bot_dir` has ever had
257    /// (`window()`'s own setup always mints a fresh, timestamped
258    /// `recording_dir` on anything but a `--branch-from` restart), while a
259    /// decision's own `frame` resets to 0 with every fresh bot - so an old
260    /// recording's frame 500 and this one's frame 500 are unrelated moments
261    /// unless this boundary tells them apart. Set once, from
262    /// `Chooser::load_history`'s own return value, at window start.
263    jev_recording_history_start: usize,
264    /// Whether the bot has stopped playing (`stall.rs`), and since when.
265    /// `None` until proven otherwise; recomputed once a pass in
266    /// `logic_impl`, read (never recomputed) by the panel and the `bot`
267    /// tool so both agree with the log and `stalled.json`.
268    stall: stall::Detector,
269    stalled: Option<stall::Stall>,
270    /// Bounded, self-driven recovery from a stall (`zbanks::recovery`):
271    /// retries every goal the bot has given up on, unconditionally rather
272    /// than only on a possession change, with escalating backoff and a cap
273    /// of tries that make no progress. A fresh one on every `restart`, same
274    /// reasoning as `stall` above.
275    recovery: recovery::Recovery,
276    /// The exact patched cartridge bytes the console was booted from - a
277    /// seek or a rebuild boots a fresh `Console` on these to reconstruct a
278    /// past frame, without re-reading or re-patching the ROM file.
279    rom: Vec<u8>,
280    /// 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,
284    /// The branch the window is currently showing and (while `Live`)
285    /// recording to.
286    active_branch: String,
287    /// `None` only while `rebuilding` is catching a fresh bot up to a fork
288    /// frame - there is nowhere yet to record TO until that finishes.
289    recorder: Option<replay::recorder::Recorder>,
290    playback: Playback,
291    /// `active_branch`'s own chain, frame 1 up to its tip - loaded once per
292    /// seek or "play" rather than re-read from disk every frame.
293    branch_entries: Vec<FrameEntry>,
294    speed: Speed,
295    speed_path: PathBuf,
296    /// Catching a fresh bot up to a fork frame before handing it control as
297    /// a new branch ("run the bot from here", via a restart - a second
298    /// `zbanks::Bot` cannot exist in this process,
299    /// `packages/zbanks/CLAUDE.md`'s "one bot per process").
300    rebuilding: Option<replay::rebuild::Rebuilder>,
301    /// The fork frame `rebuilding` is catching up to - once it finishes,
302    /// this is where its new branch's own keyframe goes
303    /// (`replay::recorder::Recorder::store_fork_keyframe`).
304    rebuild_fork_frame: u64,
305    /// `rebuilding`'s own last-reported progress, for the scrubber panel -
306    /// `replay::rebuild::Progress` is not `Clone`/kept, so this is the
307    /// window's own copy of the two numbers that matter.
308    rebuild_progress: Option<(u64, u64)>,
309}
310
311/// 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}
325
326/// The short word a history row shows for how a choice was made - the
327/// 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}
337
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}
344
345/// How many buckets the usage graph divides the current recording into.
346const USAGE_BUCKETS: u64 = 24;
347
348/// The Jev panel's usage graph: `app.jev`'s own history
349/// (`decisions::Record`, already loaded/kept in memory for the History
350/// section below - no separate read, no ledger involved), bucketed by game
351/// frame across `[0, tip_frame]` and summed only up to `playhead_frame` -
352/// scrubbing the picture back shows usage only up to wherever it is paused,
353/// per the user's own request (2026-09-22). `history_start` is
354/// `app.jev_recording_history_start`: decisions loaded from a PRIOR
355/// recording (its own doc comment on that field explains why `jev.jsonl`
356/// can hold them) are skipped, not just out-of-range ones.
357///
358/// A decision's own `frame` is the bot's tick count, which is exactly the
359/// recording's own absolute frame numbering for as long as the invariant in
360/// `packages/replay/CLAUDE.md` holds ("a recording always starts exactly
361/// when a fresh Bot does") - true for this window's own single, unbranched
362/// recording; a rebuilt branch re-ticks its bot from frame 0 too, so the
363/// 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}
397
398/// The shared guards (every process's questions, from the ledger), as the
399/// 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}
407
408/// The bot's named savestates (`ap_init` asks for "home" and "hpegs") are
409/// snapshots beside the ROM, the same directory the MCP tools use.
410struct BotStates<'a>(&'a States);
411
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}
420
421/// How wide the controller is drawn under the picture, in points.
422const PAD_WIDTH: f32 = 380.0;
423
424/// Room left around what is in a side panel and around the pad, in points.
425const MARGIN: f32 = 8.0;
426
427/// How much of the left column the Bot panel takes; the MCP log has the rest.
428const BOT_SHARE: f32 = 0.66;
429
430/// How much of the right column the State panel takes; Jev's choice history
431/// has the rest. State is a handful of short, fixed lines and Jev's history
432/// is a growing scrollable list, so it gets the minority share here where
433/// Bot (the taller content) got the majority on the left.
434const STATE_SHARE: f32 = 0.28;
435
436/// How many of the bot's goals the panels list.
437const GOALS_SHOWN: usize = 40;
438
439/// Where the bot's own files go: its debug output (goals.txt, map, ...) and
440/// its stdout, `bot.log`.
441const BOT_DIR: &str = "run/zbanks-window";
442
443/// The bot's map as the window last exported it, and the name it is written
444/// under first.
445const MAP_EXPORT: &str = "map_export.txt";
446const MAP_EXPORT_PART: &str = "map_export.txt.part";
447
448/// What the bot's first tick imports (alttp.c:31-38).
449const MAP_IMPORT: &str = "map.19.txt";
450
451/// Written beside the bot's own files while [`stall`] says the bot is
452/// stalled, removed the moment it clears - so an external watchdog can
453/// check for a stall without going through MCP at all.
454const STALLED_FILE: &str = "stalled.json";
455
456/// How often where the game is gets kept while it runs, for the window that is
457/// killed rather than closed. A snapshot is 1.3 MB, so not every second.
458const RESUME_EVERY: Duration = Duration::from_secs(30);
459
460const BEFORE_RESTART: Duration = Duration::from_millis(300);
461
462impl App {
463    /// 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    }
480
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    }
485
486    /// Go back to a snapshot. The bot carries on with what it knows, as it
487    /// 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    }
494
495    /// 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    }
557
558    /// Jev's recent goal-choice history, newest first, as JSON for the
559    /// `jev_history` tool - the same decisions the Jev panel's history list
560    /// 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    }
571
572    /// The machine changed under the holds: whoever was holding buttons was
573    /// 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    }
578
579
580    fn frame_period(&self) -> Duration {
581        self.speed.frame_period(Duration::from_secs_f64(1.0 / self.console.target_fps()))
582    }
583
584    /// The active branch's own tip: the last frame actually recorded to it.
585    /// 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    }
589
590    /// 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    }
597
598    /// Keep the branch tree on disk in step with `recorder`'s own tally, so
599    /// a seek elsewhere (this session or a later one) sees the real tip
600    /// 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    }
611
612    /// Jump to `frame` in the active branch's history and pause there -
613    /// `replay::seek::seek_to` restores the nearest keyframe and replays the
614    /// logged pad and WRAM diff forward, headless, never the bot. `frame`
615    /// past the tip clamps to it and returns to `Live` instead (nothing
616    /// 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    }
633
634    /// `active_branch`'s own history, frame 1 up to its tip, for `Playing`
635    /// 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    }
643
644    /// 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    }
675
676    /// Answer what the MCP server has asked since the last pass. A reply whose
677    /// 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    }
773
774    /// One emulated frame has run with the holds applied: count it, and
775    /// 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}
790
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}
821
822/// The per-frame update: run the emulator, then flush saves/resume. A
823/// `subsecond::call`-family patch point (research/subsecond-patch-build.md
824/// §6/§8): `#[inline(never)]` so it survives `-Copt-level=3`'s cross-CGU
825/// inlining as an addressable symbol (§8.2.2), called through
826/// `subsecond::HotFn` rather than the `subsecond::call` sugar because it takes
827/// arguments (`call`'s signature is `FnMut() -> O`, no arguments). `App`
828/// itself is never patched - only which code runs against it - so state on
829/// `app` survives a patch that a `static` (like `apps/hotdemo`'s `TICKS`)
830/// 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}
1059
1060/// Once a pass, right after `logic_impl` ran at least one emulated frame:
1061/// is the bot still playing (`stall.rs`)? `max_goals: 0` skips formatting
1062/// the goal list this caller does not need - the panel's own `report()`
1063/// call (`ui_impl`, `GOALS_SHOWN`) still gets the full one. Then, whether or
1064/// not the stall itself just changed, gives `recovery::Recovery` a chance to
1065/// act on it - it is its own no-op most frames (still in backoff, already
1066/// given up, or not stalled at all) and only actually retries anything when
1067/// its own schedule says to. Logs one line and writes/removes
1068/// `STALLED_FILE` on every STALL transition, never on a frame where nothing
1069/// changed - `app.stalled == was` is the whole guard, because `stall::Stall`
1070/// carries only `(since, reason)`, not the elapsed time, so it stays equal
1071/// 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}
1125
1126/// A `recovery::Recovery` attempt, to `run/native.log` (this process's
1127/// stderr - `stdout_to` points fd 1 at the bot's own `bot.log` instead, so
1128/// `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}
1149
1150/// The whole picture-plus-panels draw. Same patch-point shape as
1151/// `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}
1345
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";
1355
1356/// 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}
1362
1363/// `bot.log`'s printf trace has no natural end - left unbounded it reached
1364/// 407 MB with nothing to stop it (incident 2026-09-22). [`stdout_to`]
1365/// rotates it past this many bytes rather than truncating in place: the
1366/// user rejected an earlier truncate-and-keep-tail version of this fix.
1367const BOT_LOG_CAP: usize = 50 * 1024 * 1024;
1368/// How many rotated files (`bot.log.1` .. `bot.log.N`) to keep alongside the
1369/// live `bot.log`.
1370const BOT_LOG_KEEP_FILES: usize = 3;
1371
1372/// A size-based, rename-and-cascade rotating writer at `path`: at `cap`
1373/// bytes, `path` becomes `path.1` (`path.1` becomes `path.2`, and so on up
1374/// to [`BOT_LOG_KEEP_FILES`]) and a fresh `path` starts. `cap` is a
1375/// parameter (rather than always [`BOT_LOG_CAP`]) so a test can cross it in
1376/// 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}
1387
1388#[cfg(test)]
1389mod bot_log_rotation_tests {
1390    use super::*;
1391    use std::io::Write as _;
1392
1393    /// A scratch directory under the OS temp dir, unique to this process and
1394    /// 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    }
1412
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}
1456
1457/// Point this process's stdout at a PIPE, and give the pipe's read end to a
1458/// dedicated thread that copies it through a rotating writer
1459/// (`file_rotate`, size-based, `AppendCount` - rename-and-cascade, the same
1460/// scheme its own docs call "basic count") into `bot.log` in `dir`. The C
1461/// bot prints its whole trace with printf; stderr stays the app's own log.
1462/// Only ever in window mode: in `native mcp` stdout is the MCP stream.
1463///
1464/// The pipe is what makes rotation safe: the C bot's printf and the
1465/// rotating writer never touch the same file descriptor, so a rename
1466/// cascading `bot.log` -> `bot.log.1` -> ... can never race a write the way
1467/// truncating the live file out from under a still-open fd would (a
1468/// truncate-in-place version of this WAS tried and rejected for exactly
1469/// this shape of risk - see this function's own git history). The writer
1470/// 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}
1507
1508/// Open the window and run until it closes. `main.rs`'s whole job is calling
1509/// 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}