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}