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.
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.
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.
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.
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.
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.
When the next emulated frame is due.
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.
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.
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>/.
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.
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.
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.
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);
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.
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);
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 }
Go back to a snapshot. The bot carries on with what it knows, as it would in Snes9x when its user loads a state.
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.
The active branch's own tip: the last frame actually recorded to it. Scrubbing or playing past this hands off to the live bot.
Where the picture is right now, regardless of playback mode.
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.
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.
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}