From c723bc238816ebc5fe5318699313d0a0d2d29c1c Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 02:13:29 -0700 Subject: [PATCH 01/11] scsi: add ScsiBus and put the three controllers on it The AIC-7880, the ISP1040 and the WD33C93A each kept their own array of disks and their own copy of the attach, query, overlay export and CHD sync code. Each now owns a ScsiBus and keeps only its registers and DMA. ScsiTarget is what varies on a bus. A disk or CD-ROM (ScsiDevice) and a DaynaPort implement it. Besides request it has optional behaviour with do-nothing defaults: a copy-on-write layer, CHD sync, a disc changer, a block size. Controllers and the monitor ask a target for behaviour, not for its type, so a DaynaPort is no longer a mode of ScsiDevice. ScsiBus is the shared mechanism: the id table, attach, request, and overlay export and import for the snapshot code. Each controller gives its bus its INQUIRY identities. attach_from_config walks the [scsi.N] tables once: placement, scratch volume, CI overlay under /tmp, and the IRIS_NO_EXIT_ON_POWEROFF error policy, with optional snapshot disk provenance. The machines move onto it in the following commits. The WD33C93A snapshot holds registers only and never the targets, so its format is unchanged. Its bus is eight ids wide and now refuses a larger id instead of ignoring it. --- src/dev/aic7880.rs | 130 +++---------- src/dev/daynaport.rs | 6 +- src/dev/hpc3.rs | 4 +- src/dev/isp1040.rs | 82 ++------ src/dev/isp1040/tests.rs | 16 +- src/dev/wd33c93a.rs | 150 +++++---------- src/ip30/tests.rs | 2 +- src/ip30_machine.rs | 2 +- src/ip32_machine.rs | 2 +- src/scsi.rs | 75 +------- src/scsi/attach.rs | 202 ++++++++++++++++++++ src/scsi/bus.rs | 393 +++++++++++++++++++++++++++++++++++++++ src/scsi_monitor.rs | 62 +++--- 13 files changed, 732 insertions(+), 394 deletions(-) create mode 100644 src/scsi/attach.rs create mode 100644 src/scsi/bus.rs diff --git a/src/dev/aic7880.rs b/src/dev/aic7880.rs index f1099ff9..dd758b17 100644 --- a/src/dev/aic7880.rs +++ b/src/dev/aic7880.rs @@ -26,7 +26,7 @@ //! because one boot can hand the chip from one driver to the next. use crate::locks::{SpinGuard, SpinLock}; -use crate::scsi::{scsi_cmd, ScsiDataLength, ScsiDevice, ScsiIdentity, ScsiRequest}; +use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiIdentity, ScsiRequest}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -422,7 +422,7 @@ pub struct Aic7880 { /// The targets on the bus, by SCSI id (the shared SCSI devices: disks, /// CHDs, overlays, CD-ROMs). An install needs at least two -- the media /// to read and the disk to write. - targets: Mutex<[Option; 16]>, + bus: ScsiBus, /// Host memory, through the PCI bridge the chip masters. The controller /// fetches its work from there rather than being handed it a register at /// a time, so without this it can see the tag of a command and nothing @@ -467,7 +467,7 @@ impl Aic7880 { worker: Mutex::new(None), running: AtomicBool::new(false), me: me.clone(), - targets: Mutex::new(std::array::from_fn(|_| None)), + bus: ScsiBus::new(16, ScsiIdentities { disk: O2_DISK, cdrom: O2_CDROM }), dma: std::sync::OnceLock::new(), sense: Mutex::new(None), dma_misses: Mutex::new(std::collections::BTreeSet::new()), @@ -667,79 +667,21 @@ impl Aic7880 { } } - /// Attach a disk image at a SCSI id. - pub fn attach_disk_at(&self, target: u8, path: &std::path::Path) -> std::io::Result { - self.attach_at(target, path, false) + /// The SCSI bus: attach targets to it and ask what is on it. + pub fn bus(&self) -> &ScsiBus { + &self.bus } - pub fn attach_at(&self, target: u8, path: &std::path::Path, cdrom: bool) - -> std::io::Result - { - self.attach_with(target, path, cdrom, false) - } - - /// [`Self::attach_at`], copy-on-write when `overlay` is set: the image is - /// never written, writes go to `{path}.overlay`. - pub fn attach_with(&self, target: u8, path: &std::path::Path, cdrom: bool, overlay: bool) - -> std::io::Result - { - let path = path.to_str().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path is not UTF-8"))?; - self.attach_device(target, path, cdrom, Vec::new(), overlay, None) - } - - /// Attach what a `[scsi.N]` table describes: the image at `path` (empty - /// for a CD-ROM with its tray empty), a CD-ROM's changer list `discs`, - /// and copy-on-write when `overlay` is set, to `overlay_path` or - /// `{path}.overlay`. Returns the size in blocks. - pub fn attach_device( - &self, - target: u8, - path: &str, - cdrom: bool, - discs: Vec, - overlay: bool, - overlay_path: Option<&str>, - ) -> std::io::Result { - let d = ScsiDevice::open(path, cdrom, discs, overlay, overlay_path, if cdrom { O2_CDROM } else { O2_DISK })?; - let blocks = d.size() / BLOCK_SIZE as u64; - let mut targets = self.targets.lock().unwrap(); - let slot = targets.get_mut(target as usize).ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("SCSI id {target} out of range")) - })?; - *slot = Some(d); - Ok(blocks) - } - - - /// Attach at the default id, which is what a single-disk run wants. - /// The id a disk attached without one goes on. - pub fn default_target(&self) -> u8 { - self.target - } - - pub fn attach_disk(&self, path: &std::path::Path) -> std::io::Result { - self.attach_disk_at(self.target, path) - } - - /// Whether something answers at `target`. fn has_target(&self, target: u8) -> bool { - self.targets.lock().unwrap().get(target as usize).is_some_and(|t| t.is_some()) + self.bus.has_target(target) } - - /// The ids that have something on them. - pub fn attached(&self) -> Vec { - let t = self.targets.lock().unwrap(); - (0..t.len() as u8).filter(|&i| t[i as usize].is_some()).collect() - } - } /// The monitor's `scsi`/`cow` subcommands and the run-time disc changes /// come from `scsi_monitor` through this. impl crate::scsi_monitor::ScsiTargets for Aic7880 { - fn with_targets(&self, f: impl FnOnce(&mut [Option]) -> R) -> R { - let mut targets = self.targets.lock().unwrap(); - f(&mut targets[..]) + fn scsi_bus(&self) -> &ScsiBus { + &self.bus } } @@ -903,14 +845,11 @@ impl Aic7880 { return (d, 0x00); } } - let mut targets = self.targets.lock().unwrap(); - let Some(dev) = targets.get_mut(target as usize).and_then(|t| t.as_mut()) else { - return (Vec::new(), CHECK_CONDITION); - }; let req = ScsiRequest { cdb: cdb.to_vec(), data_len: ScsiDataLength::Unlimited, data_in: data_in.map(|d| d.to_vec()) }; - match dev.request(&req) { - Ok(r) => (r.data, r.status), - Err(e) => { + match self.bus.request(target, &req) { + None => (Vec::new(), CHECK_CONDITION), + Some(Ok(r)) => (r.data, r.status), + Some(Err(e)) => { eprintln!("aic7880: target {target}: {e}"); (Vec::new(), CHECK_CONDITION) } @@ -1462,13 +1401,7 @@ impl Aic7880 { /// common 2048 after its reset (the PROM had left 512 behind). fn reset_targets(&self) { let sgi = Self::him(&self.st.lock()); - for d in self.targets.lock().unwrap().iter_mut().flatten() { - if sgi && d.is_cdrom() { - d.set_logical_block_size(512); - } else { - d.power_on_block_size(); - } - } + self.bus.for_each(|_, d| d.reset_block_size(sgi)); } /// A register write with the state locked. Returns whether the @@ -1647,25 +1580,12 @@ impl Aic7880 { /// `scsi.overlay`, and return the dirty sectors of each, as the /// Indigo2's and Indy's controller does for their snapshots. pub fn export_overlays(&self, dir: &std::path::Path) -> std::io::Result)>> { - let mut targets = self.targets.lock().unwrap(); - let mut out = Vec::new(); - for (id, t) in targets.iter_mut().enumerate() { - if let Some(dev) = t.as_mut().filter(|d| d.is_cow()) { - out.push((id, dev.cow_export(&dir.join(format!("scsi{id}.overlay")))?)); - } - } - Ok(out) + self.bus.export_overlays(dir) } /// Put back the overlays [`Self::export_overlays`] wrote. pub fn import_overlays(&self, dir: &std::path::Path, dirty_sets: &[(usize, Vec)]) -> std::io::Result<()> { - let mut targets = self.targets.lock().unwrap(); - for (id, dirty) in dirty_sets { - if let Some(dev) = targets.get_mut(*id).and_then(|t| t.as_mut()).filter(|d| d.is_cow()) { - dev.cow_import(&dir.join(format!("scsi{id}.overlay")), dirty.clone())?; - } - } - Ok(()) + self.bus.import_overlays(dir, dirty_sets) } } @@ -1901,7 +1821,7 @@ mod tests { // needs a disk as much as the machine does. let img = std::env::temp_dir().join("iris-aic7880-test.img"); std::fs::write(&img, vec![0u8; 64 * BLOCK_SIZE]).expect("test image"); - c.attach_disk(&img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let base = crate::ip32::RAM_BASE; let (array, scb_addr, cdb_addr, sg_addr, buf, qout) = @@ -1957,7 +1877,7 @@ mod tests { let c = on(ram.clone()); let img = std::env::temp_dir().join("iris-aic7880-write.img"); std::fs::write(&img, vec![0u8; 64 * BLOCK_SIZE]).expect("test image"); - c.attach_disk(&img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let base = crate::ip32::RAM_BASE; let (array, scb, cdb, sg, buf, qout) = @@ -2011,7 +1931,7 @@ mod tests { let img = std::env::temp_dir().join("iris-aic7880-sg.img"); let pattern: Vec = (0..264 * BLOCK_SIZE).map(|i| (i % 253) as u8).collect(); std::fs::write(&img, &pattern).expect("test image"); - c.attach_disk(&img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let base = crate::ip32::RAM_BASE; let (array, scb, cdb, sg, buf, qout) = @@ -2057,7 +1977,7 @@ mod tests { let img = std::env::temp_dir().join("iris-aic7880-grow.img"); std::fs::write(&img, vec![0u8; 8 * BLOCK_SIZE]).expect("test image"); let c = chip(); - c.attach_at(1, &img, false).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); // WRITE(10): four blocks at block 7 of eight. assert_eq!(c.execute_write_on(&[0x2a, 0, 0, 0, 0, 7, 0, 0, 4, 0], &vec![0xab; 4 * BLOCK_SIZE], 1), 0x02, "refused"); assert_eq!(std::fs::metadata(&img).unwrap().len(), (8 * BLOCK_SIZE) as u64); @@ -2120,7 +2040,7 @@ mod tests { let c = on(ram.clone()); let img = std::env::temp_dir().join(format!("iris-aic7880-{name}.img")); std::fs::write(&img, vec![0u8; 64 * BLOCK_SIZE]).expect("test image"); - c.attach_disk_at(1, &img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let base = crate::ip32::RAM_BASE; for (off, v) in [(sram::SCB_ARRAY, base + ARRAY), (sram::QOUTFIFO, base + QOUT)] { for (i, b) in v.to_le_bytes().iter().enumerate() { @@ -2213,7 +2133,7 @@ mod tests { let c = on(ram.clone()); let img = std::env::temp_dir().join("iris-aic7880-nb.img"); std::fs::write(&img, vec![0u8; 64 * BLOCK_SIZE]).expect("test image"); - c.attach_disk_at(1, &img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let (hscbs, shared) = (0x2000u32, 0x4000u32); let tag = 3u8; { @@ -2246,7 +2166,7 @@ mod tests { let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let img = std::env::temp_dir().join(format!("iris-aic7880-nb-{}-{n}.img", std::process::id())); std::fs::write(&img, img_blocks).expect("test image"); - c.attach_disk_at(1, &img).expect("attach"); + c.bus().attach_image(1, &img, false).expect("attach"); let (hscbs, shared, tag) = (0x2000u32, 0x4000u32, 3u8); { let m = &*ram; @@ -2459,8 +2379,8 @@ mod tests { let c = chip(); let img = std::env::temp_dir().join("iris-aic7880-wsense.img"); std::fs::write(&img, vec![0u8; 8 * BLOCK_SIZE]).expect("test image"); - c.attach_at(1, &img, false).expect("attach"); - c.attach_at(4, &img, true).expect("attach CD"); + c.bus().attach_image(1, &img, false).expect("attach"); + c.bus().attach_image(4, &img, true).expect("attach CD"); let write10 = |lba: u8| [0x2a, 0, 0, 0, 0, lba, 0, 0, 1, 0]; let block = vec![0u8; BLOCK_SIZE]; diff --git a/src/dev/daynaport.rs b/src/dev/daynaport.rs index ffb7232d..c538e598 100644 --- a/src/dev/daynaport.rs +++ b/src/dev/daynaport.rs @@ -768,9 +768,9 @@ mod tests { let mut d = DaynaPort::new(3, default_mac(3), GatewayConfig::default(), Arc::new(AtomicU64::new(0))); let (_tx, mut rx) = d.take_nat_ends(); - let mut dev = crate::scsi::ScsiDevice::new_daynaport(d); - assert!(dev.is_daynaport()); - assert!(!dev.is_cdrom()); + let mut dev: Box = Box::new(d); + assert!(dev.moves_packets()); + assert!(dev.discs().is_none()); dev.request(&cdb6([dp_cmd::ENABLE, 0, 0, 0, 0, 0x80])).unwrap(); dev.request(&cdb6([dp_cmd::SET_IFACE_MODE, 0, 0, 0, 0x04, 0x80])).unwrap(); diff --git a/src/dev/hpc3.rs b/src/dev/hpc3.rs index 3b159a53..4cce7f6e 100644 --- a/src/dev/hpc3.rs +++ b/src/dev/hpc3.rs @@ -1271,7 +1271,7 @@ impl Hpc3 { } pub fn add_scsi_device(&self, controller: u8, id: usize, path: &str, is_cdrom: bool, discs: Vec, overlay: bool) -> std::io::Result<()> { - self.scsi_controller(controller).add_device(id, path, is_cdrom, discs, overlay, None) + self.scsi_controller(controller).bus().attach_device(id as u8, path, is_cdrom, discs, overlay, None).map(|_| ()) } /// Attach a DaynaPort SCSI/Link (SCSI-attached Ethernet) at `id`. @@ -1301,7 +1301,7 @@ impl Hpc3 { /// in `/tmp` so parallel `--ci` instances (and an interactive session) /// don't race on the same file. pub fn add_scsi_device_with_overlay(&self, controller: u8, id: usize, path: &str, is_cdrom: bool, discs: Vec, overlay: bool, overlay_path: &str) -> std::io::Result<()> { - self.scsi_controller(controller).add_device(id, path, is_cdrom, discs, overlay, Some(overlay_path)) + self.scsi_controller(controller).bus().attach_device(id as u8, path, is_cdrom, discs, overlay, Some(overlay_path)).map(|_| ()) } pub fn ioc(&self) -> &Ioc { diff --git a/src/dev/isp1040.rs b/src/dev/isp1040.rs index 0c3f84e9..466f9613 100644 --- a/src/dev/isp1040.rs +++ b/src/dev/isp1040.rs @@ -47,7 +47,7 @@ use parking_lot::{Condvar, Mutex, MutexGuard}; use crate::devlog::LogModule; use crate::irq::IrqLine; use crate::pci::{Bar, PciBusDevice, PciConfig, PciDma, PCI_INTA}; -use crate::scsi::{scsi_cmd, ScsiDataLength, ScsiDevice, ScsiIdentity, ScsiRequest}; +use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiIdentity, ScsiRequest}; use crate::snapshot::{get_field, hex_u16, hex_u32, load_u16_slice, toml_u16, toml_u32, toml_u8, u16_slice_to_toml, u8_slice_to_toml}; pub const PCI_VENDOR_QLOGIC: u16 = 0x1077; @@ -419,15 +419,13 @@ pub struct Core { /// Serialises driving the interrupt line; see [`Core::sync_irq`]. line_order: Mutex<()>, /// The targets on the bus, by SCSI id. - targets: Mutex<[Option; 16]>, + bus: ScsiBus, dma: OnceLock>, irq: OnceLock, /// Which of the Octane's two controllers this is (0 internal, 1 /// external): the monitor's `scsi`/`cow` address the first, /// `scsi1`/`cow1` the second. index: u8, - /// The SCSI id a disk attached without one answers at. - target: u8, pci: PciConfig, notes: Mutex>, dma_misses: Mutex>, @@ -490,7 +488,7 @@ impl Isp1040 { for (what, probe) in [ ("st", (|c: &Core| c.st.is_locked()) as fn(&Core) -> bool), ("fence", |c| c.fence.is_locked()), - ("targets", |c| c.targets.is_locked()), + ("targets", |c| c.bus.is_locked()), ] { let core = Arc::downgrade(&self.core); crate::locks::register_lock(format!("isp1040-{}::{what}", self.index), move || { @@ -517,11 +515,10 @@ impl Core { fence: Mutex::new(()), wake: Condvar::new(), line_order: Mutex::new(()), - targets: Mutex::new(std::array::from_fn(|_| None)), + bus: ScsiBus::new(16, ScsiIdentities { disk: ISP_DISK, cdrom: ISP_CDROM }), dma: OnceLock::new(), irq: OnceLock::new(), index, - target: 1, // BAR 0 is the I/O window and BAR 1 its memory-space alias, one // register file. pci: PciConfig::new( @@ -679,64 +676,15 @@ impl Core { } } - // ---- attaching targets ------------------------------------------- - - /// Attach a disk image at a SCSI id. - pub fn attach_disk_at(&self, target: u8, path: &std::path::Path) -> std::io::Result { - self.attach_at(target, path, false) - } - - pub fn attach_at(&self, target: u8, path: &std::path::Path, cdrom: bool) -> std::io::Result { - self.attach_with(target, path, cdrom, false) - } - - /// [`Self::attach_at`], copy-on-write when `overlay` is set: the image is - /// never written, writes go to `{path}.overlay`. - pub fn attach_with(&self, target: u8, path: &std::path::Path, cdrom: bool, overlay: bool) -> std::io::Result { - let path = path - .to_str() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path is not UTF-8"))?; - self.attach_device(target, path, cdrom, Vec::new(), overlay, None) - } - - /// Attach what a `[scsi.N]` table describes: the image at `path` (empty - /// for a CD-ROM with its tray empty), a CD-ROM's changer list `discs`, - /// and copy-on-write when `overlay` is set, to `overlay_path` or - /// `{path}.overlay`. Returns the size in blocks. - pub fn attach_device( - &self, - target: u8, - path: &str, - cdrom: bool, - discs: Vec, - overlay: bool, - overlay_path: Option<&str>, - ) -> std::io::Result { - let d = ScsiDevice::open(path, cdrom, discs, overlay, overlay_path, if cdrom { ISP_CDROM } else { ISP_DISK })?; - let blocks = d.size() / BLOCK_SIZE as u64; - let mut targets = self.targets.lock(); - let slot = targets.get_mut(target as usize).ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("SCSI id {target} out of range")) - })?; - *slot = Some(d); - Ok(blocks) - } - - /// The id a disk attached without one goes on. - pub fn default_target(&self) -> u8 { self.target } - - pub fn attach_disk(&self, path: &std::path::Path) -> std::io::Result { - self.attach_disk_at(self.target, path) - } + // ---- the bus ------------------------------------------------------ - /// The ids that have something on them. - pub fn attached(&self) -> Vec { - let t = self.targets.lock(); - (0..t.len() as u8).filter(|&i| t[i as usize].is_some()).collect() + /// The SCSI bus: attach targets to it and ask what is on it. + pub fn bus(&self) -> &ScsiBus { + &self.bus } fn has_target(&self, id: u8) -> bool { - self.targets.lock().get(id as usize).is_some_and(|t| t.is_some()) + self.bus.has_target(id) } // ---- DMA --------------------------------------------------------- @@ -1148,7 +1096,7 @@ impl Core { /// Run a CDB against the target; `data` is what a write sends it. None /// when a reset cancelled the command while it waited for the target. fn scsi(&self, gen: u64, target: u8, cdb: &[u8], data: Option>) -> Option<(Vec, u8)> { - let mut targets = self.targets.lock(); + let mut targets = self.bus.lock(); if self.gen.load(Ordering::Acquire) != gen { return None; } @@ -1196,11 +1144,12 @@ impl Core { cdb[1] = (cdb[1] & 0x1f) | (lun << 5); let cdb = &cdb[..(cdb_len as usize).clamp(1, CDB_FIELD_LEN)]; let block = self - .targets + .bus .lock() .get(target as usize) .and_then(|t| t.as_ref()) - .map(|d| d.block_sizes().1 as usize) + .and_then(|t| t.block_size()) + .map(|b| b as usize) .filter(|&b| b != 0) .unwrap_or(BLOCK_SIZE); let (xfer, to_dev) = Self::cdb_transfer(cdb, block); @@ -1581,9 +1530,8 @@ impl crate::traits::Device for Isp1040 { } impl crate::scsi_monitor::ScsiTargets for Isp1040 { - fn with_targets(&self, f: impl FnOnce(&mut [Option]) -> R) -> R { - let mut targets = self.targets.lock(); - f(&mut targets[..]) + fn scsi_bus(&self) -> &ScsiBus { + &self.bus } } diff --git a/src/dev/isp1040/tests.rs b/src/dev/isp1040/tests.rs index b2bd5324..466c6ec5 100644 --- a/src/dev/isp1040/tests.rs +++ b/src/dev/isp1040/tests.rs @@ -71,7 +71,7 @@ impl Rig { /// A chip with a disk of `blocks` blocks at target `id`. fn with_disk(name: &str, id: u8, blocks: usize) -> Rig { let r = Rig::bare(); - r.isp.attach_disk_at(id, &image(name, blocks)).expect("attach"); + r.isp.bus().attach_image(id, &image(name, blocks), false).expect("attach"); r } @@ -917,12 +917,10 @@ fn save_load_round_trip() { fn a_disk_attaches_where_asked_and_the_monitor_can_see_it() { use crate::scsi_monitor::ScsiTargets; let r = Rig::bare(); - assert_eq!(r.isp.default_target(), 1); - let blocks = r.isp.attach_disk(&image("attach", 32)).unwrap(); + let blocks = r.isp.bus().attach_image(1, &image("attach", 32), false).unwrap(); assert_eq!(blocks, 32); - assert_eq!(r.isp.attached(), vec![1]); assert!(r.isp.with_targets(|t| t[1].is_some() && t[0].is_none())); - assert!(r.isp.attach_disk_at(16, &image("attach", 32)).is_err()); + assert!(r.isp.bus().attach_image(16, &image("attach", 32), false).is_err()); } /// Records the bus address of each transfer. @@ -1008,7 +1006,7 @@ fn gated_rig(name: &str) -> (Rig, Arc, Arc) { let isp = Isp1040::new(); Device::start(&isp); isp.connect_dma(gate.clone()); - isp.attach_disk_at(1, &image(name, 64)).expect("attach"); + isp.bus().attach_image(1, &image(name, 64), false).expect("attach"); let (probe, line) = LevelProbe::line(); isp.connect_interrupt(line); let r = Rig { isp, mem }; @@ -1048,7 +1046,7 @@ fn a_soft_reset_does_not_wait_for_the_target() { let isp = Isp1040::new(); Device::start(&isp); isp.connect_dma(gate.clone()); - isp.attach_disk_at(1, &image("async-reset", 64)).expect("attach"); + isp.bus().attach_image(1, &image("async-reset", 64), false).expect("attach"); let r = Rig { isp, mem }; let (probe, line) = LevelProbe::line(); r.isp.connect_interrupt(line); @@ -1061,7 +1059,7 @@ fn a_soft_reset_does_not_wait_for_the_target() { r.isp.write(reg::REQUEST_QUEUE, 1, 2); gate.wait_arrival(); assert!(r.isp.command_running()); - let busy = r.isp.targets.lock(); + let busy = r.isp.bus().lock(); gate.open(); r.isp.write(reg::BUS_CTRL, CTRL_SOFT_RESET as u32, 2); assert_eq!(r.r(r.mb(4)), SIG4, "the reset happened with the target still busy"); @@ -1227,7 +1225,7 @@ fn the_ring_thread_lives_between_start_and_stop() { assert!(!isp.is_running(), "the constructor starts no thread"); let mem = FakeMem::new(); isp.connect_dma(mem.clone()); - isp.attach_disk_at(0, &image("lifecycle", 64)).expect("attach"); + isp.bus().attach_image(0, &image("lifecycle", 64), false).expect("attach"); let r = Rig { isp, mem }; r.init_rings(8, 8); r.isp.start(); diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index ddfa5d4d..985e2325 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use crate::traits::{BusRead8, BusRead16, BusRead32, BusRead64, BUS_OK, BUS_ERR, Device, FifoDevice, DmaClient, DmaStatus, Resettable, Saveable}; use crate::devlog::{LogModule, devlog}; use crate::snapshot::{get_field, toml_u8, toml_bool, u8_slice_to_toml, load_u8_slice, hex_u8}; -use crate::scsi::{self, ScsiDevice, scsi_cmd, ScsiRequest, ScsiDataLength}; +use crate::scsi::{self, ScsiBus, ScsiIdentities, scsi_cmd, ScsiRequest, ScsiDataLength}; use std::io::Write; // Ring-buffer trace of WD33C93 register accesses with consecutive-dedup, so @@ -237,8 +237,8 @@ struct Wd33c93aState { ar: u8, // Auxiliary Status Register (read-only) asr: u8, - // SCSI Devices (IDs 0-7) - devices: [Option; 8], + // The SCSI bus (IDs 0-7), shared with the chip's owner + bus: Arc, fifo: VecDeque, // Data direction flag for computing DBR (true = data in from target to host) data_direction_in: bool, @@ -305,6 +305,8 @@ pub const INDY_CDROM: crate::scsi::ScsiIdentity = crate::scsi::ScsiIdentity::new pub const INDY_DISK: crate::scsi::ScsiIdentity = crate::scsi::ScsiIdentity::new(b"SGI ", b"IRIS EMUL DISK ", b"1.0 "); pub struct Wd33c93a { + /// The targets, also held by the state the worker thread runs on. + bus: Arc, state: Arc>, cond: Arc, thread: Mutex>>, @@ -339,13 +341,15 @@ impl Wd33c93a { /// HPC3 SCSI controller number) — see `Wd33c93aState.id`'s doc comment. pub fn new_with_id(dma: Option>, irq: Option, heartbeat: Arc, deferred_int: bool, id: u8) -> Self { let deferred_int_arc = Arc::new(AtomicBool::new(deferred_int)); + let bus = Arc::new(ScsiBus::new(8, ScsiIdentities { disk: INDY_DISK, cdrom: INDY_CDROM })); Self { + bus: bus.clone(), state: Arc::new(Mutex::new(Wd33c93aState { id, regs: [0; 32], ar: 0, asr: 0, // Initially not busy, no interrupt - devices: Default::default(), + bus: bus.clone(), fifo: VecDeque::new(), data_direction_in: false, target_id: 0, @@ -384,30 +388,9 @@ impl Wd33c93a { self.cpu_cycles.set(ptr); } - /// Attach a SCSI device. - /// For CD-ROMs, `discs` is the full ordered list of ISO paths; the first - /// entry is mounted immediately. For HDDs `discs` is ignored — only - /// `path` is used. - /// - /// If `overlay_path_override` is `Some`, it specifies where the COW - /// overlay file lives. This lets CI mode isolate its overlay from an - /// interactive session sharing the same base image. Ignored when - /// `overlay` is false. - pub fn add_device( - &self, - id: usize, - path: &str, - is_cdrom: bool, - discs: Vec, - overlay: bool, - overlay_path_override: Option<&str>, - ) -> std::io::Result<()> { - let dev = ScsiDevice::open(path, is_cdrom, discs, overlay, overlay_path_override, if is_cdrom { INDY_CDROM } else { INDY_DISK })?; - let mut state = self.state.lock(); - if id < 8 { - state.devices[id] = Some(dev); - } - Ok(()) + /// The SCSI bus: attach targets to it and ask what is on it. + pub fn bus(&self) -> &Arc { + &self.bus } /// Attach a DaynaPort SCSI/Link target — a SCSI-attached Ethernet adapter. @@ -427,31 +410,27 @@ impl Wd33c93a { } let mut dp = crate::dev::daynaport::DaynaPort::new(id, mac, gateway, self.heartbeat.clone()); dp.start(); - let mut state = self.state.lock(); - state.devices[id] = Some(ScsiDevice::new_daynaport(dp)); - Ok(()) + self.bus.attach(id as u8, Box::new(dp)) } /// Reset the COW overlay on every attached device that's using COW. /// Direct-mode devices are left alone. Used by `Machine::ci_restore`. pub fn reset_all_overlays(&self) -> Vec<(usize, std::io::Result<()>)> { - let mut state = self.state.lock(); let mut results = Vec::new(); - for id in 0..8 { - if let Some(dev) = &mut state.devices[id] { - if dev.is_cow() { - results.push((id, dev.cow_reset())); - } + self.bus.for_each(|id, dev| { + if dev.cow_dirty().is_some() { + results.push((id, dev.cow_reset())); } - } + }); results } /// Number of attached CHD devices whose `.diff.chd` holds changes pending a /// fold-back into the base on a clean shutdown. pub fn pending_chd_sync_count(&self) -> usize { - let state = self.state.lock(); - state.devices.iter().flatten().filter(|d| d.pending_chd_sync().is_some()).count() + let mut n = 0; + self.bus.for_each(|_, dev| n += dev.pending_chd_sync().is_some() as usize); + n } /// Fold pending CHD diffs back into their bases ("sync"), preserving the @@ -470,19 +449,12 @@ impl Wd33c93a { // Collect pending (base, diff) pairs, releasing CHD handles under the // lock so the files can be atomically rebuilt. The rebuild itself runs // unlocked. - let pending: Vec<(std::path::PathBuf, std::path::PathBuf)> = { - let mut state = self.state.lock(); - let mut v = Vec::new(); - for id in 0..8 { - if only.is_some_and(|o| o != id) { continue; } - if let Some(dev) = &mut state.devices[id] { - if let Some(pair) = dev.take_pending_chd_sync() { - v.push(pair); - } - } + let mut pending = Vec::new(); + self.bus.for_each(|id, dev| { + if only.is_none_or(|o| o == id) { + pending.extend(dev.take_pending_chd_sync()); } - v - }; + }); let total = pending.len(); let mut done = 0usize; for (base, diff) in pending { @@ -501,18 +473,7 @@ impl Wd33c93a { /// list of `(id, dirty_sector_list)` entries so snapshot save can /// persist the dirty set alongside the raw overlay bytes. pub fn export_overlays(&self, dir: &std::path::Path) -> std::io::Result)>> { - let mut state = self.state.lock(); - let mut out = Vec::new(); - for id in 0..8 { - if let Some(dev) = &mut state.devices[id] { - if dev.is_cow() { - let dest = dir.join(format!("scsi{}.overlay", id)); - let dirty = dev.cow_export(&dest)?; - out.push((id, dirty)); - } - } - } - Ok(out) + self.bus.export_overlays(dir) } /// Replace each COW overlay with its saved counterpart in `dir` and @@ -523,16 +484,7 @@ impl Wd33c93a { dir: &std::path::Path, dirty_sets: &[(usize, Vec)], ) -> std::io::Result<()> { - let mut state = self.state.lock(); - for (id, dirty) in dirty_sets { - if let Some(dev) = &mut state.devices[*id] { - if dev.is_cow() { - let src = dir.join(format!("scsi{}.overlay", id)); - dev.cow_import(&src, dirty.clone())?; - } - } - } - Ok(()) + self.bus.import_overlays(dir, dirty_sets) } pub fn read_fifo(&self) -> u8 { @@ -911,23 +863,13 @@ impl Device for Wd33c93a { } // Any DaynaPort target owns a backend thread of its own; stop it too // (after the worker is joined, so nothing is mid-command). - { - let mut state = self.state.lock(); - for dev in state.devices.iter_mut().flatten() { - if let Some(dp) = dev.daynaport_mut() { dp.stop(); } - } - } + self.bus.for_each(|_, dev| dev.stop()); } fn start(&self) { if self.running.swap(true, Ordering::SeqCst) { return; } // Re-arm any DaynaPort backend a previous stop() shut down. No-op on // first boot — add_daynaport() already started them. - { - let mut st = self.state.lock(); - for dev in st.devices.iter_mut().flatten() { - if let Some(dp) = dev.daynaport_mut() { dp.start(); } - } - } + self.bus.for_each(|_, dev| dev.start()); let state = self.state.clone(); let cond = self.cond.clone(); let running = self.running.clone(); @@ -1125,9 +1067,8 @@ impl Device for Wd33c93a { /// The monitor's `scsi`/`cow` subcommands and the run-time disc changes /// come from `scsi_monitor` through this. impl crate::scsi_monitor::ScsiTargets for Wd33c93a { - fn with_targets(&self, f: impl FnOnce(&mut [Option]) -> R) -> R { - let mut state = self.state.lock(); - f(&mut state.devices[..]) + fn scsi_bus(&self) -> &ScsiBus { + &self.bus } } @@ -1172,9 +1113,7 @@ impl Resettable for Wd33c93a { // A DaynaPort comes up disabled with empty queues, and its NAT tables // are flushed on the backend thread's next loop — the same answer // Seeq8003::power_on gives for the onboard Ethernet. - for dev in state.devices.iter_mut().flatten() { - if let Some(dp) = dev.daynaport_mut() { dp.power_on(); } - } + state.bus.for_each(|_, dev| dev.power_on()); } } @@ -1224,6 +1163,14 @@ impl Wd33c93aState { if self.id == 0 { LogModule::Scsi } else { LogModule::Scsi1 } } + /// Whether the selected target is a DaynaPort, whose READ(6) and + /// WRITE(6) move packets, not blocks. + fn target_is_daynaport(&self) -> bool { + self.bus.with_targets(|t| { + t.get(self.target_id).and_then(|t| t.as_ref()).is_some_and(|t| t.moves_packets()) + }) + } + fn use_dma(&self) -> bool { let mode = (self.regs[regs::CONTROL as usize] >> 5) & 0x7; mode != 0 @@ -1429,7 +1376,7 @@ impl Wd33c93aState { data_in: Some(full_data), }; let tid = self.target_id; - match self.devices[tid].as_mut().map(|d| d.request(&request)) { + match self.bus.request(tid as u8, &request) { Some(Ok(response)) => self.finish_command(response.status), _ => self.finish_command(0x02), } @@ -1443,7 +1390,7 @@ impl Wd33c93aState { return; } - if self.devices[self.target_id].is_none() { + if !self.bus.has_target(self.target_id as u8) { dlog!(self.log_module(), "WD33C93A({}): No device at target {}, timing out", self.id, self.target_id); wdt!("SEL_TIMEO tgt={} cmd={:02x} asr={:02x}", self.target_id, cmd, self.asr); // First interrupt (0x42): consumed by selectbus's SBIC_WAIT(INT) poll. @@ -1696,8 +1643,7 @@ impl Wd33c93aState { _ => "UNKNOWN", }; - let is_dayna = self.devices[self.target_id].as_ref() - .map(|d| d.is_daynaport()).unwrap_or(false); + let is_dayna = self.target_is_daynaport(); let mut extra = String::new(); match cdb[0] { // On a DaynaPort these two are packet RX/TX, not block I/O. @@ -1749,8 +1695,7 @@ impl Wd33c93aState { // A DaynaPort reuses WRITE(6) to transmit one Ethernet frame, so its // data-out length is a plain byte count in CDB 3..4 — not `blocks × 512`. - let dayna = self.devices[self.target_id].as_ref() - .map(|d| d.is_daynaport()).unwrap_or(false); + let dayna = self.target_is_daynaport(); // For WRITE commands, receive data first (data out from host to target) let data_in = match cdb[0] { @@ -1825,8 +1770,7 @@ impl Wd33c93aState { }; // Make SCSI request - let device = &mut self.devices[self.target_id]; - if device.is_none() { + if !self.bus.has_target(self.target_id as u8) { self.finish_command(0x02); return; } @@ -1837,7 +1781,7 @@ impl Wd33c93aState { data_in, }; - match device.as_mut().unwrap().request(&request) { + match self.bus.request(self.target_id as u8, &request).expect("target checked above") { Ok(response) => { if !response.data.is_empty() { if auto_mode { @@ -2234,9 +2178,9 @@ mod data_out_phase_tests { let dev = Wd33c93a::new(None, None, Arc::new(AtomicU64::new(0))); { let mut s = dev.state.lock(); - s.devices[2] = Some(ScsiDevice::new( + s.bus.attach(2, Box::new(scsi::ScsiDevice::new( Box::new(crate::block_node::RawNode::from_file(file, 1 << 20)), - 1 << 20, false, path.display().to_string(), Vec::new(), INDY_DISK)); + 1 << 20, false, path.display().to_string(), Vec::new(), INDY_DISK))).unwrap(); s.advanced_mode = true; s.target_id = 2; s.regs[regs::DESTINATION_ID as usize] = 2; diff --git a/src/ip30/tests.rs b/src/ip30/tests.rs index c6dede34..79232247 100644 --- a/src/ip30/tests.rs +++ b/src/ip30/tests.rs @@ -689,7 +689,7 @@ impl IspDisk { static N: AtomicU32 = AtomicU32::new(0); let p = std::env::temp_dir().join(format!("iris-ip30-xtests-{}-{}-{name}.img", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); std::fs::write(&p, vec![0u8; 64 * 512]).unwrap(); - isp.attach_disk_at(target, &p).unwrap(); + isp.bus().attach_image(target, &p, false).unwrap(); IspDisk(p) } } diff --git a/src/ip30_machine.rs b/src/ip30_machine.rs index 30ee4232..f821075a 100644 --- a/src/ip30_machine.rs +++ b/src/ip30_machine.rs @@ -399,7 +399,7 @@ impl OctaneMachine { let (path, discs) = dev.media(); let cdrom = dev.is_cdrom(); let ci_overlay = (cfg.ci && dev.overlay && !cdrom).then(|| crate::config::ScsiDeviceConfig::ci_overlay_path(key)); - match ctl.attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { + match ctl.bus().attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { Ok(_) if path.is_empty() => eprintln!("ip30: {at}: CD-ROM, tray empty"), Ok(blocks) => eprintln!("ip30: {at}: {path} ({blocks} blocks)"), Err(e) => { diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index baaf525a..ad22c3d0 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -234,7 +234,7 @@ impl O2Machine { // Indy does, so an interactive iris holding PATH.overlay and // any number of CI runs can share the image. let ci_overlay = (cfg.ci && dev.overlay && !cdrom).then(|| crate::config::ScsiDeviceConfig::ci_overlay_path(key)); - match ctl.attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { + match ctl.bus().attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { Ok(_) if path.is_empty() => eprintln!("ip32: {at}: CD-ROM, tray empty"), Ok(blocks) => eprintln!("ip32: {at}: {path} ({blocks} blocks)"), Err(e) => { diff --git a/src/scsi.rs b/src/scsi.rs index b780759c..3af8aa2d 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -1,5 +1,11 @@ use std::io; +mod attach; +mod bus; + +pub use attach::{attach_failed, attach_from_config, Attached, DaynaportRef, TableRef}; +pub use bus::{ScsiBus, ScsiIdentities, ScsiTarget, Targets}; + use crate::block_node::{BlockNode, ChdCdNode, ChdNode, CowNode, RawNode}; /// Get the standard CDB length based on the opcode's group code @@ -78,17 +84,12 @@ pub fn request_sense_alloc_len(cdb: &[u8]) -> usize { if cdb[4] == 0 { 4 } else { cdb[4] as usize } } -/// What kind of target sits at this SCSI id. -/// -/// `Disk` and `Cdrom` share the storage command set below and differ only in -/// behaviour (block size, TOC, write protection). `DaynaPort` shares nothing -/// with them: it is a type-3 Processor device whose vendor command set reuses -/// the READ(6)/WRITE(6) opcodes for packet RX/TX, so it is dispatched *before* -/// the storage opcodes in `request()` and must never reach them. +/// What kind of storage target sits at this SCSI id: the two share the +/// command set below and differ only in behaviour (block size, TOC, write +/// protection). pub enum DeviceKind { Disk, Cdrom, - DaynaPort(Box), } /// What a target reports of itself in INQUIRY: vendor, product and @@ -110,8 +111,7 @@ impl ScsiIdentity { } pub struct ScsiDevice { - /// None = no media loaded (CD-ROM drive present but tray is empty), or a - /// device with no storage behind it at all (DaynaPort). + /// None = no media loaded (CD-ROM drive present but tray is empty). /// HDDs are never None in practice. backend: Option>, /// Capacity in bytes of the loaded media. 0 when `backend` is None. @@ -246,48 +246,6 @@ impl ScsiDevice { } } - /// Construct a DaynaPort SCSI/Link target — a type-3 Processor device with - /// no storage backing at all (no image, no CHD, no overlay). - pub fn new_daynaport(dp: crate::dev::daynaport::DaynaPort) -> Self { - Self { - backend: None, - size: 0, - kind: DeviceKind::DaynaPort(Box::new(dp)), - filename: String::new(), - discs: vec![], - buffer: Vec::new(), - pending_sense: [0u8; 18], - unit_attention: false, - phys_block_size: 512, - logical_block_size: 512, - // Never reported: a DaynaPort answers INQUIRY itself. - identity: ScsiIdentity::new(b" ", b" ", b" "), - } - } - - /// The DaynaPort behind this target, if it is one. - pub fn daynaport_mut(&mut self) -> Option<&mut crate::dev::daynaport::DaynaPort> { - match &mut self.kind { - DeviceKind::DaynaPort(dp) => Some(dp), - _ => None, - } - } - - /// The DaynaPort behind this target, if it is one. - pub fn daynaport(&self) -> Option<&crate::dev::daynaport::DaynaPort> { - match &self.kind { - DeviceKind::DaynaPort(dp) => Some(dp), - _ => None, - } - } - - /// True for a DaynaPort SCSI/Link target. Callers on the storage path use - /// this to skip block-oriented handling (the controller, for instance, must - /// not read a WRITE(6) byte count as `blocks × 512`). - pub fn is_daynaport(&self) -> bool { - matches!(self.kind, DeviceKind::DaynaPort(_)) - } - /// Whether physical media is loaded. For HDDs always true; for CD-ROMs /// false when the tray is empty. pub fn has_media(&self) -> bool { self.backend.is_some() } @@ -597,19 +555,6 @@ impl ScsiDevice { return Ok(self.check_condition(0x06, 0x28, 0x00)); } - // A DaynaPort answers its own vendor command set, which reuses the - // READ(6) (0x08) and WRITE(6) (0x0a) opcodes for packet RX/TX. Dispatch - // it here, ahead of the storage match below, or those two would be - // read as disk block transfers. It answers no storage command at all — - // not READ CAPACITY, not MODE SENSE, not READ TOC. - if let DeviceKind::DaynaPort(dp) = &mut self.kind { - let mut response = dp.request(req)?; - if let ScsiDataLength::Fixed(max_len) = req.data_len { - response.data.truncate(max_len.min(response.data.len())); - } - return Ok(response); - } - let mut response = match req.cdb[0] { scsi_cmd::TEST_UNIT_READY => self.exec_test_unit_ready(&req.cdb)?, scsi_cmd::REQUEST_SENSE => self.exec_request_sense(&req.cdb)?, diff --git a/src/scsi/attach.rs b/src/scsi/attach.rs new file mode 100644 index 00000000..6e65d454 --- /dev/null +++ b/src/scsi/attach.rs @@ -0,0 +1,202 @@ +//! Putting the `[scsi.N]` config tables on a machine's buses. +//! +//! Every machine walks the same list: place each table on a controller and +//! id, make a scratch volume, send a CI run's overlay under /tmp, attach the +//! target, and decide what a target that will not attach means. Only two +//! things are the machine's: which bus a controller number names, and, on +//! the machines that have one, how a DaynaPort is built. + +use std::path::PathBuf; + +use super::ScsiBus; +use crate::config::{DaynaportParams, MachineConfig, ScsiDeviceConfig}; + +/// One `[scsi.N]` image table, for a snapshot manifest to record. +pub struct TableRef { + /// The slot, controller * 16 + id (`config::scsi_slot`). + pub slot: u8, + pub is_cdrom: bool, + pub path: String, + /// The image's size on the host, 0 if it cannot be read. + pub size_bytes: u64, +} + +/// A `kind = "daynaport"` table. The controller that can hold one builds it, +/// since it needs the machine's network. +pub struct DaynaportRef { + pub controller: u8, + pub id: u8, + pub params: DaynaportParams, +} + +/// What attaching left for the machine. +#[derive(Default)] +pub struct Attached { + /// The `scratch = true` volume the CI socket reads and writes: the + /// lowest-id one, if several are configured. + pub scratch: Option, + /// Every disk and CD-ROM table, by slot, whether or not it attached. + pub tables: Vec, + /// The DaynaPort tables, not attached. + pub daynaports: Vec, +} + +/// A target that will not attach ends a command-line run, since booting on +/// without it only yields a confusing PROM "no such device" later. An +/// embedder (the GUI sets `IRIS_NO_EXIT_ON_POWEROFF`) must never be torn +/// down by the library, so it skips the one target and boots without it; the +/// GUI's Start preflight already warns about an unreadable disk. +pub fn attach_failed(msg: String, at: &str) -> Result<(), String> { + if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { + eprintln!("iris: warning: {msg}; continuing without {at}"); + Ok(()) + } else { + Err(msg) + } +} + +/// Attach every disk and CD-ROM table of `cfg`, logging each as +/// `{log_prefix}: SCSI ID n: ...` unless the prefix is `None`. `route` names +/// the bus a controller number is on, or says why there is none ("an O2 has +/// controllers 0 and 1"). The error is the message to end the run with. +pub fn attach_from_config<'a>( + cfg: &MachineConfig, + log_prefix: Option<&str>, + route: &dyn Fn(u8) -> Result<&'a ScsiBus, String>, +) -> Result { + let mut out = Attached::default(); + let mut keys: Vec = cfg.scsi.keys().copied().collect(); + keys.sort(); + for key in keys { + let dev = &cfg.scsi[&key]; + let (controller, id) = dev.placement(key); + let at = crate::config::scsi_label(controller, id); + if dev.is_daynaport() { + let params = dev.daynaport_params(key).map_err(|e| format!("{at}: {e}"))?; + route(controller).map_err(|e| format!("{at}: {e}"))?; + out.daynaports.push(DaynaportRef { controller, id, params }); + continue; + } + out.tables.push(TableRef { + slot: crate::config::scsi_slot(controller, id), + is_cdrom: dev.is_cdrom(), + path: dev.path.clone(), + size_bytes: std::fs::metadata(&dev.path).map(|m| m.len()).unwrap_or(0), + }); + // The file gets an SGI volume header, without which IRIX returns I/O + // errors on every read of the volume. + if let Some(path) = dev.prepare_scratch(key) { + if out.scratch.is_some() { + println!("Note: multiple scratch SCSI devices configured; CI socket will use the lowest-id one"); + } else { + out.scratch = Some(path); + } + } + let bus = route(controller).map_err(|e| format!("{at}: {e}"))?; + let (path, discs) = dev.media(); + let cdrom = dev.is_cdrom(); + // CI mode isolates each copy-on-write overlay under /tmp, so an + // interactive iris holding one and any number of CI runs can share + // the image. + let ci_overlay = (cfg.ci && dev.overlay && !cdrom).then(|| ScsiDeviceConfig::ci_overlay_path(key)); + match bus.attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { + Ok(blocks) => { + if let Some(prefix) = log_prefix { + if path.is_empty() { + eprintln!("{prefix}: {at}: CD-ROM, tray empty"); + } else { + eprintln!("{prefix}: {at}: {path} ({blocks} blocks)"); + } + } + } + Err(e) => attach_failed(format!("could not attach {path} to {at}: {e}"), &at)?, + } + } + out.tables.sort_by_key(|t| t.slot); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scsi::{ScsiIdentities, ScsiIdentity}; + + const ID: ScsiIdentity = ScsiIdentity::new(b"TEST ", b"ATTACH ", b"1.0 "); + + fn buses() -> [ScsiBus; 2] { + std::array::from_fn(|_| ScsiBus::new(16, ScsiIdentities { disk: ID, cdrom: ID })) + } + + fn route<'a>(b: &'a [ScsiBus; 2]) -> impl Fn(u8) -> Result<&'a ScsiBus, String> { + move |c| b.get(c as usize).ok_or_else(|| format!("controller {c} (two buses here)")) + } + + fn image(tag: &str) -> String { + let p = std::env::temp_dir().join(format!("iris-attach-{tag}-{}", std::process::id())); + std::fs::write(&p, vec![0u8; 4096]).unwrap(); + p.to_string_lossy().into_owned() + } + + fn table(path: &str, cdrom: bool, controller: u8) -> ScsiDeviceConfig { + ScsiDeviceConfig { path: path.into(), cdrom, controller, ..Default::default() } + } + + #[test] + fn tables_land_on_their_controller_and_id() { + let (a, b) = (image("a"), image("b")); + let mut cfg = MachineConfig::default(); + cfg.scsi.clear(); + cfg.scsi.insert(1, table(&a, false, 0)); + cfg.scsi.insert(2, table(&b, false, 1)); // controller field + cfg.scsi.insert(16 + 5, table(&b, false, 0)); // key names controller 1 + let buses = buses(); + attach_from_config(&cfg, None, &route(&buses)).unwrap(); + let ids = |b: &ScsiBus| (0..16).filter(|&i| b.has_target(i)).collect::>(); + assert_eq!(ids(&buses[0]), vec![1]); + assert_eq!(ids(&buses[1]), vec![2, 5]); + for p in [a, b] { + std::fs::remove_file(p).unwrap(); + } + } + + #[test] + fn a_bus_the_machine_lacks_is_an_error() { + let a = image("lack"); + let mut cfg = MachineConfig::default(); + cfg.scsi.clear(); + cfg.scsi.insert(1, table(&a, false, 2)); + let buses = buses(); + let e = attach_from_config(&cfg, None, &route(&buses)).err().unwrap(); + assert!(e.starts_with("SCSI 2:1: controller 2"), "{e}"); + std::fs::remove_file(a).unwrap(); + } + + #[test] + fn daynaports_are_returned_for_the_machine_to_attach() { + let mut cfg = MachineConfig::default(); + cfg.scsi.clear(); + cfg.scsi.insert(3, ScsiDeviceConfig { kind_field: crate::config::ScsiKind::Daynaport, ..Default::default() }); + let buses = buses(); + let got = attach_from_config(&cfg, None, &route(&buses)).unwrap(); + assert_eq!((got.daynaports[0].controller, got.daynaports[0].id), (0, 3)); + assert!(got.tables.is_empty(), "no image behind a DaynaPort"); + assert!(!buses[0].has_target(3), "and nothing attached"); + } + + #[test] + fn tables_come_back_by_slot_with_their_kind() { + let (a, iso) = (image("prov"), image("iso")); + let mut cfg = MachineConfig::default(); + cfg.scsi.clear(); + cfg.scsi.insert(4, table(&iso, true, 0)); + cfg.scsi.insert(1, table(&a, false, 0)); + let buses = buses(); + let got = attach_from_config(&cfg, None, &route(&buses)).unwrap(); + let kinds: Vec<_> = got.tables.iter().map(|t| (t.slot, t.is_cdrom)).collect(); + assert_eq!(kinds, vec![(1, false), (4, true)]); + assert_eq!(got.tables[0].size_bytes, 4096); + for p in [a, iso] { + std::fs::remove_file(p).unwrap(); + } + } +} diff --git a/src/scsi/bus.rs b/src/scsi/bus.rs new file mode 100644 index 00000000..d023e8b2 --- /dev/null +++ b/src/scsi/bus.rs @@ -0,0 +1,393 @@ +//! The SCSI bus: what sits on it, and the table every controller keeps. +//! +//! A controller (the WD33C93A, the AIC-7880, the ISP1040) is registers and +//! DMA; the targets behind it do not depend on which chip asks. [`ScsiTarget`] +//! is what varies on the bus, and [`ScsiBus`] is the shared mechanism: the +//! id-to-target table with the attach and query methods, which each +//! controller owns one of and reaches its targets through. + +use std::io; + +use parking_lot::{Mutex, MutexGuard}; + +use super::{ScsiDevice, ScsiIdentity, ScsiRequest, ScsiResponse}; +use crate::dev::daynaport::DaynaPort; + +/// A changer's discs: the active one, the queue (index 0 is the active +/// disc) and the physical and logical block sizes. +pub struct Discs { + pub current: String, + pub list: Vec, + pub block_sizes: (u64, u64), +} + +/// A device on a SCSI bus, as the controller drives it: a CDB goes in, data +/// and a status come out. +/// +/// A disk or CD-ROM is a [`ScsiDevice`], a DaynaPort Ethernet adapter a +/// [`DaynaPort`]. Everything beyond `request` is optional behaviour that a +/// kind of target has or does not (a copy-on-write layer, a disc changer), +/// defaulting to "not this target"; the controllers and the monitor ask for +/// the behaviour, never for the type. +pub trait ScsiTarget: Send { + /// Run one command; `req.data_in` is what a write sends the target. + fn request(&mut self, req: &ScsiRequest) -> io::Result; + + /// Return to the state a power-up leaves, as the controller's own + /// reset does. + fn power_on(&mut self) {} + + /// Start any thread the target runs of its own. + fn start(&mut self) {} + + /// Stop it, with no command part way through. + fn stop(&mut self) {} + + /// Whether READ(6) and WRITE(6) move packets, not blocks, so a + /// controller must not read their count as a number of sectors. + fn moves_packets(&self) -> bool { false } + + /// The target's lines in the monitor's `scsi status`, for the target at + /// `id`. + fn status(&self, _id: usize) -> Vec { Vec::new() } + + /// The longer report of `scsi dayna`, for a network adapter. + fn adapter_status(&self) -> Vec { Vec::new() } + + /// The logical block size, for a target that has one. + fn block_size(&self) -> Option { None } + + /// A controller's bus reset: block size back to what the controller + /// leaves, with a CD-ROM at 512 bytes if `cdrom_512`. + fn reset_block_size(&mut self, _cdrom_512: bool) {} + + // ---- copy-on-write layer ----------------------------------------- + + /// Uncommitted sectors, `None` when the target has no such layer. + fn cow_dirty(&self) -> Option { None } + + /// Merge the layer into the base image; how much was committed. + fn cow_commit(&mut self) -> io::Result { Ok(0) } + + /// Discard the layer's writes. + fn cow_reset(&mut self) -> io::Result<()> { Ok(()) } + + /// Copy the layer to `dest` and return its dirty sectors; `None` when + /// there is no layer. + fn cow_export(&mut self, _dest: &std::path::Path) -> io::Result>> { Ok(None) } + + /// Replace the layer with `source`, holding `dirty`. + fn cow_import(&mut self, _source: &std::path::Path, _dirty: Vec) -> io::Result<()> { Ok(()) } + + /// (base, diff) of a CHD whose diff holds changes to fold back. + fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { None } + + /// [`ScsiTarget::pending_chd_sync`], releasing the files so the base can + /// be rebuilt. + fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { None } + + // ---- disc changer ------------------------------------------------ + + /// The discs, `None` unless this is a CD-ROM. + fn discs(&self) -> Option { None } + + /// Make `path` the active disc. + fn load_disc(&mut self, _path: String) -> Result { Err(NOT_A_CDROM.into()) } + + /// Eject, and load the next disc in the queue; `None` with none to load. + fn eject_next(&mut self) -> Option { None } + + /// Eject and leave the drive empty. + fn unload_media(&mut self) {} + + /// Queue `path` as the next disc. + fn add_disc(&mut self, _path: String) -> Result<(), String> { Err(NOT_A_CDROM.into()) } + + /// Remove the queued disc at `ordinal`. + fn remove_disc(&mut self, _ordinal: usize) -> Result { Err(NOT_A_CDROM.into()) } + + /// Make the queued disc at `ordinal` the next. + fn move_disc_next(&mut self, _ordinal: usize) -> Result<(), String> { Err(NOT_A_CDROM.into()) } +} + +const NOT_A_CDROM: &str = "Not a CD-ROM device"; + +impl ScsiTarget for ScsiDevice { + fn request(&mut self, req: &ScsiRequest) -> io::Result { + ScsiDevice::request(self, req) + } + fn status(&self, id: usize) -> Vec { + let (phys, logical) = self.block_sizes(); + if self.is_cdrom() { + let list = self.disc_list(); + let active = if self.has_media() { self.current_disc() } else { "(tray empty)" }; + let mut lines = vec![format!("SCSI ID {}: CD-ROM {} ({} disc(s)) phys_block={} logical_block={}", + id, active, list.len(), phys, logical)]; + for (i, d) in list.iter().enumerate() { + let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; + lines.push(format!(" [{}] {}{}", i, d, tag)); + } + lines + } else { + let cow = if self.is_cow() { ", copy-on-write" } else { "" }; + vec![format!("SCSI ID {}: disk {} ({} blocks of {}{})", + id, self.current_disc(), self.size() / logical.max(1), logical, cow)] + } + } + fn block_size(&self) -> Option { Some(self.block_sizes().1) } + fn reset_block_size(&mut self, cdrom_512: bool) { + if cdrom_512 && self.is_cdrom() { + self.set_logical_block_size(512); + } else { + self.power_on_block_size(); + } + } + fn cow_dirty(&self) -> Option { self.is_cow().then(|| self.cow_dirty_count()) } + fn cow_commit(&mut self) -> io::Result { ScsiDevice::cow_commit(self) } + fn cow_reset(&mut self) -> io::Result<()> { ScsiDevice::cow_reset(self) } + fn cow_export(&mut self, dest: &std::path::Path) -> io::Result>> { + if self.is_cow() { ScsiDevice::cow_export(self, dest).map(Some) } else { Ok(None) } + } + fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { + ScsiDevice::cow_import(self, source, dirty) + } + fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + ScsiDevice::pending_chd_sync(self) + } + fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + ScsiDevice::take_pending_chd_sync(self) + } + fn discs(&self) -> Option { + self.is_cdrom().then(|| Discs { + current: self.current_disc().to_string(), + list: self.disc_list().to_vec(), + block_sizes: self.block_sizes(), + }) + } + fn load_disc(&mut self, path: String) -> Result { ScsiDevice::load_disc(self, path) } + fn eject_next(&mut self) -> Option { ScsiDevice::eject_next(self) } + fn unload_media(&mut self) { ScsiDevice::unload_media(self) } + fn add_disc(&mut self, path: String) -> Result<(), String> { ScsiDevice::add_disc(self, path) } + fn remove_disc(&mut self, ordinal: usize) -> Result { ScsiDevice::remove_disc(self, ordinal) } + fn move_disc_next(&mut self, ordinal: usize) -> Result<(), String> { ScsiDevice::move_disc_next(self, ordinal) } +} + +/// A DaynaPort answers its own vendor command set, which reuses the READ(6) +/// and WRITE(6) opcodes for packet receive and transmit; it answers no +/// storage command at all. +impl ScsiTarget for DaynaPort { + fn request(&mut self, req: &ScsiRequest) -> io::Result { + let mut response = DaynaPort::request(self, req)?; + if let super::ScsiDataLength::Fixed(max_len) = req.data_len { + response.data.truncate(max_len.min(response.data.len())); + } + Ok(response) + } + fn power_on(&mut self) { DaynaPort::power_on(self) } + fn start(&mut self) { DaynaPort::start(self) } + fn stop(&mut self) { DaynaPort::stop(self) } + fn moves_packets(&self) -> bool { true } + fn status(&self, _id: usize) -> Vec { self.status_lines().into_iter().take(1).collect() } + fn adapter_status(&self) -> Vec { self.status_lines() } +} + +/// What a controller's disks and CD-ROMs report in INQUIRY. The command set +/// is the same on every machine; who a machine says its drives are is its +/// own, so the controller gives them to its bus. +#[derive(Clone, Copy, Debug)] +pub struct ScsiIdentities { + pub disk: ScsiIdentity, + pub cdrom: ScsiIdentity, +} + +/// The targets on a bus by SCSI id, `None` where nothing answers. +pub type Targets = Vec>>; + +/// One SCSI bus: a controller's table of targets and the methods that fill +/// and query it. Locking is the bus's own; the controller holds a bus beside +/// its register state, so a monitor command or a snapshot reaches the +/// targets without the chip's lock. +pub struct ScsiBus { + identities: ScsiIdentities, + targets: Mutex, +} + +impl ScsiBus { + /// A bus of `ids` SCSI ids (8 narrow, 16 wide) with nothing on it. + pub fn new(ids: usize, identities: ScsiIdentities) -> Self { + Self { + identities, + targets: Mutex::new(std::iter::repeat_with(|| None).take(ids).collect()), + } + } + + /// Take the table, for a controller that has to hold it across a check + /// and a command. + pub fn lock(&self) -> MutexGuard<'_, Targets> { + self.targets.lock() + } + + /// Whether the table is held; for the lock registry. + pub fn is_locked(&self) -> bool { + self.targets.is_locked() + } + + /// Run `f` over the table under the lock. + pub fn with_targets(&self, f: impl FnOnce(&mut [Option>]) -> R) -> R { + f(&mut self.targets.lock()) + } + + /// Put `target` at `id`, replacing what was there. + pub fn attach(&self, id: u8, target: Box) -> io::Result<()> { + let mut targets = self.targets.lock(); + let slot = targets.get_mut(id as usize).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, format!("SCSI id {id} out of range")) + })?; + *slot = Some(target); + Ok(()) + } + + /// Attach what a `[scsi.N]` table describes: the image at `path` (empty + /// for a CD-ROM with its tray empty), a CD-ROM's changer list `discs`, + /// and copy-on-write when `overlay` is set, to `overlay_path` or the + /// default place ([`ScsiDevice::open`]). The bus gives it its INQUIRY + /// identity. Returns the size in 512-byte blocks. + pub fn attach_device( + &self, + id: u8, + path: &str, + cdrom: bool, + discs: Vec, + overlay: bool, + overlay_path: Option<&str>, + ) -> io::Result { + let identity = if cdrom { self.identities.cdrom } else { self.identities.disk }; + let dev = ScsiDevice::open(path, cdrom, discs, overlay, overlay_path, identity)?; + let blocks = dev.size() / 512; + self.attach(id, Box::new(dev))?; + Ok(blocks) + } + + /// A raw image as a disk or CD-ROM, for the controllers' tests. + #[cfg(test)] + pub fn attach_image(&self, id: u8, path: &std::path::Path, cdrom: bool) -> io::Result { + self.attach_device(id, path.to_str().unwrap(), cdrom, Vec::new(), false, None) + } + + /// Whether something answers at `id`. + pub fn has_target(&self, id: u8) -> bool { + self.targets.lock().get(id as usize).is_some_and(|t| t.is_some()) + } + + /// Run a command on the target at `id`; `None` when nothing answers. + pub fn request(&self, id: u8, req: &ScsiRequest) -> Option> { + self.targets.lock().get_mut(id as usize)?.as_mut().map(|t| t.request(req)) + } + + /// Run `f` on every target, with its id. + pub fn for_each(&self, mut f: impl FnMut(usize, &mut dyn ScsiTarget)) { + for (id, t) in self.targets.lock().iter_mut().enumerate() { + if let Some(t) = t { + f(id, &mut **t); + } + } + } + + /// Copy each target's copy-on-write overlay into `dir` as + /// `scsi.overlay`, and return the dirty sectors of each. + pub fn export_overlays(&self, dir: &std::path::Path) -> io::Result)>> { + let mut out = Vec::new(); + let mut err = None; + self.for_each(|id, t| { + if err.is_none() { + match t.cow_export(&dir.join(format!("scsi{id}.overlay"))) { + Ok(Some(dirty)) => out.push((id, dirty)), + Ok(None) => {} + Err(e) => err = Some(e), + } + } + }); + err.map_or(Ok(out), Err) + } + + /// Put back the overlays [`Self::export_overlays`] wrote. A target with + /// no entry in `dirty_sets` keeps its overlay. + pub fn import_overlays(&self, dir: &std::path::Path, dirty_sets: &[(usize, Vec)]) -> io::Result<()> { + let mut targets = self.targets.lock(); + for (id, dirty) in dirty_sets { + if let Some(t) = targets.get_mut(*id).and_then(|t| t.as_mut()) { + t.cow_import(&dir.join(format!("scsi{id}.overlay")), dirty.clone())?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scsi::scsi_cmd; + + const ID: ScsiIdentity = ScsiIdentity::new(b"TEST ", b"BUS ", b"1.0 "); + + fn bus(ids: usize) -> ScsiBus { + ScsiBus::new(ids, ScsiIdentities { disk: ID, cdrom: ID }) + } + + fn attach(b: &ScsiBus, id: u8, p: &std::path::Path, overlay: bool) -> io::Result { + b.attach_device(id, p.to_str().unwrap(), false, Vec::new(), overlay, None) + } + + fn image(tag: &str, blocks: usize) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("iris-scsibus-{tag}-{}", std::process::id())); + std::fs::write(&p, vec![0u8; blocks * 512]).unwrap(); + p + } + + fn inquiry() -> ScsiRequest { + ScsiRequest { + cdb: vec![scsi_cmd::INQUIRY, 0, 0, 0, 36, 0], + data_len: crate::scsi::ScsiDataLength::Unlimited, + data_in: None, + } + } + + #[test] + fn attach_query_and_answer_with_the_buses_identity() { + let b = bus(8); + let p = image("attach", 8); + assert!(!b.has_target(3)); + assert_eq!(attach(&b, 3, &p, false).unwrap(), 8); + assert!(b.has_target(3)); + let r = b.request(3, &inquiry()).expect("target answers").unwrap(); + assert_eq!(&r.data[8..16], b"TEST "); + assert!(b.request(2, &inquiry()).is_none(), "an empty id does not answer"); + std::fs::remove_file(p).unwrap(); + } + + #[test] + fn an_id_the_bus_does_not_have_is_refused() { + let b = bus(8); + let p = image("range", 1); + let e = attach(&b, 8, &p, false).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidInput); + assert!(attach(&bus(16), 8, &p, false).is_ok(), "a wide bus has id 8"); + std::fs::remove_file(p).unwrap(); + } + + #[test] + fn overlays_export_and_import_by_id() { + let b = bus(8); + let p = image("cow", 4); + attach(&b, 2, &p, true).unwrap(); + let dir = std::env::temp_dir().join(format!("iris-scsibus-ov-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let sets = b.export_overlays(&dir).unwrap(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].0, 2); + b.import_overlays(&dir, &sets).unwrap(); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_file(format!("{}.overlay", p.display())); + std::fs::remove_file(p).unwrap(); + } +} diff --git a/src/scsi_monitor.rs b/src/scsi_monitor.rs index abc85cb9..3e0fecf5 100644 --- a/src/scsi_monitor.rs +++ b/src/scsi_monitor.rs @@ -1,8 +1,9 @@ //! The monitor's `scsi` and `cow` commands, and the run-time disc changes //! behind them, over any SCSI controller's target table. //! -//! A controller implements [`ScsiTargets`] by lending its targets under its -//! own lock; the WD33C93A (Indy, Indigo2) and the Adaptec 7880 (O2) both do. +//! A controller implements [`ScsiTargets`] by handing over its [`ScsiBus`]; +//! the WD33C93A (Indy, Indigo2), the Adaptec 7880 (O2) and the ISP1040 +//! (Octane) all do. //! Everything that only needs the targets lives here once: `scsi status`, //! `scsi dayna`, the changer subcommands (`eject`, `add`, `list`, `del`, //! `next`), the whole of `cow`, and the [`crate::workstation::DiscChanger`] @@ -12,23 +13,28 @@ use std::io::Write; -use crate::scsi::ScsiDevice; +use crate::scsi::{ScsiBus, ScsiTarget}; -/// A SCSI controller's targets by id, `None` where nothing is attached. +/// A SCSI controller, for the targets on its bus. pub trait ScsiTargets { - /// Run `f` over the target table under the controller's lock. - fn with_targets(&self, f: impl FnOnce(&mut [Option]) -> R) -> R; + /// The bus whose targets the commands act on. + fn scsi_bus(&self) -> &ScsiBus; + + /// Run `f` over the target table, `None` where nothing is attached. + fn with_targets(&self, f: impl FnOnce(&mut [Option>]) -> R) -> R { + self.scsi_bus().with_targets(f) + } } /// The shared subcommands, for a chip's `help` text. pub const SCSI_USAGE: &str = "scsi status | scsi dayna | scsi eject | scsi add | scsi list | scsi del | scsi next "; /// Run `f` on the CD-ROM at `id`, or say why there is none. -fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut ScsiDevice) -> Result) -> Result { +fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut dyn ScsiTarget) -> Result) -> Result { ctl.with_targets(|targets| match targets.get_mut(id).and_then(|d| d.as_mut()) { None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) if !dev.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), - Some(dev) => f(dev), + Some(dev) if dev.discs().is_none() => Err(format!("SCSI ID {} is not a CD-ROM", id)), + Some(dev) => f(&mut **dev), }) } @@ -38,10 +44,9 @@ pub fn disc_status(ctl: &impl ScsiTargets) -> Vec<(usize, String, Vec, u ctl.with_targets(|targets| { targets.iter().enumerate() .filter_map(|(id, d)| { - let dev = d.as_ref()?; - if !dev.is_cdrom() { return None; } - let (phys, logical) = dev.block_sizes(); - Some((id, dev.current_disc().to_string(), dev.disc_list().to_vec(), phys, logical)) + let discs = d.as_ref()?.discs()?; + let (phys, logical) = discs.block_sizes; + Some((id, discs.current, discs.list, phys, logical)) }) .collect() }) @@ -99,24 +104,8 @@ pub fn scsi_subcommand(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) let lines = ctl.with_targets(|targets| { let mut lines = Vec::new(); for (id, dev) in targets.iter().enumerate() { - let Some(dev) = dev else { continue }; - if let Some(dp) = dev.daynaport() { - lines.push(dp.status_lines()[0].clone()); - } else if dev.is_cdrom() { - let (phys, logical) = dev.block_sizes(); - let list = dev.disc_list(); - let active = if dev.has_media() { dev.current_disc() } else { "(tray empty)" }; - lines.push(format!("SCSI ID {}: CD-ROM {} ({} disc(s)) phys_block={} logical_block={}", - id, active, list.len(), phys, logical)); - for (i, d) in list.iter().enumerate() { - let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; - lines.push(format!(" [{}] {}{}", i, d, tag)); - } - } else { - let (_, logical) = dev.block_sizes(); - let cow = if dev.is_cow() { ", copy-on-write" } else { "" }; - lines.push(format!("SCSI ID {}: disk {} ({} blocks of {}{})", - id, dev.current_disc(), dev.size() / logical.max(1), logical, cow)); + if let Some(dev) = dev { + lines.extend(dev.status(id)); } } lines @@ -132,8 +121,7 @@ pub fn scsi_subcommand(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) "dayna" => { let lines = ctl.with_targets(|targets| { targets.iter().flatten() - .filter_map(|dev| dev.daynaport().map(|dp| dp.status_lines())) - .flatten() + .flat_map(|dev| dev.adapter_status()) .collect::>() }); if lines.is_empty() { @@ -159,7 +147,7 @@ pub fn scsi_subcommand(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) Ok(()) }), "list" => id_arg(1, "Usage: scsi list ").map(|id| { - match cdrom(ctl, id, |dev| Ok(dev.disc_list().to_vec())) { + match cdrom(ctl, id, |dev| Ok(dev.discs().map(|d| d.list).unwrap_or_default())) { Ok(list) => { let _ = writeln!(w, "SCSI ID {} queue ({} disc(s)):", id, list.len()); write_queue(w, &list); @@ -199,8 +187,8 @@ pub fn cow_command(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) -> Some("status") => ctl.with_targets(|targets| { targets.iter().enumerate() .filter_map(|(id, d)| d.as_ref().map(|d| (id, d))) - .map(|(id, d)| if d.is_cow() { - format!("SCSI {}: COW overlay, {} dirty sectors", id, d.cow_dirty_count()) + .map(|(id, d)| if let Some(dirty) = d.cow_dirty() { + format!("SCSI {}: COW overlay, {} dirty sectors", id, dirty) } else { format!("SCSI {}: direct (no overlay)", id) }) @@ -209,7 +197,7 @@ pub fn cow_command(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) -> Some(op @ ("commit" | "reset")) => ctl.with_targets(|targets| { let ids: Vec = match explicit { Some(id) => vec![id], - None => (0..targets.len()).filter(|&i| targets[i].as_ref().is_some_and(|d| d.is_cow())).collect(), + None => (0..targets.len()).filter(|&i| targets[i].as_ref().is_some_and(|d| d.cow_dirty().is_some())).collect(), }; let mut lines = Vec::new(); for id in ids { From f7a4d1685bba7d8f8d11564ea80d3a4438af5375 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 02:13:34 -0700 Subject: [PATCH 02/11] ip32, ip30: attach SCSI targets with attach_from_config The O2 and the Octane each walked the [scsi.N] tables with the same loop: place the table, prepare a scratch volume, send a CI overlay to /tmp, attach, and either end the run or skip the target under IRIS_NO_EXIT_ON_POWEROFF. Both now give attach_from_config a route from controller number to bus and keep nothing else. The O2 takes its snapshot disk list from the same call. The set is the one it recorded before, disks without CD-ROMs, so existing manifests still match. The Octane has no snapshot manifest and records nothing. --- src/ip30_machine.rs | 40 ++++++-------------------- src/ip32_machine.rs | 68 +++++++++------------------------------------ 2 files changed, 21 insertions(+), 87 deletions(-) diff --git a/src/ip30_machine.rs b/src/ip30_machine.rs index f821075a..c4b0c133 100644 --- a/src/ip30_machine.rs +++ b/src/ip30_machine.rs @@ -380,39 +380,15 @@ impl OctaneMachine { /// The `[scsi.N]` devices: `controller` 0 is the internal bus, 1 the /// external one. Returns the `scratch = true` volume, if any. fn attach_scsi(cfg: &MachineConfig, ctls: &[Arc; 2]) -> Result, String> { - let mut scratch_path = None; - let mut ids: Vec = cfg.scsi.keys().copied().collect(); - ids.sort(); - for key in ids { - let dev = &cfg.scsi[&key]; - let (controller, id) = dev.placement(key); - let at = crate::config::scsi_label(controller, id); - if dev.is_daynaport() { - return Err(format!("{at}: kind = \"daynaport\" is not available on the Octane")); - } - if let Some(path) = dev.prepare_scratch(key) { - scratch_path.get_or_insert(path); - } - let ctl = ctls - .get(controller as usize) - .ok_or_else(|| format!("{at}: controller {controller} (an Octane has controllers 0 and 1)"))?; - let (path, discs) = dev.media(); - let cdrom = dev.is_cdrom(); - let ci_overlay = (cfg.ci && dev.overlay && !cdrom).then(|| crate::config::ScsiDeviceConfig::ci_overlay_path(key)); - match ctl.bus().attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { - Ok(_) if path.is_empty() => eprintln!("ip30: {at}: CD-ROM, tray empty"), - Ok(blocks) => eprintln!("ip30: {at}: {path} ({blocks} blocks)"), - Err(e) => { - let msg = format!("could not attach {path} to {at}: {e}"); - if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { - eprintln!("iris: warning: {msg}; continuing without {at}"); - } else { - return Err(msg); - } - } - } + let attached = crate::scsi::attach_from_config(cfg, Some("ip30"), &|c| { + ctls.get(c as usize) + .map(|ctl| ctl.bus()) + .ok_or_else(|| format!("controller {c} (an Octane has controllers 0 and 1)")) + })?; + if let Some(d) = attached.daynaports.first() { + return Err(format!("{}: kind = \"daynaport\" is not available on the Octane", crate::config::scsi_label(d.controller, d.id))); } - Ok(scratch_path) + Ok(attached.scratch) } /// IOC3's first port is the console: the CI socket's backend in CI diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index ad22c3d0..06227e8f 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -206,53 +206,18 @@ impl O2Machine { } // The SCSI devices, from the same `[scsi.N]` tables as the other // machines; `controller` is the bus (0 internal, 1 external). - let mut scratch_path: Option = None; - { + let attached = { let ctls = bus.scsi.lock().unwrap(); - let mut ids: Vec = cfg.scsi.keys().copied().collect(); - ids.sort(); - for key in ids { - let dev = &cfg.scsi[&key]; - let (controller, id) = dev.placement(key); - let at = crate::config::scsi_label(controller, id); - if dev.is_daynaport() { - return Err(format!("{at}: kind = \"daynaport\" is not available on the O2")); - } - if let Some(path) = dev.prepare_scratch(key) { - if scratch_path.is_some() { - println!("Note: multiple scratch SCSI devices configured; CI socket will use the lowest-id one"); - } else { - scratch_path = Some(path); - } - } - let ctl = ctls.get(controller as usize).ok_or_else(|| { - format!("{at}: controller {controller} (an O2 has controllers 0 and 1)") - })?; - let (path, discs) = dev.media(); - let cdrom = dev.is_cdrom(); - // CI mode keeps each copy-on-write overlay under /tmp, as the - // Indy does, so an interactive iris holding PATH.overlay and - // any number of CI runs can share the image. - let ci_overlay = (cfg.ci && dev.overlay && !cdrom).then(|| crate::config::ScsiDeviceConfig::ci_overlay_path(key)); - match ctl.bus().attach_device(id, &path, cdrom, discs, dev.overlay, ci_overlay.as_deref()) { - Ok(_) if path.is_empty() => eprintln!("ip32: {at}: CD-ROM, tray empty"), - Ok(blocks) => eprintln!("ip32: {at}: {path} ({blocks} blocks)"), - Err(e) => { - // As on the Indy: a configured disk that will not attach - // ends a command-line run, since booting on without it - // only yields a confusing PROM "no such device" later; - // an embedder (the GUI sets IRIS_NO_EXIT_ON_POWEROFF) - // boots without the device instead. - let msg = format!("could not attach {path} to {at}: {e}"); - if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { - eprintln!("iris: warning: {msg}; continuing without {at}"); - } else { - return Err(msg); - } - } - } - } + crate::scsi::attach_from_config(cfg, Some("ip32"), &|c| { + ctls.get(c as usize) + .map(|ctl| ctl.bus()) + .ok_or_else(|| format!("controller {c} (an O2 has controllers 0 and 1)")) + })? + }; + if let Some(d) = attached.daynaports.first() { + return Err(format!("{}: kind = \"daynaport\" is not available on the O2", crate::config::scsi_label(d.controller, d.id))); } + let scratch_path = attached.scratch; eprintln!( "iris: O2 with {} MB ({}), {}", kits.iter().sum::(), @@ -423,17 +388,10 @@ impl O2Machine { let (events, event_rx) = std::sync::mpsc::sync_channel(4); // A snapshot's disks by slot (controller * 16 + id) and size. - let mut disks: Vec = cfg - .scsi - .iter() - .filter(|(_, d)| !d.is_cdrom()) - .map(|(&key, d)| crate::snapshot::DiskRef { - id: { let (c, id) = d.placement(key); crate::config::scsi_slot(c, id) }, - path: d.path.clone(), - size_bytes: std::fs::metadata(&d.path).map(|m| m.len()).unwrap_or(0), - }) + let disks: Vec = attached.tables.iter() + .filter(|t| !t.is_cdrom) + .map(|t| crate::snapshot::DiskRef { id: t.slot, path: t.path.clone(), size_bytes: t.size_bytes }) .collect(); - disks.sort_by_key(|d| d.id); Ok(Self { bus, cpu, From c90e8440ae17c4ee308bb2f3ef875193475244a9 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 02:13:34 -0700 Subject: [PATCH 03/11] machine, hpc3: attach the Indy and Indigo2 SCSI targets with attach_from_config The Indy's loop was a third copy of the O2's and Octane's, with the same overlay, scratch and error handling and an extra DaynaPort branch. It now routes controller numbers through Hpc3::scsi_bus and hands the DaynaPort construction, which stays where the gateway is built, to attach_from_config as a closure. Hpc3's add_scsi_device and its overlay variant, which only forwarded to the chip, are gone. Controller 1 on a machine without it is now a reported error naming the slot instead of a panic. The snapshot disk list is the same set as before, CD-ROMs included, so Indy and Indigo2 manifests still match. --- src/dev/hpc3.rs | 18 ++++--- src/machine.rs | 125 +++++++++++++----------------------------------- 2 files changed, 40 insertions(+), 103 deletions(-) diff --git a/src/dev/hpc3.rs b/src/dev/hpc3.rs index 4cce7f6e..0d245a65 100644 --- a/src/dev/hpc3.rs +++ b/src/dev/hpc3.rs @@ -1270,8 +1270,14 @@ impl Hpc3 { } } - pub fn add_scsi_device(&self, controller: u8, id: usize, path: &str, is_cdrom: bool, discs: Vec, overlay: bool) -> std::io::Result<()> { - self.scsi_controller(controller).bus().attach_device(id as u8, path, is_cdrom, discs, overlay, None).map(|_| ()) + /// The SCSI bus on `controller`: 0 on both machines, 1 on the Indigo2's + /// second WD33C93A only. + pub fn scsi_bus(&self, controller: u8) -> Result<&crate::scsi::ScsiBus, String> { + match controller { + 0 => Ok(&**self.scsi_dev.bus()), + 1 => self.scsi_dev1.as_ref().map(|d| &**d.bus()).ok_or_else(|| "controller 1 (this machine has only controller 0)".to_string()), + c => Err(format!("controller {c} (valid: 0, 1)")), + } } /// Attach a DaynaPort SCSI/Link (SCSI-attached Ethernet) at `id`. @@ -1296,14 +1302,6 @@ impl Hpc3 { self.scsi_controller(controller).add_daynaport(id, params.mac, gateway) } - /// Same as `add_scsi_device` but lets the caller specify where the COW - /// overlay file lives. Used by `--ci` mode to keep per-process overlays - /// in `/tmp` so parallel `--ci` instances (and an interactive session) - /// don't race on the same file. - pub fn add_scsi_device_with_overlay(&self, controller: u8, id: usize, path: &str, is_cdrom: bool, discs: Vec, overlay: bool, overlay_path: &str) -> std::io::Result<()> { - self.scsi_controller(controller).bus().attach_device(id as u8, path, is_cdrom, discs, overlay, Some(overlay_path)).map(|_| ()) - } - pub fn ioc(&self) -> &Ioc { &self.ioc } diff --git a/src/machine.rs b/src/machine.rs index f60a38ee..048e6db9 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -463,104 +463,43 @@ impl Machine { } // Attach SCSI devices from config: each `[scsi.KEY]` table puts a - // target on a controller (`ScsiDeviceConfig::placement`). - let mut scsi_keys: Vec = cfg.scsi.keys().copied().collect(); - scsi_keys.sort(); - // Track the on-disk path of any scratch device so the CI socket can - // read/write its bytes directly (Phase 2.4). - let mut scratch_path: Option = None; - for key in scsi_keys { - let dev = &cfg.scsi[&key]; - let (controller, id) = dev.placement(key); - let label = crate::config::scsi_label(controller, id); - // DaynaPort SCSI/Link: a network adapter, not storage. None of the - // scratch / changer / overlay handling below applies — it has no - // image at all. - if dev.is_daynaport() { - let params = match dev.daynaport_params(key) { - Ok(p) => p, - Err(e) => { - eprintln!("iris: fatal: {}: {}", label, e); - std::process::exit(1); + // target on a controller (`ScsiDeviceConfig::placement`). The disks + // recorded here, and the scratch volume's path for the CI socket, + // come back with it. Disk provenance for snapshot manifests is + // captured while the MachineConfig `cfg` is still in scope (it is + // shadowed by the CPU config below): the slot (controller * 16 + id, + // see `config::scsi_slot`), the configured path and the host file + // size. + let attached = { + // Standalone CLI fails loudly and exits; see `attach_from_config`. + let attach = || -> Result { + let a = crate::scsi::attach_from_config(&cfg, None, &|c| hpc3.scsi_bus(c))?; + // A DaynaPort is built where the gateway is, by Hpc3. + for d in &a.daynaports { + let at = crate::config::scsi_label(d.controller, d.id); + match hpc3.add_scsi_daynaport(d.controller, d.id as usize, d.params) { + Ok(()) => println!( + "iris: DaynaPort SCSI/Link at {at} — MAC {}, gateway {} (guest {}/{})", + crate::net::mac_str(&d.params.mac), + d.params.subnet.gateway_ip, d.params.subnet.client_ip, d.params.subnet.netmask), + Err(e) => crate::scsi::attach_failed(format!("could not attach DaynaPort to {at}: {e}"), &at)?, } - }; - match hpc3.add_scsi_daynaport(controller, id as usize, params) { - Ok(()) => println!( - "iris: DaynaPort SCSI/Link at {} — MAC {}, gateway {} (guest {}/{})", - label, crate::net::mac_str(¶ms.mac), - params.subnet.gateway_ip, params.subnet.client_ip, params.subnet.netmask), - Err(e) => { - let msg = format!("could not attach DaynaPort to {label}: {e}"); - if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { - eprintln!("iris: warning: {msg}; continuing without {label}"); - } else { - eprintln!("iris: fatal: {msg}"); - std::process::exit(1); - } - } - } - continue; - } - // Scratch volume (see `ScsiDeviceConfig::prepare_scratch`): the - // file gets an SGI volume header, without which IRIX returns I/O - // errors on every read of /dev/rdsk/dks0dNvh and dks0dNvol. - // Convention: the host writes its payload at offset >= - // SCRATCH_PAYLOAD_OFFSET (4096); the guest reads from offset 0 of - // the vol partition, which starts at sector 8. - if let Some(path) = dev.prepare_scratch(key) { - if scratch_path.is_some() { - println!("Note: multiple scratch SCSI devices configured; CI socket will use the lowest-id one"); - } else { - scratch_path = Some(path); } - } - let (path, discs) = dev.media(); - let cdrom = dev.is_cdrom(); - // CI mode: isolate each COW overlay under /tmp so an interactive - // iris holding {base}.overlay can coexist with any number of `--ci` - // processes; cleanup happens on machine drop below. - let result = if ci_enabled && dev.overlay && !cdrom { - let ci_overlay = ScsiDeviceConfig::ci_overlay_path(key); - hpc3.add_scsi_device_with_overlay(controller, id as usize, &path, cdrom, discs, dev.overlay, &ci_overlay) - } else { - hpc3.add_scsi_device(controller, id as usize, &path, cdrom, discs, dev.overlay) + Ok(a) }; - if let Err(e) = result { - // A configured disk that won't attach (missing/corrupt image, - // or a file the macOS sandbox denies) can't host this device. - // - // Standalone CLI fails loudly and exits — booting on with a - // silently-missing device only yields a confusing PROM "no such - // device" later. But an embedder (the GUI sets - // IRIS_NO_EXIT_ON_POWEROFF) must never be torn down by the - // library: process::exit kills it outright, and panicking can't - // be caught either because release builds use panic="abort" - // (unwinding across the libchdman/JIT FFI would be UB). So we - // skip just this device and boot without it; the GUI's Start - // preflight already warns the user about an unreadable disk, and - // they can stop, re-pick or detach it, and start again. - let msg = format!("could not attach {path} to {label}: {e}"); - if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { - eprintln!("iris: warning: {msg}; continuing without {label}"); - continue; + match attach() { + Ok(a) => a, + Err(e) => { + eprintln!("iris: fatal: {e}"); + std::process::exit(1); } - eprintln!("iris: fatal: {msg}"); - std::process::exit(1); } - } - - // Disk + nvram provenance for snapshot manifests. Captured here while - // the MachineConfig `cfg` is still in scope (it is shadowed by the CPU - // config below). Identity is the slot (controller * 16 + id, see - // `config::scsi_slot`), the configured path and the host file size. - let mut disk_provenance: Vec = cfg.scsi.iter() - .filter(|(_, dev)| !dev.is_daynaport()) // no image behind a DaynaPort - .map(|(&key, dev)| { - let (controller, id) = dev.placement(key); - let size_bytes = std::fs::metadata(&dev.path).map(|m| m.len()).unwrap_or(0); - DiskRef { id: crate::config::scsi_slot(controller, id), path: dev.path.clone(), size_bytes } - }).collect(); - disk_provenance.sort_by_key(|d| d.id); + }; + let scratch_path = attached.scratch; + // The Indy and Indigo2 have always listed CD-ROMs beside the disks. + let disk_provenance: Vec = attached.tables.iter() + .map(|t| DiskRef { id: t.slot, path: t.path.clone(), size_bytes: t.size_bytes }) + .collect(); let nvram_provenance = cfg.nvram.clone(); // REX3 Graphics — Newport only; skipped in headless mode or when non-Newport board selected. From df41b08ded934d92f63de45c854c00878c5a2a26 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 03:11:25 -0700 Subject: [PATCH 04/11] scsi: one disk identity and one emulated CD-ROM identity for every machine The O2 and the Octane answered INQUIRY as "IRIS EMULATED" for both disks and CD-ROMs, the Indy as "IRIS EMUL DISK", and each controller defined its own constant. Every machine now reports the Indy's disk identity from scsi.rs, and every CD-ROM except the Indy's reports a matching "IRIS EMUL CDROM". The Indy and Indigo2 keep their Sony CDU-76S. The disk and CD-ROM model strings on the O2 and Octane change, which a guest can see in INQUIRY. --- src/dev/aic7880.rs | 7 ++----- src/dev/isp1040.rs | 9 ++------- src/dev/wd33c93a.rs | 5 ++--- src/scsi.rs | 7 +++++++ 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/dev/aic7880.rs b/src/dev/aic7880.rs index dd758b17..8db97cdc 100644 --- a/src/dev/aic7880.rs +++ b/src/dev/aic7880.rs @@ -26,7 +26,7 @@ //! because one boot can hand the chip from one driver to the next. use crate::locks::{SpinGuard, SpinLock}; -use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiIdentity, ScsiRequest}; +use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiRequest, CDROM_IDENTITY, DISK_IDENTITY}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -173,9 +173,6 @@ pub const SG_LAST: u32 = 0x8000_0000; /// in any healthy list. pub const SG_MAX: usize = 4096; -/// What the O2's targets report in INQUIRY, disks and CD-ROMs alike. -pub const O2_DISK: ScsiIdentity = ScsiIdentity::new(b"SGI ", b"IRIS EMULATED ", b"1.0 "); -pub const O2_CDROM: ScsiIdentity = O2_DISK; /// NetBSD's sequencer program uses a different arrangement from the PROM's, /// and these names come from its own headers rather than from guessing. @@ -467,7 +464,7 @@ impl Aic7880 { worker: Mutex::new(None), running: AtomicBool::new(false), me: me.clone(), - bus: ScsiBus::new(16, ScsiIdentities { disk: O2_DISK, cdrom: O2_CDROM }), + bus: ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }), dma: std::sync::OnceLock::new(), sense: Mutex::new(None), dma_misses: Mutex::new(std::collections::BTreeSet::new()), diff --git a/src/dev/isp1040.rs b/src/dev/isp1040.rs index 466f9613..1e26b612 100644 --- a/src/dev/isp1040.rs +++ b/src/dev/isp1040.rs @@ -47,7 +47,7 @@ use parking_lot::{Condvar, Mutex, MutexGuard}; use crate::devlog::LogModule; use crate::irq::IrqLine; use crate::pci::{Bar, PciBusDevice, PciConfig, PciDma, PCI_INTA}; -use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiIdentity, ScsiRequest}; +use crate::scsi::{scsi_cmd, ScsiBus, ScsiDataLength, ScsiIdentities, ScsiRequest, CDROM_IDENTITY, DISK_IDENTITY}; use crate::snapshot::{get_field, hex_u16, hex_u32, load_u16_slice, toml_u16, toml_u32, toml_u8, u16_slice_to_toml, u8_slice_to_toml}; pub const PCI_VENDOR_QLOGIC: u16 = 0x1077; @@ -56,11 +56,6 @@ pub const PCI_REVISION_ISP1040: u8 = 0x05; /// Mass storage controller, SCSI. pub const PCI_CLASS_SCSI: u32 = 0x0001_0000; -/// What a disk or CD-ROM attached here answers INQUIRY with (the same -/// identity the O2's controller gives its targets). -pub const ISP_DISK: ScsiIdentity = ScsiIdentity::new(b"SGI ", b"IRIS EMULATED ", b"1.0 "); -pub const ISP_CDROM: ScsiIdentity = ISP_DISK; - pub const BLOCK_SIZE: usize = 512; /// Register offsets. @@ -515,7 +510,7 @@ impl Core { fence: Mutex::new(()), wake: Condvar::new(), line_order: Mutex::new(()), - bus: ScsiBus::new(16, ScsiIdentities { disk: ISP_DISK, cdrom: ISP_CDROM }), + bus: ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }), dma: OnceLock::new(), irq: OnceLock::new(), index, diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index 985e2325..0fef5b1f 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -302,7 +302,6 @@ unsafe impl Sync for Wd33c93a {} /// CDU-76S the Indy shipped with: IRIX's mediad uses the vendor and product /// strings for feature detection (volume control, eject). pub const INDY_CDROM: crate::scsi::ScsiIdentity = crate::scsi::ScsiIdentity::new(b"Sony ", b"CDU-76S ", b"1.0 "); -pub const INDY_DISK: crate::scsi::ScsiIdentity = crate::scsi::ScsiIdentity::new(b"SGI ", b"IRIS EMUL DISK ", b"1.0 "); pub struct Wd33c93a { /// The targets, also held by the state the worker thread runs on. @@ -341,7 +340,7 @@ impl Wd33c93a { /// HPC3 SCSI controller number) — see `Wd33c93aState.id`'s doc comment. pub fn new_with_id(dma: Option>, irq: Option, heartbeat: Arc, deferred_int: bool, id: u8) -> Self { let deferred_int_arc = Arc::new(AtomicBool::new(deferred_int)); - let bus = Arc::new(ScsiBus::new(8, ScsiIdentities { disk: INDY_DISK, cdrom: INDY_CDROM })); + let bus = Arc::new(ScsiBus::new(8, ScsiIdentities { disk: scsi::DISK_IDENTITY, cdrom: INDY_CDROM })); Self { bus: bus.clone(), state: Arc::new(Mutex::new(Wd33c93aState { @@ -2180,7 +2179,7 @@ mod data_out_phase_tests { let mut s = dev.state.lock(); s.bus.attach(2, Box::new(scsi::ScsiDevice::new( Box::new(crate::block_node::RawNode::from_file(file, 1 << 20)), - 1 << 20, false, path.display().to_string(), Vec::new(), INDY_DISK))).unwrap(); + 1 << 20, false, path.display().to_string(), Vec::new(), scsi::DISK_IDENTITY))).unwrap(); s.advanced_mode = true; s.target_id = 2; s.regs[regs::DESTINATION_ID as usize] = 2; diff --git a/src/scsi.rs b/src/scsi.rs index 3af8aa2d..147dfe24 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -104,6 +104,13 @@ pub struct ScsiIdentity { pub revision: [u8; 4], } +/// What every machine's emulated disk reports in INQUIRY. +pub const DISK_IDENTITY: ScsiIdentity = ScsiIdentity::new(b"SGI ", b"IRIS EMUL DISK ", b"1.0 "); + +/// What an emulated CD-ROM reports, where the machine does not name a real +/// drive (the Indy and Indigo2 do: a Sony CDU-76S). +pub const CDROM_IDENTITY: ScsiIdentity = ScsiIdentity::new(b"SGI ", b"IRIS EMUL CDROM ", b"1.0 "); + impl ScsiIdentity { pub const fn new(vendor: &[u8; 8], product: &[u8; 16], revision: &[u8; 4]) -> Self { Self { vendor: *vendor, product: *product, revision: *revision } From 21b74871cdbacab58c9281a555c9a2fb8148038d Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 03:41:07 -0700 Subject: [PATCH 05/11] scsi: move ScsiDevice's target behaviour into its trait impl impl ScsiTarget for ScsiDevice forwarded a score of methods to inherent methods of the same name, so every signature existed twice. The bodies now live in the trait impl and the inherent copies are gone. attach_from_config also drops its DaynaPort callback and the three-state provenance enum. The Indy and Indigo2 attach their DaynaPorts themselves, where Hpc3 builds the gateway, and one flag says whether the snapshot manifest lists CD-ROMs, which only those two machines have always done. Manifests are unchanged. --- src/scsi.rs | 671 +++++++++++++++++++++++++----------------------- src/scsi/bus.rs | 63 +---- 2 files changed, 358 insertions(+), 376 deletions(-) diff --git a/src/scsi.rs b/src/scsi.rs index 147dfe24..1da204a9 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -4,7 +4,7 @@ mod attach; mod bus; pub use attach::{attach_failed, attach_from_config, Attached, DaynaportRef, TableRef}; -pub use bus::{ScsiBus, ScsiIdentities, ScsiTarget, Targets}; +pub use bus::{Discs, ScsiBus, ScsiIdentities, ScsiTarget, Targets}; use crate::block_node::{BlockNode, ChdCdNode, ChdNode, CowNode, RawNode}; @@ -269,55 +269,6 @@ impl ScsiDevice { Ok(()) } - /// Unload media from a CD-ROM (tray empty). - pub fn unload_media(&mut self) { - self.backend = None; - self.size = 0; - self.filename = String::new(); - self.unit_attention = true; - } - - /// Commit the divergence layer into the base image ("apply the changes"). - /// Raw: copies the dirty sectors in place; CHD: rebuilds the base from the - /// diff (recompressing) and reopens a fresh overlay. No-op if not overlaid. - /// Returns a coarse count of what was committed. The concrete behaviour is - /// [`BlockNode::commit`] — including the CHD handle close/reopen. - pub fn cow_commit(&mut self) -> io::Result { - match self.backend.as_mut() { - Some(node) => node.commit(), - None => Ok(0), - } - } - - /// Reset the divergence layer — discard all uncommitted writes ("roll - /// back"). Raw: truncates the overlay; CHD: deletes the `.diff.chd` and - /// reopens a fresh overlay over the untouched base. No-op if not overlaid. - /// The concrete behaviour is [`BlockNode::rollback`]. - pub fn cow_reset(&mut self) -> io::Result<()> { - match self.backend.as_mut() { - Some(node) => node.rollback(), - None => Ok(()), - } - } - - /// Copy the COW overlay into `dest` and return its dirty sector set. - /// Direct-mode / no-media devices return an empty list and create no file. - pub fn cow_export(&mut self, dest: &std::path::Path) -> io::Result> { - match self.backend.as_mut() { - Some(node) => node.export_overlay(dest), - None => Ok(Vec::new()), - } - } - - /// Replace the COW overlay with the contents of `source` and adopt - /// `dirty` as the dirty sector set. No-op on non-COW / no-media devices. - pub fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { - match self.backend.as_mut() { - Some(node) => node.import_overlay(source, dirty), - None => Ok(()), - } - } - /// Size of the device's uncommitted divergence, via [`BlockNode::dirty_count`]: /// exact dirty sectors for a raw overlay, a coarse 1/0 for a CHD diff. 0 if /// direct / no media. @@ -331,68 +282,6 @@ impl ScsiDevice { self.backend.as_ref().is_some_and(|node| node.is_cow()) } - /// `(base, diff)` paths if this device is a CHD writing to a `.diff.chd` - /// sidecar that holds changes worth folding back into the base on a clean - /// shutdown. `None` for in-place / non-CHD / no-media devices. - pub fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { - self.backend.as_ref().and_then(|node| node.pending_sync()) - } - - /// Take the pending-sync paths AND release the disk backend, closing the CHD - /// so the base can be atomically rebuilt by `chd_disk::flatten_diff`. Returns - /// `None` and leaves the backend in place when there's nothing to sync. - pub fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { - let pending = self.pending_chd_sync(); - if pending.is_some() { - self.backend = None; - } - pending - } - - /// 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; - } - // 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. - let current = self.discs.remove(0); - self.discs.push(current); - let next_path = self.discs[0].clone(); - - match RawNode::open_readonly(std::path::Path::new(&next_path)) { - Ok(node) => { - let size = node.size(); - self.backend = Some(Box::new(node)); - self.size = size; - // phys_block_size never changes — CD-ROM physical sectors are always 2048. - // Do NOT reset logical_block_size — MODE SELECT is a controller setting - // 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) => { - eprintln!("SCSI eject: could not open {}: {}", next_path, e); - None - } - } - } - pub fn is_cdrom(&self) -> bool { matches!(self.kind, DeviceKind::Cdrom) } /// Current active disc path (for display / status). @@ -405,105 +294,6 @@ impl ScsiDevice { &self.discs } - /// Load an explicit image path and make it the active disc immediately, - /// as if a hand swapped the disc in the drive. The path is inserted at the - /// front of the changer list (becomes index 0) and a medium-change is - /// signalled (unit attention) so the guest re-reads the TOC on its next - /// command. The image is opened as a raw ISO (`Direct` backend), matching - /// the changer's eject path. Err if this is not a CD-ROM or the file can't - /// be opened. - /// - /// Queue management by disc count: - /// - 0 discs (empty tray): the new disc becomes the sole entry. - /// - 1 disc: the existing disc is kept in the queue (pushed to index 1) - /// and the new disc becomes active at index 0, growing the queue to 2 - /// so that eject cycles rather than emptying the tray. - /// - 2+ discs: the new disc is placed at index 0 (active); if it was - /// already queued it is moved to the front rather than duplicated. - pub fn load_disc(&mut self, path: String) -> Result { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - let node = RawNode::open_readonly(std::path::Path::new(&path)) - .map_err(|e| format!("could not open {}: {}", path, e))?; - let size = node.size(); - self.backend = Some(Box::new(node)); - self.size = size; - self.filename = 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) - } - - /// Insert a new disc path at position 1 (next after current). - /// Returns Err if this is not a CD-ROM or the path doesn't exist. - pub fn add_disc(&mut self, path: String) -> Result<(), String> { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if !std::path::Path::new(&path).exists() { - return Err(format!("File not found: {}", path)); - } - // Insert at index 1 so it becomes the next disc after eject. - let insert_pos = if self.discs.is_empty() { 0 } else { 1 }; - self.discs.insert(insert_pos, path); - Ok(()) - } - - /// Remove disc at the given ordinal index. - /// Removing index 0 (current) does not eject — it only removes the path. - /// Returns Err if index is out of range. - pub fn remove_disc(&mut self, ordinal: usize) -> Result { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if ordinal >= self.discs.len() { - return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); - } - Ok(self.discs.remove(ordinal)) - } - - /// Move disc at `ordinal` to index 1 (next after current). - /// If ordinal is 0 (active), returns Err. - pub fn move_disc_next(&mut self, ordinal: usize) -> Result<(), String> { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if ordinal == 0 { - return Err("Ordinal 0 is the active disc; use 'scsi eject' to advance".to_string()); - } - if ordinal >= self.discs.len() { - return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); - } - if self.discs.len() < 2 { - return Ok(()); // already only entry - } - let path = self.discs.remove(ordinal); - // Insert at position 1 (right after the active disc at 0). - self.discs.insert(1, path); - Ok(()) - } - /// (physical_block_size, logical_block_size) /// Capacity of the loaded medium, in bytes (0 with none). pub fn size(&self) -> u64 { @@ -541,113 +331,46 @@ impl ScsiDevice { ScsiResponse { status: 0x02, data: vec![] } } - pub fn request(&mut self, req: &ScsiRequest) -> Result { - if req.cdb.is_empty() { - return Ok(self.check_condition(0x05, 0x20, 0x00)); // Illegal Request: Invalid command + fn exec_test_unit_ready(&mut self, _cdb: &[u8]) -> Result { + if self.unit_attention { + self.unit_attention = false; + // Sense key 0x06 UNIT_ATTENTION, ASC 0x28 "Not Ready to Ready Transition / Medium Changed" + return Ok(self.check_condition(0x06, 0x28, 0x00)); + } + if self.backend.is_none() { + // Sense key 0x02 NOT_READY, ASC 0x3A "Medium not present" + return Ok(self.check_condition(0x02, 0x3A, 0x00)); } + Ok(ScsiResponse { + status: 0x00, + data: vec![], + }) + } - let lun = (req.cdb[1] >> 5) & 0x7; + fn exec_request_sense(&mut self, cdb: &[u8]) -> Result { + let alloc_len = request_sense_alloc_len(cdb); + // Return stored sense data and clear it + let sense = self.pending_sense; + self.pending_sense = [0u8; 18]; + self.pending_sense[0] = 0x70; // Leave valid response code for next time + let data = sense[..alloc_len.min(sense.len())].to_vec(); + Ok(ScsiResponse { status: 0x00, data }) + } - // For most commands, only LUN 0 is valid - if lun != 0 && req.cdb[0] != scsi_cmd::INQUIRY && req.cdb[0] != scsi_cmd::REQUEST_SENSE { - return Ok(self.check_condition(0x05, 0x25, 0x00)); // Illegal Request: LUN Not Supported - } + fn exec_inquiry(&self, cdb: &[u8]) -> Result { + let alloc_len = cdb[4] as usize; + let mut data = vec![0u8; 36]; + let lun = (cdb[1] >> 5) & 0x7; - // Unit attention fires before any other command (except REQUEST_SENSE/INQUIRY). - if self.unit_attention - && req.cdb[0] != scsi_cmd::REQUEST_SENSE - && req.cdb[0] != scsi_cmd::INQUIRY - { - self.unit_attention = false; - return Ok(self.check_condition(0x06, 0x28, 0x00)); - } - - let mut response = match req.cdb[0] { - scsi_cmd::TEST_UNIT_READY => self.exec_test_unit_ready(&req.cdb)?, - scsi_cmd::REQUEST_SENSE => self.exec_request_sense(&req.cdb)?, - scsi_cmd::INQUIRY => self.exec_inquiry(&req.cdb)?, - scsi_cmd::READ_CAPACITY_10 => self.exec_read_capacity_10(&req.cdb)?, - scsi_cmd::READ_6 => self.exec_read_6(&req.cdb)?, - scsi_cmd::READ_10 => self.exec_read_10(&req.cdb)?, - scsi_cmd::WRITE_6 => self.exec_write_6(&req.cdb, req.data_in.as_ref())?, - scsi_cmd::WRITE_10 => self.exec_write_10(&req.cdb, req.data_in.as_ref())?, - scsi_cmd::START_STOP_UNIT => self.exec_start_stop_unit(&req.cdb)?, - scsi_cmd::MODE_SENSE_6 => self.exec_mode_sense_6(&req.cdb)?, - scsi_cmd::MODE_SENSE_10 => self.exec_mode_sense_10(&req.cdb)?, - scsi_cmd::WRITE_BUFFER => self.exec_write_buffer(&req.cdb, req.data_in.as_ref())?, - scsi_cmd::READ_BUFFER => self.exec_read_buffer(&req.cdb)?, - scsi_cmd::SEND_DIAGNOSTIC => self.exec_send_diagnostic(&req.cdb)?, - scsi_cmd::PREVENT_ALLOW_MEDIUM_REMOVAL => ScsiResponse { status: 0x00, data: vec![] }, - // No write-back cache to flush — our backend writes are already synchronous. - scsi_cmd::SYNCHRONIZE_CACHE_10 => ScsiResponse { status: 0x00, data: vec![] }, - scsi_cmd::MODE_SELECT_6 => self.exec_mode_select_6(&req.cdb, req.data_in.as_ref())?, - scsi_cmd::MODE_SELECT_10 => self.exec_mode_select_10(&req.cdb, req.data_in.as_ref())?, - // Nothing to do: one initiator, no medium to check, no speed to - // set. PROMs send RESERVE, RELEASE and VERIFY while probing, and - // a CD driver SET CD SPEED on open. - scsi_cmd::RESERVE_6 | scsi_cmd::RELEASE_6 | scsi_cmd::VERIFY_10 | scsi_cmd::SET_CD_SPEED => { - ScsiResponse { status: 0x00, data: vec![] } - } - scsi_cmd::READ_TOC_PMA_ATIP => self.exec_read_toc_pma_atip(&req.cdb)?, - scsi_cmd::GET_CONFIGURATION => self.exec_get_configuration(&req.cdb)?, - scsi_cmd::SGI_EJECT => self.exec_sgi_eject(&req.cdb)?, - scsi_cmd::SGI_HD2CDROM => self.exec_sgi_hd2cdrom(&req.cdb)?, - _ => { - eprintln!("SCSI: Unimplemented command {:02x} cdb={:02x?}", req.cdb[0], &req.cdb); - self.check_condition(0x05, 0x20, 0x00) // Illegal Request: Invalid Command Operation Code - } - }; - - // Handle data_len parameter - if let ScsiDataLength::Fixed(max_len) = req.data_len { - if response.data.len() > max_len { - response.data.truncate(max_len); - } - } - - Ok(response) - } - - fn exec_test_unit_ready(&mut self, _cdb: &[u8]) -> Result { - if self.unit_attention { - self.unit_attention = false; - // Sense key 0x06 UNIT_ATTENTION, ASC 0x28 "Not Ready to Ready Transition / Medium Changed" - return Ok(self.check_condition(0x06, 0x28, 0x00)); - } - if self.backend.is_none() { - // Sense key 0x02 NOT_READY, ASC 0x3A "Medium not present" - return Ok(self.check_condition(0x02, 0x3A, 0x00)); - } - Ok(ScsiResponse { - status: 0x00, - data: vec![], - }) - } - - fn exec_request_sense(&mut self, cdb: &[u8]) -> Result { - let alloc_len = request_sense_alloc_len(cdb); - // Return stored sense data and clear it - let sense = self.pending_sense; - self.pending_sense = [0u8; 18]; - self.pending_sense[0] = 0x70; // Leave valid response code for next time - let data = sense[..alloc_len.min(sense.len())].to_vec(); - Ok(ScsiResponse { status: 0x00, data }) - } - - fn exec_inquiry(&self, cdb: &[u8]) -> Result { - let alloc_len = cdb[4] as usize; - let mut data = vec![0u8; 36]; - let lun = (cdb[1] >> 5) & 0x7; - - if lun == 0 { - data[0] = if self.is_cdrom() { 0x05 } else { 0x00 }; - data[1] = if self.is_cdrom() { 0x80 } else { 0x00 }; // RMB (Removable) - data[2] = 0x02; // ANSI SCSI-2 - data[3] = 0x02; // SCSI-2 response format - data[4] = 31; // Additional length (36 - 5) - data[8..16].copy_from_slice(&self.identity.vendor); - data[16..32].copy_from_slice(&self.identity.product); - data[32..36].copy_from_slice(&self.identity.revision); + if lun == 0 { + data[0] = if self.is_cdrom() { 0x05 } else { 0x00 }; + data[1] = if self.is_cdrom() { 0x80 } else { 0x00 }; // RMB (Removable) + data[2] = 0x02; // ANSI SCSI-2 + data[3] = 0x02; // SCSI-2 response format + data[4] = 31; // Additional length (36 - 5) + data[8..16].copy_from_slice(&self.identity.vendor); + data[16..32].copy_from_slice(&self.identity.product); + data[32..36].copy_from_slice(&self.identity.revision); /*println!("SCSI: INQUIRY LUN={} AllcLen={} Type={:02x} Vendor={} Product={} Rev={}", lun, alloc_len, data[0], @@ -1295,6 +1018,324 @@ impl ScsiDevice { } } +impl ScsiTarget for ScsiDevice { + fn request(&mut self, req: &ScsiRequest) -> Result { + if req.cdb.is_empty() { + return Ok(self.check_condition(0x05, 0x20, 0x00)); // Illegal Request: Invalid command + } + + let lun = (req.cdb[1] >> 5) & 0x7; + + // For most commands, only LUN 0 is valid + if lun != 0 && req.cdb[0] != scsi_cmd::INQUIRY && req.cdb[0] != scsi_cmd::REQUEST_SENSE { + return Ok(self.check_condition(0x05, 0x25, 0x00)); // Illegal Request: LUN Not Supported + } + + // Unit attention fires before any other command (except REQUEST_SENSE/INQUIRY). + if self.unit_attention + && req.cdb[0] != scsi_cmd::REQUEST_SENSE + && req.cdb[0] != scsi_cmd::INQUIRY + { + self.unit_attention = false; + return Ok(self.check_condition(0x06, 0x28, 0x00)); + } + + let mut response = match req.cdb[0] { + scsi_cmd::TEST_UNIT_READY => self.exec_test_unit_ready(&req.cdb)?, + scsi_cmd::REQUEST_SENSE => self.exec_request_sense(&req.cdb)?, + scsi_cmd::INQUIRY => self.exec_inquiry(&req.cdb)?, + scsi_cmd::READ_CAPACITY_10 => self.exec_read_capacity_10(&req.cdb)?, + scsi_cmd::READ_6 => self.exec_read_6(&req.cdb)?, + scsi_cmd::READ_10 => self.exec_read_10(&req.cdb)?, + scsi_cmd::WRITE_6 => self.exec_write_6(&req.cdb, req.data_in.as_ref())?, + scsi_cmd::WRITE_10 => self.exec_write_10(&req.cdb, req.data_in.as_ref())?, + scsi_cmd::START_STOP_UNIT => self.exec_start_stop_unit(&req.cdb)?, + scsi_cmd::MODE_SENSE_6 => self.exec_mode_sense_6(&req.cdb)?, + scsi_cmd::MODE_SENSE_10 => self.exec_mode_sense_10(&req.cdb)?, + scsi_cmd::WRITE_BUFFER => self.exec_write_buffer(&req.cdb, req.data_in.as_ref())?, + scsi_cmd::READ_BUFFER => self.exec_read_buffer(&req.cdb)?, + scsi_cmd::SEND_DIAGNOSTIC => self.exec_send_diagnostic(&req.cdb)?, + scsi_cmd::PREVENT_ALLOW_MEDIUM_REMOVAL => ScsiResponse { status: 0x00, data: vec![] }, + // No write-back cache to flush — our backend writes are already synchronous. + scsi_cmd::SYNCHRONIZE_CACHE_10 => ScsiResponse { status: 0x00, data: vec![] }, + scsi_cmd::MODE_SELECT_6 => self.exec_mode_select_6(&req.cdb, req.data_in.as_ref())?, + scsi_cmd::MODE_SELECT_10 => self.exec_mode_select_10(&req.cdb, req.data_in.as_ref())?, + // Nothing to do: one initiator, no medium to check, no speed to + // set. PROMs send RESERVE, RELEASE and VERIFY while probing, and + // a CD driver SET CD SPEED on open. + scsi_cmd::RESERVE_6 | scsi_cmd::RELEASE_6 | scsi_cmd::VERIFY_10 | scsi_cmd::SET_CD_SPEED => { + ScsiResponse { status: 0x00, data: vec![] } + } + scsi_cmd::READ_TOC_PMA_ATIP => self.exec_read_toc_pma_atip(&req.cdb)?, + scsi_cmd::GET_CONFIGURATION => self.exec_get_configuration(&req.cdb)?, + scsi_cmd::SGI_EJECT => self.exec_sgi_eject(&req.cdb)?, + scsi_cmd::SGI_HD2CDROM => self.exec_sgi_hd2cdrom(&req.cdb)?, + _ => { + eprintln!("SCSI: Unimplemented command {:02x} cdb={:02x?}", req.cdb[0], &req.cdb); + self.check_condition(0x05, 0x20, 0x00) // Illegal Request: Invalid Command Operation Code + } + }; + + // Handle data_len parameter + if let ScsiDataLength::Fixed(max_len) = req.data_len { + if response.data.len() > max_len { + response.data.truncate(max_len); + } + } + + Ok(response) + } + + /// Commit the divergence layer into the base image ("apply the changes"). + /// Raw: copies the dirty sectors in place; CHD: rebuilds the base from the + /// diff (recompressing) and reopens a fresh overlay. No-op if not overlaid. + /// Returns a coarse count of what was committed. The concrete behaviour is + /// [`BlockNode::commit`] — including the CHD handle close/reopen. + fn cow_commit(&mut self) -> io::Result { + match self.backend.as_mut() { + Some(node) => node.commit(), + None => Ok(0), + } + } + + /// Reset the divergence layer — discard all uncommitted writes ("roll + /// back"). Raw: truncates the overlay; CHD: deletes the `.diff.chd` and + /// reopens a fresh overlay over the untouched base. No-op if not overlaid. + /// The concrete behaviour is [`BlockNode::rollback`]. + fn cow_reset(&mut self) -> io::Result<()> { + match self.backend.as_mut() { + Some(node) => node.rollback(), + None => Ok(()), + } + } + + /// Copy the COW overlay into `dest` and return its dirty sector set. + /// Direct-mode / no-media devices create no file. + fn cow_export(&mut self, dest: &std::path::Path) -> io::Result>> { + match self.backend.as_mut() { + Some(node) if node.is_cow() => node.export_overlay(dest).map(Some), + _ => Ok(None), + } + } + + /// Replace the COW overlay with the contents of `source` and adopt + /// `dirty` as the dirty sector set. No-op on non-COW / no-media devices. + fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { + match self.backend.as_mut() { + Some(node) => node.import_overlay(source, dirty), + None => Ok(()), + } + } + + /// `(base, diff)` paths if this device is a CHD writing to a `.diff.chd` + /// sidecar that holds changes worth folding back into the base on a clean + /// shutdown. `None` for in-place / non-CHD / no-media devices. + fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + self.backend.as_ref().and_then(|node| node.pending_sync()) + } + + /// Take the pending-sync paths AND release the disk backend, closing the CHD + /// so the base can be atomically rebuilt by `chd_disk::flatten_diff`. Returns + /// `None` and leaves the backend in place when there's nothing to sync. + fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + let pending = self.pending_chd_sync(); + if pending.is_some() { + self.backend = None; + } + pending + } + + /// 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. + fn eject_next(&mut self) -> Option { + 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. + let current = self.discs.remove(0); + self.discs.push(current); + let next_path = self.discs[0].clone(); + + match RawNode::open_readonly(std::path::Path::new(&next_path)) { + Ok(node) => { + let size = node.size(); + self.backend = Some(Box::new(node)); + self.size = size; + // phys_block_size never changes — CD-ROM physical sectors are always 2048. + // Do NOT reset logical_block_size — MODE SELECT is a controller setting + // 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) => { + eprintln!("SCSI eject: could not open {}: {}", next_path, e); + None + } + } + } + + /// Unload media from a CD-ROM (tray empty). + fn unload_media(&mut self) { + self.backend = None; + self.size = 0; + self.filename = String::new(); + self.unit_attention = true; + } + + /// Load an explicit image path and make it the active disc immediately, + /// as if a hand swapped the disc in the drive. The path is inserted at the + /// front of the changer list (becomes index 0) and a medium-change is + /// signalled (unit attention) so the guest re-reads the TOC on its next + /// 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. + fn load_disc(&mut self, path: String) -> Result { + if !self.is_cdrom() { + return Err("Not a CD-ROM device".to_string()); + } + let node = RawNode::open_readonly(std::path::Path::new(&path)) + .map_err(|e| format!("could not open {}: {}", path, e))?; + let size = node.size(); + self.backend = Some(Box::new(node)); + self.size = size; + self.filename = 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) + } + + /// Insert a new disc path at position 1 (next after current). + /// Returns Err if this is not a CD-ROM or the path doesn't exist. + fn add_disc(&mut self, path: String) -> Result<(), String> { + if !self.is_cdrom() { + return Err("Not a CD-ROM device".to_string()); + } + if !std::path::Path::new(&path).exists() { + return Err(format!("File not found: {}", path)); + } + // Insert at index 1 so it becomes the next disc after eject. + let insert_pos = if self.discs.is_empty() { 0 } else { 1 }; + self.discs.insert(insert_pos, path); + Ok(()) + } + + /// Remove disc at the given ordinal index. + /// Removing index 0 (current) does not eject — it only removes the path. + /// Returns Err if index is out of range. + fn remove_disc(&mut self, ordinal: usize) -> Result { + if !self.is_cdrom() { + return Err("Not a CD-ROM device".to_string()); + } + if ordinal >= self.discs.len() { + return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); + } + Ok(self.discs.remove(ordinal)) + } + + /// Move disc at `ordinal` to index 1 (next after current). + /// If ordinal is 0 (active), returns Err. + fn move_disc_next(&mut self, ordinal: usize) -> Result<(), String> { + if !self.is_cdrom() { + return Err("Not a CD-ROM device".to_string()); + } + if ordinal == 0 { + return Err("Ordinal 0 is the active disc; use 'scsi eject' to advance".to_string()); + } + if ordinal >= self.discs.len() { + return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); + } + if self.discs.len() < 2 { + return Ok(()); // already only entry + } + let path = self.discs.remove(ordinal); + // Insert at position 1 (right after the active disc at 0). + self.discs.insert(1, path); + Ok(()) + } + + fn status(&self, id: usize) -> Vec { + let (phys, logical) = self.block_sizes(); + if self.is_cdrom() { + let list = self.disc_list(); + let active = if self.has_media() { self.current_disc() } else { "(tray empty)" }; + let mut lines = vec![format!("SCSI ID {}: CD-ROM {} ({} disc(s)) phys_block={} logical_block={}", + id, active, list.len(), phys, logical)]; + for (i, d) in list.iter().enumerate() { + let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; + lines.push(format!(" [{}] {}{}", i, d, tag)); + } + lines + } else { + let cow = if self.is_cow() { ", copy-on-write" } else { "" }; + vec![format!("SCSI ID {}: disk {} ({} blocks of {}{})", + id, self.current_disc(), self.size() / logical.max(1), logical, cow)] + } + } + + fn block_size(&self) -> Option { Some(self.logical_block_size) } + + fn reset_block_size(&mut self, cdrom_512: bool) { + if cdrom_512 && self.is_cdrom() { + self.logical_block_size = 512; + } else { + self.power_on_block_size(); + } + } + + fn cow_dirty(&self) -> Option { self.is_cow().then(|| self.cow_dirty_count()) } + + fn discs(&self) -> Option { + self.is_cdrom().then(|| Discs { + current: self.filename.clone(), + list: self.discs.clone(), + block_sizes: self.block_sizes(), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1386,7 +1427,7 @@ mod tests { assert_eq!(dev.cow_dirty_count(), 1); // Export the overlay, roll back, then re-import it. - let dirty = dev.cow_export(&export).unwrap(); + let dirty = dev.cow_export(&export).unwrap().expect("a COW disk exports"); assert_eq!(dirty, vec![0u64]); dev.cow_reset().unwrap(); assert_eq!(dev.cow_dirty_count(), 0); diff --git a/src/scsi/bus.rs b/src/scsi/bus.rs index d023e8b2..02c33ac6 100644 --- a/src/scsi/bus.rs +++ b/src/scsi/bus.rs @@ -25,7 +25,8 @@ pub struct Discs { /// and a status come out. /// /// A disk or CD-ROM is a [`ScsiDevice`], a DaynaPort Ethernet adapter a -/// [`DaynaPort`]. Everything beyond `request` is optional behaviour that a +/// [`DaynaPort`]. The contract is `request` and the lifecycle (`power_on`, +/// `start`, `stop`). Everything else is optional behaviour that a /// kind of target has or does not (a copy-on-write layer, a disc changer), /// defaulting to "not this target"; the controllers and the monitor ask for /// the behaviour, never for the type. @@ -112,66 +113,6 @@ pub trait ScsiTarget: Send { const NOT_A_CDROM: &str = "Not a CD-ROM device"; -impl ScsiTarget for ScsiDevice { - fn request(&mut self, req: &ScsiRequest) -> io::Result { - ScsiDevice::request(self, req) - } - fn status(&self, id: usize) -> Vec { - let (phys, logical) = self.block_sizes(); - if self.is_cdrom() { - let list = self.disc_list(); - let active = if self.has_media() { self.current_disc() } else { "(tray empty)" }; - let mut lines = vec![format!("SCSI ID {}: CD-ROM {} ({} disc(s)) phys_block={} logical_block={}", - id, active, list.len(), phys, logical)]; - for (i, d) in list.iter().enumerate() { - let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; - lines.push(format!(" [{}] {}{}", i, d, tag)); - } - lines - } else { - let cow = if self.is_cow() { ", copy-on-write" } else { "" }; - vec![format!("SCSI ID {}: disk {} ({} blocks of {}{})", - id, self.current_disc(), self.size() / logical.max(1), logical, cow)] - } - } - fn block_size(&self) -> Option { Some(self.block_sizes().1) } - fn reset_block_size(&mut self, cdrom_512: bool) { - if cdrom_512 && self.is_cdrom() { - self.set_logical_block_size(512); - } else { - self.power_on_block_size(); - } - } - fn cow_dirty(&self) -> Option { self.is_cow().then(|| self.cow_dirty_count()) } - fn cow_commit(&mut self) -> io::Result { ScsiDevice::cow_commit(self) } - fn cow_reset(&mut self) -> io::Result<()> { ScsiDevice::cow_reset(self) } - fn cow_export(&mut self, dest: &std::path::Path) -> io::Result>> { - if self.is_cow() { ScsiDevice::cow_export(self, dest).map(Some) } else { Ok(None) } - } - fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { - ScsiDevice::cow_import(self, source, dirty) - } - fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { - ScsiDevice::pending_chd_sync(self) - } - fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { - ScsiDevice::take_pending_chd_sync(self) - } - fn discs(&self) -> Option { - self.is_cdrom().then(|| Discs { - current: self.current_disc().to_string(), - list: self.disc_list().to_vec(), - block_sizes: self.block_sizes(), - }) - } - fn load_disc(&mut self, path: String) -> Result { ScsiDevice::load_disc(self, path) } - fn eject_next(&mut self) -> Option { ScsiDevice::eject_next(self) } - fn unload_media(&mut self) { ScsiDevice::unload_media(self) } - fn add_disc(&mut self, path: String) -> Result<(), String> { ScsiDevice::add_disc(self, path) } - fn remove_disc(&mut self, ordinal: usize) -> Result { ScsiDevice::remove_disc(self, ordinal) } - fn move_disc_next(&mut self, ordinal: usize) -> Result<(), String> { ScsiDevice::move_disc_next(self, ordinal) } -} - /// A DaynaPort answers its own vendor command set, which reuses the READ(6) /// and WRITE(6) opcodes for packet receive and transmit; it answers no /// storage command at all. From 55bb9759eb4c320d82a83135cd39052d437d58dc Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 03:41:07 -0700 Subject: [PATCH 06/11] docs: describe the SCSI bus layer in HACKING and CHANGELOG --- CHANGELOG.md | 7 +++++++ HACKING.md | 26 ++++++++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6fdb4c..9fc5db0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,13 @@ hashes are given where a change is easiest to understand by reading the commit. cursor position fields are 12-bit signed; they were read as unsigned, so a cursor partly off the edge was drawn 4000 pixels away. (`src/dev/o2gfx.rs`) +### SCSI + +- Every SCSI controller attaches its targets through one shared bus, and + disks report one emulated identity on every machine. The O2 and Octane now + say `IRIS EMUL DISK` and `IRIS EMUL CDROM` in INQUIRY; the Indy and Indigo2 + keep their Sony CD-ROM. + ### Host efficiency on small machines - **Threads that have nothing to do now sleep in milliseconds, not diff --git a/HACKING.md b/HACKING.md index 68e8fdfb..c32393ee 100644 --- a/HACKING.md +++ b/HACKING.md @@ -70,16 +70,22 @@ reach the machine through one shared `MachineSlot` per machine, which the machine's `Drop` nulls, so a holder that outlives the machine (the monitor, after iris-gui's Stop) gets "machine is not running" instead of freed memory. -SCSI storage follows the same pattern one level down. `src/scsi.rs` is the -target (disk, CD-ROM, DaynaPort) any controller can hold, and -`src/scsi_monitor.rs` implements the monitor's `scsi` and `cow` subcommands and -the run-time disc changes once, over a controller's target table lent through -its `ScsiTargets` trait. The WD33C93A (Indy, Indigo2) and the Adaptec 7880 (O2) -lend theirs and keep only chip-specific subcommands (`regs`; the WD's `wdt`, -`defer`, `debug`), and `DiscChanger` is implemented once for every -`ScsiTargets`. Configuration is shared the same way: both machines attach -their devices from the `[scsi.N]` tables, where the key is a slot -(controller × 16 + id, see `config::scsi_slot`). +SCSI storage follows the same pattern one level down. `ScsiTarget` is what +varies on a bus (a `ScsiDevice` disk or CD-ROM, a `DaynaPort`): `request`, +the lifecycle, and optional behaviour with do-nothing defaults (a +copy-on-write layer, CHD sync, a disc changer), so callers ask for behaviour +and never for a type. `ScsiBus` (`src/scsi/bus.rs`) is the mechanism: the +id-to-target table with attach, request and overlay export and import, and +the controller's INQUIRY identities. The WD33C93A (Indy, Indigo2), the Adaptec +7880 (O2) and the ISP1040 (Octane) each own one and keep only their registers +and DMA. `src/scsi_monitor.rs` implements the monitor's `scsi` and `cow` +subcommands and the run-time disc changes once, over the bus a controller hands +out through `ScsiTargets::scsi_bus`; chip-specific subcommands (`regs`; the +WD's `wdt`, `defer`, `debug`) stay with the chip, and `DiscChanger` is +implemented once for every `ScsiTargets`. Configuration is shared the same +way: `scsi::attach_from_config` walks the `[scsi.N]` tables for every machine, +where the key is a slot (controller × 16 + id, see `config::scsi_slot`), and +the machine supplies only a route from controller number to bus. ## 2. Data path and endianness From d0f38a41849ef1e6b6f56088d3b27c9c6e00fce7 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 09:43:08 -0700 Subject: [PATCH 07/11] scsi: fold pending CHD diffs on exit on every controller Only the Indy and Indigo2's WD33C93A counted and folded the .diff.chd sidecars a compressed CHD disk writes to. On the O2 (AIC-7880), the Octane (ISP1040) and an Indigo2's second controller the Workstation hooks fell through to their defaults, so a clean exit with sync_chd left the diffs unfolded. The count and the fold now live on ScsiBus and take the buses of every controller on the board; the WD33C93A's copies are gone and each machine passes its own buses. --- src/dev/wd33c93a.rs | 44 ------------------------ src/ip30_machine.rs | 12 +++++++ src/ip32_machine.rs | 14 ++++++++ src/machine.rs | 13 +++++-- src/scsi/bus.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 46 deletions(-) diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index 0fef5b1f..4f24f3c8 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -424,50 +424,6 @@ impl Wd33c93a { results } - /// Number of attached CHD devices whose `.diff.chd` holds changes pending a - /// fold-back into the base on a clean shutdown. - pub fn pending_chd_sync_count(&self) -> usize { - let mut n = 0; - self.bus.for_each(|_, dev| n += dev.pending_chd_sync().is_some() as usize); - n - } - - /// Fold pending CHD diffs back into their bases ("sync"), preserving the - /// base's compression. `only` limits it to one SCSI id; `None` means all. - /// Releases each disk backend first (closing the CHD), then rebuilds - /// outside the device lock since recompression is slow. - /// `progress(done, total, fraction)` reports per-disk progress; `cancel()` - /// stops before the next disk (the in-flight rebuild also honours it), - /// leaving every un-synced base+diff intact. Returns the count synced. - pub fn sync_chd_disks( - &self, - only: Option, - progress: &mut dyn FnMut(usize, usize, f32), - cancel: &dyn Fn() -> bool, - ) -> std::io::Result { - // Collect pending (base, diff) pairs, releasing CHD handles under the - // lock so the files can be atomically rebuilt. The rebuild itself runs - // unlocked. - let mut pending = Vec::new(); - self.bus.for_each(|id, dev| { - if only.is_none_or(|o| o == id) { - pending.extend(dev.take_pending_chd_sync()); - } - }); - let total = pending.len(); - let mut done = 0usize; - for (base, diff) in pending { - if cancel() { - break; - } - progress(done, total, 0.0); - crate::chd_disk::flatten_diff(&base, &diff, &mut |f| progress(done, total, f), cancel)?; - done += 1; - progress(done, total, 1.0); - } - Ok(done) - } - /// Copy every COW overlay into `dir` as `scsi.overlay`. Returns a /// list of `(id, dirty_sector_list)` entries so snapshot save can /// persist the dirty set alongside the raw overlay bytes. diff --git a/src/ip30_machine.rs b/src/ip30_machine.rs index c4b0c133..57b95d22 100644 --- a/src/ip30_machine.rs +++ b/src/ip30_machine.rs @@ -645,6 +645,18 @@ impl Workstation for OctaneMachine { fn disc_changer(&self) -> Option> { Some(self.scsi[0].clone() as Arc) } + fn pending_chd_sync_count(&self) -> usize { + self.scsi.iter().map(|c| c.bus().pending_chd_sync_count()).sum() + } + fn sync_chd_disks( + &self, + only: Option, + progress: &mut dyn FnMut(usize, usize, f32), + cancel: &dyn Fn() -> bool, + ) -> std::io::Result { + let buses: Vec<_> = self.scsi.iter().map(|c| c.bus()).collect(); + crate::scsi::ScsiBus::sync_chd_disks(&buses, only, progress, cancel) + } fn nat_control(&self) -> Option> { self.nat.as_ref().map(|(n, _)| n.clone()) } diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index 06227e8f..944895e3 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -902,6 +902,20 @@ impl Workstation for O2Machine { self.bus.scsi.lock().unwrap().first().map(|c| c.clone() as Arc) } + fn pending_chd_sync_count(&self) -> usize { + self.bus.scsi.lock().unwrap().iter().map(|c| c.bus().pending_chd_sync_count()).sum() + } + fn sync_chd_disks( + &self, + only: Option, + progress: &mut dyn FnMut(usize, usize, f32), + cancel: &dyn Fn() -> bool, + ) -> std::io::Result { + let controllers = self.bus.scsi.lock().unwrap().clone(); + let buses: Vec<_> = controllers.iter().map(|c| c.bus()).collect(); + crate::scsi::ScsiBus::sync_chd_disks(&buses, only, progress, cancel) + } + fn nat_control(&self) -> Option> { self.nat.as_ref().map(|(n, _)| n.clone()) } diff --git a/src/machine.rs b/src/machine.rs index 048e6db9..34b46641 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -1235,11 +1235,20 @@ impl Machine { self.cpu.fasttick_count() } + /// Every SCSI bus on the board: the first controller's, and the second's + /// on a Fullhouse Indigo2. + fn scsi_buses(&self) -> Vec<&crate::scsi::ScsiBus> { + use crate::scsi_monitor::ScsiTargets; + let mut v = vec![self.hpc3.scsi().scsi_bus()]; + v.extend(self.hpc3.scsi1().map(|c| c.scsi_bus())); + v + } + /// Number of attached CHD disks whose `.diff.chd` holds changes pending a /// fold-back into the base on a clean shutdown (the "Synchronizing disks" /// step). 0 means a clean exit needs no disk sync. pub fn pending_chd_sync_count(&self) -> usize { - self.hpc3.scsi().pending_chd_sync_count() + self.scsi_buses().iter().map(|b| b.pending_chd_sync_count()).sum() } /// Fold pending CHD diffs back into their bases, preserving compression. @@ -1253,7 +1262,7 @@ impl Machine { progress: &mut dyn FnMut(usize, usize, f32), cancel: &dyn Fn() -> bool, ) -> std::io::Result { - self.hpc3.scsi().sync_chd_disks(only, progress, cancel) + crate::scsi::ScsiBus::sync_chd_disks(&self.scsi_buses(), only, progress, cancel) } /// Step the CPU `n` instructions in-line on the calling thread, with all diff --git a/src/scsi/bus.rs b/src/scsi/bus.rs index 02c33ac6..5b5930af 100644 --- a/src/scsi/bus.rs +++ b/src/scsi/bus.rs @@ -234,6 +234,50 @@ impl ScsiBus { } } + /// Targets whose `.diff.chd` holds changes waiting to be folded back into + /// the base on a clean shutdown. + pub fn pending_chd_sync_count(&self) -> usize { + let mut n = 0; + self.for_each(|_, t| n += t.pending_chd_sync().is_some() as usize); + n + } + + /// Fold pending CHD diffs on `buses` back into their bases ("sync"), + /// preserving each base's compression. `only` limits it to one SCSI id + /// (on every bus); `None` means all. Each target releases its backend + /// first, closing the CHD; the rebuild then runs with no bus lock held, + /// since recompression is slow. `progress(done, total, fraction)` reports + /// per-disk progress; `cancel()` stops before the next disk (the in-flight + /// rebuild also honours it), leaving every un-synced base and diff intact. + /// Returns the count synced. + pub fn sync_chd_disks( + buses: &[&ScsiBus], + only: Option, + progress: &mut dyn FnMut(usize, usize, f32), + cancel: &dyn Fn() -> bool, + ) -> io::Result { + let mut pending = Vec::new(); + for bus in buses { + bus.for_each(|id, t| { + if only.is_none_or(|o| o == id) { + pending.extend(t.take_pending_chd_sync()); + } + }); + } + let total = pending.len(); + let mut done = 0usize; + for (base, diff) in pending { + if cancel() { + break; + } + progress(done, total, 0.0); + crate::chd_disk::flatten_diff(&base, &diff, &mut |f| progress(done, total, f), cancel)?; + done += 1; + progress(done, total, 1.0); + } + Ok(done) + } + /// Copy each target's copy-on-write overlay into `dir` as /// `scsi.overlay`, and return the dirty sectors of each. pub fn export_overlays(&self, dir: &std::path::Path) -> io::Result)>> { @@ -331,4 +375,43 @@ mod tests { let _ = std::fs::remove_file(format!("{}.overlay", p.display())); std::fs::remove_file(p).unwrap(); } + + #[test] + fn a_chd_diff_is_counted_and_folded_on_every_bus_given() { + use libchdman_rs::hd::{create_from_reader, HdCreateOptions}; + use libchdman_rs::CHD_CODEC_ZLIB; + let base = std::env::temp_dir().join(format!("iris-scsibus-fold-{}.chd", std::process::id())); + let logical = 128 * 1024u64; + create_from_reader( + std::io::Cursor::new(vec![0xABu8; logical as usize]), + &base, + HdCreateOptions { + logical_size: logical, + hunk_size: 4096, + unit_size: 512, + codecs: [CHD_CODEC_ZLIB, 0, 0, 0], + geometry: None, + ident: None, + }, + &mut |_| {}, + &|| false, + ) + .unwrap(); + let (b0, b1) = (bus(8), bus(8)); + attach(&b1, 4, &base, false).unwrap(); + assert_eq!(b1.pending_chd_sync_count(), 0, "nothing written yet"); + let write = ScsiRequest { + cdb: vec![scsi_cmd::WRITE_10, 0, 0, 0, 0, 2, 0, 0, 1, 0], + data_len: crate::scsi::ScsiDataLength::Unlimited, + data_in: Some(vec![0x5Au8; 512]), + }; + b1.request(4, &write).unwrap().unwrap(); + assert_eq!(b1.pending_chd_sync_count(), 1); + assert_eq!(b0.pending_chd_sync_count(), 0); + let n = ScsiBus::sync_chd_disks(&[&b0, &b1], None, &mut |_, _, _| {}, &|| false).unwrap(); + assert_eq!(n, 1); + assert_eq!(b1.pending_chd_sync_count(), 0, "folded"); + let _ = std::fs::remove_file(&base); + let _ = std::fs::remove_file(crate::chd_disk::diff_path_for(&base)); + } } From 1bbd8a1887205578da51c1f45c01902e468631b5 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 10:00:40 -0700 Subject: [PATCH 08/11] scsi: open every image through one block-layer function ScsiDevice::open chose between a CHD, a CHD CD, a copy-on-write overlay and a raw file, while a disc change opened its file as raw only, so a changer could not swap to a CHD CD although a drive could be started with one. The choice moves to block_node::open_image. The bus builds the node when it attaches a target and hands it to ScsiDevice::new, and the changer's load, eject and insert paths open through the same function. --- src/block_node.rs | 43 ++++++++++++++++++ src/scsi.rs | 109 +++++++++++++++------------------------------- src/scsi/bus.rs | 14 +++++- 3 files changed, 91 insertions(+), 75 deletions(-) diff --git a/src/block_node.rs b/src/block_node.rs index 24cbed4a..526d32a3 100644 --- a/src/block_node.rs +++ b/src/block_node.rs @@ -762,6 +762,49 @@ impl BlockNode for ChdCdNode { } } +/// What an image is mounted as, which decides how its file is read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageKind { + Disk, + Cdrom, +} + +/// Open the image at `path` as a node: a CHD (hard disk or CD), a +/// copy-on-write overlay over it when `overlay` (hard disks), or the file +/// itself. `overlay_path` names the overlay file, `PATH.overlay` when `None`. +/// +/// Everything that mounts an image goes through here, so a disk attached at +/// start-up and a disc swapped in later are read the same way. +pub fn open_image( + path: &str, + kind: ImageKind, + overlay: bool, + overlay_path: Option<&str>, +) -> io::Result> { + let cdrom = kind == ImageKind::Cdrom; + Ok(if crate::chd_disk::is_chd(path) { + if cdrom { + Box::new(ChdCdNode::open(path)?) + } else { + // The `overlay` flag is the per-disk copy-on-write toggle: COW on + // → always overlay (even an uncompressed base gets a diff) and + // never auto-fold on exit (commit/roll back manually); COW off → + // write in place (uncompressed) or a diff that auto-folds on a + // clean exit (compressed). + Box::new(ChdNode::open(path, overlay)?) + } + } else if overlay && !cdrom { + let overlay_path = overlay_path.map(str::to_string).unwrap_or_else(|| format!("{path}.overlay")); + let backing = RawNode::open_readonly(Path::new(path))?; + let size = backing.size(); + Box::new(CowNode::new(Some(Box::new(backing)), &overlay_path, size)?.with_base_path(path)) + } else if cdrom { + Box::new(RawNode::open_readonly(Path::new(path))?) + } else { + Box::new(RawNode::open(Path::new(path))?) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/scsi.rs b/src/scsi.rs index 1da204a9..0214f66b 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -6,7 +6,7 @@ mod bus; pub use attach::{attach_failed, attach_from_config, Attached, DaynaportRef, TableRef}; pub use bus::{Discs, ScsiBus, ScsiIdentities, ScsiTarget, Targets}; -use crate::block_node::{BlockNode, ChdCdNode, ChdNode, CowNode, RawNode}; +use crate::block_node::{open_image, BlockNode, ImageKind}; /// Get the standard CDB length based on the opcode's group code pub fn get_cdb_length(opcode: u8) -> usize { @@ -171,69 +171,6 @@ impl ScsiDevice { } } - /// Open a disk or CD-ROM image as a target: a CHD (hard disk or CD), - /// a copy-on-write overlay over `path` when `overlay` (hard disks), or - /// the file itself; an empty `path` is a CD-ROM drive with no disc. A - /// CD-ROM changer's discs are `discs`. Any controller attaches its - /// targets this way, with its own `identity`. - /// - /// Each image becomes a [`BlockNode`]: the node owns the backend-specific - /// arithmetic, so the storage commands above never see a backend kind. - pub fn open( - path: &str, - is_cdrom: bool, - discs: Vec, - overlay: bool, - overlay_path_override: Option<&str>, - identity: ScsiIdentity, - ) -> std::io::Result { - // Empty CD-ROM (drive present, tray empty). Stored backend=None so - // TEST UNIT READY / READ CAPACITY / READ return MEDIUM NOT PRESENT, - // while INQUIRY still advertises the drive. Use insert_disc() to - // mount media later. - if is_cdrom && path.is_empty() { - return Ok(Self::new_empty_cdrom(identity)); - } - let is_chd_path = crate::chd_disk::is_chd(path); - - let (backend, size): (Box, u64) = if is_chd_path { - if is_cdrom { - let cd = ChdCdNode::open(path)?; - let sz = cd.size(); - (Box::new(cd), sz) - } else { - // HD CHD. The `overlay` flag is the per-disk copy-on-write - // toggle: COW on → always overlay (even an uncompressed base - // gets a diff) and never auto-fold on exit (commit/roll back - // manually); COW off → write in place (uncompressed) or a diff - // that auto-folds on a clean exit (compressed). - let hd = ChdNode::open(path, overlay)?; - let sz = hd.size(); - (Box::new(hd), sz) - } - } else if overlay && !is_cdrom { - let overlay_path = overlay_path_override - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("{}.overlay", path)); - let backing = RawNode::open_readonly(std::path::Path::new(path))?; - let sz = backing.size(); - let cow = CowNode::new(Some(Box::new(backing)), &overlay_path, sz)? - .with_base_path(path); - (Box::new(cow), sz) - } else { - let node = if is_cdrom { - RawNode::open_readonly(std::path::Path::new(path))? - } else { - RawNode::open(std::path::Path::new(path))? - }; - let sz = node.size(); - (Box::new(node), sz) - }; - - let disc_list = if is_cdrom { discs } else { vec![] }; - Ok(Self::new(backend, size, is_cdrom, path.to_string(), disc_list, identity)) - } - /// Construct an empty CD-ROM drive — drive present, no media inserted. /// IRIX will see the drive in `hinv` but `TEST UNIT READY` reports /// MEDIUM NOT PRESENT. @@ -260,9 +197,9 @@ impl ScsiDevice { /// 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<()> { - let node = RawNode::open_readonly(std::path::Path::new(path))?; + let node = open_image(path, ImageKind::Cdrom, false, None)?; let size = node.size(); - self.backend = Some(Box::new(node)); + self.backend = Some(node); self.size = size; self.filename = path.to_string(); self.unit_attention = true; @@ -1169,10 +1106,10 @@ impl ScsiTarget for ScsiDevice { self.discs.push(current); let next_path = self.discs[0].clone(); - match RawNode::open_readonly(std::path::Path::new(&next_path)) { + match open_image(&next_path, ImageKind::Cdrom, false, None) { Ok(node) => { let size = node.size(); - self.backend = Some(Box::new(node)); + self.backend = Some(node); self.size = size; // phys_block_size never changes — CD-ROM physical sectors are always 2048. // Do NOT reset logical_block_size — MODE SELECT is a controller setting @@ -1201,8 +1138,8 @@ impl ScsiTarget for ScsiDevice { /// as if a hand swapped the disc in the drive. The path is inserted at the /// front of the changer list (becomes index 0) and a medium-change is /// signalled (unit attention) so the guest re-reads the TOC on its next - /// 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 + /// command. The image is opened as a drive opens any disc (see + /// [`open_image`]). Err if this is not a CD-ROM or the file can't /// be opened. /// /// Queue management by disc count: @@ -1216,10 +1153,10 @@ impl ScsiTarget for ScsiDevice { if !self.is_cdrom() { return Err("Not a CD-ROM device".to_string()); } - let node = RawNode::open_readonly(std::path::Path::new(&path)) + let node = open_image(&path, ImageKind::Cdrom, false, None) .map_err(|e| format!("could not open {}: {}", path, e))?; let size = node.size(); - self.backend = Some(Box::new(node)); + self.backend = Some(node); self.size = size; self.filename = path.clone(); self.unit_attention = true; @@ -1451,7 +1388,7 @@ mod tests { fn cdrom() -> ScsiDevice { let path = std::env::temp_dir().join(format!("iris-scsi-alloc-{}.iso", std::process::id())); std::fs::write(&path, vec![0u8; 100 * 2048]).expect("test image"); - let node = RawNode::open(&path).expect("open"); + let node = crate::block_node::RawNode::open(&path).expect("open"); let identity = ScsiIdentity::new(b"TEST ", b"CD-ROM ", b"1.0 "); let mut dev = ScsiDevice::new(Box::new(node), 100 * 2048, true, path.display().to_string(), Vec::new(), identity); let _ = run(&mut dev, &[0x00, 0, 0, 0, 0, 0]); @@ -1493,4 +1430,30 @@ mod tests { assert_eq!(run(&mut dev, &[0x03, 0, 0, 0, 8, 0]).data.len(), 8); assert_eq!(run(&mut dev, &[0x03, 0, 0, 0, 0, 0]).data.len(), 4, "SCSI-2: 0 means 4 bytes"); } + + /// A disc swapped in through the changer is opened as the drive opens any + /// disc, so a CHD CD loads, not only a raw ISO. + #[test] + fn a_disc_change_loads_a_chd_cd() { + use libchdman_rs::cd::{create_from_iso, CdCreateOptions}; + let dir = std::env::temp_dir().join(format!("iris-discswap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let iso = dir.join("disc.iso"); + std::fs::write(&iso, vec![0x42u8; 16 * 2048]).unwrap(); + let chd = dir.join("disc.chd"); + create_from_iso(&iso, &chd, CdCreateOptions::default(), &mut |_| {}, &|| false).unwrap(); + + let mut cd = ScsiDevice::new_empty_cdrom(CDROM_IDENTITY); + assert!(!cd.has_media()); + ScsiTarget::load_disc(&mut cd, chd.display().to_string()).unwrap(); + assert!(cd.has_media()); + assert_eq!(cd.size(), 16 * 2048, "the CHD's size, not the file's"); + // The medium change is reported once, then the disc reads. + let tur = cd.request(&request(&[0, 0, 0, 0, 0, 0], None)).unwrap(); + assert_eq!(tur.status, 0x02, "unit attention after the change"); + let read = cd.request(&request(&[0x28, 0, 0, 0, 0, 3, 0, 0, 1, 0], None)).unwrap(); + assert_eq!(read.status, 0x00); + assert_eq!(read.data, vec![0x42u8; 2048]); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/src/scsi/bus.rs b/src/scsi/bus.rs index 5b5930af..83838d32 100644 --- a/src/scsi/bus.rs +++ b/src/scsi/bus.rs @@ -11,6 +11,7 @@ use std::io; use parking_lot::{Mutex, MutexGuard}; use super::{ScsiDevice, ScsiIdentity, ScsiRequest, ScsiResponse}; +use crate::block_node::{open_image, ImageKind}; use crate::dev::daynaport::DaynaPort; /// A changer's discs: the active one, the queue (index 0 is the active @@ -191,7 +192,7 @@ impl ScsiBus { /// Attach what a `[scsi.N]` table describes: the image at `path` (empty /// for a CD-ROM with its tray empty), a CD-ROM's changer list `discs`, /// and copy-on-write when `overlay` is set, to `overlay_path` or the - /// default place ([`ScsiDevice::open`]). The bus gives it its INQUIRY + /// default place ([`open_image`]). The bus gives it its INQUIRY /// identity. Returns the size in 512-byte blocks. pub fn attach_device( &self, @@ -203,7 +204,16 @@ impl ScsiBus { overlay_path: Option<&str>, ) -> io::Result { let identity = if cdrom { self.identities.cdrom } else { self.identities.disk }; - let dev = ScsiDevice::open(path, cdrom, discs, overlay, overlay_path, identity)?; + let dev = if cdrom && path.is_empty() { + // A drive with its tray empty answers INQUIRY and reports no + // medium; a disc is loaded into it later. + ScsiDevice::new_empty_cdrom(identity) + } else { + let kind = if cdrom { ImageKind::Cdrom } else { ImageKind::Disk }; + let node = open_image(path, kind, overlay, overlay_path)?; + let size = node.size(); + ScsiDevice::new(node, size, cdrom, path.to_string(), if cdrom { discs } else { Vec::new() }, identity) + }; let blocks = dev.size() / 512; self.attach(id, Box::new(dev))?; Ok(blocks) From af805b54cb1695b70a493f2ae783c6ae9317be63 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 10:06:01 -0700 Subject: [PATCH 09/11] scsi: keep removable media state in a changer node A CD-ROM's disc list, active path, size, tray state and pending medium change lived in ScsiDevice next to a backend that was None when the tray was empty, and ScsiTarget carried seven methods to edit them. Removable media is now a trait of its own, Removable (present, changed, eject, load), and a ChangerNode implements it and BlockNode: it forwards to the active disc, opens only that disc, and keeps the queue. It is not part of BlockNode because a tape cartridge is removable without being a block device. ScsiDevice holds a Media (a disk's node or a changer) with no Option, asks it for unit attention and not-ready, and ScsiTarget exposes one changer accessor for the monitor, the CI socket and the window. The queue rules and the monitor output are unchanged. A next disc that will not open used to turn the queue anyway and leave the old disc loaded; the drive is now left as it was. --- HACKING.md | 7 +- src/block_node.rs | 309 +++++++++++++++++++++++++++++++++++ src/dev/daynaport.rs | 2 +- src/dev/wd33c93a.rs | 2 +- src/scsi.rs | 372 +++++++++++++------------------------------ src/scsi/bus.rs | 38 +---- src/scsi_monitor.rs | 43 +++-- 7 files changed, 458 insertions(+), 315 deletions(-) diff --git a/HACKING.md b/HACKING.md index c32393ee..bd68fe09 100644 --- a/HACKING.md +++ b/HACKING.md @@ -76,7 +76,12 @@ the lifecycle, and optional behaviour with do-nothing defaults (a copy-on-write layer, CHD sync, a disc changer), so callers ask for behaviour and never for a type. `ScsiBus` (`src/scsi/bus.rs`) is the mechanism: the id-to-target table with attach, request and overlay export and import, and -the controller's INQUIRY identities. The WD33C93A (Indy, Indigo2), the Adaptec +the controller's INQUIRY identities. A disk or CD-ROM sits on a `BlockNode` +(`src/block_node.rs`), built by `open_image` for everything that mounts an +image. A CD-ROM's node is a `ChangerNode`: the queue of discs and the open +active one, with the `Removable` trait (present, changed, eject, load) +separate from `BlockNode` so that removable media which is not a block device, +a tape, can use it too. The WD33C93A (Indy, Indigo2), the Adaptec 7880 (O2) and the ISP1040 (Octane) each own one and keep only their registers and DMA. `src/scsi_monitor.rs` implements the monitor's `scsi` and `cow` subcommands and the run-time disc changes once, over the bus a controller hands diff --git a/src/block_node.rs b/src/block_node.rs index 526d32a3..3c31d076 100644 --- a/src/block_node.rs +++ b/src/block_node.rs @@ -805,6 +805,225 @@ pub fn open_image( }) } +/// Media that can be taken out and put back: a CD-ROM drive's tray. +/// +/// This is a trait of its own, not part of [`BlockNode`], because removable +/// media is not always a block device. A tape drive's cartridge is removable +/// too, but a tape grows as it is written and is read in order, so it can +/// implement this without being a node. +pub trait Removable { + /// Whether a medium is in. + fn present(&self) -> bool; + + /// Whether the medium changed since this was last asked. Asking clears + /// it, so a caller reports a change once. + fn take_changed(&mut self) -> bool; + + /// Take the medium out. Returns what the drive loads in its place, `None` + /// when it is left empty. + fn eject(&mut self) -> io::Result>; + + /// Put the image at `path` in, as a hand swapping the medium would. + fn load(&mut self, path: &str) -> io::Result<()>; +} + +/// A CD-ROM drive's tray, with the discs queued for it. +/// +/// The queue is paths; only the active disc is open. It is a node that +/// forwards to the active disc, so the storage commands neither know nor care +/// that the disc can change, and a drive with its tray empty is a node of +/// size 0 whose reads fail. The queue editing is the changer's own, not +/// [`Removable`]'s: a tape has no queue. +pub struct ChangerNode { + /// The discs, the active one first. + discs: Vec, + /// The active disc, `None` with the tray empty. + active: Option>, + changed: bool, +} + +impl ChangerNode { + /// A drive with its tray empty and nothing queued. + pub fn empty() -> Self { + Self { discs: Vec::new(), active: None, changed: false } + } + + /// A drive holding `node`, the image at `path`, with `discs` queued + /// behind it. `path` is first in the queue. + pub fn loaded(node: Box, path: &str, mut discs: Vec) -> Self { + if discs.first().map(String::as_str) != Some(path) { + discs.retain(|d| d != path); + discs.insert(0, path.to_string()); + } + Self { discs, active: Some(node), changed: false } + } + + /// The active disc's path, `None` with the tray empty. + pub fn current(&self) -> Option<&str> { + self.active.as_ref().and_then(|_| self.discs.first()).map(String::as_str) + } + + /// The queue, the active disc first. + pub fn discs(&self) -> &[String] { + &self.discs + } + + /// Take the disc out and leave the tray empty, keeping the queue. + pub fn unload(&mut self) { + self.active = None; + self.changed = true; + } + + /// Queue `path` to follow the active disc. The file has to exist; it is + /// not opened until it is loaded. + pub fn add(&mut self, path: String) -> Result<(), String> { + if !Path::new(&path).exists() { + return Err(format!("File not found: {}", path)); + } + let at = if self.discs.is_empty() { 0 } else { 1 }; + self.discs.insert(at, path); + Ok(()) + } + + /// Drop the queued disc at `ordinal`. Dropping the active disc (0) takes + /// it off the list but leaves it in the drive. + pub fn remove(&mut self, ordinal: usize) -> Result { + if ordinal >= self.discs.len() { + return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); + } + Ok(self.discs.remove(ordinal)) + } + + /// Make the queued disc at `ordinal` the one after the active disc. + pub fn move_next(&mut self, ordinal: usize) -> Result<(), String> { + if ordinal == 0 { + return Err("Ordinal 0 is the active disc; use 'scsi eject' to advance".to_string()); + } + if ordinal >= self.discs.len() { + return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); + } + if self.discs.len() < 2 { + return Ok(()); + } + let path = self.discs.remove(ordinal); + self.discs.insert(1, path); + Ok(()) + } + + fn disc(&mut self) -> io::Result<&mut Box> { + self.active + .as_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "no disc in the drive")) + } +} + +impl Removable for ChangerNode { + fn present(&self) -> bool { + self.active.is_some() + } + + fn take_changed(&mut self) -> bool { + std::mem::take(&mut self.changed) + } + + /// With none or one disc queued the tray empties and the queue is cleared; + /// with more, the queue turns and the next disc loads. If the next disc + /// will not open, the drive is left as it was. + fn eject(&mut self) -> io::Result> { + if self.discs.len() <= 1 { + if let Some(prev) = self.current() { + eprintln!("CD-ROM: ejected '{}', tray empty", prev); + } + self.unload(); + self.discs.clear(); + return Ok(None); + } + let node = open_image(&self.discs[1], ImageKind::Cdrom, false, None)?; + let gone = self.discs.remove(0); + self.discs.push(gone); + self.active = Some(node); + self.changed = true; + let next = self.discs[0].clone(); + eprintln!("CD-ROM changer: switched to '{}'", next); + Ok(Some(next)) + } + + /// The disc becomes the active one at the front of the queue. With an + /// empty tray it is the only entry; with one disc loaded that disc stays + /// queued behind it, so an eject turns the queue rather than emptying the + /// tray; with more, a disc already queued moves to the front instead of + /// appearing twice. + fn load(&mut self, path: &str) -> io::Result<()> { + let node = open_image(path, ImageKind::Cdrom, false, None)?; + self.active = Some(node); + self.changed = true; + match self.discs.len() { + 0 => self.discs.push(path.to_string()), + 1 => { + if self.discs[0] != path { + self.discs.insert(0, path.to_string()); + } + } + _ => { + self.discs.retain(|d| d != path); + self.discs.insert(0, path.to_string()); + } + } + Ok(()) + } +} + +impl BlockNode for ChangerNode { + fn size(&self) -> u64 { + self.active.as_ref().map_or(0, |d| d.size()) + } + fn read(&mut self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.disc()?.read(offset, buf) + } + fn write(&mut self, offset: u64, buf: &[u8]) -> io::Result<()> { + self.disc()?.write(offset, buf) + } + fn flush(&mut self) -> io::Result<()> { + self.active.as_mut().map_or(Ok(()), |d| d.flush()) + } + fn discard(&mut self, offset: u64, len: u64) -> io::Result<()> { + self.disc()?.discard(offset, len) + } + fn block_status(&self, offset: u64, len: u64) -> io::Result { + match &self.active { + Some(d) => d.block_status(offset, len), + None => Err(io::Error::new(io::ErrorKind::NotConnected, "no disc in the drive")), + } + } + fn backing(&self) -> Option<&dyn BlockNode> { + self.active.as_deref() + } + fn dirty_count(&self) -> usize { + self.active.as_ref().map_or(0, |d| d.dirty_count()) + } + fn is_cow(&self) -> bool { + self.active.as_ref().is_some_and(|d| d.is_cow()) + } + fn commit(&mut self) -> io::Result { + self.active.as_mut().map_or(Ok(0), |d| d.commit()) + } + fn rollback(&mut self) -> io::Result<()> { + self.active.as_mut().map_or(Ok(()), |d| d.rollback()) + } + fn overlay_paths(&self) -> Option<(PathBuf, PathBuf)> { + self.active.as_ref().and_then(|d| d.overlay_paths()) + } + fn pending_sync(&self) -> Option<(PathBuf, PathBuf)> { + self.active.as_ref().and_then(|d| d.pending_sync()) + } + fn export_overlay(&mut self, dest: &Path) -> io::Result> { + self.active.as_mut().map_or(Ok(Vec::new()), |d| d.export_overlay(dest)) + } + fn import_overlay(&mut self, source: &Path, dirty: Vec) -> io::Result<()> { + self.active.as_mut().map_or(Ok(()), |d| d.import_overlay(source, dirty)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1270,4 +1489,94 @@ mod tests { let _ = std::fs::remove_file(&base); let _ = std::fs::remove_file(crate::chd_disk::diff_path_for(&base)); } + + /// An ISO of `sectors` CD sectors, each filled with `fill`. + fn iso(tag: &str, fill: u8, sectors: usize) -> String { + let p = tmp_path(tag); + std::fs::write(&p, vec![fill; sectors * 2048]).unwrap(); + p.display().to_string() + } + + fn first_byte(c: &mut ChangerNode) -> u8 { + let mut b = [0u8; 1]; + c.read(0, &mut b).unwrap(); + b[0] + } + + fn drive_with(paths: &[&str]) -> ChangerNode { + let node = open_image(paths[0], ImageKind::Cdrom, false, None).unwrap(); + ChangerNode::loaded(node, paths[0], paths.iter().map(|p| p.to_string()).collect()) + } + + #[test] + fn ejecting_the_only_disc_empties_the_tray() { + let a = iso("only", 1, 4); + let mut c = drive_with(&[&a]); + assert!(c.present()); + assert_eq!(c.eject().unwrap(), None); + assert!(!c.present()); + assert_eq!(c.size(), 0); + assert!(c.discs().is_empty(), "no phantom entries remain"); + assert!(c.read(0, &mut [0u8; 1]).is_err(), "an empty tray reads nothing"); + assert!(c.take_changed()); + } + + #[test] + fn ejecting_with_two_discs_turns_the_queue() { + let (a, b) = (iso("turn-a", 1, 4), iso("turn-b", 2, 8)); + let mut c = drive_with(&[&a, &b]); + assert_eq!(first_byte(&mut c), 1); + assert_eq!(c.eject().unwrap().as_deref(), Some(b.as_str())); + assert_eq!((first_byte(&mut c), c.size()), (2, 8 * 2048)); + assert_eq!(c.discs(), [b.clone(), a.clone()], "the ejected disc goes to the back"); + assert_eq!(c.eject().unwrap().as_deref(), Some(a.as_str()), "and the queue wraps"); + assert_eq!(first_byte(&mut c), 1); + } + + #[test] + fn loading_a_disc_raises_the_medium_change_once() { + let (a, b) = (iso("change-a", 1, 4), iso("change-b", 2, 4)); + let mut c = ChangerNode::empty(); + assert!(!c.take_changed(), "a drive starting empty has not changed"); + c.load(&a).unwrap(); + assert!(c.take_changed()); + assert!(!c.take_changed(), "asking clears it"); + c.load(&b).unwrap(); + assert!(c.take_changed()); + assert_eq!(c.discs(), [b, a], "the first disc stays queued behind the new one"); + } + + #[test] + fn a_chd_cd_in_the_queue_loads_on_eject() { + use libchdman_rs::cd::{create_from_iso, CdCreateOptions}; + let a = iso("chd-a", 1, 4); + let src = iso("chd-src", 0x42, 16); + let chd = tmp_path("chd-cd").with_extension("chd"); + create_from_iso(Path::new(&src), &chd, CdCreateOptions::default(), &mut |_| {}, &|| false).unwrap(); + let chd = chd.display().to_string(); + let mut c = drive_with(&[&a, &chd]); + assert_eq!(c.eject().unwrap().as_deref(), Some(chd.as_str())); + assert_eq!((first_byte(&mut c), c.size()), (0x42, 16 * 2048), "the CHD's data, not the file's bytes"); + } + + #[test] + fn a_disc_that_will_not_open_leaves_the_drive_as_it_was() { + let a = iso("bad-a", 1, 4); + let mut c = drive_with(&[&a, "/nonexistent/disc.iso"]); + assert!(c.eject().is_err()); + assert!(c.present()); + assert_eq!(c.discs()[0], a, "the queue did not turn"); + assert_eq!(first_byte(&mut c), 1); + } + + #[test] + fn queueing_a_missing_file_is_refused() { + let a = iso("queue-a", 1, 4); + let mut c = drive_with(&[&a]); + assert!(c.add("/nonexistent/disc.iso".into()).unwrap_err().contains("not found")); + assert_eq!(c.discs().len(), 1); + let b = iso("queue-b", 2, 4); + c.add(b.clone()).unwrap(); + assert_eq!(c.discs(), [a, b], "a queued disc follows the active one, unopened"); + } } diff --git a/src/dev/daynaport.rs b/src/dev/daynaport.rs index c538e598..faee7e96 100644 --- a/src/dev/daynaport.rs +++ b/src/dev/daynaport.rs @@ -770,7 +770,7 @@ mod tests { let (_tx, mut rx) = d.take_nat_ends(); let mut dev: Box = Box::new(d); assert!(dev.moves_packets()); - assert!(dev.discs().is_none()); + assert!(dev.changer().is_none()); dev.request(&cdb6([dp_cmd::ENABLE, 0, 0, 0, 0, 0x80])).unwrap(); dev.request(&cdb6([dp_cmd::SET_IFACE_MODE, 0, 0, 0, 0x04, 0x80])).unwrap(); diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index 4f24f3c8..63431e7a 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -2135,7 +2135,7 @@ mod data_out_phase_tests { let mut s = dev.state.lock(); s.bus.attach(2, Box::new(scsi::ScsiDevice::new( Box::new(crate::block_node::RawNode::from_file(file, 1 << 20)), - 1 << 20, false, path.display().to_string(), Vec::new(), scsi::DISK_IDENTITY))).unwrap(); + false, path.display().to_string(), Vec::new(), scsi::DISK_IDENTITY))).unwrap(); s.advanced_mode = true; s.target_id = 2; s.regs[regs::DESTINATION_ID as usize] = 2; diff --git a/src/scsi.rs b/src/scsi.rs index 0214f66b..1d4f7332 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -4,9 +4,9 @@ mod attach; mod bus; pub use attach::{attach_failed, attach_from_config, Attached, DaynaportRef, TableRef}; -pub use bus::{Discs, ScsiBus, ScsiIdentities, ScsiTarget, Targets}; +pub use bus::{ScsiBus, ScsiIdentities, ScsiTarget, Targets}; -use crate::block_node::{open_image, BlockNode, ImageKind}; +use crate::block_node::{ChangerNode, Removable, BlockNode}; /// Get the standard CDB length based on the opcode's group code pub fn get_cdb_length(opcode: u8) -> usize { @@ -117,22 +117,49 @@ impl ScsiIdentity { } } +/// What a device reads and writes: a disk's image, or a CD-ROM drive's tray. +enum Media { + /// A disk, and the path of its image. + Fixed(Box, String), + /// A CD-ROM drive. + Changer(ChangerNode), +} + +impl Media { + fn node(&self) -> &dyn BlockNode { + match self { + Media::Fixed(n, _) => &**n, + Media::Changer(c) => c, + } + } + + fn node_mut(&mut self) -> &mut dyn BlockNode { + match self { + Media::Fixed(n, _) => &mut **n, + Media::Changer(c) => c, + } + } + + fn present(&self) -> bool { + match self { + Media::Fixed(..) => true, + Media::Changer(c) => c.present(), + } + } + + fn take_changed(&mut self) -> bool { + match self { + Media::Fixed(..) => false, + Media::Changer(c) => c.take_changed(), + } + } +} + pub struct ScsiDevice { - /// None = no media loaded (CD-ROM drive present but tray is empty). - /// HDDs are never None in practice. - backend: Option>, - /// Capacity in bytes of the loaded media. 0 when `backend` is None. - size: u64, + media: Media, kind: DeviceKind, - /// Path of the currently mounted image. Empty string when no media. - filename: String, - /// Full disc list for CD-ROM changers. Index 0 is always the active disc. - /// For HDDs this is empty (unused). - discs: Vec, buffer: Vec, // Internal buffer for WRITE_BUFFER/READ_BUFFER commands pending_sense: [u8; 18], // Stored sense data, served on REQUEST_SENSE - /// Unit Attention pending — set after disc change, cleared on next command. - unit_attention: bool, /// Physical sector size: always 2048 for CD-ROM (Yellow Book), 512 for HDD. /// Used for TOC lead-out LBA (CD spec requires TOC in physical sectors). phys_block_size: u64, @@ -143,27 +170,26 @@ pub struct ScsiDevice { identity: ScsiIdentity, } +/// A CD-ROM's physical sector (Yellow Book). +pub const CDROM_SECTOR: u64 = 2048; + const SCSI_BUFFER_SIZE: usize = 0x4000; // 16KB (16384 bytes) impl ScsiDevice { - pub fn new( - backend: Box, - size: u64, - is_cdrom: bool, - filename: String, - discs: Vec, - identity: ScsiIdentity, - ) -> Self { + /// A disk, or a CD-ROM drive holding the image `node` opened from `path` + /// with `discs` queued. + pub fn new(node: Box, is_cdrom: bool, path: String, discs: Vec, identity: ScsiIdentity) -> Self { + let media = if is_cdrom { + Media::Changer(ChangerNode::loaded(node, &path, discs)) + } else { + Media::Fixed(node, path) + }; Self { - backend: Some(backend), - size, + media, kind: if is_cdrom { DeviceKind::Cdrom } else { DeviceKind::Disk }, - filename, - discs, buffer: vec![0u8; SCSI_BUFFER_SIZE], pending_sense: [0u8; 18], - unit_attention: false, - phys_block_size: if is_cdrom { 2048 } else { 512 }, + phys_block_size: if is_cdrom { CDROM_SECTOR } else { 512 }, // 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 }, @@ -176,15 +202,11 @@ impl ScsiDevice { /// MEDIUM NOT PRESENT. pub fn new_empty_cdrom(identity: ScsiIdentity) -> Self { Self { - backend: None, - size: 0, + media: Media::Changer(ChangerNode::empty()), kind: DeviceKind::Cdrom, - filename: String::new(), - discs: vec![], buffer: vec![0u8; SCSI_BUFFER_SIZE], pending_sense: [0u8; 18], - unit_attention: false, - phys_block_size: 2048, + phys_block_size: CDROM_SECTOR, logical_block_size: 2048, identity, } @@ -192,49 +214,38 @@ impl ScsiDevice { /// Whether physical media is loaded. For HDDs always true; for CD-ROMs /// false when the tray is empty. - pub fn has_media(&self) -> bool { self.backend.is_some() } - - /// 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<()> { - let node = open_image(path, ImageKind::Cdrom, false, None)?; - let size = node.size(); - self.backend = Some(node); - self.size = size; - self.filename = path.to_string(); - self.unit_attention = true; - Ok(()) + pub fn has_media(&self) -> bool { self.media.present() } + + /// What the guest's eject does: [`Removable::eject`], whose queue rules + /// are the changer's. A disc that will not open is logged and the drive + /// left as it was. + fn eject(&mut self) { + if let Media::Changer(c) = &mut self.media { + if let Err(e) = c.eject() { + eprintln!("SCSI eject: {e}"); + } + } } /// Size of the device's uncommitted divergence, via [`BlockNode::dirty_count`]: /// exact dirty sectors for a raw overlay, a coarse 1/0 for a CHD diff. 0 if /// direct / no media. pub fn cow_dirty_count(&self) -> usize { - self.backend.as_ref().map_or(0, |node| node.dirty_count()) + self.media.node().dirty_count() } /// Whether this device has a divergence layer (a raw `.overlay` or a CHD /// `.diff.chd`) that `cow commit` / `cow reset` can act on. pub fn is_cow(&self) -> bool { - self.backend.as_ref().is_some_and(|node| node.is_cow()) + self.media.node().is_cow() } pub fn is_cdrom(&self) -> bool { matches!(self.kind, DeviceKind::Cdrom) } - /// Current active disc path (for display / status). - pub fn current_disc(&self) -> &str { - &self.filename - } - - /// All discs in the changer list. - pub fn disc_list(&self) -> &[String] { - &self.discs - } - /// (physical_block_size, logical_block_size) /// Capacity of the loaded medium, in bytes (0 with none). pub fn size(&self) -> u64 { - self.size + self.media.node().size() } /// Set the logical block size, as a controller does for its own @@ -269,12 +280,11 @@ impl ScsiDevice { } fn exec_test_unit_ready(&mut self, _cdb: &[u8]) -> Result { - if self.unit_attention { - self.unit_attention = false; + if self.media.take_changed() { // Sense key 0x06 UNIT_ATTENTION, ASC 0x28 "Not Ready to Ready Transition / Medium Changed" return Ok(self.check_condition(0x06, 0x28, 0x00)); } - if self.backend.is_none() { + if !self.media.present() { // Sense key 0x02 NOT_READY, ASC 0x3A "Medium not present" return Ok(self.check_condition(0x02, 0x3A, 0x00)); } @@ -333,11 +343,11 @@ impl ScsiDevice { } fn exec_read_capacity_10(&mut self, _cdb: &[u8]) -> Result { - if self.backend.is_none() { + if !self.media.present() { return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT } let block_size = self.logical_block_size as u32; - let last_lba = (self.size / self.logical_block_size).saturating_sub(1) as u32; + let last_lba = (self.size() / self.logical_block_size).saturating_sub(1) as u32; //eprintln!("SCSI READ CAPACITY: block_size={} last_lba={} data={:02x?}", block_size, last_lba, &[last_lba.to_be_bytes(), block_size.to_be_bytes()].concat()); let mut data = vec![0u8; 8]; @@ -363,16 +373,16 @@ impl ScsiDevice { } fn perform_read(&mut self, lba: u64, count: usize) -> Result { - let Some(backend) = self.backend.as_mut() else { + if !self.media.present() { return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT - }; + } // Check LBA bounds before attempting I/O - let last_lba = self.size / self.logical_block_size; + let last_lba = self.size() / self.logical_block_size; if count > 0 && (lba >= last_lba || lba + count as u64 > last_lba) { return Ok(self.check_condition(0x05, 0x21, 0x00)); // Illegal Request: LBA Out of Range } let mut data = vec![0u8; count * self.logical_block_size as usize]; - backend.read(lba * self.logical_block_size, &mut data)?; + self.media.node_mut().read(lba * self.logical_block_size, &mut data)?; let expected = count as u64 * self.logical_block_size; if data.len() as u64 != expected { eprintln!( @@ -405,7 +415,7 @@ impl ScsiDevice { return Ok(self.check_condition(0x07, 0x27, 0x00)); // DATA PROTECT: write protected } // Past the end is an error, not a bigger disk. - if (lba + count as u64) * self.logical_block_size > self.size { + if (lba + count as u64) * self.logical_block_size > self.size() { return Ok(self.check_condition(0x05, 0x21, 0x00)); // ILLEGAL REQUEST: LBA out of range } @@ -425,10 +435,10 @@ impl ScsiDevice { } // Writes always go through as 512-byte sectors (HDD path only, phys=logical=512) - let Some(backend) = self.backend.as_mut() else { + if !self.media.present() { return Ok(self.check_condition(0x02, 0x3A, 0x00)); - }; - if backend.write(lba * 512, data).is_err() { + } + if self.media.node_mut().write(lba * 512, data).is_err() { return Ok(self.check_condition(0x03, 0x0c, 0x00)); // MEDIUM ERROR: write error } @@ -443,9 +453,7 @@ impl ScsiDevice { let loej = (cdb[4] & 0x02) != 0; let start = (cdb[4] & 0x01) != 0; if loej && !start && self.is_cdrom() { - // Eject requested. eject_next() handles the count-driven cases: - // 0/1 disc empties the tray; 2+ discs cycle to the next. - self.eject_next(); + self.eject(); } Ok(ScsiResponse { status: 0x00, data: vec![] }) } @@ -469,7 +477,7 @@ impl ScsiDevice { let block_desc: Vec = if dbd { vec![] } else { - let total_blocks = (self.size / self.logical_block_size) as u32; + let total_blocks = (self.size() / self.logical_block_size) as u32; vec![ 0x00, // density code (total_blocks >> 16) as u8, @@ -485,7 +493,7 @@ impl ScsiDevice { // Synthesize geometry from disk size (HDD pages only) let heads: u32 = 16; let spt: u32 = 63; - let total_blocks_u64 = self.size / self.logical_block_size; + let total_blocks_u64 = self.size() / self.logical_block_size; let cylinders = ((total_blocks_u64 + (heads * spt - 1) as u64) / (heads * spt) as u64) as u32; let mut pages: Vec = Vec::new(); @@ -839,7 +847,7 @@ impl ScsiDevice { } fn exec_read_toc_pma_atip(&mut self, cdb: &[u8]) -> Result { - if self.backend.is_none() { + if !self.media.present() { return Ok(self.check_condition(0x02, 0x3A, 0x00)); // NOT READY / MEDIUM NOT PRESENT } let msf = (cdb[1] & 0x02) != 0; @@ -847,7 +855,7 @@ impl ScsiDevice { let alloc_len = alloc_len_10(cdb); // TOC LBAs are always in physical CD sectors (2048 bytes each). - let total_lba = (self.size / self.phys_block_size) as u32; + let total_lba = (self.size() / self.phys_block_size) as u32; //eprintln!("SCSI READ TOC: format={} msf={} total_lba={} (current block_size={})", // format, msf, total_lba, self.logical_block_size); @@ -918,7 +926,7 @@ impl ScsiDevice { if !self.is_cdrom() { return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command for HDD } - self.eject_next(); + self.eject(); Ok(ScsiResponse { status: 0x00, data: vec![] }) } @@ -969,11 +977,10 @@ impl ScsiTarget for ScsiDevice { } // Unit attention fires before any other command (except REQUEST_SENSE/INQUIRY). - if self.unit_attention - && req.cdb[0] != scsi_cmd::REQUEST_SENSE + if req.cdb[0] != scsi_cmd::REQUEST_SENSE && req.cdb[0] != scsi_cmd::INQUIRY + && self.media.take_changed() { - self.unit_attention = false; return Ok(self.check_condition(0x06, 0x28, 0x00)); } @@ -1029,10 +1036,7 @@ impl ScsiTarget for ScsiDevice { /// Returns a coarse count of what was committed. The concrete behaviour is /// [`BlockNode::commit`] — including the CHD handle close/reopen. fn cow_commit(&mut self) -> io::Result { - match self.backend.as_mut() { - Some(node) => node.commit(), - None => Ok(0), - } + self.media.node_mut().commit() } /// Reset the divergence layer — discard all uncommitted writes ("roll @@ -1040,35 +1044,27 @@ impl ScsiTarget for ScsiDevice { /// reopens a fresh overlay over the untouched base. No-op if not overlaid. /// The concrete behaviour is [`BlockNode::rollback`]. fn cow_reset(&mut self) -> io::Result<()> { - match self.backend.as_mut() { - Some(node) => node.rollback(), - None => Ok(()), - } + self.media.node_mut().rollback() } /// Copy the COW overlay into `dest` and return its dirty sector set. /// Direct-mode / no-media devices create no file. fn cow_export(&mut self, dest: &std::path::Path) -> io::Result>> { - match self.backend.as_mut() { - Some(node) if node.is_cow() => node.export_overlay(dest).map(Some), - _ => Ok(None), - } + let node = self.media.node_mut(); + if node.is_cow() { node.export_overlay(dest).map(Some) } else { Ok(None) } } /// Replace the COW overlay with the contents of `source` and adopt /// `dirty` as the dirty sector set. No-op on non-COW / no-media devices. fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { - match self.backend.as_mut() { - Some(node) => node.import_overlay(source, dirty), - None => Ok(()), - } + self.media.node_mut().import_overlay(source, dirty) } /// `(base, diff)` paths if this device is a CHD writing to a `.diff.chd` /// sidecar that holds changes worth folding back into the base on a clean /// shutdown. `None` for in-place / non-CHD / no-media devices. fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { - self.backend.as_ref().and_then(|node| node.pending_sync()) + self.media.node().pending_sync() } /// Take the pending-sync paths AND release the disk backend, closing the CHD @@ -1077,167 +1073,30 @@ impl ScsiTarget for ScsiDevice { fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { let pending = self.pending_chd_sync(); if pending.is_some() { - self.backend = None; + // Nothing talks to the disk once the guest has stopped; it answers + // as a drive with its tray empty. + self.media = Media::Changer(ChangerNode::empty()); } pending } - /// 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. - fn eject_next(&mut self) -> Option { + /// The drive's tray and queue, `None` for a disk. + fn changer(&mut self) -> Option<&mut ChangerNode> { 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. - let current = self.discs.remove(0); - self.discs.push(current); - let next_path = self.discs[0].clone(); - - match open_image(&next_path, ImageKind::Cdrom, false, None) { - Ok(node) => { - let size = node.size(); - self.backend = Some(node); - self.size = size; - // phys_block_size never changes — CD-ROM physical sectors are always 2048. - // Do NOT reset logical_block_size — MODE SELECT is a controller setting - // 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) => { - eprintln!("SCSI eject: could not open {}: {}", next_path, e); - None - } - } - } - - /// Unload media from a CD-ROM (tray empty). - fn unload_media(&mut self) { - self.backend = None; - self.size = 0; - self.filename = String::new(); - self.unit_attention = true; - } - - /// Load an explicit image path and make it the active disc immediately, - /// as if a hand swapped the disc in the drive. The path is inserted at the - /// front of the changer list (becomes index 0) and a medium-change is - /// signalled (unit attention) so the guest re-reads the TOC on its next - /// command. The image is opened as a drive opens any disc (see - /// [`open_image`]). 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. - fn load_disc(&mut self, path: String) -> Result { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - let node = open_image(&path, ImageKind::Cdrom, false, None) - .map_err(|e| format!("could not open {}: {}", path, e))?; - let size = node.size(); - self.backend = Some(node); - self.size = size; - self.filename = 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()); - } + match &mut self.media { + Media::Changer(c) => Some(c), + Media::Fixed(..) => None, } - Ok(path) - } - - /// Insert a new disc path at position 1 (next after current). - /// Returns Err if this is not a CD-ROM or the path doesn't exist. - fn add_disc(&mut self, path: String) -> Result<(), String> { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if !std::path::Path::new(&path).exists() { - return Err(format!("File not found: {}", path)); - } - // Insert at index 1 so it becomes the next disc after eject. - let insert_pos = if self.discs.is_empty() { 0 } else { 1 }; - self.discs.insert(insert_pos, path); - Ok(()) - } - - /// Remove disc at the given ordinal index. - /// Removing index 0 (current) does not eject — it only removes the path. - /// Returns Err if index is out of range. - fn remove_disc(&mut self, ordinal: usize) -> Result { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if ordinal >= self.discs.len() { - return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); - } - Ok(self.discs.remove(ordinal)) - } - - /// Move disc at `ordinal` to index 1 (next after current). - /// If ordinal is 0 (active), returns Err. - fn move_disc_next(&mut self, ordinal: usize) -> Result<(), String> { - if !self.is_cdrom() { - return Err("Not a CD-ROM device".to_string()); - } - if ordinal == 0 { - return Err("Ordinal 0 is the active disc; use 'scsi eject' to advance".to_string()); - } - if ordinal >= self.discs.len() { - return Err(format!("Ordinal {} out of range (0..{})", ordinal, self.discs.len())); - } - if self.discs.len() < 2 { - return Ok(()); // already only entry - } - let path = self.discs.remove(ordinal); - // Insert at position 1 (right after the active disc at 0). - self.discs.insert(1, path); - Ok(()) } fn status(&self, id: usize) -> Vec { let (phys, logical) = self.block_sizes(); if self.is_cdrom() { - let list = self.disc_list(); - let active = if self.has_media() { self.current_disc() } else { "(tray empty)" }; + let Media::Changer(changer) = &self.media else { return Vec::new() }; + let list = changer.discs(); + let active = changer.current().unwrap_or("(tray empty)"); let mut lines = vec![format!("SCSI ID {}: CD-ROM {} ({} disc(s)) phys_block={} logical_block={}", id, active, list.len(), phys, logical)]; for (i, d) in list.iter().enumerate() { @@ -1247,8 +1106,9 @@ impl ScsiTarget for ScsiDevice { lines } else { let cow = if self.is_cow() { ", copy-on-write" } else { "" }; + let path = match &self.media { Media::Fixed(_, p) => p.as_str(), Media::Changer(_) => "" }; vec![format!("SCSI ID {}: disk {} ({} blocks of {}{})", - id, self.current_disc(), self.size() / logical.max(1), logical, cow)] + id, path, self.size() / logical.max(1), logical, cow)] } } @@ -1263,14 +1123,6 @@ impl ScsiTarget for ScsiDevice { } fn cow_dirty(&self) -> Option { self.is_cow().then(|| self.cow_dirty_count()) } - - fn discs(&self) -> Option { - self.is_cdrom().then(|| Discs { - current: self.filename.clone(), - list: self.discs.clone(), - block_sizes: self.block_sizes(), - }) - } } #[cfg(test)] @@ -1305,7 +1157,6 @@ mod tests { let node = crate::block_node::RawNode::open(&path).unwrap(); let mut dev = ScsiDevice::new( Box::new(node), - 4 * 512, false, path.display().to_string(), vec![], @@ -1346,7 +1197,6 @@ mod tests { .with_base_path(&base); let mut dev = ScsiDevice::new( Box::new(node), - 2 * 512, false, base.display().to_string(), vec![], @@ -1390,7 +1240,7 @@ mod tests { std::fs::write(&path, vec![0u8; 100 * 2048]).expect("test image"); let node = crate::block_node::RawNode::open(&path).expect("open"); let identity = ScsiIdentity::new(b"TEST ", b"CD-ROM ", b"1.0 "); - let mut dev = ScsiDevice::new(Box::new(node), 100 * 2048, true, path.display().to_string(), Vec::new(), identity); + let mut dev = ScsiDevice::new(Box::new(node), true, path.display().to_string(), Vec::new(), identity); let _ = run(&mut dev, &[0x00, 0, 0, 0, 0, 0]); let _ = run(&mut dev, &[0x03, 0, 0, 0, 18, 0]); dev @@ -1445,7 +1295,7 @@ mod tests { let mut cd = ScsiDevice::new_empty_cdrom(CDROM_IDENTITY); assert!(!cd.has_media()); - ScsiTarget::load_disc(&mut cd, chd.display().to_string()).unwrap(); + cd.changer().unwrap().load(&chd.display().to_string()).unwrap(); assert!(cd.has_media()); assert_eq!(cd.size(), 16 * 2048, "the CHD's size, not the file's"); // The medium change is reported once, then the disc reads. diff --git a/src/scsi/bus.rs b/src/scsi/bus.rs index 83838d32..acd8e007 100644 --- a/src/scsi/bus.rs +++ b/src/scsi/bus.rs @@ -11,17 +11,9 @@ use std::io; use parking_lot::{Mutex, MutexGuard}; use super::{ScsiDevice, ScsiIdentity, ScsiRequest, ScsiResponse}; -use crate::block_node::{open_image, ImageKind}; +use crate::block_node::{open_image, ChangerNode, ImageKind}; use crate::dev::daynaport::DaynaPort; -/// A changer's discs: the active one, the queue (index 0 is the active -/// disc) and the physical and logical block sizes. -pub struct Discs { - pub current: String, - pub list: Vec, - pub block_sizes: (u64, u64), -} - /// A device on a SCSI bus, as the controller drives it: a CDB goes in, data /// and a status come out. /// @@ -88,31 +80,10 @@ pub trait ScsiTarget: Send { /// be rebuilt. fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { None } - // ---- disc changer ------------------------------------------------ - - /// The discs, `None` unless this is a CD-ROM. - fn discs(&self) -> Option { None } - - /// Make `path` the active disc. - fn load_disc(&mut self, _path: String) -> Result { Err(NOT_A_CDROM.into()) } - - /// Eject, and load the next disc in the queue; `None` with none to load. - fn eject_next(&mut self) -> Option { None } - - /// Eject and leave the drive empty. - fn unload_media(&mut self) {} - - /// Queue `path` as the next disc. - fn add_disc(&mut self, _path: String) -> Result<(), String> { Err(NOT_A_CDROM.into()) } - - /// Remove the queued disc at `ordinal`. - fn remove_disc(&mut self, _ordinal: usize) -> Result { Err(NOT_A_CDROM.into()) } - - /// Make the queued disc at `ordinal` the next. - fn move_disc_next(&mut self, _ordinal: usize) -> Result<(), String> { Err(NOT_A_CDROM.into()) } + /// The drive's tray and queue of discs, `None` unless this is a CD-ROM. + fn changer(&mut self) -> Option<&mut ChangerNode> { None } } -const NOT_A_CDROM: &str = "Not a CD-ROM device"; /// A DaynaPort answers its own vendor command set, which reuses the READ(6) /// and WRITE(6) opcodes for packet receive and transmit; it answers no @@ -211,8 +182,7 @@ impl ScsiBus { } else { let kind = if cdrom { ImageKind::Cdrom } else { ImageKind::Disk }; let node = open_image(path, kind, overlay, overlay_path)?; - let size = node.size(); - ScsiDevice::new(node, size, cdrom, path.to_string(), if cdrom { discs } else { Vec::new() }, identity) + ScsiDevice::new(node, cdrom, path.to_string(), discs, identity) }; let blocks = dev.size() / 512; self.attach(id, Box::new(dev))?; diff --git a/src/scsi_monitor.rs b/src/scsi_monitor.rs index 3e0fecf5..2a68306f 100644 --- a/src/scsi_monitor.rs +++ b/src/scsi_monitor.rs @@ -13,7 +13,8 @@ use std::io::Write; -use crate::scsi::{ScsiBus, ScsiTarget}; +use crate::block_node::{ChangerNode, Removable}; +use crate::scsi::{ScsiBus, ScsiTarget, CDROM_SECTOR}; /// A SCSI controller, for the targets on its bus. pub trait ScsiTargets { @@ -29,12 +30,14 @@ pub trait ScsiTargets { /// The shared subcommands, for a chip's `help` text. pub const SCSI_USAGE: &str = "scsi status | scsi dayna | scsi eject | scsi add | scsi list | scsi del | scsi next "; -/// Run `f` on the CD-ROM at `id`, or say why there is none. -fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut dyn ScsiTarget) -> Result) -> Result { +/// Run `f` on the changer of the CD-ROM at `id`, or say why there is none. +fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut ChangerNode) -> Result) -> Result { ctl.with_targets(|targets| match targets.get_mut(id).and_then(|d| d.as_mut()) { None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) if dev.discs().is_none() => Err(format!("SCSI ID {} is not a CD-ROM", id)), - Some(dev) => f(&mut **dev), + Some(dev) => match dev.changer() { + None => Err(format!("SCSI ID {} is not a CD-ROM", id)), + Some(changer) => f(changer), + }, }) } @@ -42,11 +45,12 @@ fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut dyn ScsiTarge /// block size). pub fn disc_status(ctl: &impl ScsiTargets) -> Vec<(usize, String, Vec, u64, u64)> { ctl.with_targets(|targets| { - targets.iter().enumerate() + targets.iter_mut().enumerate() .filter_map(|(id, d)| { - let discs = d.as_ref()?.discs()?; - let (phys, logical) = discs.block_sizes; - Some((id, discs.current, discs.list, phys, logical)) + let dev = d.as_mut()?; + let logical = dev.block_size()?; + let changer = dev.changer()?; + Some((id, changer.current().unwrap_or("").to_string(), changer.discs().to_vec(), CDROM_SECTOR, logical)) }) .collect() }) @@ -55,35 +59,40 @@ pub fn disc_status(ctl: &impl ScsiTargets) -> Vec<(usize, String, Vec, u /// Put the image at `path` in CD-ROM `id` and make it the active disc, as a /// hand swapping the disc would. Returns the path loaded. pub fn load_disc(ctl: &impl ScsiTargets, id: usize, path: String) -> Result { - cdrom(ctl, id, |dev| dev.load_disc(path)) + cdrom(ctl, id, |changer| { + changer.load(&path).map_err(|e| format!("could not open {}: {}", path, e))?; + Ok(path) + }) } /// Eject CD-ROM `id`'s disc and load the next in its changer list; returns /// the new active path. pub fn eject_disc(ctl: &impl ScsiTargets, id: usize) -> Result { - cdrom(ctl, id, |dev| { - dev.eject_next().ok_or_else(|| format!("SCSI ID {} has only one disc (nothing to eject to)", id)) + cdrom(ctl, id, |changer| match changer.eject() { + Ok(Some(path)) => Ok(path), + Ok(None) => Err(format!("SCSI ID {} has only one disc (nothing to eject to)", id)), + Err(e) => Err(format!("SCSI ID {}: could not load the next disc: {}", id, e)), }) } /// Eject CD-ROM `id`'s disc and leave the drive empty. pub fn eject_to_empty(ctl: &impl ScsiTargets, id: usize) -> Result<(), String> { - cdrom(ctl, id, |dev| { dev.unload_media(); Ok(()) }) + cdrom(ctl, id, |changer| { changer.unload(); Ok(()) }) } /// Queue `path` as the next disc after an eject on CD-ROM `id`. pub fn add_disc(ctl: &impl ScsiTargets, id: usize, path: String) -> Result<(), String> { - cdrom(ctl, id, |dev| dev.add_disc(path)) + cdrom(ctl, id, |changer| changer.add(path)) } /// Remove the disc at `ordinal` from CD-ROM `id`'s queue (not the active one). pub fn remove_disc(ctl: &impl ScsiTargets, id: usize, ordinal: usize) -> Result { - cdrom(ctl, id, |dev| dev.remove_disc(ordinal)) + cdrom(ctl, id, |changer| changer.remove(ordinal)) } /// Move the disc at `ordinal` to be the next after an eject on CD-ROM `id`. pub fn move_disc_next(ctl: &impl ScsiTargets, id: usize, ordinal: usize) -> Result<(), String> { - cdrom(ctl, id, |dev| dev.move_disc_next(ordinal)) + cdrom(ctl, id, |changer| changer.move_next(ordinal)) } fn write_queue(w: &mut dyn Write, list: &[String]) { @@ -147,7 +156,7 @@ pub fn scsi_subcommand(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) Ok(()) }), "list" => id_arg(1, "Usage: scsi list ").map(|id| { - match cdrom(ctl, id, |dev| Ok(dev.discs().map(|d| d.list).unwrap_or_default())) { + match cdrom(ctl, id, |changer| Ok(changer.discs().to_vec())) { Ok(list) => { let _ = writeln!(w, "SCSI ID {} queue ({} disc(s)):", id, list.len()); write_queue(w, &list); From 0c55ae4418f5a1e2f85cda198ed9a287c1f6df89 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 10:11:55 -0700 Subject: [PATCH 10/11] scsi: give each controller its bus at construction Each controller made an empty ScsiBus in its constructor and the machine reached in afterwards to attach targets. The machine now builds the bus, fills it from the [scsi.N] tables and passes it to the controller, so a controller exists with its targets and no machine attaches through a controller. Each chip owns the kind of bus it drives (width and INQUIRY identities) and offers it as new_bus(). The bus could take identities at attach instead, but that would change attach_device and move each identity out of its chip. A target attached after boot, by the monitor or the CI socket, goes on the same bus through the controller. The Default impls of the WD33C93A and ISP1040 had no users and go with the empty-bus constructors, which are now test-only. The Indy, Indigo2, O2 and Octane attach their SCSI targets a little earlier in start-up, so their per-target log lines come before the flash and NVRAM messages they used to follow. --- HACKING.md | 6 ++-- src/dev/aic7880.rs | 46 ++++++++++++++++++++++++---- src/dev/hpc3.rs | 52 ++++++++++++++++++++++---------- src/dev/isp1040.rs | 25 +++++++++------- src/dev/isp1040/tests.rs | 25 ++++++++++++++++ src/dev/wd33c93a.rs | 51 +++++++++++++++++++++++-------- src/ip30/tests.rs | 2 +- src/ip30_machine.rs | 12 ++++---- src/ip32.rs | 15 +++++----- src/ip32_machine.rs | 20 ++++++------- src/machine.rs | 65 +++++++++++++++++++++------------------- 11 files changed, 217 insertions(+), 102 deletions(-) diff --git a/HACKING.md b/HACKING.md index bd68fe09..df1fb1b9 100644 --- a/HACKING.md +++ b/HACKING.md @@ -82,8 +82,10 @@ image. A CD-ROM's node is a `ChangerNode`: the queue of discs and the open active one, with the `Removable` trait (present, changed, eject, load) separate from `BlockNode` so that removable media which is not a block device, a tape, can use it too. The WD33C93A (Indy, Indigo2), the Adaptec -7880 (O2) and the ISP1040 (Octane) each own one and keep only their registers -and DMA. `src/scsi_monitor.rs` implements the monitor's `scsi` and `cow` +7880 (O2) and the ISP1040 (Octane) each make an empty bus of their own kind +(`new_bus`); the machine fills it from config and gives it to the controller's +constructor, so a controller exists with its targets and keeps only its +registers and DMA. `src/scsi_monitor.rs` implements the monitor's `scsi` and `cow` subcommands and the run-time disc changes once, over the bus a controller hands out through `ScsiTargets::scsi_bus`; chip-specific subcommands (`regs`; the WD's `wdt`, `defer`, `debug`) stay with the chip, and `DiscChanger` is diff --git a/src/dev/aic7880.rs b/src/dev/aic7880.rs index 8db97cdc..769560f9 100644 --- a/src/dev/aic7880.rs +++ b/src/dev/aic7880.rs @@ -449,14 +449,23 @@ pub const PCI_DEVICE_AIC7880: u16 = 0x8078; pub const PCI_CLASS_SCSI: u32 = 0x0100_00; impl Aic7880 { + /// An empty bus of the kind this chip drives: ids 0-15, and the + /// emulated disk and CD-ROM. The machine fills it from `[scsi.N]` and + /// hands it to [`Self::new_at`]. + pub fn new_bus() -> ScsiBus { + ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }) + } + + /// The controller on an empty bus, for the tests. + #[cfg(test)] pub fn new() -> Arc { - Self::new_at(0) + Self::new_at(0, Self::new_bus()) } - /// The controller at `index`: 0 on the internal bus, 1 on the external - /// connector. Commands run inline until [`crate::traits::Device::start`] - /// gives it its own thread. - pub fn new_at(index: u8) -> Arc { + /// The controller at `index` on `bus`: 0 on the internal bus, 1 on the + /// external connector. Commands run inline until + /// [`crate::traits::Device::start`] gives it its own thread. + pub fn new_at(index: u8, bus: ScsiBus) -> Arc { Arc::new_cyclic(|me| Self { index, st: SpinLock::new(State::default()), @@ -464,7 +473,7 @@ impl Aic7880 { worker: Mutex::new(None), running: AtomicBool::new(false), me: me.clone(), - bus: ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }), + bus, dma: std::sync::OnceLock::new(), sense: Mutex::new(None), dma_misses: Mutex::new(std::collections::BTreeSet::new()), @@ -2475,4 +2484,29 @@ mod tests { assert_eq!(c.read(reg::INTSTAT, 1) as u8, intstat::CMDCMPLT); assert!(c.st.lock().held.is_empty()); } + + /// The machine fills the bus from `[scsi.N]` and gives it to the + /// controller, which answers for what is on it and still takes a target + /// attached later through the same bus. + #[test] + fn a_controller_built_on_a_filled_bus_answers_for_its_target() { + let img = std::env::temp_dir().join(format!("iris-aic7880-filled-{}.img", std::process::id())); + std::fs::write(&img, vec![0u8; 64 * 512]).unwrap(); + let bus = Aic7880::new_bus(); + bus.attach_image(3, &img, false).unwrap(); + let c = Aic7880::new_at(1, bus); + assert!(c.bus().has_target(3)); + assert!(!c.bus().has_target(4)); + let inquiry = crate::scsi::ScsiRequest { + cdb: vec![crate::scsi::scsi_cmd::INQUIRY, 0, 0, 0, 36, 0], + data_len: crate::scsi::ScsiDataLength::Unlimited, + data_in: None, + }; + let r = c.bus().request(3, &inquiry).expect("target answers").unwrap(); + assert_eq!(&r.data[8..16], b"SGI "); + assert_eq!(&r.data[16..32], b"IRIS EMUL DISK "); + c.bus().attach_image(4, &img, false).unwrap(); + assert!(c.bus().has_target(4), "a target attached after boot goes on the same bus"); + let _ = std::fs::remove_file(img); + } } diff --git a/src/dev/hpc3.rs b/src/dev/hpc3.rs index 0d245a65..13c4f275 100644 --- a/src/dev/hpc3.rs +++ b/src/dev/hpc3.rs @@ -1106,8 +1106,20 @@ pub struct Hpc3 { } impl Hpc3 { - pub fn new(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc) -> Self { - Self::with_net(eeprom, ioc, guinness, heartbeat, NetworkConfig::default(), false, AudioConfig::default(), "nvram.bin".to_string(), RtcOffset::default(), true) + /// The empty SCSI buses of a board: one controller on an Indy (guinness), + /// two on an Indigo2. A machine fills them from `[scsi.N]` and gives them + /// to [`Self::with_net`]. + pub fn new_scsi_buses(guinness: bool) -> Vec { + (0..if guinness { 1 } else { 2 }).map(|_| Wd33c93a::new_bus()).collect() + } + + /// The bus `controller` names among a board's `buses`, or why there is none. + pub fn route_scsi(buses: &[crate::scsi::ScsiBus], controller: u8) -> Result<&crate::scsi::ScsiBus, String> { + match controller { + c if (c as usize) < buses.len() => Ok(&buses[c as usize]), + 1 => Err("controller 1 (this machine has only controller 0)".to_string()), + c => Err(format!("controller {c} (valid: 0, 1)")), + } } /// `no_audio` skips HAL2 audio init (used by `--noaudio` and also by full @@ -1115,7 +1127,8 @@ impl Hpc3 { /// `nvram_path` is the on-disk NVRAM file (loaded at startup, default save /// target for `iris-ci rtc-save`); `rtc_offset` shifts the RTC's /// host-time seed (`[rtc_offset]`). - pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, no_audio: bool, audio: AudioConfig, nvram_path: String, rtc_offset: RtcOffset, scsi_deferred_int: bool) -> Self { + pub fn with_net(eeprom: Arc>, ioc: Ioc, guinness: bool, heartbeat: Arc, net: NetworkConfig, no_audio: bool, audio: AudioConfig, nvram_path: String, rtc_offset: RtcOffset, scsi_deferred_int: bool, scsi_buses: Vec) -> Self { + let mut scsi_buses = scsi_buses.into_iter(); let nfs = net.nfs; let port_forwards = net.port_forward; let subnet = net.nat_subnet.unwrap_or_default(); @@ -1213,14 +1226,16 @@ impl Hpc3 { // lingering PDMA INT on the same line; see Hpc3Interrupts. let scsi0_irq = irqs.line(HPC3_INTSTAT_SCSI0_DEV); - let scsi_dev = Arc::new(Wd33c93a::new_with_id(Some(scsi0_dma), Some(scsi0_irq), heartbeat.clone(), scsi_deferred_int, 0)); + let scsi_dev = Arc::new(Wd33c93a::new_with_id(Some(scsi0_dma), Some(scsi0_irq), heartbeat.clone(), scsi_deferred_int, 0, + scsi_buses.next().expect("a board has a SCSI bus"))); let _ = scsi_wd_lock.set(scsi_dev.clone()); // Fullhouse's second WD33C93A — see scsi1_wd_lock's doc comment above. let scsi_dev1 = if !guinness { let scsi1_dma = Arc::new(PdmaClientImpl { channel: pdma_channels[9].clone() }); let scsi1_irq = irqs.line(HPC3_INTSTAT_SCSI1_DEV); - let dev = Arc::new(Wd33c93a::new_with_id(Some(scsi1_dma), Some(scsi1_irq), heartbeat.clone(), scsi_deferred_int, 1)); + let dev = Arc::new(Wd33c93a::new_with_id(Some(scsi1_dma), Some(scsi1_irq), heartbeat.clone(), scsi_deferred_int, 1, + scsi_buses.next().expect("a fullhouse board has a second SCSI bus"))); let _ = scsi1_wd_lock.set(dev.clone()); Some(dev) } else { @@ -1270,16 +1285,6 @@ impl Hpc3 { } } - /// The SCSI bus on `controller`: 0 on both machines, 1 on the Indigo2's - /// second WD33C93A only. - pub fn scsi_bus(&self, controller: u8) -> Result<&crate::scsi::ScsiBus, String> { - match controller { - 0 => Ok(&**self.scsi_dev.bus()), - 1 => self.scsi_dev1.as_ref().map(|d| &**d.bus()).ok_or_else(|| "controller 1 (this machine has only controller 0)".to_string()), - c => Err(format!("controller {c} (valid: 0, 1)")), - } - } - /// Attach a DaynaPort SCSI/Link (SCSI-attached Ethernet) at `id`. /// /// Each DaynaPort gets its **own** `NatEngine` on its own subnet, separate @@ -2366,7 +2371,7 @@ mod tests { let hpc = Hpc3::with_net( ee.clone(), Ioc::new_ci(true), false, Arc::new(AtomicU64::new(0)), NetworkConfig::default(), true, AudioConfig::default(), nvram.clone(), - RtcOffset::default(), true, + RtcOffset::default(), true, Hpc3::new_scsi_buses(false), ); hpc.rtc.write8(500, 0x5a); ee.lock().set_word(0x74, 0x3530); @@ -2398,6 +2403,7 @@ mod tests { String::new(), RtcOffset::default(), true, + Hpc3::new_scsi_buses(true), ) } @@ -2571,6 +2577,7 @@ mod enet_rx_chain_tests { String::new(), RtcOffset::default(), true, + Hpc3::new_scsi_buses(true), ) } @@ -2596,4 +2603,17 @@ mod enet_rx_chain_tests { sq_rxintr's restart path never runs and receive never resumes", ); } + + /// A target for controller 1 on an Indy, which has one, is an error that + /// names the machine; an Indigo2 has the second bus. + #[test] + fn the_second_scsi_controller_exists_on_an_indigo2_only() { + let indy = Hpc3::new_scsi_buses(true); + assert!(Hpc3::route_scsi(&indy, 0).is_ok()); + let e = Hpc3::route_scsi(&indy, 1).err().unwrap(); + assert!(e.contains("only controller 0"), "{e}"); + let indigo2 = Hpc3::new_scsi_buses(false); + assert!(Hpc3::route_scsi(&indigo2, 1).is_ok()); + assert!(Hpc3::route_scsi(&indigo2, 2).err().unwrap().contains("valid: 0, 1")); + } } diff --git a/src/dev/isp1040.rs b/src/dev/isp1040.rs index 1e26b612..31e946fb 100644 --- a/src/dev/isp1040.rs +++ b/src/dev/isp1040.rs @@ -429,20 +429,25 @@ pub struct Core { heartbeat: OnceLock>, } -impl Default for Isp1040 { - fn default() -> Self { Self::new() } -} - fn le16(b: &[u8], off: usize) -> u16 { u16::from_le_bytes([b[off], b[off + 1]]) } impl Isp1040 { + /// An empty bus of the kind this chip drives: ids 0-15, and the + /// emulated disk and CD-ROM. The machine fills it from `[scsi.N]` and + /// hands it to [`Self::new_at`]. + pub fn new_bus() -> ScsiBus { + ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }) + } + + /// The controller on an empty bus, for the tests. + #[cfg(test)] pub fn new() -> Self { - Self::new_at(0) + Self::new_at(0, Self::new_bus()) } - /// The controller at `index`: 0 internal, 1 external. - pub fn new_at(index: u8) -> Self { - Self { core: Arc::new(Core::new_at(index)), worker: Mutex::new(None) } + /// The controller at `index` (0 internal, 1 external) on `bus`. + pub fn new_at(index: u8, bus: ScsiBus) -> Self { + Self { core: Arc::new(Core::new_at(index, bus)), worker: Mutex::new(None) } } /// Start the ring thread (idempotent) and let it take whatever was @@ -501,7 +506,7 @@ impl Drop for Isp1040 { } impl Core { - fn new_at(index: u8) -> Self { + fn new_at(index: u8, bus: ScsiBus) -> Self { let st = State::new(); let gen = st.ctl.gen; Self { @@ -510,7 +515,7 @@ impl Core { fence: Mutex::new(()), wake: Condvar::new(), line_order: Mutex::new(()), - bus: ScsiBus::new(16, ScsiIdentities { disk: DISK_IDENTITY, cdrom: CDROM_IDENTITY }), + bus, dma: OnceLock::new(), irq: OnceLock::new(), index, diff --git a/src/dev/isp1040/tests.rs b/src/dev/isp1040/tests.rs index 466c6ec5..a21f8c29 100644 --- a/src/dev/isp1040/tests.rs +++ b/src/dev/isp1040/tests.rs @@ -1242,3 +1242,28 @@ fn the_ring_thread_lives_between_start_and_stop() { assert_eq!(r.r(reg::REQUEST_QUEUE), 1); assert_eq!(g32(&r.status(0), 4), 0x7001); } + +/// The machine fills the bus from `[scsi.N]` and gives it to the controller, +/// which answers for what is on it and still takes a target attached later +/// through the same bus. +#[test] +fn a_controller_built_on_a_filled_bus_answers_for_its_target() { + let img = std::env::temp_dir().join(format!("iris-isp1040-filled-{}.img", std::process::id())); + std::fs::write(&img, vec![0u8; 64 * 512]).unwrap(); + let bus = Isp1040::new_bus(); + bus.attach_image(3, &img, false).unwrap(); + let isp = Isp1040::new_at(1, bus); + assert!(isp.bus().has_target(3)); + assert!(!isp.bus().has_target(4)); + let inquiry = crate::scsi::ScsiRequest { + cdb: vec![crate::scsi::scsi_cmd::INQUIRY, 0, 0, 0, 36, 0], + data_len: crate::scsi::ScsiDataLength::Unlimited, + data_in: None, + }; + let r = isp.bus().request(3, &inquiry).expect("target answers").unwrap(); + assert_eq!(&r.data[8..16], b"SGI "); + assert_eq!(&r.data[16..32], b"IRIS EMUL DISK "); + isp.bus().attach_image(4, &img, false).unwrap(); + assert!(isp.bus().has_target(4), "a target attached after boot goes on the same bus"); + let _ = std::fs::remove_file(img); +} diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index 63431e7a..45ae92c2 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -328,19 +328,24 @@ pub struct Wd33c93a { } impl Wd33c93a { - pub fn new(dma: Option>, irq: Option, heartbeat: Arc) -> Self { - Self::new_with_config(dma, irq, heartbeat, true) + /// An empty bus of the kind this chip drives: ids 0-7, and the Indy's + /// Sony CD-ROM. The machine fills it from `[scsi.N]` and hands it to + /// [`Self::new_with_id`]. + pub fn new_bus() -> ScsiBus { + ScsiBus::new(8, ScsiIdentities { disk: scsi::DISK_IDENTITY, cdrom: INDY_CDROM }) } - pub fn new_with_config(dma: Option>, irq: Option, heartbeat: Arc, deferred_int: bool) -> Self { - Self::new_with_id(dma, irq, heartbeat, deferred_int, 0) + /// A chip on an empty bus, for the tests. + #[cfg(test)] + pub fn new(dma: Option>, irq: Option, heartbeat: Arc) -> Self { + Self::new_with_id(dma, irq, heartbeat, true, 0, Self::new_bus()) } - /// Same as `new_with_config`, but tags every log line with `id` (the - /// HPC3 SCSI controller number) — see `Wd33c93aState.id`'s doc comment. - pub fn new_with_id(dma: Option>, irq: Option, heartbeat: Arc, deferred_int: bool, id: u8) -> Self { + /// The chip on `bus`, tagging every log line with `id` (the HPC3 SCSI + /// controller number) — see `Wd33c93aState.id`'s doc comment. + pub fn new_with_id(dma: Option>, irq: Option, heartbeat: Arc, deferred_int: bool, id: u8, bus: ScsiBus) -> Self { let deferred_int_arc = Arc::new(AtomicBool::new(deferred_int)); - let bus = Arc::new(ScsiBus::new(8, ScsiIdentities { disk: scsi::DISK_IDENTITY, cdrom: INDY_CDROM })); + let bus = Arc::new(bus); Self { bus: bus.clone(), state: Arc::new(Mutex::new(Wd33c93aState { @@ -1027,11 +1032,6 @@ impl crate::scsi_monitor::ScsiTargets for Wd33c93a { } } -impl Default for Wd33c93a { - fn default() -> Self { - Self::new(None, None, Arc::new(AtomicU64::new(0))) - } -} // ============================================================================ // Resettable + Saveable for Wd33c93a @@ -2193,4 +2193,29 @@ mod data_out_phase_tests { let _ = std::fs::remove_file(&path); assert_eq!(written, sector, "the sector never reached the disk"); } + + /// The machine fills the bus from `[scsi.N]` and gives it to the chip, + /// which answers for what is on it, with the Indy's Sony CD-ROM, and still + /// takes a target attached later through the same bus. + #[test] + fn a_chip_built_on_a_filled_bus_answers_for_its_target() { + let img = std::env::temp_dir().join(format!("iris-wd33c93a-filled-{}.img", std::process::id())); + std::fs::write(&img, vec![0u8; 64 * 512]).unwrap(); + let bus = Wd33c93a::new_bus(); + bus.attach_image(3, &img, true).unwrap(); + let dev = Wd33c93a::new_with_id(None, None, Arc::new(AtomicU64::new(0)), true, 0, bus); + assert!(dev.bus().has_target(3)); + assert!(!dev.bus().has_target(4)); + let inquiry = ScsiRequest { + cdb: vec![scsi_cmd::INQUIRY, 0, 0, 0, 36, 0], + data_len: ScsiDataLength::Unlimited, + data_in: None, + }; + let r = dev.bus().request(3, &inquiry).expect("target answers").unwrap(); + assert_eq!(&r.data[8..16], b"Sony "); + assert_eq!(&r.data[16..32], b"CDU-76S "); + dev.bus().attach_image(4, &img, false).unwrap(); + assert!(dev.bus().has_target(4), "a target attached after boot goes on the same bus"); + let _ = std::fs::remove_file(img); + } } diff --git a/src/ip30/tests.rs b/src/ip30/tests.rs index 79232247..6f29b674 100644 --- a/src/ip30/tests.rs +++ b/src/ip30/tests.rs @@ -473,7 +473,7 @@ impl Octane { ioc3.attach_eth(eth.clone()); ioc3.connect_superio_interrupt(bridge.pin_line(SUPERIO_PIN as u8)); - let isp = [Arc::new(Isp1040::new_at(0)), Arc::new(Isp1040::new_at(1))]; + let isp = [Arc::new(Isp1040::new_at(0, Isp1040::new_bus())), Arc::new(Isp1040::new_at(1, Isp1040::new_bus()))]; bridge.attach(SLOT_ISP0 as u8, isp[0].clone()); bridge.attach(SLOT_ISP1 as u8, isp[1].clone()); bridge.attach(SLOT_IOC3 as u8, ioc3.clone()); diff --git a/src/ip30_machine.rs b/src/ip30_machine.rs index 57b95d22..51e8b060 100644 --- a/src/ip30_machine.rs +++ b/src/ip30_machine.rs @@ -162,7 +162,10 @@ impl OctaneMachine { // The status bar's activity bits, shared with the graphics board. let heartbeat = Arc::new(AtomicU64::new(0)); let mac = cfg.network().mac; - let scsi = [Arc::new(Isp1040::new_at(0)), Arc::new(Isp1040::new_at(1))]; + // The buses are filled from `[scsi.N]` before the controllers exist. + let [bus_a, bus_b] = [Isp1040::new_bus(), Isp1040::new_bus()]; + let scratch_path = Self::attach_scsi(cfg, [&bus_a, &bus_b])?; + let scsi = [Arc::new(Isp1040::new_at(0, bus_a)), Arc::new(Isp1040::new_at(1, bus_b))]; for s in &scsi { s.set_heartbeat(heartbeat.clone()); } @@ -240,7 +243,6 @@ impl OctaneMachine { } })); - let scratch_path = Self::attach_scsi(cfg, &scsi)?; eprintln!( "iris: Octane with {} MB ({}), {}", banks.iter().sum::() >> 20, @@ -379,10 +381,10 @@ impl OctaneMachine { /// The `[scsi.N]` devices: `controller` 0 is the internal bus, 1 the /// external one. Returns the `scratch = true` volume, if any. - fn attach_scsi(cfg: &MachineConfig, ctls: &[Arc; 2]) -> Result, String> { + fn attach_scsi(cfg: &MachineConfig, buses: [&crate::scsi::ScsiBus; 2]) -> Result, String> { let attached = crate::scsi::attach_from_config(cfg, Some("ip30"), &|c| { - ctls.get(c as usize) - .map(|ctl| ctl.bus()) + buses.get(c as usize) + .copied() .ok_or_else(|| format!("controller {c} (an Octane has controllers 0 and 1)")) })?; if let Some(d) = attached.daynaports.first() { diff --git a/src/ip32.rs b/src/ip32.rs index 58c2a45f..cdebe618 100644 --- a/src/ip32.rs +++ b/src/ip32.rs @@ -161,11 +161,12 @@ pub const MACE_RTC_SIZE: u32 = 0x0001_0000; /// What is plugged into an O2 beyond its memory: the SCSI controllers (the /// machine attaches their disks from `[scsi.N]`, see `O2Machine::new`), and /// where the PROM's flash (its environment) is kept. -#[derive(Clone, Debug, Default)] +#[derive(Default)] pub struct Ip32Options { - /// The two on-board AIC-7880s. Without them the PROM finds nothing to - /// boot from. - pub scsi: bool, + /// The buses of the two on-board AIC-7880s (internal, external), with + /// their targets attached. Without the controllers the PROM finds nothing + /// to boot from. + pub scsi: Option<[crate::scsi::ScsiBus; 2]>, /// A file holding the flash, so `setenv` survives from run to run. pub flash_file: Option, /// A PS/2 keyboard on port 0, and a mouse on port 1 if `Some(true)`. @@ -474,14 +475,14 @@ impl Ip32Bus { bus.mace.enable_ps2(mouse); } bus.gbe.connect_irq(bus.crime.regs.clone()); - if opts.scsi { + if let Some([bus_a, bus_b]) = opts.scsi { // The controller fetches its work from host memory, which it // reaches through the PCI bridge it is attached to. - let scsi = crate::dev::aic7880::Aic7880::new(); + let scsi = crate::dev::aic7880::Aic7880::new_at(0, bus_a); // The second controller. Real hardware has it whether or not // anything is plugged into it, and a kernel that expects two // waits on the one that is missing. - let scsi_b = crate::dev::aic7880::Aic7880::new_at(1); + let scsi_b = crate::dev::aic7880::Aic7880::new_at(1, bus_b); bus.mace.pci.attach(IP32_SCSI_SLOT_B, "aic7880-b", scsi_b.clone()); bus.mace.pci.attach(IP32_SCSI_SLOT, "aic7880", scsi.clone()); let mut list = bus.scsi.lock().unwrap(); diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index 944895e3..0ee302c6 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -193,8 +193,16 @@ impl O2Machine { Some(mac) => Some(crate::config::parse_mac(mac).map_err(|e| format!("network.mac: {e}"))?), None => None, }; + // The SCSI devices, from the same `[scsi.N]` tables as the other + // machines; `controller` is the bus (0 internal, 1 external). The + // buses are filled before the controllers exist. + let scsi = [crate::dev::aic7880::Aic7880::new_bus(), crate::dev::aic7880::Aic7880::new_bus()]; + let attached = crate::scsi::attach_from_config(cfg, Some("ip32"), &|c| { + scsi.get(c as usize) + .ok_or_else(|| format!("controller {c} (an O2 has controllers 0 and 1)")) + })?; let opts = Ip32Options { - scsi: true, + scsi: Some(scsi), flash_file: Some(cfg.nvram.clone().into()), keyboard: (!cfg.headless).then_some(o2.mouse), mac, @@ -204,16 +212,6 @@ impl O2Machine { if !cfg.no_audio { bus.mace.audio.enable_output(cfg.audio.clone()); } - // The SCSI devices, from the same `[scsi.N]` tables as the other - // machines; `controller` is the bus (0 internal, 1 external). - let attached = { - let ctls = bus.scsi.lock().unwrap(); - crate::scsi::attach_from_config(cfg, Some("ip32"), &|c| { - ctls.get(c as usize) - .map(|ctl| ctl.bus()) - .ok_or_else(|| format!("controller {c} (an O2 has controllers 0 and 1)")) - })? - }; if let Some(d) = attached.daynaports.first() { return Err(format!("{}: kind = \"daynaport\" is not available on the O2", crate::config::scsi_label(d.controller, d.id))); } diff --git a/src/machine.rs b/src/machine.rs index 34b46641..02daccfd 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -442,7 +442,24 @@ impl Machine { ioc.set_timer_manager(timer_manager.clone()); ioc.set_heartbeat(heartbeat.clone()); ioc.set_clock_ticks(fasttick_count.clone()); - let hpc3 = Hpc3::with_net(eeprom_hpc3.clone(), ioc.clone(), guinness, heartbeat.clone(), cfg.network(), cfg.no_audio, cfg.audio.clone(), cfg.nvram.clone(), cfg.rtc_offset, cfg.scsi_deferred_int); + // Attach SCSI devices from config: each `[scsi.KEY]` table puts a + // target on a controller (`ScsiDeviceConfig::placement`). The disks + // recorded here, and the scratch volume's path for the CI socket, + // come back with it. Disk provenance for snapshot manifests is + // captured while the MachineConfig `cfg` is still in scope (it is + // shadowed by the CPU config below): the slot (controller * 16 + id, + // see `config::scsi_slot`), the configured path and the host file + // size. + // The buses are filled before the controllers exist and handed to them. + let scsi_buses = Hpc3::new_scsi_buses(guinness); + let attached = match crate::scsi::attach_from_config(&cfg, None, &|c| Hpc3::route_scsi(&scsi_buses, c)) { + Ok(a) => a, + Err(e) => { + eprintln!("iris: fatal: {e}"); + std::process::exit(1); + } + }; + let hpc3 = Hpc3::with_net(eeprom_hpc3.clone(), ioc.clone(), guinness, heartbeat.clone(), cfg.network(), cfg.no_audio, cfg.audio.clone(), cfg.nvram.clone(), cfg.rtc_offset, cfg.scsi_deferred_int, scsi_buses); hpc3.set_timer_manager(timer_manager.clone()); // Backdoor-inject the configured (or default) Ethernet station @@ -462,39 +479,25 @@ impl Machine { if initialized { eeprom.update_ip28_checksum(); } } - // Attach SCSI devices from config: each `[scsi.KEY]` table puts a - // target on a controller (`ScsiDeviceConfig::placement`). The disks - // recorded here, and the scratch volume's path for the CI socket, - // come back with it. Disk provenance for snapshot manifests is - // captured while the MachineConfig `cfg` is still in scope (it is - // shadowed by the CPU config below): the slot (controller * 16 + id, - // see `config::scsi_slot`), the configured path and the host file - // size. - let attached = { - // Standalone CLI fails loudly and exits; see `attach_from_config`. - let attach = || -> Result { - let a = crate::scsi::attach_from_config(&cfg, None, &|c| hpc3.scsi_bus(c))?; - // A DaynaPort is built where the gateway is, by Hpc3. - for d in &a.daynaports { - let at = crate::config::scsi_label(d.controller, d.id); - match hpc3.add_scsi_daynaport(d.controller, d.id as usize, d.params) { - Ok(()) => println!( - "iris: DaynaPort SCSI/Link at {at} — MAC {}, gateway {} (guest {}/{})", - crate::net::mac_str(&d.params.mac), - d.params.subnet.gateway_ip, d.params.subnet.client_ip, d.params.subnet.netmask), - Err(e) => crate::scsi::attach_failed(format!("could not attach DaynaPort to {at}: {e}"), &at)?, - } - } - Ok(a) - }; - match attach() { - Ok(a) => a, - Err(e) => { - eprintln!("iris: fatal: {e}"); - std::process::exit(1); + // A DaynaPort is built where the gateway is, by Hpc3; a target that + // will not attach ends the run (see `attach_from_config`). + let daynaports = || -> Result<(), String> { + for d in &attached.daynaports { + let at = crate::config::scsi_label(d.controller, d.id); + match hpc3.add_scsi_daynaport(d.controller, d.id as usize, d.params) { + Ok(()) => println!( + "iris: DaynaPort SCSI/Link at {at} — MAC {}, gateway {} (guest {}/{})", + crate::net::mac_str(&d.params.mac), + d.params.subnet.gateway_ip, d.params.subnet.client_ip, d.params.subnet.netmask), + Err(e) => crate::scsi::attach_failed(format!("could not attach DaynaPort to {at}: {e}"), &at)?, } } + Ok(()) }; + if let Err(e) = daynaports() { + eprintln!("iris: fatal: {e}"); + std::process::exit(1); + } let scratch_path = attached.scratch; // The Indy and Indigo2 have always listed CD-ROMs beside the disks. let disk_provenance: Vec = attached.tables.iter() From 6da522ddbe1d1fa2975a94db5773945f213cfde8 Mon Sep 17 00:00:00 2001 From: James Braid Date: Thu, 29 Oct 2026 10:20:52 -0700 Subject: [PATCH 11/11] docs: note the CHD disc change and the exit fold in CHANGELOG Both are visible to a user: a CHD CD swapped in at run time now loads, and quitting with sync_chd folds the diffs on every controller. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc5db0a..21f04be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ hashes are given where a change is easiest to understand by reading the commit. disks report one emulated identity on every machine. The O2 and Octane now say `IRIS EMUL DISK` and `IRIS EMUL CDROM` in INQUIRY; the Indy and Indigo2 keep their Sony CD-ROM. +- A disc change loads a CHD CD as well as an ISO, and the O2, the Octane and + the Indigo2's second controller fold pending CHD diffs on a clean exit as the + first controller always did. ### Host efficiency on small machines