diff --git a/Cargo.toml b/Cargo.toml index 9ae7394..3e020cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ blake3 = "1" png = "0.17" parking_lot = "0.12" spin = "0.10.0" +rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } gdbstub = { version = "0.7", features = ["std"] } gdbstub_arch = "0.3" cranelift-codegen = { version = "0.116", optional = true } diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 585688d..48ee8fd 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -150,7 +150,7 @@ impl JitEnv { /// Action a config tab asks the app to perform that needs app-level state /// (e.g. a confirmation modal) the immediate-mode tab UI doesn't own. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ConfigAction { #[default] None, @@ -169,6 +169,9 @@ pub enum ConfigAction { /// the app should run the platform's privilege flow (Linux setcap/pkexec, /// macOS ChmodBPF install, Windows driver check) via `capture_access`. EnablePacketCapture, + /// User picked a disc image for a CD-ROM while the machine is running — + /// send Cmd::LoadDisc immediately without waiting for restart. + LoadDisc { id: u8, path: String }, } /// Everything a config tab hands back to the app for one frame. @@ -194,10 +197,10 @@ pub fn show_tab( ) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, - Tab::Disks => { let e = show_disks(ui, cfg); TabOutcome { disks_changed: e.changed, disk_picked: e.picked, ..Default::default() } } + Tab::Disks => { let (e, a) = show_disks(ui, cfg); TabOutcome { action: a, disks_changed: e.changed, disk_picked: e.picked, ..Default::default() } } Tab::Network => { let net = show_network(ui, cfg, host, disk_folders, pcap_ifaces); - TabOutcome { action: net.action, net, ..Default::default() } + TabOutcome { action: net.action.clone(), net, ..Default::default() } } Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() } Tab::Display => { show_display(ui, cfg); TabOutcome::default() } @@ -301,8 +304,9 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) { }); } -fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { +fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) { let mut edit = PathEdit::default(); + let mut action = ConfigAction::None; ui.heading("SCSI devices"); ui.horizontal(|ui| { ui.label("IDs 1–7. CD-ROMs typically use 4–6."); @@ -342,6 +346,13 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { DISK_FILTERS); edit.changed |= e.changed; edit.picked |= e.picked; + // For CD-ROMs, picking a path immediately loads the disc into + // the running machine (equivalent to Ctrl+F12). The core's + // count-driven queue decides whether this replaces the current + // disc or joins the changer cycle. + if dev.cdrom && e.picked && !dev.path.is_empty() { + action = ConfigAction::LoadDisc { id, path: dev.path.clone() }; + } ui.end_row(); if dev.path.ends_with(".chd") && !build_features::CHD { ui.label(""); @@ -440,7 +451,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { } } if let Some(id) = to_delete { cfg.scsi.remove(&id); } - edit + (edit, action) } /// A soft-invalid subnet the user just entered, surfaced to the app so it can diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index 62b5338..d322ce0 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -40,6 +40,9 @@ pub enum Cmd { /// Discard a single disk's COW overlay ("roll back") — delete the /// `.diff.chd` / `.overlay`. File-level; only valid while stopped. CowReset { base: String, chd: bool }, + /// Load a disc image into a CD-ROM device (live hot-swap). + /// Valid only while running. The path is loaded as the active disc. + LoadDisc { id: u8, path: String }, Quit, } @@ -568,6 +571,25 @@ fn worker_loop( Err(e) => { let _ = evt_tx.send(Evt::Error(format!("screenshot failed: {e}"))); } } } + Ok(Cmd::LoadDisc { id, path }) => { + match machine.as_ref() { + Some(m) => { + match m.hpc3().scsi().load_disc(id as usize, path.clone()) { + Ok(loaded_path) => { + let filename = std::path::Path::new(&loaded_path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| loaded_path.clone()); + let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: loaded {filename}"))); + } + Err(e) => { + let _ = evt_tx.send(Evt::Error(format!("SCSI #{id}: {e}"))); + } + } + } + None => { let _ = evt_tx.send(Evt::Error("load disc: not running".into())); } + } + } Ok(Cmd::Quit) | Err(_) => { *ps2_slot.lock() = None; if let Some(m) = machine.take() { diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 0afc917..d089aa6 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -1859,6 +1859,16 @@ impl App { ConfigAction::TestCamera => self.open_camera_test(), ConfigAction::RefreshPcapIfaces => self.refresh_pcap_ifaces(), ConfigAction::EnablePacketCapture => self.run_enable_packet_capture(), + ConfigAction::LoadDisc { id, path } => { + if self.emu.is_running() { + self.emu.send(Cmd::LoadDisc { id, path: path.clone() }); + let filename = std::path::Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + self.toast(format!("SCSI #{}: loaded {}", id, filename)); + } + } ConfigAction::None => {} } if out.disks_changed { self.mark_dirty(); } @@ -2603,6 +2613,27 @@ impl eframe::App for App { ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen)); } + // Ctrl+F12 opens a file picker to load a CD-ROM disc on the fly (hot-swap) + // without needing to configure it in iris.toml or use the SCSI menu. + if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F12)) { + if self.emu.is_running() { + // Find the first CD-ROM device + let cdrom_id = self.cfg.scsi.iter() + .find(|(_, dev)| dev.cdrom) + .map(|(id, _)| *id); + + if let Some(id) = cdrom_id { + if let Some(path) = scsi_menu::pick_iso("Load CD-ROM disc") { + self.emu.send(Cmd::LoadDisc { id, path }); + } + } else { + self.toast("No CD-ROM drive attached"); + } + } else { + self.toast("Load disc: machine not running"); + } + } + // Ctrl + / Ctrl - / Ctrl 0 zoom controls (helps on Linux where egui's // default text size can look small on HiDPI / fractional-scale Wayland). let (zoom_in, zoom_out, zoom_reset) = ctx.input(|i| ( diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index d9e7ead..e8c740e 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -138,7 +138,7 @@ fn pick_disk(title: &str) -> Option { .map(|p| p.to_string_lossy().into_owned()) } -fn pick_iso(title: &str) -> Option { +pub fn pick_iso(title: &str) -> Option { rfd::FileDialog::new() .set_title(title) .add_filter("ISO images", &["iso", "chd"]) diff --git a/iris.toml b/iris.toml index d633edc..eb93491 100644 --- a/iris.toml +++ b/iris.toml @@ -125,6 +125,19 @@ bind = "localhost" #cdrom = true #discs = ["second.iso", "cdrom4.iso", "patches.iso"] +# Runtime disc switching (no extra config needed). +# Load any ISO/CHD at runtime by pressing Ctrl+F12 (RCtrl+F12 in the standalone +# window, Ctrl/Cmd+F12 in the GUI) and picking a file. Behaviour follows the +# number of discs queued for the drive: +# - empty tray: the picked disc simply loads +# - one disc: loading another replaces it +# - two+ discs: ejecting cycles through them; a single disc ejects to empty +# `path` is optional: omit it (or comment it out) to boot with an empty tray and +# insert media later. +#[scsi.4] +#cdrom = true +#path = "cdrom4.iso" # optional — omit to start with an empty tray + #[vino] #source = "camera" diff --git a/src/config.rs b/src/config.rs index a06700e..9bd3000 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,9 @@ pub const VALID_BANK_SIZES: &[u32] = &[0, 8, 16, 32, 64, 128]; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScsiDeviceConfig { /// Path to the disk image or ISO file (primary/current disc). + /// For CD-ROMs this may be omitted (defaults to empty string) to start the + /// drive with an empty tray; media can be loaded at runtime. + #[serde(default)] pub path: String, /// Additional ISO images for CD-ROM changers (ignored for HDD). #[serde(default)] @@ -479,10 +482,10 @@ impl MachineConfig { if *id == 0 || *id > 7 { return Err(format!("SCSI ID {} is out of range (1–7)", id)); } - // CD-ROM with empty path + no changer entries = drive present, no - // media loaded. This is a valid runtime state (see - // Wd33c93a::add_device empty-CD-ROM path / insert_disc). - let _ = dev; // explicitly keep the binding for future checks + // A CD-ROM may legitimately start with an empty tray (no path, no + // discs) and have media loaded at runtime; any discs list is valid + // as a changer queue. So there is nothing CD-ROM-specific to check. + let _ = dev; } Ok(()) } diff --git a/src/machine.rs b/src/machine.rs index 5a368b3..e4e40c7 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -265,13 +265,21 @@ impl Machine { } } let (path, discs) = if dev.cdrom { - let mut list = dev.discs.clone(); - if list.is_empty() { + // Build the changer list, skipping empty paths (an empty path + // means "drive present, tray empty" — a valid CD-ROM state + // where media is loaded later at runtime). + let mut list: Vec = Vec::new(); + if !dev.path.is_empty() { list.push(dev.path.clone()); - } else if list[0] != dev.path { - list.insert(0, dev.path.clone()); } - (list[0].clone(), list) + for d in &dev.discs { + if !d.is_empty() && !list.contains(d) { + list.push(d.clone()); + } + } + // Active disc is the first entry, or empty (no media) if none. + let active = list.first().cloned().unwrap_or_default(); + (active, list) } else { (dev.path.clone(), vec![]) }; diff --git a/src/main.rs b/src/main.rs index 6ae69aa..9543ce1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,7 +119,8 @@ fn main() { use winit::event_loop::EventLoop; let event_loop = EventLoop::new().unwrap(); let rex3 = machine.get_rex3().expect("rex3 must be present in non-headless mode"); - let ui = Ui::new(machine.get_ps2(), rex3, machine.get_timer_manager(), &event_loop, scale, scroll_pixels_per_line, lock_aspect_ratio); + let scsi = machine.hpc3().scsi().clone(); + let ui = Ui::new(machine.get_ps2(), rex3, scsi, machine.get_timer_manager(), &event_loop, scale, scroll_pixels_per_line, lock_aspect_ratio); ui.run(event_loop); } diff --git a/src/scsi.rs b/src/scsi.rs index f72ba54..e404741 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -339,11 +339,23 @@ impl ScsiDevice { pending } - /// Advance to the next disc in the list (wraps around). - /// Returns the new active disc path, or None if this is not a CD-ROM - /// or there is only one disc. + /// Eject the current disc. Behaviour depends on how many discs are queued: + /// - 0 or 1 disc: the tray empties (drive present, no media). + /// - 2+ discs: cycle to the next disc in the changer list (wraps around). + /// Returns the newly-active disc path, or None when the tray is emptied or + /// this is not a CD-ROM. pub fn eject_next(&mut self) -> Option { - if !self.is_cdrom || self.discs.len() <= 1 { + if !self.is_cdrom { + return None; + } + // 0 or 1 disc: eject empties the tray entirely. + if self.discs.len() <= 1 { + let prev_disc = self.filename.clone(); + self.unload_media(); + self.discs.clear(); // no phantom entries remain + if !prev_disc.is_empty() { + eprintln!("CD-ROM: ejected '{}', tray empty", prev_disc); + } return None; } // Rotate: move front to back, new front becomes active. @@ -361,6 +373,7 @@ impl ScsiDevice { // that persists across disc changes, just like on real hardware. self.filename = next_path.clone(); self.unit_attention = true; // signal medium change on next command + eprintln!("CD-ROM changer: switched to '{}'", next_path); Some(next_path) } Err(e) => { @@ -389,6 +402,14 @@ impl ScsiDevice { /// command. The image is opened as a raw ISO (`Direct` backend), matching /// the changer's eject path. Err if this is not a CD-ROM or the file can't /// be opened. + /// + /// Queue management by disc count: + /// - 0 discs (empty tray): the new disc becomes the sole entry. + /// - 1 disc: the existing disc is kept in the queue (pushed to index 1) + /// and the new disc becomes active at index 0, growing the queue to 2 + /// so that eject cycles rather than emptying the tray. + /// - 2+ discs: the new disc is placed at index 0 (active); if it was + /// already queued it is moved to the front rather than duplicated. pub fn load_disc(&mut self, path: String) -> Result { if !self.is_cdrom { return Err("Not a CD-ROM device".to_string()); @@ -398,11 +419,29 @@ impl ScsiDevice { let size = f.metadata().map(|m| m.len()).unwrap_or(0); self.backend = Some(DiskBackend::Direct(f)); self.size = size; - // phys/logical block sizes persist across disc changes (controller - // settings), exactly as in eject_next. self.filename = path.clone(); - self.unit_attention = true; // signal medium change on next command - self.discs.insert(0, path.clone()); + self.unit_attention = true; + + match self.discs.len() { + // Empty tray: new disc is the only entry. + 0 => self.discs.push(path.clone()), + // One disc already loaded: keep it queued at index 1 so eject + // cycles between the two instead of emptying the tray. + 1 => { + if self.discs[0] != path { + // Push the current disc to the back, new disc goes to front. + self.discs.insert(0, path.clone()); + } + // If same path, nothing to change — already loaded. + } + // 2+ discs: make new disc active at front, dedup if already queued. + _ => { + if let Some(pos) = self.discs.iter().position(|d| d == &path) { + self.discs.remove(pos); + } + self.discs.insert(0, path.clone()); + } + } Ok(path) } @@ -702,10 +741,9 @@ impl ScsiDevice { let loej = (cdb[4] & 0x02) != 0; let start = (cdb[4] & 0x01) != 0; if loej && !start && self.is_cdrom { - // Eject requested — advance to next disc in changer list. - if self.discs.len() > 1 { - self.eject_next(); - } + // Eject requested. eject_next() handles the count-driven cases: + // 0/1 disc empties the tray; 2+ discs cycle to the next. + self.eject_next(); } Ok(ScsiResponse { status: 0x00, data: vec![] }) } @@ -1091,18 +1129,14 @@ impl ScsiDevice { } /// SGI vendor command 0xC4 — eject / next disc. - /// On real hardware this spins the tray out. We advance to the next disc in - /// the changer list and raise Unit Attention so IRIX re-reads the TOC. + /// On real hardware this spins the tray out. eject_next() handles the + /// count-driven cases: 0/1 disc empties the tray; 2+ discs cycle to the + /// next and raise Unit Attention so IRIX re-reads the TOC. fn exec_sgi_eject(&mut self, _cdb: &[u8]) -> Result { if !self.is_cdrom { return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command for HDD } - if self.discs.len() > 1 { - self.eject_next(); - eprintln!("SCSI SGI_EJECT: switched to disc {}", self.filename); - } else { - eprintln!("SCSI SGI_EJECT: no additional discs in changer list"); - } + self.eject_next(); Ok(ScsiResponse { status: 0x00, data: vec![] }) } diff --git a/src/ui.rs b/src/ui.rs index 2ef35fb..4c5b05d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -15,6 +15,7 @@ use crate::compositor::{Compositor, SwCompositor}; use crate::gl_compositor::GlCompositor; use crate::debug_overlay::DebugOverlay; use crate::hptimer::{TimerManager, TimerReturn}; +use crate::wd33c93a::Wd33c93a; use glutin::config::ConfigTemplateBuilder; use glutin::context::{ContextAttributesBuilder, PossiblyCurrentContext}; use glutin::display::GetGlDisplay; @@ -541,6 +542,7 @@ struct MouseDelta { pub struct Ui { ps2: Arc, rex3: Arc, + scsi: Arc, window: Arc, window_size: Arc>>, scale_snap: Arc>>, @@ -552,7 +554,7 @@ pub struct Ui { } impl Ui { - pub fn new(ps2: Arc, rex3: Arc, timer_manager: Arc, event_loop: &EventLoop<()>, scale: u32, scroll_pixels_per_line: f64, lock_aspect_ratio: bool) -> Self { + pub fn new(ps2: Arc, rex3: Arc, scsi: Arc, timer_manager: Arc, event_loop: &EventLoop<()>, scale: u32, scroll_pixels_per_line: f64, lock_aspect_ratio: bool) -> Self { // The Indy's default video mode is 1280×1024; open the window at that // size (plus the status bar). The renderer snaps to the real resolution // via resize() once the PROM/IRIX programs its actual mode. @@ -603,12 +605,12 @@ impl Ui { *rex3.renderer.lock() = Some(Box::new(renderer)); - Self { ps2, rex3, window, window_size, scale_snap, display_res, timer_manager, initial_scale: scale, scroll_pixels_per_line, lock_aspect_ratio } + Self { ps2, rex3, scsi, window, window_size, scale_snap, display_res, timer_manager, initial_scale: scale, scroll_pixels_per_line, lock_aspect_ratio } } /// Run the UI event loop (blocks the current thread) pub fn run(self, event_loop: EventLoop<()>) { - let Ui { ps2, rex3, window, window_size, scale_snap, display_res, timer_manager, initial_scale, scroll_pixels_per_line, lock_aspect_ratio } = self; + let Ui { ps2, rex3, scsi, window, window_size, scale_snap, display_res, timer_manager, initial_scale, scroll_pixels_per_line, lock_aspect_ratio } = self; let scale = initial_scale; let mut mouse_grabbed = false; @@ -670,7 +672,7 @@ impl Ui { } } WindowEvent::KeyboardInput { event, .. } => { - Self::handle_keyboard(&ps2, &rex3, &scale_snap, event, &mut mouse_grabbed, &mut rctrl_held, &window); + Self::handle_keyboard(&ps2, &rex3, &scsi, &scale_snap, event, &mut mouse_grabbed, &mut rctrl_held, &window); } WindowEvent::MouseInput { state, button, .. } => { if mouse_grabbed { @@ -770,7 +772,7 @@ impl Ui { } } - fn handle_keyboard(ps2: &Ps2Controller, rex3: &Rex3, scale_snap: &Mutex>, + fn handle_keyboard(ps2: &Ps2Controller, rex3: &Rex3, scsi: &Wd33c93a, scale_snap: &Mutex>, input: KeyEvent, grabbed: &mut bool, rctrl_held: &mut bool, window: &Window) { use std::sync::atomic::Ordering; @@ -802,6 +804,38 @@ impl Ui { return; } + // RCtrl+F12: hot-swap CD-ROM disc (open file picker and load into first CD-ROM device) + if keycode == KeyCode::F12 && pressed && !input.repeat && *rctrl_held { + // Find the first CD-ROM device (disc_status only lists CD-ROMs) + let cdrom_id = scsi.disc_status().first().map(|(id, ..)| *id); + + if let Some(id) = cdrom_id { + // Open file picker (blocks the event loop but winit tolerates it on most platforms) + if let Some(path) = rfd::FileDialog::new() + .set_title("Load CD-ROM disc") + .add_filter("ISO images", &["iso", "chd"]) + .add_filter("All", &["*"]) + .pick_file() + { + let path_str = path.to_string_lossy().into_owned(); + match scsi.load_disc(id, path_str.clone()) { + Ok(_) => { + let filename = path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path_str.clone()); + eprintln!("SCSI #{}: loaded {}", id, filename); + } + Err(e) => { + eprintln!("SCSI #{}: {}", id, e); + } + } + } + } else { + eprintln!("No CD-ROM drive attached"); + } + return; + } + // RCtrl+1 / RCtrl+2: snap window to 1x or 2x scale. if pressed && !input.repeat && *rctrl_held { let snap = match keycode {