Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
33 changes: 27 additions & 6 deletions iris-gui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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() }
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -331,6 +335,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit {
overlay: false,
scratch: false,
size_mb: None,
hotswappable: false,
});
}
});
Expand All @@ -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("");
Expand Down Expand Up @@ -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);
Expand All @@ -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<usize> = None;
for (i, disc) in dev.discs.iter_mut().enumerate() {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions iris-gui/src/dialogs/new_machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ impl NewMachineDialog {
overlay: false,
scratch: false,
size_mb: None,
hotswappable: false,
});
}
if self.attach_cdrom && !self.cdrom4_path.is_empty() {
Expand All @@ -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() };
Expand Down
22 changes: 22 additions & 0 deletions iris-gui/src/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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() {
Expand Down
37 changes: 36 additions & 1 deletion iris-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand Down Expand Up @@ -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| (
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions iris-gui/src/scsi_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ fn pick_disk(title: &str) -> Option<String> {
.map(|p| p.to_string_lossy().into_owned())
}

fn pick_iso(title: &str) -> Option<String> {
pub fn pick_iso(title: &str) -> Option<String> {
rfd::FileDialog::new()
.set_title(title)
.add_filter("ISO images", &["iso", "chd"])
Expand All @@ -153,14 +153,14 @@ pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option<String> {
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"))
}
Expand Down
12 changes: 12 additions & 0 deletions iris.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
35 changes: 30 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -32,6 +34,12 @@ pub struct ScsiDeviceConfig {
/// already exists or `scratch=false`.
#[serde(default)]
pub size_mb: Option<u32>,
/// 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.
Expand Down Expand Up @@ -384,6 +392,7 @@ fn default_scsi() -> std::collections::HashMap<u8, ScsiDeviceConfig> {
overlay: false,
scratch: false,
size_mb: None,
hotswappable: false,
});
map.insert(4, ScsiDeviceConfig {
path: "cdrom4.iso".to_string(),
Expand All @@ -392,6 +401,7 @@ fn default_scsi() -> std::collections::HashMap<u8, ScsiDeviceConfig> {
overlay: false,
scratch: false,
size_mb: None,
hotswappable: false,
});
map
}
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -672,6 +696,7 @@ impl Cli {
overlay: false,
scratch: false,
size_mb: None,
hotswappable: false,
});
entry.path = path;
entry.cdrom = cdrom;
Expand Down Expand Up @@ -790,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");
Expand Down
22 changes: 17 additions & 5 deletions src/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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![])
};
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading