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/3] 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/3] 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"); From 496629f00268c3bbd5f090f37374d2fbbfab21d4 Mon Sep 17 00:00:00 2001 From: GenSayer <64618080+GenSayer@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:39:35 -0700 Subject: [PATCH 3/3] Remove hotswappable and implement the CD switching logic the way techomancer asked Basically, if cdrom is not defined at start, then the cd drive starts empty. Then, you can load an iso and it will be available. If there is only one iso and it is ejected, then it becomes empty. But you can always queue more isos, so if there is more than one, it should cycle between them. --- iris-gui/src/config_ui.rs | 26 +++----- iris-gui/src/dialogs/new_machine.rs | 2 - iris-gui/src/main.rs | 10 +-- iris-gui/src/scsi_menu.rs | 4 +- iris.toml | 21 +++--- src/config.rs | 36 ++--------- src/machine.rs | 8 +-- src/scsi.rs | 99 +++++++++++++---------------- src/ui.rs | 5 -- src/wd33c93a.rs | 22 ------- 10 files changed, 76 insertions(+), 157 deletions(-) diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 7887436..48ee8fd 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -169,8 +169,8 @@ 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. + /// 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 }, } @@ -335,7 +335,6 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) overlay: false, scratch: false, size_mb: None, - hotswappable: false, }); } }); @@ -347,9 +346,11 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) 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() { + // 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(); @@ -421,17 +422,6 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) 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); @@ -442,7 +432,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) } }); - if dev.cdrom && !dev.hotswappable { + if dev.cdrom { ui.label("Extra changer discs:"); let mut drop_idx: Option = None; for (i, disc) in dev.discs.iter_mut().enumerate() { diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index 71edf53..d50fd28 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -218,7 +218,6 @@ impl NewMachineDialog { overlay: false, scratch: false, size_mb: None, - hotswappable: false, }); } if self.attach_cdrom && !self.cdrom4_path.is_empty() { @@ -229,7 +228,6 @@ 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/main.rs b/iris-gui/src/main.rs index af91322..d089aa6 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -2617,19 +2617,15 @@ impl eframe::App for App { // 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 + // Find the first CD-ROM device let cdrom_id = self.cfg.scsi.iter() - .find(|(_, dev)| dev.cdrom && dev.hotswappable) + .find(|(_, dev)| dev.cdrom) .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"); } @@ -2756,7 +2752,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, hotswappable: false, + overlay: false, scratch: false, size_mb: None, }); 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 cdabbae..e8c740e 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -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, hotswappable: false, + path, discs: vec![], cdrom: false, overlay: false, scratch: false, size_mb: None, }); 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, hotswappable: false, + overlay: false, scratch: false, size_mb: None, }); Some(format!("scsi{id}: empty CD-ROM drive attached")) } diff --git a/iris.toml b/iris.toml index 1bc8401..eb93491 100644 --- a/iris.toml +++ b/iris.toml @@ -125,17 +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. +# 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 -#hotswappable = true -#path = "cdrom4.iso" # optional — omit to start with an empty tray +#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 c881bc4..9bd3000 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +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). + /// 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). @@ -34,12 +35,6 @@ 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. @@ -392,7 +387,6 @@ fn default_scsi() -> std::collections::HashMap { overlay: false, scratch: false, size_mb: None, - hotswappable: false, }); map.insert(4, ScsiDeviceConfig { path: "cdrom4.iso".to_string(), @@ -401,7 +395,6 @@ fn default_scsi() -> std::collections::HashMap { overlay: false, scratch: false, size_mb: None, - hotswappable: false, }); map } @@ -489,24 +482,10 @@ impl MachineConfig { if *id == 0 || *id > 7 { return Err(format!("SCSI ID {} is out of range (1–7)", id)); } - 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" - )); - } - } + // 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(()) } @@ -696,7 +675,6 @@ impl Cli { overlay: false, scratch: false, size_mb: None, - hotswappable: false, }); entry.path = path; entry.cdrom = cdrom; @@ -815,7 +793,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, hotswappable: false, + overlay: false, scratch: false, size_mb: None, }); let s = toml::to_string_pretty(&cfg).expect("serialize"); let back: MachineConfig = toml::from_str(&s).expect("deserialize"); diff --git a/src/machine.rs b/src/machine.rs index 0f25f94..e4e40c7 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -266,8 +266,8 @@ impl Machine { } let (path, discs) = if dev.cdrom { // 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). + // 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()); @@ -312,10 +312,6 @@ 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/scsi.rs b/src/scsi.rs index f1dce5e..e404741 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -154,10 +154,6 @@ 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) @@ -177,7 +173,6 @@ 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, } } @@ -196,7 +191,6 @@ impl ScsiDevice { unit_attention: false, phys_block_size: 2048, logical_block_size: 2048, - hotswappable: false, } } @@ -204,16 +198,6 @@ 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<()> { @@ -355,27 +339,25 @@ 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. - /// In hotswappable mode, ejects simply clear the tray instead of cycling. + /// 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 { return None; } - if self.hotswappable { - // Hotswappable: eject empties the tray; no cycling + // 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(); // Clear the disc list so no phantom entries remain + self.discs.clear(); // no phantom entries remain if !prev_disc.is_empty() { - eprintln!("CD-ROM hotswap: ejected '{}', tray empty", prev_disc); + eprintln!("CD-ROM: ejected '{}', tray empty", prev_disc); } return None; } - if self.discs.len() <= 1 { - return None; - } // Rotate: move front to back, new front becomes active. let current = self.discs.remove(0); self.discs.push(current); @@ -421,9 +403,13 @@ impl ScsiDevice { /// 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. + /// 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()); @@ -433,17 +419,28 @@ 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.unit_attention = true; - 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()); + 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) } @@ -744,11 +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 (legacy), - // or clear the tray (hotswappable). - if self.hotswappable || 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![] }) } @@ -1134,22 +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.hotswappable || self.discs.len() > 1 { - self.eject_next(); - 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"); - } + self.eject_next(); Ok(ScsiResponse { status: 0x00, data: vec![] }) } diff --git a/src/ui.rs b/src/ui.rs index 731b889..4c5b05d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -810,11 +810,6 @@ impl Ui { 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") diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index f532873..eb708d8 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -478,28 +478,6 @@ 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();