Skip to content
Merged
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
21 changes: 16 additions & 5 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 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 @@ -342,6 +346,13 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit {
DISK_FILTERS);
edit.changed |= e.changed;
edit.picked |= e.picked;
// For CD-ROMs, picking a path immediately loads the disc into
// the running machine (equivalent to Ctrl+F12). The core's
// count-driven queue decides whether this replaces the current
// disc or joins the changer cycle.
if dev.cdrom && e.picked && !dev.path.is_empty() {
action = ConfigAction::LoadDisc { id, path: dev.path.clone() };
}
ui.end_row();
if dev.path.ends_with(".chd") && !build_features::CHD {
ui.label("");
Expand Down Expand Up @@ -440,7 +451,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit {
}
}
if let Some(id) = to_delete { cfg.scsi.remove(&id); }
edit
(edit, action)
}

/// A soft-invalid subnet the user just entered, surfaced to the app so it can
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
31 changes: 31 additions & 0 deletions 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,27 @@ impl eframe::App for App {
ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen));
}

// Ctrl+F12 opens a file picker to load a CD-ROM disc on the fly (hot-swap)
// without needing to configure it in iris.toml or use the SCSI menu.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F12)) {
if self.emu.is_running() {
// Find the first CD-ROM device
let cdrom_id = self.cfg.scsi.iter()
.find(|(_, dev)| dev.cdrom)
.map(|(id, _)| *id);

if let Some(id) = cdrom_id {
if let Some(path) = scsi_menu::pick_iso("Load CD-ROM disc") {
self.emu.send(Cmd::LoadDisc { id, path });
}
} else {
self.toast("No CD-ROM drive attached");
}
} else {
self.toast("Load disc: machine not running");
}
}

// Ctrl + / Ctrl - / Ctrl 0 zoom controls (helps on Linux where egui's
// default text size can look small on HiDPI / fractional-scale Wayland).
let (zoom_in, zoom_out, zoom_reset) = ctx.input(|i| (
Expand Down
2 changes: 1 addition & 1 deletion 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 Down
13 changes: 13 additions & 0 deletions iris.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ bind = "localhost"
#cdrom = true
#discs = ["second.iso", "cdrom4.iso", "patches.iso"]

# Runtime disc switching (no extra config needed).
# Load any ISO/CHD at runtime by pressing Ctrl+F12 (RCtrl+F12 in the standalone
# window, Ctrl/Cmd+F12 in the GUI) and picking a file. Behaviour follows the
# number of discs queued for the drive:
# - empty tray: the picked disc simply loads
# - one disc: loading another replaces it
# - two+ discs: ejecting cycles through them; a single disc ejects to empty
# `path` is optional: omit it (or comment it out) to boot with an empty tray and
# insert media later.
#[scsi.4]
#cdrom = true
#path = "cdrom4.iso" # optional — omit to start with an empty tray

#[vino]
#source = "camera"

Expand Down
11 changes: 7 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ pub const VALID_BANK_SIZES: &[u32] = &[0, 8, 16, 32, 64, 128];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScsiDeviceConfig {
/// Path to the disk image or ISO file (primary/current disc).
/// For CD-ROMs this may be omitted (defaults to empty string) to start the
/// drive with an empty tray; media can be loaded at runtime.
#[serde(default)]
pub path: String,
/// Additional ISO images for CD-ROM changers (ignored for HDD).
#[serde(default)]
Expand Down Expand Up @@ -479,10 +482,10 @@ impl MachineConfig {
if *id == 0 || *id > 7 {
return Err(format!("SCSI ID {} is out of range (1–7)", id));
}
// CD-ROM with empty path + no changer entries = drive present, no
// media loaded. This is a valid runtime state (see
// Wd33c93a::add_device empty-CD-ROM path / insert_disc).
let _ = dev; // explicitly keep the binding for future checks
// A CD-ROM may legitimately start with an empty tray (no path, no
// discs) and have media loaded at runtime; any discs list is valid
// as a changer queue. So there is nothing CD-ROM-specific to check.
let _ = dev;
}
Ok(())
}
Expand Down
18 changes: 13 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" — a valid CD-ROM state
// 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
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
74 changes: 54 additions & 20 deletions src/scsi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,23 @@ impl ScsiDevice {
pending
}

/// Advance to the next disc in the list (wraps around).
/// Returns the new active disc path, or None if this is not a CD-ROM
/// or there is only one disc.
/// Eject the current disc. Behaviour depends on how many discs are queued:
/// - 0 or 1 disc: the tray empties (drive present, no media).
/// - 2+ discs: cycle to the next disc in the changer list (wraps around).
/// Returns the newly-active disc path, or None when the tray is emptied or
/// this is not a CD-ROM.
pub fn eject_next(&mut self) -> Option<String> {
if !self.is_cdrom || self.discs.len() <= 1 {
if !self.is_cdrom {
return None;
}
// 0 or 1 disc: eject empties the tray entirely.
if self.discs.len() <= 1 {
let prev_disc = self.filename.clone();
self.unload_media();
self.discs.clear(); // no phantom entries remain
if !prev_disc.is_empty() {
eprintln!("CD-ROM: ejected '{}', tray empty", prev_disc);
}
return None;
}
// Rotate: move front to back, new front becomes active.
Expand All @@ -361,6 +373,7 @@ impl ScsiDevice {
// that persists across disc changes, just like on real hardware.
self.filename = next_path.clone();
self.unit_attention = true; // signal medium change on next command
eprintln!("CD-ROM changer: switched to '{}'", next_path);
Some(next_path)
}
Err(e) => {
Expand Down Expand Up @@ -389,6 +402,14 @@ impl ScsiDevice {
/// command. The image is opened as a raw ISO (`Direct` backend), matching
/// the changer's eject path. Err if this is not a CD-ROM or the file can't
/// be opened.
///
/// Queue management by disc count:
/// - 0 discs (empty tray): the new disc becomes the sole entry.
/// - 1 disc: the existing disc is kept in the queue (pushed to index 1)
/// and the new disc becomes active at index 0, growing the queue to 2
/// so that eject cycles rather than emptying the tray.
/// - 2+ discs: the new disc is placed at index 0 (active); if it was
/// already queued it is moved to the front rather than duplicated.
pub fn load_disc(&mut self, path: String) -> Result<String, String> {
if !self.is_cdrom {
return Err("Not a CD-ROM device".to_string());
Expand All @@ -398,11 +419,29 @@ impl ScsiDevice {
let size = f.metadata().map(|m| m.len()).unwrap_or(0);
self.backend = Some(DiskBackend::Direct(f));
self.size = size;
// phys/logical block sizes persist across disc changes (controller
// settings), exactly as in eject_next.
self.filename = path.clone();
self.unit_attention = true; // signal medium change on next command
self.discs.insert(0, path.clone());
self.unit_attention = true;

match self.discs.len() {
// Empty tray: new disc is the only entry.
0 => self.discs.push(path.clone()),
// One disc already loaded: keep it queued at index 1 so eject
// cycles between the two instead of emptying the tray.
1 => {
if self.discs[0] != path {
// Push the current disc to the back, new disc goes to front.
self.discs.insert(0, path.clone());
}
// If same path, nothing to change — already loaded.
}
// 2+ discs: make new disc active at front, dedup if already queued.
_ => {
if let Some(pos) = self.discs.iter().position(|d| d == &path) {
self.discs.remove(pos);
}
self.discs.insert(0, path.clone());
}
}
Ok(path)
}

Expand Down Expand Up @@ -702,10 +741,9 @@ impl ScsiDevice {
let loej = (cdb[4] & 0x02) != 0;
let start = (cdb[4] & 0x01) != 0;
if loej && !start && self.is_cdrom {
// Eject requested — advance to next disc in changer list.
if self.discs.len() > 1 {
self.eject_next();
}
// Eject requested. eject_next() handles the count-driven cases:
// 0/1 disc empties the tray; 2+ discs cycle to the next.
self.eject_next();
}
Ok(ScsiResponse { status: 0x00, data: vec![] })
}
Expand Down Expand Up @@ -1091,18 +1129,14 @@ impl ScsiDevice {
}

/// SGI vendor command 0xC4 — eject / next disc.
/// On real hardware this spins the tray out. We advance to the next disc in
/// the changer list and raise Unit Attention so IRIX re-reads the TOC.
/// On real hardware this spins the tray out. eject_next() handles the
/// count-driven cases: 0/1 disc empties the tray; 2+ discs cycle to the
/// next and raise Unit Attention so IRIX re-reads the TOC.
fn exec_sgi_eject(&mut self, _cdb: &[u8]) -> Result<ScsiResponse, std::io::Error> {
if !self.is_cdrom {
return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command for HDD
}
if self.discs.len() > 1 {
self.eject_next();
eprintln!("SCSI SGI_EJECT: switched to disc {}", self.filename);
} else {
eprintln!("SCSI SGI_EJECT: no additional discs in changer list");
}
self.eject_next();
Ok(ScsiResponse { status: 0x00, data: vec![] })
}

Expand Down
Loading
Loading