From 914db6f983a9fda5ac4f3121e799f255255c7746 Mon Sep 17 00:00:00 2001 From: technomancer Date: Sat, 24 Oct 2026 10:03:31 -0700 Subject: [PATCH] scsi: one configuration and one monitor command for every controller The O2 read its disks from its own `o2.disks` list and ignored the `[scsi.N]` tables, so changer lists, scratch volumes, CHD images, CI overlays and the --scsi1/--cdrom4 options never reached it, and the monitor's `scsi` command did not exist there: it was implemented inside the WD33C93A. Configuration. Both machines now attach their devices from `[scsi.N]`. `controller` names the bus on the O2 as it does on the Indigo2 (0 internal, 1 external); the validator accepts controller 1 on both and refuses a DaynaPort on the O2, whose Ethernet is built in. The key is a slot: N 1-7 is id N on controller 0, N = 16 + id is that id on controller 1, so `[scsi.1]` and `[scsi.17]` put a disk at id 1 on each controller; `controller = 1` on a low key still works, a slot key contradicted by `controller`, two tables naming one target, id 0 and controller 2 are refused. Snapshot manifests record disks by slot. `o2.disks` is deprecated: `load_toml` folds it into `[scsi.N]` and says so. The changer-list, CI-overlay-path and scratch-volume logic the Indy loop had inline is now on ScsiDeviceConfig and shared; the Indy loop also honours `kind = "cdrom"` without `cdrom = true`. An O2 image that will not open ends the run as on the Indy (an embedder boots without it). Monitor. `src/scsi_monitor.rs` implements `scsi status`, `scsi dayna`, the changer subcommands, the whole of `cow` and the DiscChanger operations once, over a controller's target table lent through its `ScsiTargets` trait. The WD33C93A keeps `regs`, `wdt`, `defer` and `debug`; the Adaptec 7880 keeps `regs` (its interrupt status, queues and totals). DiscChanger has one blanket impl over ScsiTargets. On the O2, controller 0 answers to `scsi`/`scsi0`/`cow` and controller 1 to `scsi1`/`cow1`; both controllers are registered with the monitor. `scsi status` lists disks as well as CD-ROMs. The O2 also gains `scratch_path` and `disc_changer`, so iris-ci's scratch and cdrom commands and the window's disc change work there. Docs: HELP.md, FEATURES.md, debug.md, HACKING.md, docs/o2-ip32.md, docs/indigo2-ip22.md, iris.toml, CHANGELOG.md. Tests: slot placement and its refusals, the O2 controller rule, the `o2.disks` fold. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 23 ++++ FEATURES.md | 26 +++-- HACKING.md | 11 ++ HELP.md | 13 ++- debug.md | 13 ++- docs/indigo2-ip22.md | 7 +- docs/o2-ip32.md | 40 +++++-- iris.toml | 2 + src/config.rs | 256 ++++++++++++++++++++++++++++++++++++++--- src/dev/aic7880.rs | 93 ++++++++++++++- src/dev/wd33c93a.rs | 264 +++---------------------------------------- src/ip32.rs | 32 +----- src/ip32_machine.rs | 95 ++++++++++++---- src/lib.rs | 1 + src/machine.rs | 119 +++++++------------ src/scsi_monitor.rs | 238 ++++++++++++++++++++++++++++++++++++++ src/workstation.rs | 13 ++- 17 files changed, 820 insertions(+), 426 deletions(-) create mode 100644 src/scsi_monitor.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 15da6df5..e6f62de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ hashes are given where a change is easiest to understand by reading the commit. display. Runs IRIX 6.5.22 to the desktop with OpenGL through the rendering engine, and NetBSD 11 to multi-user and X. Needs the O2 PROM image; not in iris-gui yet. +- **Disks come from `[scsi.N]`.** The O2 reads the same SCSI tables as the + Indy and Indigo2, with `controller` naming the bus (0 internal, 1 + external): changer lists, scratch volumes, CHD images, copy-on-write + overlays and the `--scsi1`/`--cdrom4` options now work on it, disc changes + reach its CD-ROMs, and an image that will not open stops the run as it + does elsewhere. `o2.disks` is deprecated and folded into `[scsi.N]` on + load. The monitor's `scsi` and `cow` commands (`scsi1`/`cow1` for the + external bus) work on the O2's Adaptec controllers: status, disc changes, + overlay commit and reset, and the chip's registers. - **Scripting and inspection.** The O2's devices have monitor commands, and `o2 ` (also on `[o2] control_port`) takes screen shots, types, moves the mouse and reads memory ([debug.md](debug.md#sgi-o2-ip32)). @@ -38,6 +47,20 @@ hashes are given where a change is easiest to understand by reading the commit. page in six. IRIX on an R5000 O2 panicked with "init died" for the same reason; it now boots to the desktop. +### SCSI configuration + +- **One id on both controllers.** A `[scsi.N]` key is a slot: 1–7 on + controller 0, 16 + id on controller 1, so `[scsi.1]` and `[scsi.17]` put a + disk at id 1 on each of an Indigo2's or O2's controllers. `controller = 1` + in a low key still works, and two tables naming one target are refused. + Snapshot manifests record disks by slot, so an Indigo2 snapshot with a disk + on controller 1 taken before this change does not match its configuration. +- **One `scsi` command for every controller.** The monitor's `scsi` and `cow` + subcommands and the run-time disc changes are implemented once, in + `src/scsi_monitor.rs`, over a controller's target table; the WD33C93A and + the Adaptec 7880 lend theirs and keep only their own subcommands (`regs`, + and the WD's `wdt`, `defer`, `debug`). `scsi status` now lists disks too. + ### Newport (REX3) - **Conformance tests for RGBMODE and the command FIFO.** A 24-bit RGB fill diff --git a/FEATURES.md b/FEATURES.md index 7d6c51db..30faca04 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -190,11 +190,14 @@ the first `[section]`, because TOML tables continue until the next header. | `scsi..overlay` | `false` | Raw-image COW sidecar; CHD uses its own diff sidecar. | | `scsi..scratch` | `false` | Host-managed raw scratch volume; cannot be CD-ROM/overlay. | | `scsi..size_mb` | `64 when unset` | Size of a newly created scratch image; existing files are unchanged. | -| `scsi..controller` | `0` | 0 or 1; controller 1 is accepted only on indigo2_ip22. | - -IDs are 1–7. If `scsi` is entirely omitted, core defaults attach SCSI #1 -as `scsi1.raw` and SCSI #4 as `cdrom4.iso`. An explicit map replaces that -default map; missing IDs are unattached. `kind = "daynaport"` needs no image. +| `scsi..controller` | `0` | 0 or 1; controller 1 is accepted on indigo2_ip22 and o2_ip32 (on the O2 it is the external bus). A key of 16 + ID names controller 1 directly and does not need it. | + +IDs are 1–7. The key is a slot: `[scsi.N]` with N 1–7 is id N on controller 0 +(or on `controller`, when set), and N = 16 + id is that id on controller 1, +so both controllers can carry the same id; two tables naming one target are +rejected. If `scsi` is entirely omitted, core defaults attach SCSI #1 as +`scsi1.raw` and SCSI #4 as `cdrom4.iso`. An explicit map replaces that +default map; missing slots are unattached. `kind = "daynaport"` needs no image. An explicit non-disk kind takes precedence over the legacy cdrom flag. ### [network] @@ -292,9 +295,12 @@ Omit `[nfs]` to disable the share; the default forward list is empty. ### [o2] -Used with `machine.profile = "o2_ip32"`; the Indy/Indigo2 top-level `prom`, -`nvram`, `banks` and `[scsi.N]` keys do not apply. Paths are relative to where -iris runs. See [the O2 guide](docs/o2-ip32.md). +Used with `machine.profile = "o2_ip32"`. The O2's disks are the `[scsi.N]` +tables above, with `controller` as the bus (0 internal, 1 external); the +Indy/Indigo2 top-level `prom`, `nvram` and `banks` keys do not apply. Paths are +relative to where iris runs. See [the O2 guide](docs/o2-ip32.md). An older +config's `o2.disks` list is folded into `[scsi.N]` when the file is loaded and +reported as deprecated. | Key | Default | Values and behavior | |---|---|---| @@ -302,7 +308,6 @@ iris runs. See [the O2 guide](docs/o2-ip32.md). | `o2.flash` | unset | File the PROM's flash (its environment) is loaded from and saved to. | | `o2.ram_mb` | `256` | Memory in MB, fitted largest DIMM kits first (up to 1024). | | `o2.dimms` | unset | Instead of `ram_mb`: the kit in each DIMM pair, in MB (32, 64, 128 or 256). | -| `o2.disks` | `[]` | SCSI devices: `{ id, path, cdrom = false, bus = 0, overlay = false }`; bus 0 internal, 1 external; `overlay` writes to `PATH.overlay` and keeps the image as it was (as `[scsi.N] overlay`). | | `o2.serial_port` | unset | TCP port on 127.0.0.1 for the first serial port. | | `o2.serial_log` | unset | File that copies the first serial port's output. | | `o2.graphics` | `true` | PS/2 keyboard and the graphics console; `false` for a serial console. | @@ -320,7 +325,8 @@ IP22/IP24 four-bank IRIX 6.5 behavior remains separate from IP28's verified two-bank 1 GB layout. Extreme requires IP22. All non-Newport boards require `heads=1` and `resolution="guest"`. IP28 IRIX needs IMPACT; its PROM has an embedded fallback. -The config validator currently permits SCSI controller 1 only on IP22. +The config validator permits SCSI controller 1 only on IP22 and the O2, the +machines with two controllers. Standalone CLI options update the loaded config where supplied; boolean enable switches do not provide a general way to clear every saved true setting. diff --git a/HACKING.md b/HACKING.md index 0c29c035..45306e28 100644 --- a/HACKING.md +++ b/HACKING.md @@ -68,6 +68,17 @@ 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`). + ## 2. Data path and endianness **Word-transparent architecture.** Host `u32`/`u64` values are bit diff --git a/HELP.md b/HELP.md index 1851f031..8a73ec79 100644 --- a/HELP.md +++ b/HELP.md @@ -553,12 +553,15 @@ resolution = "guest" # or "1024x768", "1280x960", "1280x1024" # ── SCSI ───────────────────────────────────────────────────────────────────── -# Valid IDs: 1–7. For a hard disk, set cdrom = false. +# Valid IDs: 1–7. For a hard disk, set cdrom = false. On a machine with two +# controllers (Indigo2, O2) [scsi.N] is id N on controller 0 and [scsi.16+N] +# is id N on controller 1, so one id can be used on both; `controller = 1` +# in a [scsi.N] table does the same for that one device. [scsi.1] path = "scsi1.raw" # raw or .chd image cdrom = false overlay = false # true = copy-on-write overlay in scsi1.raw.overlay -# controller = 0 # Indigo2 only: 0 or 1 +# controller = 0 # Indigo2 and O2 have two: 0 or 1 (the O2's 1 is its external bus); or write [scsi.17] # A CD-ROM. path may be empty to start with an empty tray. [scsi.4] @@ -650,7 +653,11 @@ kernel step on each other's block size setting. | 6 | Additional disk or tape | | 7 | Additional disk | -The controller itself is ID 0. +The controller itself is ID 0. On the Indigo2 and the O2, which have two +controllers, `[scsi.N]` keys 1–7 are ids on controller 0 and keys 17–23 are +ids 1–7 on controller 1 (16 slots per controller); `controller = 1` in a low +key's table is the older spelling and still works. The monitor's `scsi1` +addresses the second controller. --- diff --git a/debug.md b/debug.md index 8a25ea46..328f6fbc 100644 --- a/debug.md +++ b/debug.md @@ -304,8 +304,13 @@ interpreter), `--features jitv2_smc_check` (writes onto executing pages), and | `scsi dayna` | DaynaPort MAC, addresses and counters | | `scsi wdt [N]` / `scsi wdt file ` | WD33C93A transaction ring: last `N` / all to a file **[DEV]** | | `scsi debug ` | Per-command trace **[DEV]** | -| `scsi0 ...` / `scsi1 ...` | The same, for controller 0 / controller 1 (Indigo2) | -| `cow status` / `cow commit [id]` / `cow reset [id]` | Copy-on-write overlays | +| `scsi0 ...` / `scsi1 ...` | The same, for controller 0 / controller 1 (Indigo2 and O2) | +| `cow status` / `cow commit [id]` / `cow reset [id]` | Copy-on-write overlays (`cow1 ...` on the O2's second controller) | + +On the O2 the controllers are Adaptec 7880s: `scsi regs` shows that chip's +interrupt status, queues and command totals, and `wdt`, `defer`, `debug` and +`dayna` are not there. Ids are per controller, as in the `[scsi.N]` tables +(`[scsi.17]` is id 1 on controller 1, so `scsi1 eject 1`). Queue position `[0]` is the mounted disc, `[1]` the next on eject. A disc change reaches IRIX as a Unit Attention ("medium changed") on its next @@ -477,7 +482,9 @@ With `[ultra64] enabled = true`: `ultra status | send | reset | r ### SGI O2 (IP32) -The O2's devices register their own commands (see [the O2 guide](docs/o2-ip32.md)): +The O2's devices register their own commands (see [the O2 guide](docs/o2-ip32.md)); +its SCSI controllers answer to `scsi`/`cow` and `scsi1`/`cow1` as described under +[SCSI, CD-ROM and disks](#scsi-cd-rom-and-disks): | Command | Description | |---|---| diff --git a/docs/indigo2-ip22.md b/docs/indigo2-ip22.md index 6993acf7..72ba3f9c 100644 --- a/docs/indigo2-ip22.md +++ b/docs/indigo2-ip22.md @@ -19,9 +19,10 @@ No separate build is required — the same `iris` / `iris-gui` binary supports I (default `nveeprom.bin`, `--nveeprom`) and written back with the monitor's `nveeprom save` or automatically on normal Stop. `[network] mac` is injected into a blank slot before boot; an existing MAC is retained. -- **SCSI:** two WD33C93A controllers. Put a device on the second one with - `controller = 1` in its `[scsi.N]` section; `scsi0` / `scsi1` in the monitor - address each controller. +- **SCSI:** two WD33C93A controllers. A device goes on the second one as + `[scsi.16+N]` (so `[scsi.17]` is id 1 there, and id 1 can be on both), or + with `controller = 1` in its `[scsi.N]` section; `scsi0` / `scsi1` in the + monitor address each controller. - **Interrupts:** INT2 and the fullhouse cascade are modelled; the IRIX kernel never hooks GIO interrupt 2, so vertical retrace is delivered the way the table below describes. diff --git a/docs/o2-ip32.md b/docs/o2-ip32.md index 63a0f444..ddf8bacf 100644 --- a/docs/o2-ip32.md +++ b/docs/o2-ip32.md @@ -15,8 +15,9 @@ The O2 does not run in iris-gui yet; start it from the `iris` command line. ## Configure the machine -The O2 has its own `[o2]` section; the Indy and Indigo2 top-level keys -(`prom`, `nvram`, `banks`, `[scsi.N]`) do not apply to it. +The O2 has its own `[o2]` section for what only it has. Its disks are the +same `[scsi.N]` tables as the other machines'; the Indy and Indigo2 top-level +keys `prom`, `nvram` and `banks` do not apply to it. ```toml [machine] @@ -30,8 +31,14 @@ ram_mb = 1024 serial_port = 2291 serial_log = "serial.log" control_port = 2293 -disks = [ { id = 1, path = "irix6522.raw" } ] forwards = [ { proto = "tcp", host_port = 2292, guest_port = 22 } ] + +[scsi.1] +path = "irix6522.raw" + +[scsi.4] +path = "irix6522.iso" +cdrom = true ``` Paths are relative to the directory iris runs in. Every key is listed in @@ -58,12 +65,27 @@ settings survive a restart. The PROM image itself is never written. ## Disks and CD-ROMs -`disks` lists SCSI devices on the two Adaptec 7880 controllers: `id`, `path`, -`cdrom = true` for a CD-ROM image, and `bus` 0 (internal, the default) or 1 -(external). IRIX expects the system disk at id 1 on bus 0. Disk images are -written in place, as on the other machines, unless `overlay = true`: then the -image is never written and the changes go to `PATH.overlay` (delete it to -start over). +The `[scsi.N]` tables ([FEATURES.md](../FEATURES.md#scsiid)) list the devices +on the two Adaptec 7880 controllers. `N` 1–7 is the id on the internal bus +(controller 0); `N` = 16 + id is that id on the external bus (controller 1), +so `[scsi.17]` is id 1 outside and both buses can carry an id 1. `controller += 1` in a low-numbered table means the same. IRIX expects the system disk at +id 1 on bus 0 and looks at ids 1 to 4 on the internal bus. In the monitor, +`scsi` and `cow` address controller 0 and `scsi1` and `cow1` controller 1 +([debug.md](../debug.md#scsi-cd-rom-and-disks)). Everything the +tables offer elsewhere works here: raw and CHD images, `cdrom = true` with a +`discs` changer list (RCtrl+F12 in the window, `iris-ci cdrom-eject`), +`overlay = true` for copy-on-write to `PATH.overlay` (delete it to start +over), and a `scratch = true` volume for the CI socket's file injection +([STORAGE.md](../STORAGE.md)). The one exception is `kind = "daynaport"`: the +O2's Ethernet is built in. The `--scsi1`, `--cdrom4` and related command-line +options apply as well. An image that cannot be opened stops iris before the +PROM runs, as on the other machines. + +Older configs listed the disks as `o2.disks = [ { id, path, cdrom, bus, +overlay } ]`. That key is deprecated: iris folds it into `[scsi.N]` (with +`controller = bus`) when it loads the file and says so; move the entries over +to silence the note. ## Snapshots diff --git a/iris.toml b/iris.toml index 81bc6efd..d02fde25 100644 --- a/iris.toml +++ b/iris.toml @@ -47,6 +47,8 @@ banks = [128, 128, 0, 0] path = "scsi1.raw" cdrom = false #overlay = true +#controller = 0 # Indigo2 and O2 have a second controller (1); on the O2 it is the external bus. + # [scsi.17] is id 1 on controller 1 without this key, so an id can be on both. # Internal hard disk #[scsi.2] diff --git a/src/config.rs b/src/config.rs index d008a576..2b22205f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,14 +80,27 @@ pub struct ScsiDeviceConfig { /// already exists or `scratch=false`. #[serde(default)] pub size_mb: Option, - /// Which HPC3 SCSI chip this target lives on: 0 (default) or 1. Indigo2 - /// (IP22 fullhouse) has two independent WD33C93A controllers; Indy - /// (IP24 guinness) only has controller 0, so `controller = 1` is - /// rejected outside the Indigo2 profile — see `MachineConfig::validate`. + /// Which SCSI controller this target lives on: 0 (default) or 1. Indigo2 + /// (IP22 fullhouse) has two independent WD33C93A controllers and the O2 + /// two Adaptec 7880s (0 the internal bus, 1 the external connector); + /// Indy (IP24 guinness) only has controller 0, so `controller = 1` is + /// rejected there — see `MachineConfig::validate`. #[serde(default)] pub controller: u8, } +/// The `[scsi.N]` key, and the snapshot manifest's disk number, that names +/// `id` on `controller`: the id itself on controller 0, 16 + id on +/// controller 1. Sixteen slots per controller, as SCSI ids go. +pub fn scsi_slot(controller: u8, id: u8) -> u8 { + controller * 16 + id +} + +/// `SCSI ID 1` on controller 0, `SCSI 1:1` on another, for messages. +pub fn scsi_label(controller: u8, id: u8) -> String { + if controller == 0 { format!("SCSI ID {id}") } else { format!("SCSI {controller}:{id}") } +} + impl Default for ScsiDeviceConfig { fn default() -> Self { Self { @@ -118,6 +131,67 @@ impl ScsiDeviceConfig { pub fn is_cdrom(&self) -> bool { self.kind() == ScsiKind::Cdrom } pub fn is_daynaport(&self) -> bool { self.kind() == ScsiKind::Daynaport } + /// Where the `[scsi.KEY]` table puts this target, as (controller, id). + /// Keys 1–7 are ids on `controller` (0 unless the key says otherwise); + /// a key of 16 + id names controller 1 directly, so the same id can be + /// used on both controllers. See [`scsi_slot`]. + pub fn placement(&self, key: u8) -> (u8, u8) { + if key >= 16 { (key / 16, key % 16) } else { (self.controller, key) } + } + + /// The media this target starts with: the active image and, for a + /// CD-ROM, the changer list built from `path` and `discs` with empty + /// entries skipped (an empty `path` is a drive with its tray empty, to be + /// loaded at run time). A disk is just its path. + pub fn media(&self) -> (String, Vec) { + if !self.is_cdrom() { + return (self.path.clone(), vec![]); + } + let mut list: Vec = Vec::new(); + if !self.path.is_empty() { + list.push(self.path.clone()); + } + for d in &self.discs { + if !d.is_empty() && !list.contains(d) { + list.push(d.clone()); + } + } + let active = list.first().cloned().unwrap_or_default(); + (active, list) + } + + /// Where a `--ci` run keeps the copy-on-write overlay of the disk at + /// `id`: under /tmp and named for the process, so an interactive iris + /// holding `PATH.overlay` can coexist with any number of CI runs. The + /// files are kept for post-mortem inspection. + pub fn ci_overlay_path(id: u8) -> String { + format!("/tmp/iris-ci-{}-scsi{}.overlay", std::process::id(), id) + } + + /// For `scratch = true`: make sure the volume exists, creating it with a + /// minimal SGI volume header and `size_mb` MB when it does not, and + /// return its path for the CI socket's scratch commands. None when the + /// target is not a scratch volume, or cannot be one (a CD-ROM or an + /// overlay is not host-writable in place), which is reported. + pub fn prepare_scratch(&self, id: u8) -> Option { + if !self.scratch { + return None; + } + if self.is_cdrom() || self.overlay { + println!("Note: SCSI ID {}: scratch=true is incompatible with cdrom/overlay; ignoring scratch flag", id); + return None; + } + let path = std::path::Path::new(&self.path); + if !path.exists() { + let size_mb = self.size_mb.unwrap_or(64) as u64; + match crate::sgi_vh::create_scratch_image(path, size_mb * 1024 * 1024) { + Ok(()) => println!("iris: created scratch volume {} ({} MB, with SGI VH)", self.path, size_mb), + Err(e) => println!("Note: could not create scratch volume {}: {}", self.path, e), + } + } + Some(path.to_path_buf()) + } + /// A DaynaPort target for this SCSI id, with defaults filled in. /// Errors describe a bad `mac` / `subnet`; `validate()` catches those first. pub fn daynaport_params(&self, id: u8) -> Result { @@ -427,6 +501,16 @@ impl MachineProfile { } /// MC/IOC/HPC3 Guinness vs Fullhouse layout. Indy IP24 is Guinness (`true`). + /// How many SCSI controllers the machine has: two on the Indigo2 (two + /// WD33C93As) and the O2 (two Adaptec 7880s, the second on the external + /// connector), one on the Indy. + pub fn scsi_controllers(self) -> u8 { + match self { + Self::Indigo2Ip22 | Self::O2Ip32 => 2, + _ => 1, + } + } + pub fn guinness(self) -> bool { matches!(self, Self::IndyIp24) } @@ -717,8 +801,12 @@ pub struct O2Section { /// Or the kit in each DIMM pair from A, in MB (32, 64, 128 or 256). #[serde(default)] pub dimms: Option>, - /// Disks and CD-ROMs on the two SCSI controllers. - #[serde(default)] + /// Deprecated: the O2's SCSI devices are the `[scsi.N]` tables, with + /// `controller` naming the bus (0 internal, 1 external), as on the + /// Indigo2. A `disks` list in an older config is folded into `scsi` when + /// the file is loaded ([`MachineConfig::fold_deprecated`]) and is never + /// written back. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub disks: Vec, /// A TCP port for the serial console (telnet 127.0.0.1 PORT). #[serde(default)] @@ -753,6 +841,7 @@ pub struct O2Section { pub forwards: Vec, } +/// An entry of the deprecated `o2.disks` list; see [`O2Section::disks`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct O2Disk { @@ -1531,7 +1620,10 @@ impl MachineConfig { return Self::default(); }; match toml::from_str::(&text) { - Ok(cfg) => cfg, + Ok(mut cfg) => { + cfg.fold_deprecated(); + cfg + } Err(e) => { eprintln!("Configuration error: failed to parse {}:\n{}", path, e); // Common footgun: backslashes in a double-quoted string (Windows @@ -1550,6 +1642,27 @@ impl MachineConfig { } } + /// Translate keys an older iris wrote into their current form, saying so + /// once. `o2.disks` becomes `[scsi.N]` entries with `controller` = bus; + /// the list replaces the `scsi` map, since a config that has it never + /// meant the Indy's default disks. + pub fn fold_deprecated(&mut self) { + if self.o2.disks.is_empty() { + return; + } + eprintln!("iris: `o2.disks` is deprecated; list the O2's disks as [scsi.N] with controller = bus (0 internal, 1 external)"); + self.scsi.clear(); + for d in std::mem::take(&mut self.o2.disks) { + self.scsi.insert(d.id, ScsiDeviceConfig { + path: d.path, + cdrom: d.cdrom, + overlay: d.overlay, + controller: d.bus, + ..Default::default() + }); + } + } + /// Validate bank sizes, returns a description of any errors. pub fn validate(&self) -> Result<(), String> { if self.graphics.heads != 1 && self.graphics.heads != 2 { @@ -1572,15 +1685,39 @@ impl MachineConfig { return Err("graphics.resolution presets require Newport (graphics.board = newport)".into()); } } - for (&id, dev) in &self.scsi { - if dev.controller != 0 && self.machine.profile != MachineProfile::Indigo2Ip22 { + let mut placed: std::collections::HashMap<(u8, u8), u8> = std::collections::HashMap::new(); + let mut keys: Vec = self.scsi.keys().copied().collect(); + keys.sort(); + for key in keys { + let dev = &self.scsi[&key]; + let (controller, id) = dev.placement(key); + if id == 0 || id > 7 { + return Err(format!("scsi.{}: SCSI ID {} is out of range (1–7; the controller itself is 0)", key, id)); + } + if key >= 16 && dev.controller != 0 && dev.controller != controller { return Err(format!( - "scsi.{}: controller {} is only valid on Indigo2 (machine.profile = indigo2_ip22) — Indy has a single SCSI controller (0)", - id, dev.controller + "scsi.{}: the key already puts the target on controller {}; controller = {} contradicts it", + key, controller, dev.controller )); } - if dev.controller > 1 { - return Err(format!("scsi.{}: controller {} is invalid (valid: 0, 1)", id, dev.controller)); + if controller > 1 { + return Err(format!("scsi.{}: controller {} is invalid (valid: 0, 1)", key, controller)); + } + if controller != 0 && self.machine.profile.scsi_controllers() < 2 { + return Err(format!( + "scsi.{}: controller {} is only valid on Indigo2 (machine.profile = indigo2_ip22) and O2 (o2_ip32), which have two SCSI controllers — this machine has one (0)", + key, controller + )); + } + if let Some(other) = placed.insert((controller, id), key) { + return Err(format!( + "scsi.{} and scsi.{} both name {}", other, key, scsi_label(controller, id) + )); + } + if dev.is_daynaport() && self.machine.profile == MachineProfile::O2Ip32 { + return Err(format!( + "scsi.{}: kind = \"daynaport\" is not available on the O2 (its Ethernet is built in)", key + )); } } if self.scale < 1 || self.scale > 4 { @@ -1611,9 +1748,6 @@ impl MachineConfig { parse_mac(mac).map_err(|e| format!("network.mac: {}", e))?; } for (id, dev) in &self.scsi { - if *id == 0 || *id > 7 { - return Err(format!("SCSI ID {} is out of range (1–7)", id)); - } // A CD-ROM may legitimately start with an empty tray (no path, no // discs) and have media loaded at runtime; any discs list is valid // as a changer queue. So there is nothing CD-ROM-specific to check. @@ -2254,6 +2388,96 @@ mod export_tests { cfg.validate().expect("the IP28 MC can"); } } + + /// The O2 reads the same `[scsi.N]` tables as the other machines, with + /// `controller` as the bus; like the Indigo2 it has two. Its Ethernet is + /// built in, so a DaynaPort target is refused. + #[test] + fn o2_takes_scsi_tables_with_controller_as_bus() { + let mut cfg = MachineConfig::default(); + cfg.machine.profile = MachineProfile::O2Ip32; + cfg.machine.cpu = CpuModel::R10000; + cfg.scsi.insert(2, ScsiDeviceConfig { path: "external.raw".into(), controller: 1, ..Default::default() }); + cfg.validate().expect("the O2 has two SCSI controllers"); + cfg.machine.profile = MachineProfile::IndyIp24; + cfg.machine.cpu = CpuModel::default(); + assert!(cfg.validate().unwrap_err().contains("controller 1"), "the Indy has one"); + cfg.machine.profile = MachineProfile::O2Ip32; + cfg.machine.cpu = CpuModel::R10000; + cfg.scsi.insert(3, ScsiDeviceConfig { kind_field: ScsiKind::Daynaport, ..Default::default() }); + assert!(cfg.validate().unwrap_err().contains("daynaport")); + } + + /// `[scsi.17]` is id 1 on controller 1, so one id can be on both + /// controllers; `controller = 1` on a low key still works; a key that + /// names a controller cannot be contradicted, two tables cannot name the + /// same target, and id 0 and controller 2 do not exist. + #[test] + fn slot_keys_place_targets_on_either_controller() { + let mut cfg: MachineConfig = toml::from_str(r#" + [machine] + profile = "indigo2_ip22" + [scsi.1] + path = "internal.raw" + [scsi.17] + path = "external.raw" + [scsi.4] + path = "cd.iso" + cdrom = true + controller = 1 + "#).expect("parse"); + cfg.validate().expect("two controllers, no collision"); + assert_eq!(cfg.scsi[&1].placement(1), (0, 1)); + assert_eq!(cfg.scsi[&17].placement(17), (1, 1)); + assert_eq!(cfg.scsi[&4].placement(4), (1, 4)); + assert_eq!(scsi_slot(1, 1), 17); + assert_eq!(scsi_label(0, 1), "SCSI ID 1"); + assert_eq!(scsi_label(1, 1), "SCSI 1:1"); + + cfg.scsi.insert(20, ScsiDeviceConfig { path: "twice.iso".into(), cdrom: true, ..Default::default() }); + let err = cfg.validate().unwrap_err(); + assert!(err.contains("scsi.4 and scsi.20 both name SCSI 1:4"), "{err}"); + cfg.scsi.remove(&20); + + cfg.scsi.insert(18, ScsiDeviceConfig { path: "x.raw".into(), controller: 0, ..Default::default() }); + cfg.validate().expect("controller 0 on a slot key is just the default"); + cfg.scsi.get_mut(&18).unwrap().controller = 2; + assert!(cfg.validate().unwrap_err().contains("contradicts")); + cfg.scsi.remove(&18); + + cfg.scsi.insert(16, ScsiDeviceConfig { path: "x.raw".into(), ..Default::default() }); + assert!(cfg.validate().unwrap_err().contains("out of range")); + cfg.scsi.remove(&16); + cfg.scsi.insert(33, ScsiDeviceConfig { path: "x.raw".into(), ..Default::default() }); + assert!(cfg.validate().unwrap_err().contains("controller 2 is invalid")); + cfg.scsi.remove(&33); + + cfg.machine.profile = MachineProfile::IndyIp24; + assert!(cfg.validate().unwrap_err().contains("this machine has one")); + } + + /// An older O2 config's `o2.disks` becomes `[scsi.N]` entries, replacing + /// the default disks, and is gone from the section afterwards. + #[test] + fn deprecated_o2_disks_fold_into_scsi() { + let mut cfg: MachineConfig = toml::from_str(r#" + [machine] + profile = "o2_ip32" + [o2] + disks = [ { id = 1, path = "sys.raw", overlay = true }, + { id = 4, path = "cd.iso", cdrom = true, bus = 1 } ] + "#).expect("parse"); + cfg.fold_deprecated(); + assert!(cfg.o2.disks.is_empty()); + assert_eq!(cfg.scsi.len(), 2, "the list replaces the default map"); + assert_eq!(cfg.scsi[&1].path, "sys.raw"); + assert!(cfg.scsi[&1].overlay); + assert_eq!(cfg.scsi[&1].controller, 0); + assert!(cfg.scsi[&4].is_cdrom()); + assert_eq!(cfg.scsi[&4].controller, 1); + let s = toml::to_string_pretty(&cfg).expect("serialize"); + assert!(!s.contains("disks"), "the deprecated key is never written back:\n{s}"); + } } #[cfg(test)] diff --git a/src/dev/aic7880.rs b/src/dev/aic7880.rs index bbe5ff89..90765893 100644 --- a/src/dev/aic7880.rs +++ b/src/dev/aic7880.rs @@ -338,6 +338,9 @@ pub struct Aic7880 { dma: std::sync::OnceLock>, /// The SCSI id a disk attached without one answers at. target: u8, + /// Which of the O2's two controllers this is (0 internal, 1 external): + /// the monitor's `scsi`/`cow` address the first, `scsi1`/`cow1` the second. + index: u8, /// The controller's own sense, as (key, ASC, ASCQ): a command for a /// logical unit the target does not have, handed out by the REQUEST /// SENSE that follows. A check condition followed by "no sense" tells a @@ -358,7 +361,14 @@ pub const PCI_CLASS_SCSI: u32 = 0x0100_00; impl Aic7880 { pub fn new() -> Self { + Self::new_at(0) + } + + /// The controller at `index`: 0 on the internal bus, 1 on the external + /// connector. + pub fn new_at(index: u8) -> Self { Self { + index, st: Mutex::new(State::default()), targets: Mutex::new(std::array::from_fn(|_| None)), dma: std::sync::OnceLock::new(), @@ -571,7 +581,23 @@ impl Aic7880 { -> std::io::Result { let path = path.to_str().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path is not UTF-8"))?; - let d = ScsiDevice::open(path, cdrom, Vec::new(), overlay, None, if cdrom { O2_CDROM } else { O2_DISK })?; + 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(|| { @@ -603,6 +629,71 @@ impl Aic7880 { (0..t.len() as u8).filter(|&i| t[i as usize].is_some()).collect() } +} + +/// The monitor's `scsi`/`cow` subcommands and the run-time disc changes +/// come from `scsi_monitor` through this. +impl crate::scsi_monitor::ScsiTargets for Aic7880 { + fn with_targets(&self, f: impl FnOnce(&mut [Option]) -> R) -> R { + let mut targets = self.targets.lock().unwrap(); + f(&mut targets[..]) + } +} + +/// Controller 0 answers to `scsi`/`scsi0` and `cow`, controller 1 to `scsi1` +/// and `cow1`. Only `scsi regs` is this chip's own; the rest is +/// `scsi_monitor`'s, so the WD33C93A's `wdt`, `defer` and `debug` are not here. +impl crate::traits::Device for Aic7880 { + fn step(&self, _cycles: u64) {} + fn stop(&self) {} + fn start(&self) {} + fn is_running(&self) -> bool { true } + fn get_clock(&self) -> u64 { 0 } + + fn register_commands(&self) -> Vec<(String, String)> { + let usage = crate::scsi_monitor::SCSI_USAGE; + if self.index == 0 { + vec![ + ("scsi".to_string(), format!("SCSI (Adaptec 7880, controller 0): {usage} | scsi regs")), + ("scsi0".to_string(), "Alias for `scsi` (controller 0)".to_string()), + ("cow".to_string(), "COW overlays on controller 0: cow status | cow commit [id] | cow reset [id]".to_string()), + ] + } else { + vec![ + (format!("scsi{}", self.index), format!("SCSI controller {} (external): same subcommands as `scsi`", self.index)), + (format!("cow{}", self.index), format!("COW overlays on controller {}: same subcommands as `cow`", self.index)), + ] + } + } + + fn execute_command(&self, cmd: &str, args: &[&str], mut w: Box) -> Result<(), String> { + use std::io::Write as _; + if cmd.starts_with("scsi") { + if let Some(done) = crate::scsi_monitor::scsi_subcommand(self, args, &mut *w) { + return done; + } + if args.first().copied() == Some("regs") { + let st = self.st.lock().unwrap(); + let _ = writeln!(w, "Adaptec AIC-7880, controller {}", self.index); + let _ = writeln!(w, " INTSTAT={:02x} SSTAT0={:02x} SSTAT1={:02x} SEQADDR={:#x}", st.intstat, st.sstat0, st.sstat1, st.seqaddr); + let _ = writeln!(w, " queue in: {} waiting (pos {}) queue out: {} waiting (pos {})", st.qin.len(), st.qinpos, st.qout.len(), st.qoutpos); + let _ = writeln!(w, " sequencer pauses: {} commands: {} ({} bytes in, {} out)", st.pauses, st.total_cmds, st.total_in, st.total_out); + for n in &st.notes { + let _ = writeln!(w, " note: {n}"); + } + return Ok(()); + } + return Err(format!("Usage: {} | scsi regs", crate::scsi_monitor::SCSI_USAGE)); + } + if cmd.starts_with("cow") { + return crate::scsi_monitor::cow_command(self, args, &mut *w); + } + Err("Command not found".to_string()) + } +} + +impl Aic7880 { + /// Whether this address is the one disk we have. /// /// A controller that answers for every target and LUN makes a driver diff --git a/src/dev/wd33c93a.rs b/src/dev/wd33c93a.rs index 7ff7f84d..50aa7864 100644 --- a/src/dev/wd33c93a.rs +++ b/src/dev/wd33c93a.rs @@ -432,95 +432,6 @@ impl Wd33c93a { Ok(()) } - /// Mount media on a CD-ROM device (newly inserts or swaps existing). - /// Errors if the slot is empty, is not a CD-ROM, or the file can't open. - pub fn insert_disc(&self, id: usize, path: &str) -> Result<(), String> { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) if !dev.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), - Some(dev) => dev.insert_media(path).map_err(|e| format!("insert_disc: {}", e)), - } - } - - /// Unload media from a CD-ROM (leave the drive present but empty). - /// Use this when you want "drive present, no disc" rather than the - /// changer-cycle behaviour of `eject_disc`. - pub fn eject_to_empty(&self, id: usize) -> Result<(), String> { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) if !dev.is_cdrom() => Err(format!("SCSI ID {} is not a CD-ROM", id)), - Some(dev) => { dev.unload_media(); Ok(()) } - } - } - - /// Eject the current disc on a CD-ROM device and advance to the next in - /// the changer list. Returns the new active path, or an error string. - pub fn eject_disc(&self, id: usize) -> Result { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) => dev.eject_next().ok_or_else(|| { - if !dev.is_cdrom() { - format!("SCSI ID {} is not a CD-ROM", id) - } else { - format!("SCSI ID {} has only one disc (nothing to eject to)", id) - } - }), - } - } - - /// Load an arbitrary image path into a CD-ROM device and make it the active - /// disc immediately (medium change). Returns the loaded path. - pub fn load_disc(&self, id: usize, path: String) -> Result { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) => dev.load_disc(path), - } - } - - /// Add a disc path at position 1 (next-after-eject) for a CD-ROM device. - pub fn add_disc(&self, id: usize, path: String) -> Result<(), String> { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) => dev.add_disc(path), - } - } - - /// Remove a disc by ordinal from a CD-ROM device's queue. - pub fn remove_disc(&self, id: usize, ordinal: usize) -> Result { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) => dev.remove_disc(ordinal), - } - } - - /// Move a disc by ordinal to position 1 (next-after-eject). - pub fn move_disc_next(&self, id: usize, ordinal: usize) -> Result<(), String> { - let mut state = self.state.lock(); - match state.devices.get_mut(id).and_then(|d| d.as_mut()) { - None => Err(format!("No device at SCSI ID {}", id)), - Some(dev) => dev.move_disc_next(ordinal), - } - } - - /// Return disc info for all attached CD-ROM devices. - pub fn disc_status(&self) -> Vec<(usize, String, Vec, u64, u64)> { - let state = self.state.lock(); - state.devices.iter().enumerate() - .filter_map(|(id, d)| { - let dev = d.as_ref()?; - if !dev.is_cdrom() { return None; } - let (phys, logical) = dev.block_sizes(); - Some((id, dev.current_disc().to_string(), dev.disc_list().to_vec(), phys, logical)) - }) - .collect() - } - /// 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<()>)> { @@ -1080,6 +991,10 @@ impl Device for Wd33c93a { fn execute_command(&self, cmd: &str, args: &[&str], mut writer: Box) -> Result<(), String> { if cmd == "scsi" || cmd == "scsi0" || cmd == "scsi1" { + // status, dayna and the changer subcommands are every controller's. + if let Some(done) = crate::scsi_monitor::scsi_subcommand(self, args, &mut *writer) { + return done; + } match args.first().copied() { Some("wdt") => { #[cfg(feature = "developer")] { @@ -1197,174 +1112,25 @@ impl Device for Wd33c93a { writeln!(writer, "SCSI deferred interrupts {}", if val { "enabled" } else { "disabled" }).unwrap(); return Ok(()); } - Some("dayna") => { - { - let state = self.state.lock(); - let mut found = false; - for dev in state.devices.iter().flatten() { - if let Some(dp) = dev.daynaport() { - found = true; - for line in dp.status_lines() { - writeln!(writer, "{}", line).unwrap(); - } - } - } - if !found { - writeln!(writer, "No DaynaPort targets attached").unwrap(); - } - } - return Ok(()); - } - Some("status") => { - { - let state = self.state.lock(); - for dev in state.devices.iter().flatten() { - if let Some(dp) = dev.daynaport() { - writeln!(writer, "{}", dp.status_lines()[0]).unwrap(); - } - } - } - let discs = self.disc_status(); - if discs.is_empty() { - writeln!(writer, "No CD-ROM devices attached").unwrap(); - } else { - for (id, active, list, phys, logical) in discs { - writeln!(writer, "SCSI ID {}: {} ({} disc(s)) phys_block={} logical_block={}", - id, active, list.len(), phys, logical).unwrap(); - for (i, d) in list.iter().enumerate() { - let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; - writeln!(writer, " [{}] {}{}", i, d, tag).unwrap(); - } - } - } - return Ok(()); - } - Some("eject") => { - let id: usize = args.get(1) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi eject ".to_string())?; - match self.eject_disc(id) { - Ok(path) => writeln!(writer, "SCSI ID {}: switched to {}", id, path).unwrap(), - Err(e) => writeln!(writer, "Error: {}", e).unwrap(), - } - return Ok(()); - } - Some("add") => { - let id: usize = args.get(1) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi add ".to_string())?; - let path = args.get(2) - .ok_or_else(|| "Usage: scsi add ".to_string())? - .to_string(); - match self.add_disc(id, path.clone()) { - Ok(()) => writeln!(writer, "SCSI ID {}: added {} as next disc", id, path).unwrap(), - Err(e) => writeln!(writer, "Error: {}", e).unwrap(), - } - return Ok(()); - } - Some("list") => { - let id: usize = args.get(1) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi list ".to_string())?; - let state = self.state.lock(); - match state.devices.get(id).and_then(|d| d.as_ref()) { - None => writeln!(writer, "No device at SCSI ID {}", id).unwrap(), - Some(dev) if !dev.is_cdrom() => writeln!(writer, "SCSI ID {} is not a CD-ROM", id).unwrap(), - Some(dev) => { - let list = dev.disc_list(); - writeln!(writer, "SCSI ID {} queue ({} disc(s)):", id, list.len()).unwrap(); - for (i, d) in list.iter().enumerate() { - writeln!(writer, " [{}] {}{}", i, d, - if i == 0 { " " } else if i == 1 { " " } else { "" }).unwrap(); - } - } - } - return Ok(()); - } - Some("del") => { - let id: usize = args.get(1) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi del ".to_string())?; - let ordinal: usize = args.get(2) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi del ".to_string())?; - match self.remove_disc(id, ordinal) { - Ok(path) => writeln!(writer, "SCSI ID {}: removed [{}] {}", id, ordinal, path).unwrap(), - Err(e) => writeln!(writer, "Error: {}", e).unwrap(), - } - return Ok(()); - } - Some("next") => { - let id: usize = args.get(1) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi next ".to_string())?; - let ordinal: usize = args.get(2) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| "Usage: scsi next ".to_string())?; - match self.move_disc_next(id, ordinal) { - Ok(()) => writeln!(writer, "SCSI ID {}: [{}] moved to next position", id, ordinal).unwrap(), - Err(e) => writeln!(writer, "Error: {}", e).unwrap(), - } - return Ok(()); - } - _ => return Err("Usage: scsi status | scsi dayna | scsi eject | scsi add | scsi list | scsi del | scsi next | scsi debug ".to_string()), + _ => return Err(format!("Usage: {} | scsi regs | scsi wdt [N] | scsi defer | scsi debug ", crate::scsi_monitor::SCSI_USAGE)), } } if cmd == "cow" { - let mut state = self.state.lock(); - match args.first().copied() { - Some("status") => { - for (id, dev) in state.devices.iter().enumerate() { - if let Some(d) = dev { - if d.is_cow() { - writeln!(writer, "SCSI {}: COW overlay, {} dirty sectors", id, d.cow_dirty_count()).unwrap(); - } else { - writeln!(writer, "SCSI {}: direct (no overlay)", id).unwrap(); - } - } - } - return Ok(()); - } - Some("commit") => { - let ids: Vec = if let Some(id_str) = args.get(1) { - vec![id_str.parse().map_err(|_| "invalid SCSI ID".to_string())?] - } else { - (0..8).filter(|&i| state.devices[i].as_ref().map(|d| d.is_cow()).unwrap_or(false)).collect() - }; - for id in ids { - if let Some(dev) = &mut state.devices[id] { - match dev.cow_commit() { - Ok(n) if n > 0 => writeln!(writer, "SCSI {}: committed {} sectors to base image", id, n).unwrap(), - Ok(_) => writeln!(writer, "SCSI {}: nothing to commit", id).unwrap(), - Err(e) => writeln!(writer, "SCSI {}: commit failed: {}", id, e).unwrap(), - } - } - } - return Ok(()); - } - Some("reset") => { - let ids: Vec = if let Some(id_str) = args.get(1) { - vec![id_str.parse().map_err(|_| "invalid SCSI ID".to_string())?] - } else { - (0..8).filter(|&i| state.devices[i].as_ref().map(|d| d.is_cow()).unwrap_or(false)).collect() - }; - for id in ids { - if let Some(dev) = &mut state.devices[id] { - match dev.cow_reset() { - Ok(()) => writeln!(writer, "SCSI {}: overlay reset (all writes discarded)", id).unwrap(), - Err(e) => writeln!(writer, "SCSI {}: reset failed: {}", id, e).unwrap(), - } - } - } - return Ok(()); - } - _ => return Err("Usage: cow status | cow commit [id] | cow reset [id]".to_string()), - } + return crate::scsi_monitor::cow_command(self, args, &mut *writer); } Err("Command not found".to_string()) } } +/// 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[..]) + } +} + impl Default for Wd33c93a { fn default() -> Self { Self::new(None, None, Arc::new(AtomicU64::new(0))) diff --git a/src/ip32.rs b/src/ip32.rs index 73633cf4..f767dbff 100644 --- a/src/ip32.rs +++ b/src/ip32.rs @@ -156,33 +156,18 @@ pub const MACE_RTC: u32 = MACE_BASE + 0x003a_0000; pub const MACE_RTC_SIZE: u32 = 0x0001_0000; -/// What is plugged into an O2 beyond its memory: the SCSI controllers and -/// their disks, and where the PROM's flash (its environment) is kept. +/// 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)] pub struct Ip32Options { /// The two on-board AIC-7880s. Without them the PROM finds nothing to /// boot from. pub scsi: bool, - pub disks: Vec, /// A file holding the flash, so `setenv` survives from run to run. pub flash_file: Option, } -#[derive(Clone, Debug)] -pub struct Ip32Disk { - /// SCSI id; None is the controller's default. - pub id: Option, - /// Which controller: 0 is the internal bus, 1 the external connector. - /// IRIX looks only at ids 1 to 4 on the internal one, as the case has - /// room for no more, so further drives go outside. - pub bus: u8, - pub path: std::path::PathBuf, - /// A CD-ROM, which the PROM's install menu insists on seeing. - pub cdrom: bool, - /// Copy-on-write: writes go to `{path}.overlay` (see `O2Disk::overlay`). - pub overlay: bool, -} - /// Whether the per-register access counters (MACE's, GBE's, the rendering /// engine's) run: `IRIS_IP32_HOT`. They are for finding a driver polling a /// bit nothing sets, and each access took a lock and a map update, every @@ -381,18 +366,9 @@ impl Ip32Bus { // 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 = std::sync::Arc::new(crate::dev::aic7880::Aic7880::new()); + let scsi_b = std::sync::Arc::new(crate::dev::aic7880::Aic7880::new_at(1)); bus.mace.pci.attach(IP32_SCSI_SLOT_B, "aic7880-b", scsi_b.clone()); bus.mace.pci.attach(IP32_SCSI_SLOT, "aic7880", scsi.clone()); - for d in &opts.disks { - let ctl = if d.bus == 0 { &scsi } else { &scsi_b }; - let id = d.id.unwrap_or(ctl.default_target()); - let at = if d.bus == 0 { format!("id {id}") } else { format!("{}:{id}", d.bus) }; - match ctl.attach_with(id, &d.path, d.cdrom, d.overlay) { - Ok(b) => eprintln!("ip32: SCSI {at}: {} ({b} blocks)", d.path.display()), - Err(e) => eprintln!("ip32: SCSI {at}: {}: {e}", d.path.display()), - } - } let mut list = bus.scsi.lock().unwrap(); list.push(scsi); list.push(scsi_b); diff --git a/src/ip32_machine.rs b/src/ip32_machine.rs index a5b2220f..4338a26f 100644 --- a/src/ip32_machine.rs +++ b/src/ip32_machine.rs @@ -33,7 +33,7 @@ use crate::dev::crime_mem::O2Memory; use crate::dev::ng1::rex3::Renderer; use crate::disp::Rex3Screen; use crate::hptimer::TimerManager; -use crate::ip32::{Ip32Bus, Ip32Disk, Ip32Options, FIRMWARE_LOAD_VA, FIRMWARE_SPAN, NO_ECC_BASE, RAM_BASE, RAM_WINDOW_SIZE}; +use crate::ip32::{Ip32Bus, Ip32Options, FIRMWARE_LOAD_VA, FIRMWARE_SPAN, NO_ECC_BASE, RAM_BASE, RAM_WINDOW_SIZE}; use crate::traits::{BusDevice, Device, MachineEvent, Signal}; use crate::workstation::{MachineRef, MachineSlot, Workstation}; @@ -141,6 +141,8 @@ pub struct O2Machine { disks: Vec, /// The flash file, recorded in a snapshot's manifest. flash_path: Option, + /// The `scratch = true` volume, for the CI socket's scratch commands. + scratch_path: Option, /// Where the machine is in its start/stop lifecycle (see /// `Workstation::lifecycle`); never `Running`, which `status` reads from /// the CPU. @@ -159,18 +161,7 @@ impl O2Machine { None => crate::dev::crime_mem::kits_for(o2.ram_mb)?, }; let mem = O2Memory::from_kits(&kits)?; - if let Some(d) = o2.disks.iter().find(|d| d.bus > 1) { - return Err(format!("O2 disk {}: bus {} (an O2 has controllers 0 and 1)", d.path, d.bus)); - } - let opts = Ip32Options { - scsi: true, - disks: o2 - .disks - .iter() - .map(|d| Ip32Disk { id: Some(d.id), bus: d.bus, path: d.path.clone().into(), cdrom: d.cdrom, overlay: d.overlay }) - .collect(), - flash_file: o2.flash.clone().map(Into::into), - }; + let opts = Ip32Options { scsi: true, flash_file: o2.flash.clone().map(Into::into) }; let mut bus = Ip32Bus::with_options(mem, prom, opts); // MACE audio plays through the host unless audio is off. if !cfg.no_audio { @@ -180,6 +171,55 @@ impl O2Machine { bus.enable_graphics_with_mouse(o2.mouse); } let bus = Arc::new(bus); + // 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); + } + } + } + } + } eprintln!( "iris: O2 with {} MB ({}), {}", kits.iter().sum::(), @@ -314,21 +354,26 @@ impl O2Machine { monitor.register_device(crate::devlog::init_devlog()); monitor.register_device(cpu.clone().as_device()); monitor.register_device(bus.clone()); + // The SCSI controllers' `scsi`/`scsi1` and `cow`/`cow1`. + for ctl in bus.scsi.lock().unwrap().iter() { + monitor.register_device(ctl.clone()); + } monitor.register_device(Arc::new(O2Control::new(bus.clone()))); let monitor_port = cfg.monitor_port.unwrap_or(crate::monitor::DEFAULT_PORT); let (events, event_rx) = std::sync::mpsc::sync_channel(4); - // A snapshot's disks by controller and id (bus * 16 + id) and size. - let disks = o2 - .disks + // A snapshot's disks by slot (controller * 16 + id) and size. + let mut disks: Vec = cfg + .scsi .iter() - .filter(|d| !d.cdrom) - .map(|d| crate::snapshot::DiskRef { - id: d.bus * 16 + d.id, + .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), }) .collect(); + disks.sort_by_key(|d| d.id); Ok(Self { bus, cpu, @@ -349,6 +394,7 @@ impl O2Machine { lifecycle: crate::machine_state::GuestState::Idle, disks, flash_path: o2.flash.clone(), + scratch_path, }) } @@ -758,8 +804,8 @@ impl Drop for O2Machine { } } -/// The O2 as the front ends see it. Disc changes, the scratch volume, the CI -/// rollback and `--load-elf` are not there yet; the trait's defaults say so. +/// The O2 as the front ends see it. The CI rollback and `--load-elf` are not +/// there yet; the trait's defaults say so. impl Workstation for O2Machine { fn start(&mut self) { O2Machine::start(self) } fn stop(&mut self) { O2Machine::stop(self) } @@ -797,6 +843,13 @@ impl Workstation for O2Machine { fn save_snapshot(&mut self, name: &str) -> Result<(), String> { O2Machine::save_snapshot(self, name) } fn load_snapshot(&mut self, name: &str) -> Result<(), String> { O2Machine::load_snapshot(self, name) } + fn scratch_path(&self) -> Option<&std::path::Path> { self.scratch_path.as_deref() } + /// Disc changes on the internal bus (controller 0), as the Indigo2 offers + /// its first controller. + fn disc_changer(&self) -> Option> { + self.bus.scsi.lock().unwrap().first().map(|c| c.clone() as Arc) + } + fn nat_control(&self) -> Option> { self.nat.as_ref().map(|(n, _)| n.clone()) } diff --git a/src/lib.rs b/src/lib.rs index 2ce83c6f..ca1af110 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,6 +189,7 @@ pub mod bench_runner; pub mod cow_disk; pub mod chd_disk; pub mod scsi; +pub mod scsi_monitor; pub mod irq; pub mod host_audio; pub mod pci; diff --git a/src/machine.rs b/src/machine.rs index 596721c2..835a3c7c 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -6,7 +6,7 @@ use std::net::TcpStream; use std::sync::mpsc; use std::thread; -use crate::config::{GraphicsBoard, MachineConfig, MachineProfile, NetworkConfig}; +use crate::config::{GraphicsBoard, MachineConfig, MachineProfile, NetworkConfig, ScsiDeviceConfig}; use crate::machine_state::GuestState; use crate::traits::{BusDevice, Device, Resettable, Saveable, MachineEvent}; use crate::locks::LockMonitor; @@ -418,39 +418,37 @@ impl Machine { if initialized { eeprom.update_ip28_checksum(); } } - // Attach SCSI devices from config (IDs 1–7). - let mut scsi_ids: Vec = cfg.scsi.keys().copied().collect(); - scsi_ids.sort(); - // CI mode: isolate each COW overlay under /tmp so an interactive - // iris holding {base}.overlay can coexist with any number of `--ci` - // processes. Files are kept for post-mortem inspection; cleanup - // happens on machine drop below. - let ci_pid = std::process::id(); + // 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 id in scsi_ids { - let dev = &cfg.scsi[&id]; + 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(id) { + let params = match dev.daynaport_params(key) { Ok(p) => p, Err(e) => { - eprintln!("iris: fatal: SCSI ID {}: {}", id, e); + eprintln!("iris: fatal: {}: {}", label, e); std::process::exit(1); } }; - match hpc3.add_scsi_daynaport(dev.controller, id as usize, params) { + match hpc3.add_scsi_daynaport(controller, id as usize, params) { Ok(()) => println!( - "iris: DaynaPort SCSI/Link at SCSI ID {} — MAC {}, gateway {} (guest {}/{})", - id, crate::net::mac_str(¶ms.mac), + "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 SCSI ID {id}: {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 SCSI ID {id}"); + eprintln!("iris: warning: {msg}; continuing without {label}"); } else { eprintln!("iris: fatal: {msg}"); std::process::exit(1); @@ -459,64 +457,29 @@ impl Machine { } continue; } - // Scratch volume: pre-create a raw file with a minimal SGI Volume - // Header if it doesn't exist. Refuse cdrom/overlay combinations — - // scratch must be a host-writable raw file. Default size 64 MB. - // - // The VH lays out partition 7 ("vol") spanning sectors 8..end and - // partition 8 ("vh") spanning sectors 0..7 (the VH itself). - // Without a VH, IRIX recognises the device but returns I/O error - // on every read because /dev/rdsk/dks0dNvh and /dev/rdsk/dks0dNvol - // both consult the partition table at sector 0. - // - // Convention: host writes payload via scratch-write at offset >= - // SCRATCH_PAYLOAD_OFFSET (4096). Guest reads from offset 0 of - // /dev/rdsk/dks0dNvol (which maps to sector 8 of the disk by - // partition 7's first_block=8). - if dev.scratch { - if dev.cdrom || dev.overlay { - println!("Note: SCSI ID {}: scratch=true is incompatible with cdrom/overlay; ignoring scratch flag", id); + // 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 { - let path = std::path::Path::new(&dev.path); - if !path.exists() { - let size_mb = dev.size_mb.unwrap_or(64) as u64; - let bytes = size_mb * 1024 * 1024; - match crate::sgi_vh::create_scratch_image(path, bytes) { - Ok(()) => println!("iris: created scratch volume {} ({} MB, with SGI VH)", dev.path, size_mb), - Err(e) => println!("Note: could not create scratch volume {}: {}", dev.path, e), - } - } - 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.to_path_buf()); - } + scratch_path = Some(path); } } - let (path, discs) = if dev.cdrom { - // Build the changer list, skipping empty paths (an empty path - // means "drive present, tray empty" — a valid CD-ROM state - // where media is loaded later at runtime). - let mut list: Vec = Vec::new(); - if !dev.path.is_empty() { - list.push(dev.path.clone()); - } - for d in &dev.discs { - if !d.is_empty() && !list.contains(d) { - list.push(d.clone()); - } - } - // Active disc is the first entry, or empty (no media) if none. - let active = list.first().cloned().unwrap_or_default(); - (active, list) - } else { - (dev.path.clone(), vec![]) - }; - let result = if ci_enabled && dev.overlay && !dev.cdrom { - let ci_overlay = format!("/tmp/iris-ci-{}-scsi{}.overlay", ci_pid, id); - hpc3.add_scsi_device_with_overlay(dev.controller, id as usize, &path, dev.cdrom, discs, dev.overlay, &ci_overlay) + 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(dev.controller, id as usize, &path, dev.cdrom, discs, dev.overlay) + 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, @@ -532,9 +495,9 @@ impl Machine { // 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 SCSI ID {id}: {e}"); + 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 SCSI ID {id}"); + eprintln!("iris: warning: {msg}; continuing without {label}"); continue; } eprintln!("iris: fatal: {msg}"); @@ -544,12 +507,14 @@ impl Machine { // 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 configured path + host file size. + // 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(|(&id, dev)| { + .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, path: dev.path.clone(), size_bytes } + DiskRef { id: crate::config::scsi_slot(controller, id), path: dev.path.clone(), size_bytes } }).collect(); disk_provenance.sort_by_key(|d| d.id); let nvram_provenance = cfg.nvram.clone(); diff --git a/src/scsi_monitor.rs b/src/scsi_monitor.rs new file mode 100644 index 00000000..abc85cb9 --- /dev/null +++ b/src/scsi_monitor.rs @@ -0,0 +1,238 @@ +//! 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. +//! 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`] +//! operations the window, the CI socket and iris-gui use. What is one chip's +//! alone (`scsi regs`, the WD's `wdt`, `defer` and `debug`) stays with the +//! chip, which tries [`scsi_subcommand`] first and handles the rest itself. + +use std::io::Write; + +use crate::scsi::ScsiDevice; + +/// A SCSI controller's targets by id, `None` where nothing is attached. +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 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 { + 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), + }) +} + +/// Every CD-ROM: (id, current disc, the changer list, physical and logical +/// block size). +pub fn disc_status(ctl: &impl ScsiTargets) -> Vec<(usize, String, Vec, u64, u64)> { + ctl.with_targets(|targets| { + targets.iter().enumerate() + .filter_map(|(id, d)| { + let dev = d.as_ref()?; + if !dev.is_cdrom() { return None; } + let (phys, logical) = dev.block_sizes(); + Some((id, dev.current_disc().to_string(), dev.disc_list().to_vec(), phys, logical)) + }) + .collect() + }) +} + +/// 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)) +} + +/// 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)) + }) +} + +/// 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(()) }) +} + +/// 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)) +} + +/// 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)) +} + +/// 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)) +} + +fn write_queue(w: &mut dyn Write, list: &[String]) { + for (i, d) in list.iter().enumerate() { + let tag = if i == 0 { " " } else if i == 1 { " " } else { "" }; + let _ = writeln!(w, " [{}] {}{}", i, d, tag); + } +} + +/// The `scsi` subcommands every controller has. `None` when `args` names +/// none of them, so the chip can try its own. +pub fn scsi_subcommand(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) -> Option> { + let id_arg = |n: usize, usage: &str| -> Result { + args.get(n).and_then(|s| s.parse().ok()).ok_or_else(|| usage.to_string()) + }; + let r = match args.first().copied()? { + "status" => { + 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)); + } + } + lines + }); + if lines.is_empty() { + let _ = writeln!(w, "No devices attached"); + } + for l in lines { + let _ = writeln!(w, "{}", l); + } + Ok(()) + } + "dayna" => { + let lines = ctl.with_targets(|targets| { + targets.iter().flatten() + .filter_map(|dev| dev.daynaport().map(|dp| dp.status_lines())) + .flatten() + .collect::>() + }); + if lines.is_empty() { + let _ = writeln!(w, "No DaynaPort targets attached"); + } + for l in lines { + let _ = writeln!(w, "{}", l); + } + Ok(()) + } + "eject" => id_arg(1, "Usage: scsi eject ").map(|id| { + match eject_disc(ctl, id) { + Ok(path) => { let _ = writeln!(w, "SCSI ID {}: switched to {}", id, path); } + Err(e) => { let _ = writeln!(w, "Error: {}", e); } + } + }), + "add" => id_arg(1, "Usage: scsi add ").and_then(|id| { + let path = args.get(2).ok_or_else(|| "Usage: scsi add ".to_string())?.to_string(); + match add_disc(ctl, id, path.clone()) { + Ok(()) => { let _ = writeln!(w, "SCSI ID {}: added {} as next disc", id, path); } + Err(e) => { let _ = writeln!(w, "Error: {}", e); } + } + Ok(()) + }), + "list" => id_arg(1, "Usage: scsi list ").map(|id| { + match cdrom(ctl, id, |dev| Ok(dev.disc_list().to_vec())) { + Ok(list) => { + let _ = writeln!(w, "SCSI ID {} queue ({} disc(s)):", id, list.len()); + write_queue(w, &list); + } + Err(e) => { let _ = writeln!(w, "{}", e); } + } + }), + "del" => id_arg(1, "Usage: scsi del ").and_then(|id| { + let ordinal = id_arg(2, "Usage: scsi del ")?; + match remove_disc(ctl, id, ordinal) { + Ok(path) => { let _ = writeln!(w, "SCSI ID {}: removed [{}] {}", id, ordinal, path); } + Err(e) => { let _ = writeln!(w, "Error: {}", e); } + } + Ok(()) + }), + "next" => id_arg(1, "Usage: scsi next ").and_then(|id| { + let ordinal = id_arg(2, "Usage: scsi next ")?; + match move_disc_next(ctl, id, ordinal) { + Ok(()) => { let _ = writeln!(w, "SCSI ID {}: [{}] moved to next position", id, ordinal); } + Err(e) => { let _ = writeln!(w, "Error: {}", e); } + } + Ok(()) + }), + _ => return None, + }; + Some(r) +} + +/// `cow status | cow commit [id] | cow reset [id]` over the controller's +/// copy-on-write targets. +pub fn cow_command(ctl: &impl ScsiTargets, args: &[&str], w: &mut dyn Write) -> Result<(), String> { + let explicit: Option = match args.get(1) { + Some(s) => Some(s.parse().map_err(|_| "invalid SCSI ID".to_string())?), + None => None, + }; + let lines: Vec = match args.first().copied() { + 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()) + } else { + format!("SCSI {}: direct (no overlay)", id) + }) + .collect() + }), + 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(), + }; + let mut lines = Vec::new(); + for id in ids { + let Some(dev) = targets.get_mut(id).and_then(|d| d.as_mut()) else { continue }; + lines.push(if op == "commit" { + match dev.cow_commit() { + Ok(n) if n > 0 => format!("SCSI {}: committed {} sectors to base image", id, n), + Ok(_) => format!("SCSI {}: nothing to commit", id), + Err(e) => format!("SCSI {}: commit failed: {}", id, e), + } + } else { + match dev.cow_reset() { + Ok(()) => format!("SCSI {}: overlay reset (all writes discarded)", id), + Err(e) => format!("SCSI {}: reset failed: {}", id, e), + } + }); + } + lines + }), + _ => return Err("Usage: cow status | cow commit [id] | cow reset [id]".to_string()), + }; + for l in lines { + let _ = writeln!(w, "{}", l); + } + Ok(()) +} diff --git a/src/workstation.rs b/src/workstation.rs index 6f1b02da..4635f046 100644 --- a/src/workstation.rs +++ b/src/workstation.rs @@ -24,7 +24,6 @@ use parking_lot::Mutex; use crate::config::MachineConfig; use crate::gdb_stub::CpuDebug; use crate::dev::ps2::Ps2Controller; -use crate::dev::wd33c93a::Wd33c93a; use crate::dev::z85c30::CiSerialBackend; use crate::gfx_display::GfxDisplay; use crate::hptimer::TimerManager; @@ -310,18 +309,20 @@ pub trait DiscChanger: Send + Sync { fn eject_to_empty(&self, id: usize) -> Result<(), String>; } -impl DiscChanger for Wd33c93a { +/// Every controller that lends its targets (the WD33C93A, the Adaptec 7880) +/// takes disc changes the same way; the operations are `scsi_monitor`'s. +impl DiscChanger for T { fn disc_status(&self) -> Vec<(usize, String, Vec, u64, u64)> { - Wd33c93a::disc_status(self) + crate::scsi_monitor::disc_status(self) } fn load_disc(&self, id: usize, path: String) -> Result { - Wd33c93a::load_disc(self, id, path) + crate::scsi_monitor::load_disc(self, id, path) } fn eject_disc(&self, id: usize) -> Result { - Wd33c93a::eject_disc(self, id) + crate::scsi_monitor::eject_disc(self, id) } fn eject_to_empty(&self, id: usize) -> Result<(), String> { - Wd33c93a::eject_to_empty(self, id) + crate::scsi_monitor::eject_to_empty(self, id) } }