diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6fdb4c..21f04be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,16 @@ 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. +- 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 - **Threads that have nothing to do now sleep in milliseconds, not diff --git a/HACKING.md b/HACKING.md index 68e8fdfb..df1fb1b9 100644 --- a/HACKING.md +++ b/HACKING.md @@ -70,16 +70,29 @@ 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. 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 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 +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 diff --git a/src/block_node.rs b/src/block_node.rs index 24cbed4a..3c31d076 100644 --- a/src/block_node.rs +++ b/src/block_node.rs @@ -762,6 +762,268 @@ 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))?) + }) +} + +/// 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::*; @@ -1227,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/aic7880.rs b/src/dev/aic7880.rs index f1099ff9..769560f9 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, 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. @@ -422,7 +419,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 @@ -452,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()), @@ -467,7 +473,7 @@ impl Aic7880 { worker: Mutex::new(None), running: AtomicBool::new(false), me: me.clone(), - targets: Mutex::new(std::array::from_fn(|_| None)), + bus, dma: std::sync::OnceLock::new(), sense: Mutex::new(None), dma_misses: Mutex::new(std::collections::BTreeSet::new()), @@ -667,79 +673,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) - } - - 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) + /// The SCSI bus: attach targets to it and ask what is on it. + pub fn bus(&self) -> &ScsiBus { + &self.bus } - - /// 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()) - } - - /// 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() + self.bus.has_target(target) } - } /// 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 +851,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 +1407,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 +1586,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 +1827,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 +1883,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 +1937,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 +1983,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 +2046,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 +2139,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 +2172,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 +2385,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]; @@ -2558,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/daynaport.rs b/src/dev/daynaport.rs index ffb7232d..faee7e96 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.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/hpc3.rs b/src/dev/hpc3.rs index 3b159a53..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,10 +1285,6 @@ 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) - } - /// Attach a DaynaPort SCSI/Link (SCSI-attached Ethernet) at `id`. /// /// Each DaynaPort gets its **own** `NatEngine` on its own subnet, separate @@ -1296,14 +1307,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).add_device(id, path, is_cdrom, discs, overlay, Some(overlay_path)) - } - pub fn ioc(&self) -> &Ioc { &self.ioc } @@ -2368,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); @@ -2400,6 +2403,7 @@ mod tests { String::new(), RtcOffset::default(), true, + Hpc3::new_scsi_buses(true), ) } @@ -2573,6 +2577,7 @@ mod enet_rx_chain_tests { String::new(), RtcOffset::default(), true, + Hpc3::new_scsi_buses(true), ) } @@ -2598,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 0c3f84e9..31e946fb 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, 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. @@ -419,15 +414,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>, @@ -436,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 @@ -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 || { @@ -508,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 { @@ -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, 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..a21f8c29 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(); @@ -1244,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 ddfa5d4d..45ae92c2 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, @@ -302,9 +302,10 @@ 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. + bus: Arc, state: Arc>, cond: Arc, thread: Mutex>>, @@ -327,25 +328,32 @@ 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(bus); 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 +392,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,92 +414,26 @@ 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() - } - - /// 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 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); - } - } - } - v - }; - 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. 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 +444,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 +823,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,17 +1027,11 @@ 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 } } -impl Default for Wd33c93a { - fn default() -> Self { - Self::new(None, None, Arc::new(AtomicU64::new(0))) - } -} // ============================================================================ // Resettable + Saveable for Wd33c93a @@ -1172,9 +1068,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 +1118,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 +1331,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 +1345,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 +1598,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 +1650,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 +1725,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 +1736,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 +2133,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)); + 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; @@ -2294,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 c6dede34..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()); @@ -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..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,40 +381,16 @@ 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.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); - } - } - } + fn attach_scsi(cfg: &MachineConfig, buses: [&crate::scsi::ScsiBus; 2]) -> Result, String> { + let attached = crate::scsi::attach_from_config(cfg, Some("ip30"), &|c| { + 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() { + 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 @@ -669,6 +647,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.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 baaf525a..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,55 +212,10 @@ 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 mut scratch_path: Option = None; - { - 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.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); - } - } - } - } + 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 +386,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, @@ -944,6 +900,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 f60a38ee..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,105 +479,30 @@ 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`). - 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); - } - }; - match hpc3.add_scsi_daynaport(controller, id as usize, params) { + // 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 {} — 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); - } - } + "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)?, } - 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) - }; - 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; - } - eprintln!("iris: fatal: {msg}"); - std::process::exit(1); } + Ok(()) + }; + if let Err(e) = daynaports() { + eprintln!("iris: fatal: {e}"); + 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. @@ -1296,11 +1238,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. @@ -1314,7 +1265,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.rs b/src/scsi.rs index b780759c..1d4f7332 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -1,6 +1,12 @@ use std::io; -use crate::block_node::{BlockNode, ChdCdNode, ChdNode, CowNode, RawNode}; +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::{ChangerNode, Removable, BlockNode}; /// Get the standard CDB length based on the opcode's group code pub fn get_cdb_length(opcode: u8) -> usize { @@ -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 @@ -103,29 +104,62 @@ 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 } } } +/// 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), or a - /// device with no storage behind it at all (DaynaPort). - /// 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, @@ -136,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 }, @@ -164,192 +197,33 @@ 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. 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, } } - /// 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() } - - /// 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 size = node.size(); - self.backend = Some(Box::new(node)); - self.size = size; - self.filename = path.to_string(); - self.unit_attention = true; - 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(()), + 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}"); + } } } @@ -357,192 +231,21 @@ impl ScsiDevice { /// 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()) - } - - /// `(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 - } - } + 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 - } - - /// 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 { - self.size + self.media.node().size() } /// Set the logical block size, as a controller does for its own @@ -576,93 +279,12 @@ 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 - } - - 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)); - } - - // 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)?, - 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; + 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)); } @@ -721,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]; @@ -751,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!( @@ -793,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 } @@ -813,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 } @@ -831,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![] }) } @@ -857,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, @@ -873,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(); @@ -1227,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; @@ -1235,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); @@ -1306,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![] }) } @@ -1343,6 +963,168 @@ 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 req.cdb[0] != scsi_cmd::REQUEST_SENSE + && req.cdb[0] != scsi_cmd::INQUIRY + && self.media.take_changed() + { + 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 { + self.media.node_mut().commit() + } + + /// 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<()> { + 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>> { + 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<()> { + 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.media.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() { + // 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 + } + + /// The drive's tray and queue, `None` for a disk. + fn changer(&mut self) -> Option<&mut ChangerNode> { + if !self.is_cdrom() { + return None; + } + match &mut self.media { + Media::Changer(c) => Some(c), + Media::Fixed(..) => None, + } + } + + fn status(&self, id: usize) -> Vec { + let (phys, logical) = self.block_sizes(); + if self.is_cdrom() { + 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() { + 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 { "" }; + let path = match &self.media { Media::Fixed(_, p) => p.as_str(), Media::Changer(_) => "" }; + vec![format!("SCSI ID {}: disk {} ({} blocks of {}{})", + id, path, 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()) } +} + #[cfg(test)] mod tests { use super::*; @@ -1375,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![], @@ -1416,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![], @@ -1434,7 +1214,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); @@ -1458,9 +1238,9 @@ 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 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 @@ -1500,4 +1280,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()); + 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. + 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/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..acd8e007 --- /dev/null +++ b/src/scsi/bus.rs @@ -0,0 +1,397 @@ +//! 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::block_node::{open_image, ChangerNode, ImageKind}; +use crate::dev::daynaport::DaynaPort; + +/// 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`]. 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. +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 } + + /// The drive's tray and queue of discs, `None` unless this is a CD-ROM. + fn changer(&mut self) -> Option<&mut ChangerNode> { None } +} + + +/// 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 ([`open_image`]). 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 = 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)?; + ScsiDevice::new(node, cdrom, path.to_string(), discs, 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); + } + } + } + + /// 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)>> { + 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(); + } + + #[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)); + } +} diff --git a/src/scsi_monitor.rs b/src/scsi_monitor.rs index abc85cb9..2a68306f 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,31 @@ use std::io::Write; -use crate::scsi::ScsiDevice; +use crate::block_node::{ChangerNode, Removable}; +use crate::scsi::{ScsiBus, ScsiTarget, CDROM_SECTOR}; -/// 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 { +/// 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.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), - Some(dev) => f(dev), + Some(dev) => match dev.changer() { + None => Err(format!("SCSI ID {} is not a CD-ROM", id)), + Some(changer) => f(changer), + }, }) } @@ -36,12 +45,12 @@ fn cdrom(ctl: &impl ScsiTargets, id: usize, f: impl FnOnce(&mut ScsiDevice) - /// 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 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 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() }) @@ -50,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]) { @@ -99,24 +113,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 +130,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 +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.disc_list().to_vec())) { + 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); @@ -199,8 +196,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 +206,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 {