From e5029f57d3e3b2e5cf7cbb315df2bbebb8ba9431 Mon Sep 17 00:00:00 2001 From: GenSayer <64618080+GenSayer@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:19:27 -0700 Subject: [PATCH 1/2] Add hotswappable CD-ROM mode with runtime disc switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `hotswappable` flag for CD-ROM devices that enables on-the-fly disc switching via keyboard shortcuts (RCtrl+F12 native, Ctrl+F12 GUI) without pre-configuring a changer queue or restarting the emulator. Fixes three bugs in legacy changer mode: - load_disc() accumulated discs at index 0 instead of replacing - eject through IRIX failed when hotswappable=true (len guard) New features: - hotswappable = true per-drive config (iris.toml + GUI checkbox) - Runtime disc loading via file picker (RCtrl+F12 / Ctrl+F12) - Empty tray on startup when path omitted (hotswappable only) - GUI config editor: browse button loads discs immediately - GUI: "Extra changer discs" hidden when hotswappable=true Validation: - Errors when hotswappable=false + empty path (suggests enabling, cleaner error message than before) - Errors when hotswappable=true + discs list (mutually exclusive) - Ctrl+F12 disabled with helpful message when hotswappable=false Console logging: - "CD-ROM hotswap: ejected '', tray empty" (hotswappable eject) - "CD-ROM changer: switched to ''" (legacy changer cycle) - "SCSI SGI_EJECT: tray emptied" (hotswappable SGI eject) Config example: [scsi.4] cdrom = true hotswappable = true # path optional — start with empty tray Changes: 13 files, 699 lines - Core: config.rs, scsi.rs, wd33c93a.rs, machine.rs, main.rs, ui.rs - GUI: config_ui.rs, main.rs, handle.rs, scsi_menu.rs, dialogs/new_machine.rs - Docs: iris.toml - Build: Cargo.toml (added rfd for file picker) --- Cargo.toml | 1 + iris-gui/src/config_ui.rs | 33 +++++++++++++--- iris-gui/src/dialogs/new_machine.rs | 2 + iris-gui/src/handle.rs | 22 +++++++++++ iris-gui/src/main.rs | 37 +++++++++++++++++- iris-gui/src/scsi_menu.rs | 6 +-- iris.toml | 12 ++++++ src/config.rs | 33 ++++++++++++++-- src/machine.rs | 22 ++++++++--- src/main.rs | 3 +- src/scsi.rs | 59 ++++++++++++++++++++++++++--- src/ui.rs | 49 +++++++++++++++++++++--- src/wd33c93a.rs | 22 +++++++++++ 13 files changed, 270 insertions(+), 31 deletions(-) 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..7887436 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 hotswappable 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."); @@ -331,6 +335,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); } }); @@ -342,6 +347,11 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { DISK_FILTERS); edit.changed |= e.changed; edit.picked |= e.picked; + // For hotswappable CD-ROMs, picking a path immediately loads the + // disc into the running machine (equivalent to Ctrl+F12). + if dev.cdrom && dev.hotswappable && 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(""); @@ -411,6 +421,17 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { ui.checkbox(&mut dev.scratch, ""); ui.end_row(); + if dev.cdrom { + ui.label("Hotswappable") + .on_hover_text( + "Enable on-the-fly disc switching via Ctrl+F12. \ + Eject clears the tray instead of cycling to the next disc. \ + The drive starts with an empty tray even if no path is configured."); + ui.checkbox(&mut dev.hotswappable, "") + .on_hover_text("Use Ctrl+F12 to load any disc at runtime"); + ui.end_row(); + } + if dev.scratch { ui.label("Scratch size (MB)"); let mut sz = dev.size_mb.unwrap_or(64); @@ -421,7 +442,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { } }); - if dev.cdrom { + if dev.cdrom && !dev.hotswappable { ui.label("Extra changer discs:"); let mut drop_idx: Option = None; for (i, disc) in dev.discs.iter_mut().enumerate() { @@ -440,7 +461,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/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index d50fd28..71edf53 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -218,6 +218,7 @@ impl NewMachineDialog { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); } if self.attach_cdrom && !self.cdrom4_path.is_empty() { @@ -228,6 +229,7 @@ impl NewMachineDialog { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); } let name = if self.name.trim().is_empty() { "indy".to_string() } else { self.name.trim().to_string() }; 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..af91322 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,31 @@ 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 hotswappable CD-ROM device + let cdrom_id = self.cfg.scsi.iter() + .find(|(_, dev)| dev.cdrom && dev.hotswappable) + .map(|(id, _)| *id); + // Check if there's a non-hotswappable CD-ROM instead + let non_hs = self.cfg.scsi.iter().any(|(_, dev)| dev.cdrom && !dev.hotswappable); + + 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 if non_hs { + self.toast("CD-ROM not hotswappable (enable hotswappable=true in config)"); + } 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| ( @@ -2721,7 +2756,7 @@ impl eframe::App for App { let path_str = result.path.to_string_lossy().into_owned(); self.cfg.scsi.insert(result.scsi_id, iris::config::ScsiDeviceConfig { path: path_str.clone(), discs: vec![], cdrom: false, - overlay: false, scratch: false, size_mb: None, + overlay: false, scratch: false, size_mb: None, hotswappable: false, }); self.mark_dirty(); self.toast(format!("created {path_str} and attached at scsi{}", result.scsi_id)); diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index d9e7ead..cdabbae 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"]) @@ -153,14 +153,14 @@ pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option { ScsiAction::None => None, ScsiAction::AttachHdd { id, path } => { cfg.scsi.insert(id, ScsiDeviceConfig { - path, discs: vec![], cdrom: false, overlay: false, scratch: false, size_mb: None, + path, discs: vec![], cdrom: false, overlay: false, scratch: false, size_mb: None, hotswappable: false, }); Some(format!("scsi{id}: HDD attached")) } ScsiAction::AttachEmptyCdrom { id } => { cfg.scsi.insert(id, ScsiDeviceConfig { path: String::new(), discs: vec![], cdrom: true, - overlay: false, scratch: false, size_mb: None, + overlay: false, scratch: false, size_mb: None, hotswappable: false, }); Some(format!("scsi{id}: empty CD-ROM drive attached")) } diff --git a/iris.toml b/iris.toml index d633edc..1bc8401 100644 --- a/iris.toml +++ b/iris.toml @@ -125,6 +125,18 @@ bind = "localhost" #cdrom = true #discs = ["second.iso", "cdrom4.iso", "patches.iso"] +# Hotswappable CD-ROM (recommended for interactive disc switching). +# With hotswappable = true you can 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 — no need to pre-list discs here. Loading a disc replaces the +# current one (no changer queue accumulates), and eject simply empties the tray. +# `path` is optional in this mode: omit it (or comment it out) to boot with an +# empty drive and insert media later. +#[scsi.4] +#cdrom = true +#hotswappable = 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..3a3509a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ 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 hotswappable CD-ROMs, this can be omitted (defaults to empty string). + #[serde(default)] pub path: String, /// Additional ISO images for CD-ROM changers (ignored for HDD). #[serde(default)] @@ -32,6 +34,12 @@ pub struct ScsiDeviceConfig { /// already exists or `scratch=false`. #[serde(default)] pub size_mb: Option, + /// Hotswappable mode (CD-ROM only): load_disc replaces the current disc + /// instead of accumulating a changer queue, and eject clears the tray + /// instead of cycling to the next disc. Designed for on-the-fly disc + /// switching via keyboard shortcuts. Ignored for HDDs. + #[serde(default)] + pub hotswappable: bool, } /// Protocol for port forwarding. @@ -384,6 +392,7 @@ fn default_scsi() -> std::collections::HashMap { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); map.insert(4, ScsiDeviceConfig { path: "cdrom4.iso".to_string(), @@ -392,6 +401,7 @@ fn default_scsi() -> std::collections::HashMap { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); map } @@ -479,10 +489,24 @@ 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 + if dev.cdrom { + // hotswappable + discs list is contradictory: discs is for the + // legacy changer queue; hotswappable replaces it with runtime loading. + if dev.hotswappable && !dev.discs.is_empty() { + return Err(format!( + "SCSI ID {id}: hotswappable=true and a discs list are mutually exclusive; \ + remove the discs list or set hotswappable=false" + )); + } + // Non-hotswappable CD-ROM with no media = mis-configuration. + // Empty + no discs is valid only in hotswappable mode. + if !dev.hotswappable && dev.path.is_empty() && dev.discs.is_empty() { + return Err(format!( + "SCSI ID {id}: CD-ROM has no disc configured (path and discs are both empty); \ + set a path/discs, or enable hotswappable=true to start with an empty tray" + )); + } + } } Ok(()) } @@ -672,6 +696,7 @@ impl Cli { overlay: false, scratch: false, size_mb: None, + hotswappable: false, }); entry.path = path; entry.cdrom = cdrom; diff --git a/src/machine.rs b/src/machine.rs index 5a368b3..0f25f94 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" — valid for hotswappable + // CD-ROMs 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![]) }; @@ -304,6 +312,10 @@ impl Machine { eprintln!("iris: fatal: {msg}"); std::process::exit(1); } + // Apply hotswappable mode for CD-ROMs + if dev.cdrom && dev.hotswappable { + let _ = hpc3.scsi().set_hotswappable(id as usize, true); + } } // Disk + nvram provenance for snapshot manifests. Captured here while 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..f1dce5e 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -154,6 +154,10 @@ pub struct ScsiDevice { /// Logical block size: LBA→byte offset = lba * logical_block_size. /// Defaults to 512. IRIX switches via MODE SELECT (512↔2048). Persists across disc changes. logical_block_size: u64, + /// Hotswappable mode (CD-ROM only): `load_disc` replaces the active disc + /// rather than growing a changer queue, and eject empties the tray instead + /// of cycling to the next disc. Set from `ScsiDeviceConfig.hotswappable`. + hotswappable: bool, } const SCSI_BUFFER_SIZE: usize = 0x4000; // 16KB (16384 bytes) @@ -173,6 +177,7 @@ impl ScsiDevice { // CD-ROM drives default to 2048-byte logical blocks (Sony CDU-76S behaviour). // HDD defaults to 512. dksc switches CD-ROM to 512 for EFS, back to 2048 for ISO. logical_block_size: if is_cdrom { 2048 } else { 512 }, + hotswappable: false, } } @@ -191,6 +196,7 @@ impl ScsiDevice { unit_attention: false, phys_block_size: 2048, logical_block_size: 2048, + hotswappable: false, } } @@ -198,6 +204,16 @@ impl ScsiDevice { /// false when the tray is empty. pub fn has_media(&self) -> bool { self.backend.is_some() } + /// Set hotswappable mode for CD-ROMs. In hotswappable mode, `load_disc` + /// replaces the current disc instead of accumulating a changer queue. + pub fn set_hotswappable(&mut self, hotswappable: bool) { + if self.is_cdrom { + self.hotswappable = hotswappable; + } + } + + pub fn is_hotswappable(&self) -> bool { self.hotswappable } + /// Mount media on a previously-empty CD-ROM, or swap the disc on a /// loaded one. Sets `unit_attention` so the guest re-reads capacity. pub fn insert_media(&mut self, path: &str) -> io::Result<()> { @@ -342,8 +358,22 @@ impl ScsiDevice { /// 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. + /// In hotswappable mode, ejects simply clear the tray instead of cycling. pub fn eject_next(&mut self) -> Option { - if !self.is_cdrom || self.discs.len() <= 1 { + if !self.is_cdrom { + return None; + } + if self.hotswappable { + // Hotswappable: eject empties the tray; no cycling + let prev_disc = self.filename.clone(); + self.unload_media(); + self.discs.clear(); // Clear the disc list so no phantom entries remain + if !prev_disc.is_empty() { + eprintln!("CD-ROM hotswap: ejected '{}', tray empty", prev_disc); + } + return None; + } + if self.discs.len() <= 1 { return None; } // Rotate: move front to back, new front becomes active. @@ -361,6 +391,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 +420,10 @@ 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. + /// + /// In hotswappable mode, the disc list is replaced with only the new disc + /// (no accumulation). In legacy changer mode, the new disc is inserted at + /// index 0 and the list grows. pub fn load_disc(&mut self, path: String) -> Result { if !self.is_cdrom { return Err("Not a CD-ROM device".to_string()); @@ -402,7 +437,14 @@ impl ScsiDevice { // 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()); + + if self.hotswappable { + // Hotswappable: replace the entire disc list with just this one disc + self.discs = vec![path.clone()]; + } else { + // Legacy changer: insert at front, accumulating the list + self.discs.insert(0, path.clone()); + } Ok(path) } @@ -702,8 +744,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 { + // Eject requested — advance to next disc in changer list (legacy), + // or clear the tray (hotswappable). + if self.hotswappable || self.discs.len() > 1 { self.eject_next(); } } @@ -1097,9 +1140,13 @@ impl ScsiDevice { if !self.is_cdrom { return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command for HDD } - if self.discs.len() > 1 { + if self.hotswappable || self.discs.len() > 1 { self.eject_next(); - eprintln!("SCSI SGI_EJECT: switched to disc {}", self.filename); + if self.filename.is_empty() { + eprintln!("SCSI SGI_EJECT: tray emptied"); + } else { + eprintln!("SCSI SGI_EJECT: switched to disc {}", self.filename); + } } else { eprintln!("SCSI SGI_EJECT: no additional discs in changer list"); } diff --git a/src/ui.rs b/src/ui.rs index 2ef35fb..731b889 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,43 @@ 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 { + // Check if the drive is in hotswappable mode + if !scsi.is_hotswappable(id) { + eprintln!("SCSI #{}: hotswap disabled (set hotswappable=true in iris.toml)", id); + return; + } + // 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 { diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index eb708d8..f532873 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -478,6 +478,28 @@ impl Wd33c93a { } } + /// Enable or disable hotswappable mode for a CD-ROM device. + /// In hotswappable mode, load_disc replaces the current disc instead of + /// accumulating a changer queue, and eject clears the tray. + pub fn set_hotswappable(&self, id: usize, hotswappable: bool) -> Result<(), String> { + let mut state = self.state.lock(); + match state.devices.get_mut(id).and_then(|d| d.as_mut()) { + None => Err(format!("No device at SCSI ID {}", id)), + Some(dev) => { + dev.set_hotswappable(hotswappable); + Ok(()) + } + } + } + + /// Check if a CD-ROM device is in hotswappable mode. + pub fn is_hotswappable(&self, id: usize) -> bool { + let state = self.state.lock(); + state.devices.get(id) + .and_then(|d| d.as_ref()) + .map_or(false, |dev| dev.is_hotswappable()) + } + /// Remove a disc by ordinal from a CD-ROM device's queue. pub fn remove_disc(&self, id: usize, ordinal: usize) -> Result { let mut state = self.state.lock(); From 2958b243a3efba01d13e45a3c66b1fb26b84bdd6 Mon Sep 17 00:00:00 2001 From: GenSayer <64618080+GenSayer@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:30:32 -0700 Subject: [PATCH 2/2] Oops...dunno how the agent missed that --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 3a3509a..c881bc4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -815,7 +815,7 @@ mod export_tests { let mut cfg = MachineConfig::default(); cfg.scsi.insert(4, ScsiDeviceConfig { path: "/abs/cd.chd".into(), discs: vec![], cdrom: true, - overlay: false, scratch: false, size_mb: None, + overlay: false, scratch: false, size_mb: None, hotswappable: false, }); let s = toml::to_string_pretty(&cfg).expect("serialize"); let back: MachineConfig = toml::from_str(&s).expect("deserialize");