diff --git a/Cargo.toml b/Cargo.toml index 7795138..ea7845f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,14 @@ camera = ["dep:nokhwa", "dep:v4l"] pcap = ["dep:pcap"] # N64 development board (Ultra64) — GIO slot 0 + shm IPC. ultra64 = ["dep:shared_memory", "dep:raw_sync"] +# DaynaPort SCSI/Link target: a SCSI-attached Ethernet adapter (type 3 +# Processor device) selectable per SCSI id, giving the guest a second network +# path that does not go through the onboard SEEQ. Needs a guest driver — see +# github.com/techomancer/irixdayna for the IRIX one. Each target runs its own +# NAT gateway (or PCAP bridge with --features pcap) on its own subnet. +# Off by default — enable with `cargo build --features daynaport`, then select +# it at runtime with `kind = "daynaport"` in an `[scsi.N]` section. +daynaport = [] [dependencies] clap = { version = "4", features = ["derive"] } diff --git a/HELP.md b/HELP.md index 5da7678..117d3c1 100644 --- a/HELP.md +++ b/HELP.md @@ -371,6 +371,15 @@ cdrom = true # cdrom = true # discs = ["irix65.iso", "extras.iso", "patches.iso"] +# DaynaPort SCSI/Link — Ethernet over the SCSI bus. Needs a build with +# --features daynaport and a driver in the guest (IRIX: irixdayna -> dp0). +# It has no disk image; mac and subnet are optional (defaults derived from the +# SCSI id / 192.168.10.0/24). See docs/daynaport.md. +# [scsi.3] +# kind = "daynaport" +# mac = "00:80:19:12:34:56" +# subnet = "192.168.10.0/24" + # VINO video-in (IndyCam emulation). # source: "test_pattern" | "camera" | "black" # standard: "ntsc" | "pal" diff --git a/README.md b/README.md index fd00572..6f9fe44 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ cargo run --release --features ci_clock # synthetic deterministic C cargo run --release --features chd # mount .chd disk/CD-ROM images directly (via libchdman-rs); off by default to keep builds light cargo run --release --features camera # use host camera as the IndyCam video source (macOS AVFoundation via nokhwa). See [vino] in iris.toml. cargo run --release --features pcap # bridge guest networking onto a real host interface via libpcap instead of the built-in NAT gateway. See [network] in iris.toml. +cargo run --release --features daynaport # DaynaPort SCSI/Link: Ethernet over the SCSI bus, selectable per SCSI id. Needs a guest driver. See docs/daynaport.md. ``` ### CHD image support (`--features chd`) @@ -195,6 +196,28 @@ back to the NAT gateway, and `--list-net-interfaces` reports that the feature is missing. +## DaynaPort SCSI/Link (`--features daynaport`) + +A SCSI-attached Ethernet adapter (SCSI type 3, Processor) selectable on any +SCSI id — a second network path for the guest that goes over the SCSI bus +instead of the onboard SEEQ. Off by default, because it is only useful with a +guest driver; IRIX has none in the box (see +[irixdayna](https://github.com/techomancer/irixdayna), where it appears as +`dp0`). + +```toml +[scsi.3] +kind = "daynaport" # default "disk"; "cdrom" / cdrom = true unchanged +mac = "00:80:19:12:34:56" # optional; default derived from the SCSI id +subnet = "192.168.10.0/24" # optional; this target's own NAT subnet +``` + +Each DaynaPort runs its own NAT gateway (or PCAP bridge, in a `--features pcap` +build) on its own subnet, so `dp0` and `ec0` never share a network. `scsi dayna` +in the monitor shows its MAC, addresses and counters. Full protocol and +verification notes: [docs/daynaport.md](docs/daynaport.md). + + ## R5000 CPU (`--features r5k`) Switches the emulated CPU from R4400 to R5000: diff --git a/docs/daynaport.md b/docs/daynaport.md new file mode 100644 index 0000000..40e0838 --- /dev/null +++ b/docs/daynaport.md @@ -0,0 +1,178 @@ +# DaynaPort SCSI/Link target + +IRIS can present a **DaynaPort SCSI/Link** (DP0801 / DP0802) — a SCSI-attached +Ethernet adapter — on any SCSI id. It is a second, architecture-independent +network path for the guest: no GIO card, no onboard SEEQ, just the SCSI bus. + +The device is a SCSI **type 3 (Processor)** target that moves Ethernet frames +with five vendor-specific 6-byte CDBs. Modern re-implementations (BlueSCSI V2, +ZuluSCSI, PiSCSI, SCSI2SD) speak the same protocol, which is how vintage SGI, +Mac and Atari machines get networking today. + +**It needs a guest driver.** IRIX has no DaynaPort driver in the box; without +one the target is visible on the bus (`hinv` shows a SCSI device at that id) and +nothing else happens. The IRIX driver lives at +[github.com/techomancer/irixdayna](https://github.com/techomancer/irixdayna) +(6.5 in the root, 5.3 under `irix5.3/`), where it appears as `dp0`. + +## Build + +Off by default — it is only useful with that driver: + +```sh +cargo build --release --features daynaport # iris CLI +cargo build --release -p iris-gui --features daynaport # GUI +``` + +Without the feature, a config that asks for one fails at startup with +`DaynaPort support not compiled in (rebuild with --features daynaport)`. + +## Configure + +```toml +[scsi.3] +kind = "daynaport" # default "disk"; "cdrom" (or cdrom = true) unchanged +mac = "00:80:19:12:34:56" # optional +subnet = "192.168.10.0/24" # optional; this target's own NAT subnet +``` + +- `kind` is the new spelling of the target type. `cdrom = true` still means + `kind = "cdrom"`, so no existing config changes. +- `mac` defaults to `00:80:19:44:50:` — the real DaynaPort `00:80:19` + OUI, then `44 50` ("DP") and the target id, so two targets never collide. It + is deliberately *not* the IRIX driver's `00:80:19:00:00:NN` placeholder, so a + MAC actually read from the device is visibly different from a made-up one. +- `subnet` defaults to `192.168.10.0/24`: gateway `.1`, guest `.2`. It must + differ from the machine-wide `nat_subnet` (ec0's) — startup validation rejects + a collision. +- `path`, `discs`, `overlay`, `scratch` do not apply and are rejected: there is + no image behind a network adapter. + +In the GUI the target type is a dropdown on the Disks tab (HDD / CD-ROM / +DaynaPort), with MAC and subnet fields next to it. + +## Networking topology + +Each DaynaPort runs **its own `NatEngine`** on its own thread (`daynaN-nat`), +separate from the onboard SEEQ's. So `dp0` and `ec0` are on different subnets +and traffic through the DaynaPort is unmistakable — useful for testing, and it +keeps a broken DaynaPort from disturbing the onboard NIC. + +Inherited from the machine-wide config: + +- **Backend selection** (`[network] mode`): `nat` or, in a `--features pcap` + build, `pcap` — a DaynaPort can be bridged onto a real host interface exactly + as `ec0` can. +- **The NFS export** (`[nfs]`), which is served in-process with no host sockets, + so the guest can mount it over either interface. + +**Not** inherited: host **port forwards**. Only one engine can own a host +listening port, so forwards stay with the onboard NIC. + +## Monitor + +``` +scsi dayna # MAC, gateway/client/netmask, enable + broadcast state, counters +scsi status # one line per DaynaPort, then the CD-ROM listing +net status # NAT tables (shared command; shows the onboard NIC's engine) +``` + +## Protocol + +Reference: **SLINKCMD.TXT** (Roger Burrows, rev 1.20). Implemented in +`src/daynaport.rs`; the record format is what `dp_do_rx()` in the IRIX driver +consumes. All multi-byte fields are big-endian on the wire. + +| Opcode | Name | Direction | +|---|---|---| +| `0x08` | READ — receive packet(s) | device → host | +| `0x09` | RETRIEVE STATS | device → host | +| `0x0A` | WRITE — transmit packet | host → device | +| `0x0C` | SET INTERFACE MODE | no data | +| `0x0E` | ENABLE/DISABLE | no data | + +`0x08` and `0x0A` are the same opcodes as SCSI READ(6)/WRITE(6). A DaynaPort is +dispatched on device kind **before** the storage opcodes in +`ScsiDevice::request` (`src/scsi.rs`) and answers no storage command at all — +no READ CAPACITY, no MODE SENSE, no READ TOC. The WD33C93A also needs to know: +a DaynaPort WRITE(6) transfers a plain byte count from CDB 3..4, not +`blocks × 512`. + +### READ response + +Records back to back, each: + +``` + offset size field + 0 2 pktlen, BIG-ENDIAN — frame length INCLUDING a 4-byte trailing + CRC, EXCLUDING this 6-byte header + 2 4 flags, BIG-ENDIAN — 0x00000010 = more packets still queued, + 0x00000000 = last record, 0xFFFFFFFF = dropped (unused here) + 6 pktlen the Ethernet frame, then 4 CRC bytes +``` + +Rules that matter, all covered by the unit tests in `src/daynaport.rs`: + +- **`pktlen` includes the 4 CRC bytes and the payload physically carries + them.** Getting this wrong truncates every frame by 4 bytes — frames that are + *almost* right, the worst kind of wrong. The CRC value is not checked by + anyone; zeros are fine. +- **`pktlen == 0` means "no more records".** An idle device answers with six + zero bytes immediately. READ never blocks: the driver polls it every 10 ms and + a blocking read wedges the interface. +- **MORE (`0x10`) is set on every record but the last of a response**, and on + the last one too if frames are still queued (the driver then issues another + READ instead of waiting for its next tick). The driver stops parsing at the + first record without MORE, so an intermediate record without it silently drops + the rest of the response. +- **A record is never emitted past the requested transfer length.** The frame + stays queued for the next READ instead. At the driver's 3072-byte ask, two + max-size frames fit and a third does not. + +`0xFFFFFFFF` (dropped → the driver does a full disable/enable/set-mode cycle) is +never emitted: a full RX ring discards silently, which is less disruptive. + +## Snapshots + +Nothing DaynaPort-specific is saved, matching `seeq8003`: the backend's sockets +and NAT tables can't be snapshotted anyway, and in-flight frames are dropped. +On restore the interface comes back disabled with empty queues, and the guest +driver's next ENABLE/SET MODE brings it up. A machine reset (`power_on`) does the +same and flushes the NAT tables. + +## Verifying it end to end + +`hinv` from the PROM command monitor proves INQUIRY and the type-3 dispatch: + +``` +>> hinv -v + SCSI Device: Controller 0 ID 3 +``` + +Everything past that needs the guest driver. From a checkout of `irixdayna` +next to `iris`: + +```sh +cd ../irixdayna +scripts/iris-build.sh --release 5.3 --boot-test +``` + +The acceptance ladder, in order — each rung isolates a different part of the +protocol: + +1. **Detected** — `dp0: DaynaPort SCSI/Link at scsi(0) target N lun 0` + (INQUIRY + type-3 dispatch). +2. **MAC read** — `ifconfig dp0 up` logs the configured MAC rather than the + `00:80:19:00:00:NN` placeholder (`0x09`, `0x0E`, `0x0C`). +3. **ARP** — `arp -a` after pinging the gateway shows a resolved entry. First + proof both directions work, and the first thing broadcast filtering breaks. +4. **Ping** — `ping 192.168.10.1` gets replies. +5. **TCP** — `ftp`/`telnet` through the gateway; stress-tests the multi-packet + READ path. +6. **Throughput** — an order of magnitude below reference suggests the MORE flag + is never set and every frame costs a full 10 ms poll. + +If ARP resolves but ping does not, suspect `pktlen` off by the 4 CRC bytes. If +nothing resolves at all, suspect byte order in the record header, or broadcast +being filtered out. Building the driver with `-DDP_LOG_NET` plus IRIS's own +`eth_summary()` traces gives both ends of every frame. diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 4c99214..a5cc465 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -69,6 +69,13 @@ appstore = ["bundled"] # interfaces in a dropdown and the in-process VM can actually bridge onto them. # Build with: cargo build -p iris-gui --features pcap pcap = ["iris/pcap"] +# DaynaPort SCSI/Link target — Ethernet over the SCSI bus, selectable per SCSI +# id on the Disks tab. Off by default: it is only useful with a guest driver +# (IRIX: github.com/techomancer/irixdayna), and without one the device just sits +# on the bus. The Disks tab still shows the option in a build without it, with a +# "rebuild with --features daynaport" warning, so a config stays editable. +# Build with: cargo build -p iris-gui --features daynaport +daynaport = ["iris/daynaport"] # Emulate an R5000 CPU instead of the default R4400 (compile-time: the cache # model differs deeply). Surfaced read-only on the Memory tab via # iris::build_features::CPU. Build with: cargo build -p iris-gui --features r5k diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 397429f..5077130 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -4,7 +4,8 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use iris::config::{ ForwardBind, ForwardProto, GraphicsBoard, JitConfig, MachineConfig, MachineProfile, NetMode, - NfsConfig, PortForwardConfig, ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, + NfsConfig, PortForwardConfig, ScsiDeviceConfig, ScsiKind, VinoSource, VinoStandard, + VALID_BANK_SIZES, }; use iris::nfsudp::NfsVersion; use iris::vc2_timings::NewportResolution; @@ -553,15 +554,53 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) } else if ui.button("Attach…").clicked() { cfg.scsi.insert(id, ScsiDeviceConfig { path: format!("scsi{id}.raw"), - discs: vec![], - cdrom: false, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); } }); if let Some(dev) = cfg.scsi.get_mut(&id) { + // A DaynaPort has no image, no media and no overlay — it is an + // Ethernet adapter on the SCSI bus. Show its own short form + // instead of the storage rows below. + if dev.is_daynaport() { + Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| { + ui.label("Type"); + scsi_type_combo(ui, id, dev, &mut edit); + ui.end_row(); + + if !build_features::DAYNAPORT { + ui.label(""); + ui.label(RichText::new( + "⚠ this build lacks DaynaPort support — rebuild with --features daynaport") + .color(Color32::from_rgb(230, 140, 70))); + ui.end_row(); + } + + ui.label("MAC address") + .on_hover_text("Blank = derived from the SCSI id (00:80:19:44:50:)."); + let mut mac = dev.mac.clone().unwrap_or_default(); + if ui.add(TextEdit::singleline(&mut mac).hint_text("00:80:19:44:50:03")).changed() { + dev.mac = if mac.trim().is_empty() { None } else { Some(mac) }; + edit.changed = true; + } + ui.end_row(); + + ui.label("NAT subnet") + .on_hover_text("This target runs its own NAT gateway, separate from ec0's. \ + Gateway gets .1, the guest gets .2. Blank = 192.168.10.0/24."); + let mut subnet = dev.subnet.clone().unwrap_or_default(); + if ui.add(TextEdit::singleline(&mut subnet).hint_text("192.168.10.0/24")).changed() { + dev.subnet = if subnet.trim().is_empty() { None } else { Some(subnet) }; + edit.changed = true; + } + ui.end_row(); + }); + ui.label(RichText::new( + "DaynaPort SCSI/Link — Ethernet over the SCSI bus. The guest needs a driver \ + for it (IRIX: github.com/techomancer/irixdayna); IRIX sees it as dp0, separate \ + from the onboard ec0.").weak().small()); + continue; + } Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| { ui.label("Image path"); let e = path_row(ui, ("scsi_path", id), &mut dev.path, @@ -610,21 +649,14 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> (PathEdit, ConfigAction) ui.label("Type"); let was_cd = dev.cdrom; - let mut is_cd = dev.cdrom; - ComboBox::from_id_salt(("type", id)) - .selected_text(if is_cd { "CD-ROM" } else { "HDD" }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut is_cd, false, "HDD"); - ui.selectable_value(&mut is_cd, true, "CD-ROM"); - }); + scsi_type_combo(ui, id, dev, &mut edit); // Switching to CD-ROM defaults to an empty drive (no media): // clear the auto-generated HDD placeholder path so it doesn't // look like a (missing) disc. Load media via "Insert disc…" in // the SCSI menu, or just type a path here. - if is_cd && !was_cd && dev.path == format!("scsi{id}.raw") { + if dev.cdrom && !was_cd && dev.path == format!("scsi{id}.raw") { dev.path.clear(); } - dev.cdrom = is_cd; ui.end_row(); if dev.cdrom && dev.path.is_empty() { ui.label(""); @@ -1591,6 +1623,40 @@ struct PathEdit { picked: bool, } +/// SCSI target-type picker. `kind`/`cdrom` are two spellings of the same +/// setting in the config, so write both from one place: a DaynaPort must not +/// keep a stale `cdrom = true`, and a disk/CD-ROM must not keep +/// `kind = "daynaport"`. DaynaPort is offered even in a build without the +/// feature — with a warning next to it — so an existing config stays editable. +fn scsi_type_combo(ui: &mut Ui, id: u8, dev: &mut ScsiDeviceConfig, edit: &mut PathEdit) { + let mut kind = dev.kind(); + let before = kind; + ComboBox::from_id_salt(("type", id)) + .selected_text(match kind { + ScsiKind::Disk => "HDD", + ScsiKind::Cdrom => "CD-ROM", + ScsiKind::Daynaport => "DaynaPort (Ethernet)", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut kind, ScsiKind::Disk, "HDD"); + ui.selectable_value(&mut kind, ScsiKind::Cdrom, "CD-ROM"); + ui.selectable_value(&mut kind, ScsiKind::Daynaport, "DaynaPort (Ethernet)"); + }); + if kind != before { + edit.changed = true; + if kind == ScsiKind::Daynaport { + // No image, no media, no overlay behind a network adapter — and + // leaving them set makes the config fail validation at Start. + dev.path.clear(); + dev.discs.clear(); + dev.overlay = false; + dev.scratch = false; + } + } + dev.kind_field = kind; + dev.cdrom = kind == ScsiKind::Cdrom; +} + /// A TextEdit + 📁 Browse button that updates `value` in place. See [`PathEdit`]. fn path_row( ui: &mut Ui, diff --git a/iris-gui/src/dialogs/new_machine.rs b/iris-gui/src/dialogs/new_machine.rs index a5830b9..94aada7 100644 --- a/iris-gui/src/dialogs/new_machine.rs +++ b/iris-gui/src/dialogs/new_machine.rs @@ -243,21 +243,14 @@ impl NewMachineDialog { if !self.scsi1_path.is_empty() { cfg.scsi.insert(1, ScsiDeviceConfig { path: self.scsi1_path.clone(), - discs: vec![], - cdrom: false, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); } if self.attach_cdrom && !self.cdrom4_path.is_empty() { cfg.scsi.insert(4, ScsiDeviceConfig { path: self.cdrom4_path.clone(), - discs: vec![], cdrom: true, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); } let name = if self.name.trim().is_empty() { "indy".to_string() } else { self.name.trim().to_string() }; diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 1f3bd8b..bffd0a4 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -790,6 +790,8 @@ impl App { let mut out = Vec::new(); for (&id, dev) in &self.cfg.scsi { if dev.scratch { continue; } + // A DaynaPort is a network adapter — no image to be missing. + if dev.is_daynaport() { continue; } // Empty CD-ROM (no path, no changer entries) means "drive present, // tray empty" — a valid configured state, not missing. if dev.cdrom && dev.path.is_empty() && dev.discs.is_empty() { @@ -2967,6 +2969,10 @@ impl App { } for id in ids { let d = &self.cfg.scsi[&id]; + if d.is_daynaport() { + ui.label(format!("scsi{id} DaynaPort (Ethernet)")); + continue; + } let kind = if d.cdrom { "CD" } else { "HDD" }; ui.label(format!("scsi{id} {kind}: {}", abs_path(&d.path))); } @@ -3234,8 +3240,7 @@ impl eframe::App for App { if let Some(result) = self.create_disk.take_result() { let path_str = result.path.to_string_lossy().into_owned(); self.cfg.scsi.insert(result.scsi_id, iris::config::ScsiDeviceConfig { - path: path_str.clone(), discs: vec![], cdrom: false, - overlay: false, scratch: false, size_mb: None, + path: path_str.clone(), ..Default::default() }); self.mark_dirty(); self.toast(format!("created {path_str} and attached at scsi{}", result.scsi_id)); diff --git a/iris-gui/src/safe_stop.rs b/iris-gui/src/safe_stop.rs index a231ede..4064d3f 100644 --- a/iris-gui/src/safe_stop.rs +++ b/iris-gui/src/safe_stop.rs @@ -51,6 +51,7 @@ pub fn evaluate(status: &Status, cfg: &MachineConfig) -> UnsafeReasons { let persists_to_base = !dev.cdrom && !dev.overlay && !dev.scratch + && !dev.is_daynaport() // no image behind a network adapter && !dev.path.ends_with(".chd"); if persists_to_base { r.writable_disks.push(*id); diff --git a/iris-gui/src/scsi_menu.rs b/iris-gui/src/scsi_menu.rs index 0c2c03f..5cafc5b 100644 --- a/iris-gui/src/scsi_menu.rs +++ b/iris-gui/src/scsi_menu.rs @@ -52,7 +52,16 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { ui.close(); } } - Some(d) if d.cdrom => { + Some(d) if d.is_daynaport() => { + ui.label("DaynaPort SCSI/Link (Ethernet). Configure its MAC and \ + subnet on the Config tab."); + ui.separator(); + if ui.button("Detach DaynaPort").clicked() { + action = ScsiAction::Detach { id }; + ui.close(); + } + } + Some(d) if d.is_cdrom() => { let has_media = !d.path.is_empty() && Path::new(&d.path).exists(); if has_media { if ui.button("Eject (tray empty)").clicked() { @@ -116,7 +125,8 @@ pub fn draw(ui: &mut Ui, cfg: &MachineConfig) -> ScsiAction { fn render_label(id: u8, dev: Option<&ScsiDeviceConfig>) -> String { match dev { None => format!("SCSI #{id}: (empty)"), - Some(d) if d.cdrom => { + Some(d) if d.is_daynaport() => format!("SCSI #{id}: DaynaPort (Ethernet)"), + Some(d) if d.is_cdrom() => { if d.path.is_empty() { format!("SCSI #{id}: CD (no media)") } else if !Path::new(&d.path).exists() { @@ -167,22 +177,16 @@ pub fn apply(cfg: &mut MachineConfig, action: ScsiAction) -> Option { match action { ScsiAction::None => None, ScsiAction::AttachHdd { id, path } => { - cfg.scsi.insert(id, ScsiDeviceConfig { - path, discs: vec![], cdrom: false, overlay: false, scratch: false, size_mb: None, - }); + cfg.scsi.insert(id, ScsiDeviceConfig { path, ..Default::default() }); Some(format!("scsi{id}: HDD attached")) } ScsiAction::AttachEmptyCdrom { id } => { - cfg.scsi.insert(id, ScsiDeviceConfig { - path: String::new(), discs: vec![], cdrom: true, - overlay: false, scratch: false, size_mb: None, - }); + cfg.scsi.insert(id, ScsiDeviceConfig { cdrom: true, ..Default::default() }); Some(format!("scsi{id}: empty CD-ROM drive attached (Stop→Start if VM is running)")) } ScsiAction::AttachCdromWithDisc { id, path } => { cfg.scsi.insert(id, ScsiDeviceConfig { - path: path.clone(), discs: vec![], cdrom: true, - overlay: false, scratch: false, size_mb: None, + path: path.clone(), cdrom: true, ..Default::default() }); Some(format!("scsi{id}: CD-ROM attached with disc")) } diff --git a/iris.toml b/iris.toml index 0f037a4..154662c 100644 --- a/iris.toml +++ b/iris.toml @@ -77,6 +77,19 @@ cdrom = false #scratch = true #size_mb = 64 +# DaynaPort SCSI/Link — Ethernet over the SCSI bus (SCSI type 3, Processor). +# Requires a build with `--features daynaport` AND a driver in the guest; IRIX +# has none in the box (see github.com/techomancer/irixdayna, where it shows up +# as dp0). Has no disk image: path/discs/overlay/scratch don't apply. +# mac — optional; default 00:80:19:44:50: +# subnet — optional; this target's own NAT subnet (gateway .1, guest .2). +# Must differ from nat_subnet (ec0's). Default 192.168.10.0/24. +# See docs/daynaport.md. +#[scsi.3] +#kind = "daynaport" +#mac = "00:80:19:12:34:56" +#subnet = "192.168.10.0/24" + # NAT subnet for the internal network (CIDR notation). # The gateway gets host .1 and IRIX gets host .2. # Change this if 192.168.0.x conflicts with your local network. diff --git a/rules/scsi/daynaport-target-gotchas.md b/rules/scsi/daynaport-target-gotchas.md new file mode 100644 index 0000000..c297617 --- /dev/null +++ b/rules/scsi/daynaport-target-gotchas.md @@ -0,0 +1,46 @@ +# DaynaPort target: the three things that bite + +Added 2026-08-12 with `--features daynaport` (`src/daynaport.rs`). Protocol per +SLINKCMD.TXT rev 1.20, cross-checked against `dp_do_rx()` in +[irixdayna](https://github.com/techomancer/irixdayna). Verified so far: unit +tests in `src/daynaport.rs`, and the PROM bus scan finding the target +(`hinv -v` → `SCSI Device: Controller 0 ID 3`). The full `dp0` ladder (ARP → +ping → TCP) needs the guest driver and has **not** been run yet. + +## 1. `0x08`/`0x0A` are READ(6)/WRITE(6) — dispatch on device kind first + +A DaynaPort's packet RX/TX opcodes are byte-identical to SCSI READ(6) and +WRITE(6). Added as extra arms in `ScsiDevice::request`'s `match req.cdb[0]`, +the storage arms win and the "device" tries to read disk blocks. So +`ScsiDevice` carries a `DeviceKind` and dispatches `DeviceKind::DaynaPort` +*before* the storage match. A DaynaPort answers no storage command at all. + +## 2. The collision reaches up into the controller, too + +Not just the device: `Wd33c93aState::process_scsi_command` computes the +**data-out length** for WRITE(6) as `blocks × 512` from `cdb[4]`. A DaynaPort +WRITE(6) carries a plain byte count in `cdb[3..4]`, so without a +`WRITE_6 if dayna` arm the controller waits on DMA for hundreds of KB that the +guest never sends. This is the part that is easy to miss — the device-level +dispatch looks like the whole job. + +## 3. The RX record header: CRC accounting and the MORE flag + +Per record: `pktlen` (2, BE) + `flags` (4, BE) + frame + 4 CRC bytes. + +- `pktlen` **includes** the 4 trailing CRC bytes *and* the payload must + physically contain them (zeros are fine — nobody checks the value). Omit them + and every frame arrives 4 bytes short: almost right, which is the worst kind + of wrong. Symptom: ARP resolves, ping doesn't. +- `flags & 0x10` (MORE) must be set on **every record but the last one in the + response**, not only when frames remain queued. The driver's parser stops at + the first record without MORE, so an intermediate record missing it silently + drops the rest of the response. Set it on the last record too when the device + still has frames queued — the driver then issues another READ immediately + instead of waiting out its 10 ms poll (this is what throughput hangs on). +- `pktlen == 0` is "nothing more here". An idle READ returns six zero bytes + immediately. **Never block waiting for a frame** — the driver polls every + 10 ms and a blocking read wedges the interface. +- Never emit a record past the CDB's requested transfer length; leave the frame + queued instead. At the driver's 3072-byte ask, two max-size frames fit + (2 × 1524 = 3048) and a third does not. diff --git a/src/config.rs b/src/config.rs index 248d41c..2ffa868 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,33 @@ use std::net::Ipv4Addr; /// Valid memory bank sizes in MB. pub const VALID_BANK_SIZES: &[u32] = &[0, 8, 16, 32, 64, 128]; +/// What sits at a SCSI id. `cdrom = true` remains the historical spelling for +/// `kind = "cdrom"`; either works and they mean the same thing. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ScsiKind { + /// Hard disk (raw image, CHD, or COW overlay). + #[default] + Disk, + /// CD-ROM drive (may start with an empty tray). + Cdrom, + /// DaynaPort SCSI/Link — a SCSI-attached Ethernet adapter. Has no disk + /// image at all. Requires a build with `--features daynaport`. + Daynaport, +} + +impl ScsiKind { + fn is_default(&self) -> bool { *self == ScsiKind::Disk } + + pub fn label(self) -> &'static str { + match self { + Self::Disk => "Hard disk", + Self::Cdrom => "CD-ROM", + Self::Daynaport => "DaynaPort SCSI/Link", + } + } +} + /// Configuration for a single SCSI device. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScsiDeviceConfig { @@ -16,8 +43,26 @@ pub struct ScsiDeviceConfig { /// Additional ISO images for CD-ROM changers (ignored for HDD). #[serde(default)] pub discs: Vec, - /// true = CD-ROM, false = hard disk. + /// true = CD-ROM, false = hard disk. Kept for compatibility: it is the + /// original spelling of `kind = "cdrom"` and every existing config uses it. + /// Use `kind` for anything that is not a disk or a CD-ROM. + #[serde(default)] pub cdrom: bool, + /// Target type. Defaults to `disk`; `cdrom = true` still selects a CD-ROM + /// on its own. Read it through [`ScsiDeviceConfig::kind`] rather than + /// directly, so the two spellings stay reconciled. + #[serde(default, rename = "kind", skip_serializing_if = "ScsiKind::is_default")] + pub kind_field: ScsiKind, + /// DaynaPort only: explicit MAC address, e.g. `"00:80:19:12:34:56"`. + /// Default is derived from the SCSI id so two targets never collide. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mac: Option, + /// DaynaPort only: NAT subnet in CIDR notation for *this* target's gateway, + /// e.g. `"192.168.10.0/24"`. Each DaynaPort runs its own NAT engine, so + /// this must differ from the machine-wide `nat_subnet` used by `ec0`. + /// Gateway gets host .1, the guest gets host .2. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subnet: Option, /// Enable copy-on-write overlay. Base image is never modified; writes go to /// `{path}.overlay`. Delete the overlay file to reset to clean state. #[serde(default)] @@ -37,6 +82,82 @@ pub struct ScsiDeviceConfig { pub size_mb: Option, } +impl Default for ScsiDeviceConfig { + fn default() -> Self { + Self { + path: String::new(), + discs: vec![], + cdrom: false, + kind_field: ScsiKind::Disk, + mac: None, + subnet: None, + overlay: false, + scratch: false, + size_mb: None, + } + } +} + +impl ScsiDeviceConfig { + /// The target type, reconciling the `kind` key with the older `cdrom` bool. + /// An explicit `kind` wins; `cdrom = true` alone still means CD-ROM. + pub fn kind(&self) -> ScsiKind { + match self.kind_field { + ScsiKind::Disk if self.cdrom => ScsiKind::Cdrom, + k => k, + } + } + + pub fn is_cdrom(&self) -> bool { self.kind() == ScsiKind::Cdrom } + pub fn is_daynaport(&self) -> bool { self.kind() == ScsiKind::Daynaport } + + /// 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 { + let mac = match &self.mac { + Some(s) => parse_mac(s)?, + None => [0x00, 0x80, 0x19, 0x44, 0x50, id], + }; + let subnet = match &self.subnet { + Some(cidr) => { + let (gateway_ip, client_ip, netmask) = parse_nat_subnet(cidr)?; + NatSubnet { gateway_ip, client_ip, netmask } + } + None => NatSubnet { + gateway_ip: Ipv4Addr::new(192, 168, 10, 1), + client_ip: Ipv4Addr::new(192, 168, 10, 2), + netmask: Ipv4Addr::new(255, 255, 255, 0), + }, + }; + Ok(DaynaportParams { mac, subnet }) + } +} + +/// Resolved DaynaPort settings for one SCSI target. +#[derive(Debug, Clone, Copy)] +pub struct DaynaportParams { + pub mac: [u8; 6], + pub subnet: NatSubnet, +} + +/// Parse `"00:80:19:12:34:56"` (or `-` separated) into six octets. +pub fn parse_mac(s: &str) -> Result<[u8; 6], String> { + let parts: Vec<&str> = s.split(|c| c == ':' || c == '-').collect(); + if parts.len() != 6 { + return Err(format!("\"{}\" is not a MAC address (expected six octets)", s)); + } + let mut mac = [0u8; 6]; + for (i, p) in parts.iter().enumerate() { + mac[i] = u8::from_str_radix(p, 16) + .map_err(|_| format!("\"{}\" is not a MAC address (bad octet \"{}\")", s, p))?; + } + if mac[0] & 0x01 != 0 { + return Err(format!("{} is a multicast address; a station MAC must have bit 0 of the \ + first octet clear", s)); + } + Ok(mac) +} + /// Protocol for port forwarding. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] @@ -744,19 +865,12 @@ fn default_scsi() -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); map.insert(1, ScsiDeviceConfig { path: "scsi1.raw".to_string(), - discs: vec![], - cdrom: false, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); map.insert(4, ScsiDeviceConfig { path: "cdrom4.iso".to_string(), - discs: vec![], cdrom: true, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); map } @@ -887,7 +1001,36 @@ impl MachineConfig { // A CD-ROM may legitimately start with an empty tray (no path, no // discs) and have media loaded at runtime; any discs list is valid // as a changer queue. So there is nothing CD-ROM-specific to check. - let _ = dev; + if dev.is_daynaport() { + if dev.cdrom || dev.overlay || dev.scratch { + return Err(format!( + "SCSI ID {}: kind = \"daynaport\" has no disk image, so cdrom / \ + overlay / scratch don't apply", id)); + } + if let Some(mac) = &dev.mac { + parse_mac(mac).map_err(|e| format!("SCSI ID {}: mac: {}", id, e))?; + } + if let Some(cidr) = &dev.subnet { + parse_nat_subnet(cidr) + .map_err(|e| format!("SCSI ID {}: subnet \"{}\": {}", id, cidr, e))?; + } + // Each DaynaPort runs its own NAT gateway. Sharing a subnet with + // ec0 gives the guest two interfaces on one network and nothing + // routes predictably. + let dp = dev.daynaport_params(*id) + .map_err(|e| format!("SCSI ID {}: {}", id, e))?; + let main = self.nat_subnet.as_deref() + .map(|c| parse_nat_subnet(c).map(|(g, c2, n)| NatSubnet { + gateway_ip: g, client_ip: c2, netmask: n })) + .transpose()? + .unwrap_or_default(); + if dp.subnet.gateway_ip == main.gateway_ip { + return Err(format!( + "SCSI ID {}: DaynaPort subnet {} collides with the machine's NAT subnet \ + (ec0). Give the DaynaPort its own, e.g. subnet = \"192.168.10.0/24\".", + id, dp.subnet.gateway_ip)); + } + } } Ok(()) } @@ -1071,12 +1214,8 @@ impl Cli { let apply_scsi = |map: &mut std::collections::HashMap, id: u8, path: String, cdrom: bool, extra: Vec| { let entry = map.entry(id).or_insert_with(|| ScsiDeviceConfig { - path: String::new(), - discs: vec![], cdrom, - overlay: false, - scratch: false, - size_mb: None, + ..Default::default() }); entry.path = path; entry.cdrom = cdrom; @@ -1194,8 +1333,7 @@ mod export_tests { fn toml_export_roundtrips() { let mut cfg = MachineConfig::default(); cfg.scsi.insert(4, ScsiDeviceConfig { - path: "/abs/cd.chd".into(), discs: vec![], cdrom: true, - overlay: false, scratch: false, size_mb: None, + path: "/abs/cd.chd".into(), cdrom: true, ..Default::default() }); let s = toml::to_string_pretty(&cfg).expect("serialize"); let back: MachineConfig = toml::from_str(&s).expect("deserialize"); @@ -1205,6 +1343,86 @@ mod export_tests { println!("--- exported toml ---\n{s}"); } + #[test] + fn scsi_kind_daynaport_parses_and_round_trips() { + let cfg: MachineConfig = toml::from_str(r#" + [scsi.3] + kind = "daynaport" + mac = "00:80:19:aa:bb:cc" + subnet = "192.168.7.0/24" + "#).expect("parse"); + let dev = &cfg.scsi[&3]; + assert!(dev.is_daynaport()); + assert!(!dev.is_cdrom()); + cfg.validate().expect("validate"); + + let params = dev.daynaport_params(3).expect("params"); + assert_eq!(params.mac, [0x00, 0x80, 0x19, 0xaa, 0xbb, 0xcc]); + assert_eq!(params.subnet.gateway_ip, Ipv4Addr::new(192, 168, 7, 1)); + assert_eq!(params.subnet.client_ip, Ipv4Addr::new(192, 168, 7, 2)); + + let s = toml::to_string_pretty(&cfg).expect("serialize"); + let back: MachineConfig = toml::from_str(&s).expect("deserialize"); + assert!(back.scsi[&3].is_daynaport(), "kind must survive export:\n{s}"); + } + + /// `cdrom = true` predates `kind`; it must keep working, and a plain disk + /// must not start serializing a redundant `kind = "disk"`. + #[test] + fn cdrom_bool_still_selects_a_cdrom() { + let cfg: MachineConfig = toml::from_str(r#" + [scsi.4] + path = "cd.iso" + cdrom = true + [scsi.1] + path = "disk.raw" + "#).expect("parse"); + assert!(cfg.scsi[&4].is_cdrom()); + assert_eq!(cfg.scsi[&1].kind(), ScsiKind::Disk); + let s = toml::to_string_pretty(&cfg).expect("serialize"); + assert!(!s.contains("kind"), "default kind should not be emitted:\n{s}"); + } + + #[test] + fn daynaport_defaults_to_its_own_subnet_and_a_derived_mac() { + let cfg: MachineConfig = toml::from_str("[scsi.5]\nkind = \"daynaport\"\n").expect("parse"); + cfg.validate().expect("validate"); + let p = cfg.scsi[&5].daynaport_params(5).expect("params"); + assert_eq!(p.mac, [0x00, 0x80, 0x19, 0x44, 0x50, 5]); + // Not the driver's own 00:80:19:00:00:NN placeholder, or the acceptance + // test can't tell a real MAC read from a made-up one. + assert_ne!(p.mac, [0x00, 0x80, 0x19, 0x00, 0x00, 5]); + assert_eq!(p.subnet.gateway_ip, Ipv4Addr::new(192, 168, 10, 1)); + assert_ne!(p.subnet.gateway_ip, NatSubnet::default().gateway_ip); + } + + #[test] + fn daynaport_rejects_a_subnet_that_collides_with_ec0() { + let cfg: MachineConfig = toml::from_str(r#" + nat_subnet = "192.168.10.0/24" + [scsi.3] + kind = "daynaport" + "#).expect("parse"); + let err = cfg.validate().expect_err("subnet collision must be rejected"); + assert!(err.contains("collides"), "{err}"); + } + + #[test] + fn daynaport_rejects_disk_only_options() { + let cfg: MachineConfig = toml::from_str( + "[scsi.3]\nkind = \"daynaport\"\noverlay = true\n").expect("parse"); + assert!(cfg.validate().is_err()); + } + + #[test] + fn parse_mac_accepts_both_separators_and_rejects_junk() { + assert_eq!(parse_mac("00:80:19:12:34:56").unwrap(), [0, 0x80, 0x19, 0x12, 0x34, 0x56]); + assert_eq!(parse_mac("00-80-19-12-34-56").unwrap(), [0, 0x80, 0x19, 0x12, 0x34, 0x56]); + assert!(parse_mac("00:80:19:12:34").is_err()); + assert!(parse_mac("zz:80:19:12:34:56").is_err()); + assert!(parse_mac("01:80:19:12:34:56").is_err(), "multicast bit must be rejected"); + } + #[test] fn indy_ip24_profile_validates_and_is_guinness() { let mut cfg = MachineConfig::default(); diff --git a/src/daynaport.rs b/src/daynaport.rs new file mode 100644 index 0000000..2215ecb --- /dev/null +++ b/src/daynaport.rs @@ -0,0 +1,805 @@ +// DaynaPort SCSI/Link (DP0801 / DP0802) — a SCSI-attached Ethernet adapter. +// +// The card presents as a SCSI **type 3 (Processor)** target and moves Ethernet +// frames with five vendor-specific 6-byte CDBs (0x08 READ, 0x09 RETRIEVE +// STATS, 0x0A WRITE, 0x0C SET INTERFACE MODE, 0x0E ENABLE/DISABLE). Modern +// re-implementations (BlueSCSI V2, ZuluSCSI, PiSCSI, SCSI2SD) speak the same +// protocol, which is how vintage SGI/Mac/Atari machines get networking over +// nothing but a SCSI bus. Reference: SLINKCMD.TXT (Roger Burrows, rev 1.20). +// +// ── Where this sits in IRIS ────────────────────────────────────────────────── +// +// This is the same shape as `seeq8003` with a different front end: the device +// owns two `rtrb` rings and hands the far ends to a `NatEngine` (or PcapEngine) +// running on its own `daynaN-nat` thread. Instead of DMA descriptor rings driven +// by MMIO registers, the front end is five SCSI CDBs: +// +// WRITE(6) → push a frame to `tx_prod` (guest → world) +// READ(6) → pop frames from `rx_cons` (world → guest), each wrapped in +// a 6-byte DaynaPort record header +// +// The whole NAT stack — DHCP, DNS, ICMP, TCP/UDP, NFS, `NatControl` telemetry — +// comes along unchanged. +// +// ── Concurrency ────────────────────────────────────────────────────────────── +// +// `request()` runs on the WD33C93A worker thread with `Wd33c93aState` locked. +// Everything it touches is either owned outright (`&mut self`) or lock-free +// (the rtrb rings, the atomics), so it never blocks and never calls back up +// into the controller — see HACKING.md on per-device concurrency. +// +// ── Endianness ─────────────────────────────────────────────────────────────── +// +// Every multi-byte protocol field is big-endian *on the wire* and is built with +// explicit `to_be_bytes()` / shifts here at the protocol edge. Nothing in this +// file byte-swaps a host integer. + +use std::io::Error as IoError; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread; + +use parking_lot::{Condvar, Mutex}; +use rtrb::RingBuffer; + +use crate::devlog::LogModule; +use crate::net::{eth_summary, mac_str, GatewayConfig, NatControl, NatEngine}; +use crate::scsi::{ScsiRequest, ScsiResponse}; + +// ── Command set ────────────────────────────────────────────────────────────── +// NB: 0x08 and 0x0A collide with SCSI READ(6)/WRITE(6). A DaynaPort target is +// dispatched *before* the storage opcodes in `ScsiDevice::request`, so these +// never reach the disk path. +pub mod dp_cmd { + /// Receive queued packet(s). Device → host. + pub const READ: u8 = 0x08; + /// Retrieve statistics (MAC address + counters). Device → host. + pub const RETRIEVE_STATS: u8 = 0x09; + /// Transmit one packet. Host → device. + pub const WRITE: u8 = 0x0a; + /// Set interface mode (broadcast/multicast filtering). No data. + pub const SET_IFACE_MODE: u8 = 0x0c; + /// Enable / disable the interface. No data. + pub const ENABLE: u8 = 0x0e; +} + +/// Per-record header on a READ response: 2-byte length + 4-byte flags. +const RX_HDR_LEN: usize = 6; +/// Trailing CRC the protocol counts in `pktlen` but nobody validates. The +/// driver strips `pktlen - 4` bytes and discards these, so zeros are fine — +/// but they must physically be there or every frame arrives 4 bytes short. +const CRC_LEN: usize = 4; + +/// READ record flag: more packets are still queued in the device; the host +/// should issue another READ immediately rather than waiting for its next poll. +const FLAG_MORE: u32 = 0x0000_0010; +/// READ CDB byte 5 bit 6: pack as many whole frames as fit into one response. +/// Bit 7 is undocumented; every implementation accepts the driver's `0xC0`. +const READ_FLAG_MULTI: u8 = 0x40; + +/// SET INTERFACE MODE byte 4: receive broadcasts. +const MODE_BROADCAST: u8 = 0x04; +/// ENABLE/DISABLE byte 5: 0x80 = enable, 0x00 = disable. +const ENABLE_ON: u8 = 0x80; + +/// RETRIEVE STATS response length: 6-byte MAC + 12 bytes of counters. +const STATS_LEN: usize = 18; + +/// Upper bound on a single READ response, so a bogus CDB can't make us +/// allocate wildly. The IRIX driver asks for 3072. +const MAX_READ_LEN: usize = 16 * 1024; + +/// Frames in flight per direction. Same depth as `seeq8003`. +const CHAN_CAPACITY: usize = 256; + +/// Smallest thing that can plausibly be an Ethernet frame (dst+src+type). +const MIN_FRAME: usize = 14; +/// Largest frame we will hand to the backend (1500 MTU + 14 header + 4 VLAN). +const MAX_FRAME: usize = 1518; + +/// Default NAT subnet for a DaynaPort target. Deliberately *not* the onboard +/// SEEQ's 192.168.0.0/24: each target gets its own `NatEngine`, and putting +/// `dp0` on a different subnet from `ec0` proves traffic really went through +/// the DaynaPort. Override per target with `subnet = "..."` in `[scsi.N]`. +pub const DEFAULT_SUBNET: &str = "192.168.10.0/24"; + +/// Default MAC for target `id`: the real DaynaPort `00:80:19` OUI, then +/// `44:50` ("DP") and the SCSI target id, so two targets never collide. +/// Deliberately distinct from the IRIX driver's `00:80:19:00:00:NN` +/// placeholder, so a MAC read via RETRIEVE STATS is visibly different from one +/// the driver made up. +pub fn default_mac(id: usize) -> [u8; 6] { + [0x00, 0x80, 0x19, 0x44, 0x50, id as u8] +} + +#[derive(Default, Clone, Copy)] +struct DpStats { + tx_frames: u64, + tx_dropped: u64, + rx_frames: u64, + rx_filtered: u64, +} + +pub struct DaynaPort { + /// SCSI id this target answers on — used for the default MAC and thread name. + target_id: usize, + mac: [u8; 6], + /// Set by ENABLE/DISABLE (0x0E). While false, RX returns empty responses + /// and TX frames are dropped. + enabled: bool, + /// Set by SET INTERFACE MODE (0x0C) byte 4 bit 2. + broadcast: bool, + config: GatewayConfig, + running: Arc, + nat_ctl: Arc, + /// Activity heartbeat shared with the display thread (lights the status + /// bar's network indicators, same as the onboard SEEQ). + heartbeat: Arc, + + /// guest → world. Filled by WRITE(6), drained by the NAT thread. + tx_prod: rtrb::Producer>, + /// world → guest. Filled by the NAT thread, drained by READ(6). + rx_cons: rtrb::Consumer>, + /// The far ends, held until `start()` moves them into the NAT thread. + nat_ends: Option<(rtrb::Consumer>, rtrb::Producer>)>, + /// Signalled by us when a TX frame is queued (the NAT thread waits on it). + tx_wake: Arc<(Mutex<()>, Condvar)>, + /// Signalled by the NAT thread when an RX frame is queued. Nothing waits on + /// it here — the driver polls READ every 10 ms — but `NatEngine` needs it. + rx_wake: Arc<(Mutex<()>, Condvar)>, + + stats: DpStats, + /// Sense data served on REQUEST SENSE, set by the last CHECK CONDITION. + pending_sense: [u8; 18], +} + +impl DaynaPort { + pub fn new(target_id: usize, mac: [u8; 6], config: GatewayConfig, + heartbeat: Arc) -> Self { + let (tx_prod, tx_cons) = RingBuffer::new(CHAN_CAPACITY); + let (rx_prod, rx_cons) = RingBuffer::new(CHAN_CAPACITY); + Self { + target_id, + mac, + enabled: false, + broadcast: false, + config, + running: Arc::new(AtomicBool::new(false)), + nat_ctl: NatControl::new(), + heartbeat, + tx_prod, + rx_cons, + nat_ends: Some((tx_cons, rx_prod)), + tx_wake: Arc::new((Mutex::new(()), Condvar::new())), + rx_wake: Arc::new((Mutex::new(()), Condvar::new())), + stats: DpStats::default(), + pending_sense: [0u8; 18], + } + } + + pub fn mac(&self) -> [u8; 6] { self.mac } + pub fn target_id(&self) -> usize { self.target_id } + + /// Shared NAT control/stats handle (debug toggles, table reset, and the + /// guest-frame counter the GUI's network indicator samples). + pub fn nat_control(&self) -> Arc { self.nat_ctl.clone() } + + /// NAT addresses this target hands the guest: (client_ip, gateway_ip, + /// netmask) — i.e. what the guest's `dp0` should be configured as. + pub fn gateway_addrs(&self) -> (std::net::Ipv4Addr, std::net::Ipv4Addr, std::net::Ipv4Addr) { + (self.config.client_ip, self.config.gateway_ip, self.config.netmask) + } + + /// Spawn the backend thread. Mirrors `seeq8003::Device::start`, including + /// the PCAP fallback message, so a DaynaPort can be bridged too. + pub fn start(&mut self) { + if self.running.swap(true, Ordering::SeqCst) { return; } + let Some((tx_cons, rx_prod)) = self.nat_ends.take() else { + self.running.store(false, Ordering::SeqCst); + return; + }; + let config = self.config.clone(); + let running_nat = self.running.clone(); + let tx_wake_nat = self.tx_wake.clone(); + let rx_wake_nat = self.rx_wake.clone(); + let nat_ctl = self.nat_ctl.clone(); + let id = self.target_id; + let name = format!("dayna{}-nat", id); + thread::Builder::new().name(name).spawn(move || { + #[cfg(feature = "pcap")] + { + use crate::net::NetBackend; + if config.mode == crate::config::NetMode::Pcap { + eprintln!("iris: DaynaPort {} backend = PCAP (bridged)", id); + let mut engine = crate::net_pcap::PcapEngine::new( + config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl); + engine.run(); + return; + } + eprintln!("iris: DaynaPort {} backend = NAT (software gateway)", id); + NatEngine::new(config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl).run(); + } + #[cfg(not(feature = "pcap"))] + { + if config.mode == crate::config::NetMode::Pcap { + eprintln!("iris: DaynaPort {}: [network] mode = \"pcap\" requested but this \ + build lacks --features pcap; falling back to NAT gateway.", id); + } else { + eprintln!("iris: DaynaPort {} backend = NAT (software gateway)", id); + } + NatEngine::new(config, tx_cons, rx_prod, + rx_wake_nat, tx_wake_nat, + running_nat, nat_ctl).run(); + } + }).expect("dayna-nat spawn"); + } + + /// Stop the backend thread. The thread owns its ring endpoints and exits on + /// its next loop iteration; fresh rings are allocated here so a later + /// `start()` works. In-flight frames are discarded — the same answer + /// `seeq8003::stop` gives. + pub fn stop(&mut self) { + if !self.running.swap(false, Ordering::SeqCst) { return; } + self.tx_wake.1.notify_all(); + let (tx_prod, tx_cons) = RingBuffer::new(CHAN_CAPACITY); + let (rx_prod, rx_cons) = RingBuffer::new(CHAN_CAPACITY); + self.tx_prod = tx_prod; + self.rx_cons = rx_cons; + self.nat_ends = Some((tx_cons, rx_prod)); + } + + /// Machine reset: interface off, queued frames dropped, NAT tables flushed + /// on the backend thread's next iteration (as `Seeq8003::power_on` does). + pub fn power_on(&mut self) { + self.enabled = false; + self.broadcast = false; + self.drain_rx(); + self.stats = DpStats::default(); + self.nat_ctl.reset_nat.store(true, Ordering::Release); + } + + /// Human-readable status for `scsi dayna`. + pub fn status_lines(&self) -> Vec { + let (client, gw, mask) = self.gateway_addrs(); + vec![ + format!("SCSI ID {}: DaynaPort SCSI/Link", self.target_id), + format!(" MAC : {}", mac_str(&self.mac)), + format!(" Gateway MAC: {}", mac_str(&self.config.gateway_mac)), + format!(" Gateway IP : {} client {} netmask {}", gw, client, mask), + format!(" State : {} broadcast={} backend={}", + if self.enabled { "enabled" } else { "disabled" }, + self.broadcast, + if self.running.load(Ordering::Relaxed) { "running" } else { "stopped" }), + format!(" TX : {} frames, {} dropped (ring full)", + self.stats.tx_frames, self.stats.tx_dropped), + format!(" RX : {} frames, {} filtered, {} queued", + self.stats.rx_frames, self.stats.rx_filtered, self.rx_cons.slots()), + ] + } + + // ── SCSI command dispatch ──────────────────────────────────────────────── + + /// Execute one CDB. Never blocks: the driver polls READ every 10 ms and a + /// blocking read would wedge the interface. + pub fn request(&mut self, req: &ScsiRequest) -> Result { + let cdb = &req.cdb; + if cdb.len() < 6 { + return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command + } + let resp = match cdb[0] { + crate::scsi::scsi_cmd::TEST_UNIT_READY => good(), + crate::scsi::scsi_cmd::REQUEST_SENSE => self.exec_request_sense(cdb), + crate::scsi::scsi_cmd::INQUIRY => self.exec_inquiry(cdb), + dp_cmd::READ => self.exec_read(cdb), + dp_cmd::RETRIEVE_STATS => self.exec_retrieve_stats(cdb), + dp_cmd::WRITE => self.exec_write(cdb, req.data_in.as_ref()), + dp_cmd::SET_IFACE_MODE => self.exec_set_iface_mode(cdb), + dp_cmd::ENABLE => self.exec_enable(cdb), + other => { + dlog_dev!(LogModule::Net, "DaynaPort {}: unsupported command {:02x} cdb={:02x?}", + self.target_id, other, cdb); + self.check_condition(0x05, 0x20, 0x00) // Illegal Request: Invalid Command + } + }; + Ok(resp) + } + + /// INQUIRY — identify as a DaynaPort SCSI/Link. The IRIX driver matches on + /// the `"Dayna"` / `"SCSI/Link"` prefixes only, but Mac and Atari drivers + /// are pickier, so emit the full padded strings. + fn exec_inquiry(&self, cdb: &[u8]) -> ScsiResponse { + let alloc_len = cdb[4] as usize; + let lun = (cdb[1] >> 5) & 0x7; + let mut data = vec![0u8; 36]; + if lun == 0 { + data[0] = 0x03; // Processor device + data[1] = 0x00; // not removable + data[2] = 0x02; // ANSI SCSI-2 + data[3] = 0x02; // SCSI-2 response format + data[4] = 31; // additional length (36 - 5) + data[8..16].copy_from_slice(b"Dayna "); + data[16..32].copy_from_slice(b"SCSI/Link "); + data[32..36].copy_from_slice(b"1.4a"); + } else { + data[0] = 0x7F; // LUN not present + } + data.truncate(alloc_len.min(data.len())); + ScsiResponse { status: 0x00, data } + } + + fn exec_request_sense(&mut self, cdb: &[u8]) -> ScsiResponse { + let alloc_len = cdb[4] as usize; + let sense = self.pending_sense; + self.pending_sense = [0u8; 18]; + self.pending_sense[0] = 0x70; + let data = sense[..sense.len().min(alloc_len.max(18))].to_vec(); + ScsiResponse { status: 0x00, data } + } + + /// 0x08 READ — hand the host every queued frame that fits. + /// + /// Response layout, records back to back: + /// + /// ```text + /// off size field + /// 0 2 pktlen, BIG-ENDIAN — frame length INCLUDING the 4-byte + /// trailing CRC, EXCLUDING this 6-byte header + /// 2 4 flags, BIG-ENDIAN — 0x10 = more still queued, 0 = last + /// 6 pktlen the Ethernet frame, then 4 CRC bytes + /// ``` + /// + /// A `pktlen` of 0 means "nothing (more) here", so an idle device answers + /// with six zero bytes rather than stalling. + fn exec_read(&mut self, cdb: &[u8]) -> ScsiResponse { + let want = ((((cdb[3] as usize) << 8) | cdb[4] as usize)).min(MAX_READ_LEN); + // Bit 6 = multi-packet. Anything else falls back to one frame per READ + // rather than erroring (byte 5 is only partly documented). + let multi = (cdb[5] & READ_FLAG_MULTI) != 0; + + let mut records: Vec> = Vec::new(); + let mut used = 0usize; + while self.enabled { + let frame_len = match self.rx_cons.peek() { + Ok(f) => f.len(), + Err(_) => break, + }; + // Never emit a record that would overrun the requested transfer + // length — the driver bounds-checks and silently drops the tail. + // Leave the frame queued for the next READ instead… unless it + // would not fit in an *empty* response either, in which case + // leaving it queued would stall the ring forever: drop it. + if used + RX_HDR_LEN + frame_len + CRC_LEN > want { + if used > 0 || RX_HDR_LEN + frame_len + CRC_LEN <= want { break; } + let _ = self.rx_cons.pop(); + self.stats.rx_filtered += 1; + continue; + } + let frame = self.rx_cons.pop().expect("peek succeeded so pop must succeed"); + if !self.accepts(&frame) { + self.stats.rx_filtered += 1; + continue; + } + dlog_dev!(LogModule::Net, "DaynaPort {} RX {}", self.target_id, eth_summary(&frame)); + used += RX_HDR_LEN + frame.len() + CRC_LEN; + records.push(frame); + if !multi { break; } + } + + // MORE on the last record tells the driver to issue another READ right + // away instead of waiting for its next 10 ms tick; on any earlier record + // it is what makes the driver keep parsing this response at all. + let more_queued = !self.rx_cons.is_empty(); + let n = records.len(); + let mut data = Vec::with_capacity(used.max(RX_HDR_LEN)); + for (i, frame) in records.iter().enumerate() { + let pktlen = (frame.len() + CRC_LEN) as u16; + let flags: u32 = if i + 1 < n || more_queued { FLAG_MORE } else { 0 }; + data.extend_from_slice(&pktlen.to_be_bytes()); + data.extend_from_slice(&flags.to_be_bytes()); + data.extend_from_slice(frame); + data.extend_from_slice(&[0u8; CRC_LEN]); // CRC placeholder; driver discards it + } + if n > 0 { + self.stats.rx_frames += n as u64; + self.heartbeat.fetch_or(crate::rex3::Rex3::HB_ENET_RX, Ordering::Relaxed); + } else { + // Nothing queued (or the interface is disabled): a zero pktlen is + // how the driver is told to stop parsing. Even that never exceeds + // the requested transfer length. + data.resize(RX_HDR_LEN.min(want), 0); + } + ScsiResponse { status: 0x00, data } + } + + /// 0x09 RETRIEVE STATS — 6-byte MAC first, then counters. The IRIX driver + /// asks for 18 bytes and reads the MAC out of the head of it. + fn exec_retrieve_stats(&mut self, cdb: &[u8]) -> ScsiResponse { + let alloc_len = if cdb[4] == 0 { STATS_LEN } else { cdb[4] as usize }; + let mut data = vec![0u8; STATS_LEN]; + data[..6].copy_from_slice(&self.mac); + // Bytes 6..18 are packet/error counters; the driver ignores them. + data[6..10].copy_from_slice(&(self.stats.rx_frames as u32).to_be_bytes()); + data[10..14].copy_from_slice(&(self.stats.tx_frames as u32).to_be_bytes()); + data.truncate(alloc_len.min(STATS_LEN)); + ScsiResponse { status: 0x00, data } + } + + /// 0x0A WRITE — one Ethernet frame, no CRC appended, length in CDB 3..4. + /// + /// A full ring drops the frame and still reports GOOD: the driver has no + /// retry path, so failing the command only makes things worse. + fn exec_write(&mut self, cdb: &[u8], data_in: Option<&Vec>) -> ScsiResponse { + let len = ((cdb[3] as usize) << 8) | cdb[4] as usize; + let Some(buf) = data_in else { return good(); }; + let n = len.min(buf.len()).min(MAX_FRAME); + if n < MIN_FRAME || !self.enabled { + dlog_dev!(LogModule::Net, "DaynaPort {} TX dropped (len={} enabled={})", + self.target_id, n, self.enabled); + return good(); + } + let frame = buf[..n].to_vec(); + dlog_dev!(LogModule::Net, "DaynaPort {} TX {}", self.target_id, eth_summary(&frame)); + if self.tx_prod.push(frame).is_err() { + self.stats.tx_dropped += 1; + } else { + self.stats.tx_frames += 1; + self.tx_wake.1.notify_one(); + self.heartbeat.fetch_or(crate::rex3::Rex3::HB_ENET_TX, Ordering::Relaxed); + } + good() + } + + /// 0x0C SET INTERFACE MODE — byte 4 bit 2 requests broadcast reception. + /// The driver sends this after every enable and on `SIOCADDMULTI`. + fn exec_set_iface_mode(&mut self, cdb: &[u8]) -> ScsiResponse { + self.broadcast = (cdb[4] & MODE_BROADCAST) != 0; + dlog_dev!(LogModule::Net, "DaynaPort {}: set mode flags={:02x} broadcast={}", + self.target_id, cdb[4], self.broadcast); + good() + } + + /// 0x0E ENABLE/DISABLE — byte 5 `0x80` enables. Enabling starts from a + /// clean slate: anything the backend queued while the interface was down is + /// discarded, exactly as a card that was not listening would have missed it. + fn exec_enable(&mut self, cdb: &[u8]) -> ScsiResponse { + let on = (cdb[5] & ENABLE_ON) != 0; + if on && !self.enabled { + self.drain_rx(); + } + self.enabled = on; + dlog_dev!(LogModule::Net, "DaynaPort {}: {}", self.target_id, + if on { "enabled" } else { "disabled" }); + good() + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /// Address filter. A NAT backend only ever sends us frames for the guest or + /// for broadcast, so this is close to a no-op there — but a PCAP-bridged + /// DaynaPort sees the whole LAN, and without broadcast the guest never sees + /// an ARP reply and nothing works. + fn accepts(&self, frame: &[u8]) -> bool { + if frame.len() < MIN_FRAME { return false; } + let dst = &frame[0..6]; + if dst == self.mac { return true; } + const BCAST: [u8; 6] = [0xff; 6]; + if dst == BCAST { return self.broadcast; } + // Multicast (group bit set) rides along with broadcast: the driver only + // ever asks for 0x04, and IRIX filters multicast itself. + if dst[0] & 1 != 0 { return self.broadcast; } + false + } + + fn drain_rx(&mut self) { + while self.rx_cons.pop().is_ok() {} + } + + fn check_condition(&mut self, key: u8, asc: u8, ascq: u8) -> ScsiResponse { + self.pending_sense = [0u8; 18]; + self.pending_sense[0] = 0x70; // current error + self.pending_sense[2] = key; + self.pending_sense[7] = 10; // additional length + self.pending_sense[12] = asc; + self.pending_sense[13] = ascq; + ScsiResponse { status: 0x02, data: vec![] } + } + + /// Test hook: take the backend ends so a test can drive both sides of the + /// rings without spawning a `NatEngine`. + #[cfg(test)] + fn take_nat_ends(&mut self) -> (rtrb::Consumer>, rtrb::Producer>) { + self.nat_ends.take().expect("nat ends already taken") + } +} + +fn good() -> ScsiResponse { + ScsiResponse { status: 0x00, data: vec![] } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scsi::{ScsiDataLength, ScsiRequest}; + + fn dp() -> (DaynaPort, rtrb::Consumer>, rtrb::Producer>) { + let mut d = DaynaPort::new(3, default_mac(3), GatewayConfig::default(), + Arc::new(AtomicU64::new(0))); + let (tx_cons, rx_prod) = d.take_nat_ends(); + (d, tx_cons, rx_prod) + } + + fn cdb6(b: [u8; 6]) -> ScsiRequest { + ScsiRequest { cdb: b.to_vec(), data_len: ScsiDataLength::Unlimited, data_in: None } + } + + fn frame(dst: [u8; 6], len: usize) -> Vec { + let mut f = vec![0xAAu8; len]; + f[0..6].copy_from_slice(&dst); + f + } + + fn enable(d: &mut DaynaPort) { + d.request(&cdb6([dp_cmd::ENABLE, 0, 0, 0, 0, 0x80])).unwrap(); + d.request(&cdb6([dp_cmd::SET_IFACE_MODE, 0, 0, 0, 0x04, 0x80])).unwrap(); + } + + fn read_cdb(len: usize) -> ScsiRequest { + cdb6([dp_cmd::READ, 0, 0, (len >> 8) as u8, len as u8, 0xC0]) + } + + #[test] + fn inquiry_identifies_as_dayna_processor() { + let (mut d, _tx, _rx) = dp(); + let r = d.request(&cdb6([0x12, 0, 0, 0, 36, 0])).unwrap(); + assert_eq!(r.status, 0x00); + assert_eq!(r.data.len(), 36); + assert_eq!(r.data[0], 0x03, "must be SCSI type 3 (Processor)"); + assert_eq!(&r.data[8..13], b"Dayna"); + assert_eq!(&r.data[8..16], b"Dayna "); + assert_eq!(&r.data[16..25], b"SCSI/Link"); + assert_eq!(&r.data[16..32], b"SCSI/Link "); + assert_eq!(r.data[4], 31); + } + + #[test] + fn inquiry_truncates_to_allocation_length() { + let (mut d, _tx, _rx) = dp(); + let r = d.request(&cdb6([0x12, 0, 0, 0, 5, 0])).unwrap(); + assert_eq!(r.data.len(), 5); + } + + #[test] + fn inquiry_reports_no_device_on_nonzero_lun() { + let (mut d, _tx, _rx) = dp(); + let r = d.request(&cdb6([0x12, 0x20, 0, 0, 36, 0])).unwrap(); + assert_eq!(r.data[0], 0x7F); + } + + #[test] + fn retrieve_stats_returns_mac_first() { + let (mut d, _tx, _rx) = dp(); + let r = d.request(&cdb6([dp_cmd::RETRIEVE_STATS, 0, 0, 0, 18, 0])).unwrap(); + assert_eq!(r.data.len(), 18); + assert_eq!(&r.data[..6], &default_mac(3)); + } + + #[test] + fn idle_read_returns_zero_pktlen_not_a_stall() { + let (mut d, _tx, _rx) = dp(); + enable(&mut d); + let r = d.request(&read_cdb(3072)).unwrap(); + assert_eq!(r.status, 0x00); + assert_eq!(r.data.len(), 6); + assert_eq!(&r.data[..2], &[0, 0], "pktlen 0 = no more records"); + } + + #[test] + fn read_record_header_counts_the_crc() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + let f = frame(default_mac(3), 64); + rx.push(f.clone()).unwrap(); + + let r = d.request(&read_cdb(3072)).unwrap(); + let pktlen = ((r.data[0] as usize) << 8) | r.data[1] as usize; + assert_eq!(pktlen, f.len() + 4, "pktlen must include the 4 CRC bytes"); + let flags = u32::from_be_bytes([r.data[2], r.data[3], r.data[4], r.data[5]]); + assert_eq!(flags, 0, "only record and nothing queued → last record"); + assert_eq!(&r.data[6..6 + f.len()], &f[..]); + assert_eq!(r.data.len(), 6 + pktlen, "payload must physically carry the CRC bytes"); + } + + #[test] + fn multi_packet_sets_more_on_every_record_but_the_last() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + rx.push(frame(default_mac(3), 100)).unwrap(); + rx.push(frame(default_mac(3), 200)).unwrap(); + + let r = d.request(&read_cdb(3072)).unwrap(); + // record 0 + let len0 = ((r.data[0] as usize) << 8) | r.data[1] as usize; + assert_eq!(len0, 104); + assert_eq!(u32::from_be_bytes([r.data[2], r.data[3], r.data[4], r.data[5]]), FLAG_MORE); + // record 1 + let off = 6 + len0; + let len1 = ((r.data[off] as usize) << 8) | r.data[off + 1] as usize; + assert_eq!(len1, 204); + assert_eq!(u32::from_be_bytes([r.data[off + 2], r.data[off + 3], + r.data[off + 4], r.data[off + 5]]), 0); + assert_eq!(r.data.len(), 6 + len0 + 6 + len1); + } + + #[test] + fn single_packet_mode_leaves_the_rest_queued_and_flags_more() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + rx.push(frame(default_mac(3), 100)).unwrap(); + rx.push(frame(default_mac(3), 100)).unwrap(); + + // byte 5 without bit 6 → one frame per READ + let r = d.request(&cdb6([dp_cmd::READ, 0, 0, 0x0C, 0x00, 0x00])).unwrap(); + assert_eq!(r.data.len(), 6 + 104); + assert_eq!(u32::from_be_bytes([r.data[2], r.data[3], r.data[4], r.data[5]]), FLAG_MORE, + "device still has a frame queued → MORE"); + } + + #[test] + fn read_never_overruns_the_requested_transfer_length() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + rx.push(frame(default_mac(3), 1514)).unwrap(); + rx.push(frame(default_mac(3), 1514)).unwrap(); + rx.push(frame(default_mac(3), 1514)).unwrap(); + + let r = d.request(&read_cdb(3072)).unwrap(); + assert!(r.data.len() <= 3072, "response {} > requested 3072", r.data.len()); + // 2 × (6 + 1514 + 4) = 3048 fits; a third would not. + assert_eq!(r.data.len(), 2 * (6 + 1518)); + // The straggler stays queued, so the last record must say MORE. + let off = 6 + 1518; + assert_eq!(u32::from_be_bytes([r.data[off + 2], r.data[off + 3], + r.data[off + 4], r.data[off + 5]]), FLAG_MORE); + } + + /// A frame too big for even an empty response must be dropped, not left to + /// block the head of the ring on every subsequent READ. + #[test] + fn oversize_frame_is_dropped_rather_than_stalling_the_queue() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + rx.push(frame(default_mac(3), 1514)).unwrap(); + rx.push(frame(default_mac(3), 64)).unwrap(); + + // A 128-byte ask can never carry the 1514-byte frame. + let r = d.request(&read_cdb(128)).unwrap(); + assert_eq!(r.data.len(), 6 + 68, "the small frame behind it must get through"); + } + + #[test] + fn read_filters_by_address_and_honours_broadcast() { + let (mut d, _tx, mut rx) = dp(); + enable(&mut d); + rx.push(frame([0x00, 0x11, 0x22, 0x33, 0x44, 0x55], 64)).unwrap(); // not ours + rx.push(frame([0xff; 6], 64)).unwrap(); // broadcast + let r = d.request(&read_cdb(3072)).unwrap(); + assert_eq!(r.data.len(), 6 + 68, "only the broadcast frame survives"); + + // Same again with broadcast reception turned off. + d.request(&cdb6([dp_cmd::SET_IFACE_MODE, 0, 0, 0, 0x00, 0x80])).unwrap(); + rx.push(frame([0xff; 6], 64)).unwrap(); + let r = d.request(&read_cdb(3072)).unwrap(); + assert_eq!(r.data.len(), 6); + assert_eq!(&r.data[..2], &[0, 0]); + } + + #[test] + fn disabled_interface_reads_empty_and_drops_transmits() { + let (mut d, mut tx, mut rx) = dp(); + rx.push(frame(default_mac(3), 64)).unwrap(); + let r = d.request(&read_cdb(3072)).unwrap(); + assert_eq!(&r.data[..2], &[0, 0]); + + let f = frame([0xff; 6], 64); + let w = ScsiRequest { + cdb: vec![dp_cmd::WRITE, 0, 0, 0, 64, 0], + data_len: ScsiDataLength::Unlimited, + data_in: Some(f), + }; + assert_eq!(d.request(&w).unwrap().status, 0x00, "TX while down still reports GOOD"); + assert!(tx.pop().is_err(), "nothing should reach the backend"); + } + + #[test] + fn enable_discards_frames_queued_while_down() { + let (mut d, _tx, mut rx) = dp(); + rx.push(frame(default_mac(3), 64)).unwrap(); + enable(&mut d); + let r = d.request(&read_cdb(3072)).unwrap(); + assert_eq!(&r.data[..2], &[0, 0], "stale frame must not be delivered"); + } + + #[test] + fn write_pushes_the_frame_verbatim() { + let (mut d, mut tx, _rx) = dp(); + enable(&mut d); + let mut f = frame([0x01, 0x02, 0x03, 0x04, 0x05, 0x06], 100); + f[6..12].copy_from_slice(&default_mac(3)); + let w = ScsiRequest { + cdb: vec![dp_cmd::WRITE, 0, 0, (100 >> 8) as u8, 100u8, 0], + data_len: ScsiDataLength::Unlimited, + data_in: Some(f.clone()), + }; + assert_eq!(d.request(&w).unwrap().status, 0x00); + assert_eq!(tx.pop().unwrap(), f, "frame must go out byte-for-byte, no CRC appended"); + } + + #[test] + fn write_uses_the_cdb_length_not_the_buffer_length() { + let (mut d, mut tx, _rx) = dp(); + enable(&mut d); + // Buffer padded past the frame (DMA rounds up); CDB says 60 bytes. + let mut buf = frame([0xff; 6], 60); + buf.extend_from_slice(&[0u8; 40]); + let w = ScsiRequest { + cdb: vec![dp_cmd::WRITE, 0, 0, 0, 60, 0], + data_len: ScsiDataLength::Unlimited, + data_in: Some(buf), + }; + d.request(&w).unwrap(); + assert_eq!(tx.pop().unwrap().len(), 60); + } + + /// The whole point of the device-kind dispatch: READ(6)/WRITE(6) on a + /// DaynaPort are packet RX/TX, and must never reach the storage opcodes. + #[test] + fn scsi_device_routes_read6_to_the_packet_path_not_the_disk_path() { + 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()); + + 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(); + rx.push(frame(default_mac(3), 64)).unwrap(); + + // Same CDB a disk would read 12 blocks of 512 bytes for. + let r = dev.request(&read_cdb(3072)).unwrap(); + assert_eq!(r.status, 0x00); + assert_eq!(r.data.len(), 6 + 68, "must be one 64-byte frame + header + CRC"); + + // INQUIRY still identifies the DaynaPort, not "IRIS EMUL DISK". + let inq = dev.request(&cdb6([0x12, 0, 0, 0, 36, 0])).unwrap(); + assert_eq!(inq.data[0], 0x03); + assert_eq!(&inq.data[8..13], b"Dayna"); + + // And no storage command is answered. + let cap = dev.request(&ScsiRequest { + cdb: vec![0x25, 0, 0, 0, 0, 0, 0, 0, 0, 0], + data_len: ScsiDataLength::Unlimited, + data_in: None, + }).unwrap(); + assert_eq!(cap.status, 0x02, "READ CAPACITY must not be answered"); + } + + #[test] + fn unsupported_command_reports_illegal_request() { + let (mut d, _tx, _rx) = dp(); + // READ CAPACITY — a storage command a DaynaPort must never answer. + let r = d.request(&ScsiRequest { + cdb: vec![0x25, 0, 0, 0, 0, 0, 0, 0, 0, 0], + data_len: ScsiDataLength::Unlimited, + data_in: None, + }).unwrap(); + assert_eq!(r.status, 0x02); + let sense = d.request(&cdb6([0x03, 0, 0, 0, 18, 0])).unwrap(); + assert_eq!(sense.data[2], 0x05, "ILLEGAL REQUEST"); + assert_eq!(sense.data[12], 0x20, "invalid command operation code"); + } +} diff --git a/src/hpc3.rs b/src/hpc3.rs index 7d9f8d1..919e900 100644 --- a/src/hpc3.rs +++ b/src/hpc3.rs @@ -1008,6 +1008,10 @@ pub struct Hpc3 { scsi_dev: Arc, hal2: Option>, pdma_dump: Arc, + /// The machine's gateway settings, kept so a DaynaPort SCSI target can be + /// given its own `NatEngine` sharing the backend selection (NAT vs PCAP) + /// and the NFS export, but with its own subnet and MAC. + net_base: GatewayConfig, /// Indy (Guinness) vs Indigo2 (fullhouse). No HPC3 register divergence from /// Indy today — retained for future fullhouse paths (EISA pbus, dual INT2). #[allow(dead_code)] @@ -1102,6 +1106,7 @@ impl Hpc3 { nfs_pcap_ip, ..GatewayConfig::default() }; + let net_base = gateway_cfg.clone(); let seeq = Arc::new(Seeq8003::with_config(Some(seeq_irq), Some(enet_rx_dma), Some(enet_tx_dma), gateway_cfg, heartbeat.clone())); // Publish seeq to both the DMA ops (CTRL reads) and the irq (status checks in set_interrupt) let _ = enet_seeq_lock.set(seeq.clone()); @@ -1131,6 +1136,7 @@ impl Hpc3 { scsi_dev, hal2, pdma_dump, + net_base, guinness, } } @@ -1150,6 +1156,28 @@ impl Hpc3 { self.scsi_dev.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 + /// from the onboard SEEQ's — so `dp0` and `ec0` land on different networks + /// and traffic through the DaynaPort is unmistakable. Backend selection + /// (NAT vs PCAP) and the NFS export are inherited from `[network]`/`[nfs]`; + /// host port forwards are **not**, since only one engine can own a host + /// listening port. + pub fn add_scsi_daynaport(&self, id: usize, params: crate::config::DaynaportParams) -> std::io::Result<()> { + let gateway = GatewayConfig { + // A distinct gateway MAC per target: same 02:00:DE:AD prefix as the + // SEEQ's, then DA ("Dayna") and the SCSI id. + gateway_mac: [0x02, 0x00, 0xDE, 0xAD, 0xDA, id as u8], + gateway_ip: params.subnet.gateway_ip, + client_ip: params.subnet.client_ip, + netmask: params.subnet.netmask, + port_forwards: vec![], + ..self.net_base.clone() + }; + self.scsi_dev.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) diff --git a/src/lib.rs b/src/lib.rs index fb80e2b..7955dcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,10 @@ pub mod build_features { /// N64 development board (Ultra64 GIO card) + POSIX shm bridge to an /// external gopher64. The GUI gates the "Enable dev board" toggle on this. pub const ULTRA64: bool = cfg!(feature = "ultra64"); + /// DaynaPort SCSI/Link target — a SCSI-attached Ethernet adapter that can + /// be configured on any SCSI id. The GUI gates the "DaynaPort" device kind + /// on this. + pub const DAYNAPORT: bool = cfg!(feature = "daynaport"); /// Lightning build strips breakpoint checks and the traceback buffer /// from the MIPS executor hot path. Interactive debugging (GDB stub, /// monitor breakpoints) is non-functional in this build. @@ -77,6 +81,8 @@ pub mod xdmcp; #[cfg(feature = "pcap")] pub mod net_pcap; pub mod seeq8003; +#[cfg(feature = "daynaport")] +pub mod daynaport; pub mod cow_disk; #[cfg(feature = "chd")] pub mod chd_disk; diff --git a/src/machine.rs b/src/machine.rs index 6c17c88..60b2035 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -291,6 +291,34 @@ impl Machine { let mut scratch_path: Option = None; for id in scsi_ids { let dev = &cfg.scsi[&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) { + Ok(p) => p, + Err(e) => { + eprintln!("iris: fatal: SCSI ID {}: {}", id, e); + std::process::exit(1); + } + }; + match hpc3.add_scsi_daynaport(id as usize, params) { + Ok(()) => println!( + "iris: DaynaPort SCSI/Link at SCSI ID {} — MAC {}, gateway {} (guest {}/{})", + id, 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}"); + if std::env::var_os("IRIS_NO_EXIT_ON_POWEROFF").is_some() { + eprintln!("iris: warning: {msg}; continuing without SCSI ID {id}"); + } else { + eprintln!("iris: fatal: {msg}"); + std::process::exit(1); + } + } + } + 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. @@ -378,10 +406,12 @@ 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. - let mut disk_provenance: Vec = cfg.scsi.iter().map(|(&id, dev)| { - let size_bytes = std::fs::metadata(&dev.path).map(|m| m.len()).unwrap_or(0); - DiskRef { id, path: dev.path.clone(), size_bytes } - }).collect(); + let mut disk_provenance: Vec = cfg.scsi.iter() + .filter(|(_, dev)| !dev.is_daynaport()) // no image behind a DaynaPort + .map(|(&id, dev)| { + let size_bytes = std::fs::metadata(&dev.path).map(|m| m.len()).unwrap_or(0); + DiskRef { 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.rs b/src/scsi.rs index e404741..ae31c9c 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -132,13 +132,28 @@ impl DiskBackend { } } +/// 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. +pub enum DeviceKind { + Disk, + Cdrom, + #[cfg(feature = "daynaport")] + DaynaPort(Box), +} + pub struct ScsiDevice { - /// None = no media loaded (CD-ROM drive present but tray is empty). + /// 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, - is_cdrom: bool, + 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. @@ -163,7 +178,7 @@ impl ScsiDevice { Self { backend: Some(backend), size, - is_cdrom, + kind: if is_cdrom { DeviceKind::Cdrom } else { DeviceKind::Disk }, filename, discs, buffer: vec![0u8; SCSI_BUFFER_SIZE], @@ -183,7 +198,7 @@ impl ScsiDevice { Self { backend: None, size: 0, - is_cdrom: true, + kind: DeviceKind::Cdrom, filename: String::new(), discs: vec![], buffer: vec![0u8; SCSI_BUFFER_SIZE], @@ -194,6 +209,52 @@ impl ScsiDevice { } } + /// Construct a DaynaPort SCSI/Link target — a type-3 Processor device with + /// no storage backing at all (no image, no CHD, no overlay). + #[cfg(feature = "daynaport")] + pub fn new_daynaport(dp: crate::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, + } + } + + /// The DaynaPort behind this target, if it is one. + #[cfg(feature = "daynaport")] + pub fn daynaport_mut(&mut self) -> Option<&mut crate::daynaport::DaynaPort> { + match &mut self.kind { + DeviceKind::DaynaPort(dp) => Some(dp), + _ => None, + } + } + + /// The DaynaPort behind this target, if it is one. + #[cfg(feature = "daynaport")] + pub fn daynaport(&self) -> Option<&crate::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 { + #[cfg(feature = "daynaport")] + { matches!(self.kind, DeviceKind::DaynaPort(_)) } + #[cfg(not(feature = "daynaport"))] + { false } + } + /// 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() } @@ -345,7 +406,7 @@ impl ScsiDevice { /// 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 { + if !self.is_cdrom() { return None; } // 0 or 1 disc: eject empties the tray entirely. @@ -383,7 +444,7 @@ impl ScsiDevice { } } - pub fn is_cdrom(&self) -> bool { self.is_cdrom } + pub fn is_cdrom(&self) -> bool { matches!(self.kind, DeviceKind::Cdrom) } /// Current active disc path (for display / status). pub fn current_disc(&self) -> &str { @@ -411,7 +472,7 @@ impl ScsiDevice { /// - 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 { + if !self.is_cdrom() { return Err("Not a CD-ROM device".to_string()); } let f = OpenOptions::new().read(true).open(&path) @@ -448,7 +509,7 @@ impl ScsiDevice { /// 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 { + if !self.is_cdrom() { return Err("Not a CD-ROM device".to_string()); } if !std::path::Path::new(&path).exists() { @@ -464,7 +525,7 @@ impl ScsiDevice { /// 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 { + if !self.is_cdrom() { return Err("Not a CD-ROM device".to_string()); } if ordinal >= self.discs.len() { @@ -476,7 +537,7 @@ impl ScsiDevice { /// 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 { + if !self.is_cdrom() { return Err("Not a CD-ROM device".to_string()); } if ordinal == 0 { @@ -534,6 +595,20 @@ impl ScsiDevice { return Ok(self.check_condition(0x06, 0x28, 0x00)); } + // A DaynaPort answers its own vendor command set, which reuses the + // READ(6) (0x08) and WRITE(6) (0x0a) opcodes for packet RX/TX. Dispatch + // it here, ahead of the storage match below, or those two would be + // read as disk block transfers. It answers no storage command at all — + // not READ CAPACITY, not MODE SENSE, not READ TOC. + #[cfg(feature = "daynaport")] + 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)?, @@ -602,12 +677,12 @@ impl ScsiDevice { let lun = (cdb[1] >> 5) & 0x7; if lun == 0 { - data[0] = if self.is_cdrom { 0x05 } else { 0x00 }; - data[1] = if self.is_cdrom { 0x80 } else { 0x00 }; // RMB (Removable) + data[0] = if self.is_cdrom() { 0x05 } else { 0x00 }; + data[1] = if self.is_cdrom() { 0x80 } else { 0x00 }; // RMB (Removable) data[2] = 0x02; // ANSI SCSI-2 data[3] = 0x02; // SCSI-2 response format data[4] = 31; // Additional length (36 - 5) - if self.is_cdrom { + if self.is_cdrom() { // Match Sony CDU-76S — SGI Indy shipped with this drive and IRIX mediad // uses the vendor/product strings for feature detection (volume control, eject). data[8..16].copy_from_slice(b"Sony "); @@ -702,7 +777,7 @@ impl ScsiDevice { } fn perform_write(&mut self, lba: u64, count: usize, data_in: Option<&Vec>) -> Result { - if self.is_cdrom { + if self.is_cdrom() { return Ok(ScsiResponse { status: 0x02, // Check Condition data: vec![], @@ -740,7 +815,7 @@ impl ScsiDevice { // Byte 4: bit1=LOEJ, bit0=START let loej = (cdb[4] & 0x02) != 0; let start = (cdb[4] & 0x01) != 0; - if loej && !start && self.is_cdrom { + 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(); @@ -758,7 +833,7 @@ impl ScsiDevice { // Returning a page-8-incompatible response triggers the noisy // "Got wrong page" path; CHECK CONDITION with ASC 0x24 hits the // clean "Cache data unavailable" / "bad_sense" path instead. - if self.is_cdrom && matches!(page_code, 0x08 | 0x03 | 0x04) { + if self.is_cdrom() && matches!(page_code, 0x08 | 0x03 | 0x04) { return Ok(self.check_condition(0x05, 0x24, 0x00)); // Illegal Request: Invalid field in CDB } @@ -805,7 +880,7 @@ impl ScsiDevice { // Page 0x2a: CD Capabilities and Mechanical Status (MMC, CD-ROM only) // sr.c reads this to determine drive speed and capabilities. // Return minimal read-only CD-ROM capabilities at 4x speed. - if self.is_cdrom && want_page(0x2a) { + if self.is_cdrom() && want_page(0x2a) { pages.extend_from_slice(&[ 0x2a, 0x12, // page code, length (18 bytes follow) 0x00, 0x00, // methods 1/2 not supported @@ -823,7 +898,7 @@ impl ScsiDevice { } // Page 0x0e: CD Audio Control (CD-ROM only) — required by IRIX mediad - if self.is_cdrom && want_page(0x0e) { + if self.is_cdrom() && want_page(0x0e) { pages.extend_from_slice(&[ 0x0e, 0x0e, // page code, length 0x04, // IMMED=1 @@ -841,7 +916,7 @@ impl ScsiDevice { } // Pages 0x03/0x04 are HDD-only (rigid disk geometry) - if !self.is_cdrom { + if !self.is_cdrom() { // Page 0x03: Format Parameters if want_page(0x03) { let bps = lbs as u16; @@ -886,8 +961,8 @@ impl ScsiDevice { let total_len = 4 + bd_len + pages.len(); let mut data = vec![0u8; total_len]; data[0] = (total_len - 1) as u8; // Mode Data Length (excludes byte 0) - data[1] = if self.is_cdrom { 0x01 } else { 0x00 }; // Medium type (0x01 = 120mm optical for CD-ROM) - data[2] = if self.is_cdrom { 0x80 } else { 0x00 }; // WP bit for CD-ROM (read-only media) + data[1] = if self.is_cdrom() { 0x01 } else { 0x00 }; // Medium type (0x01 = 120mm optical for CD-ROM) + data[2] = if self.is_cdrom() { 0x80 } else { 0x00 }; // WP bit for CD-ROM (read-only media) data[3] = bd_len as u8; data[4..4 + bd_len].copy_from_slice(&block_desc); data[4 + bd_len..].copy_from_slice(&pages); @@ -1133,7 +1208,7 @@ impl ScsiDevice { /// count-driven cases: 0/1 disc empties the tray; 2+ discs cycle to the /// next and raise Unit Attention so IRIX re-reads the TOC. fn exec_sgi_eject(&mut self, _cdb: &[u8]) -> Result { - if !self.is_cdrom { + if !self.is_cdrom() { return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid command for HDD } self.eject_next(); @@ -1150,7 +1225,7 @@ impl ScsiDevice { } fn exec_get_configuration(&mut self, _cdb: &[u8]) -> Result { - if !self.is_cdrom { + if !self.is_cdrom() { return Ok(self.check_condition(0x05, 0x20, 0x00)); // Invalid Command } // Minimal response: header + Feature 0x0000 (Profile List) with CD-ROM profile diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index 75675c5..bc2b7d8 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -446,6 +446,42 @@ impl Wd33c93a { Ok(()) } + /// Attach a DaynaPort SCSI/Link target — a SCSI-attached Ethernet adapter. + /// Unlike every other target it has no file backing at all, so none of the + /// image/CHD/overlay path above runs. Its backend thread (NAT gateway, or + /// PCAP bridge) is started here, before the device becomes visible on the + /// bus, so the first INQUIRY already finds a live interface. + #[cfg(feature = "daynaport")] + pub fn add_daynaport( + &self, + id: usize, + mac: [u8; 6], + gateway: crate::net::GatewayConfig, + ) -> std::io::Result<()> { + if id >= 8 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, "SCSI ID out of range")); + } + let mut dp = crate::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(()) + } + + #[cfg(not(feature = "daynaport"))] + pub fn add_daynaport( + &self, + _id: usize, + _mac: [u8; 6], + _gateway: crate::net::GatewayConfig, + ) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "DaynaPort support not compiled in (rebuild with --features daynaport)", + )) + } + /// 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> { @@ -1002,9 +1038,27 @@ impl Device for Wd33c93a { if let Some(t) = self.thread.lock().take() { let _ = t.join(); } + // Any DaynaPort target owns a backend thread of its own; stop it too + // (after the worker is joined, so nothing is mid-command). + #[cfg(feature = "daynaport")] + { + let mut state = self.state.lock(); + for dev in state.devices.iter_mut().flatten() { + if let Some(dp) = dev.daynaport_mut() { dp.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. + #[cfg(feature = "daynaport")] + { + let mut st = self.state.lock(); + for dev in st.devices.iter_mut().flatten() { + if let Some(dp) = dev.daynaport_mut() { dp.start(); } + } + } let state = self.state.clone(); let cond = self.cond.clone(); let running = self.running.clone(); @@ -1061,7 +1115,7 @@ impl Device for Wd33c93a { fn register_commands(&self) -> Vec<(String, String)> { vec![ - ("scsi".to_string(), "SCSI: scsi regs | scsi status | scsi wdt [N] | scsi wdt file | scsi eject | scsi add | scsi list | scsi del | scsi next | scsi debug [DEV] | scsi defer ".to_string()), + ("scsi".to_string(), "SCSI: scsi regs | scsi status | scsi dayna | scsi wdt [N] | scsi wdt file | scsi eject | scsi add | scsi list | scsi del | scsi next | scsi debug [DEV] | scsi defer ".to_string()), ("cow".to_string(), "COW overlay: cow status | cow commit [id] | cow reset [id]".to_string()), ] } @@ -1175,7 +1229,37 @@ impl Device for Wd33c93a { writeln!(writer, "SCSI deferred interrupts {}", if val { "enabled" } else { "disabled" }).unwrap(); return Ok(()); } + Some("dayna") => { + #[cfg(feature = "daynaport")] + { + 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(); + } + } + #[cfg(not(feature = "daynaport"))] + writeln!(writer, "DaynaPort support not built in (rebuild with --features daynaport)").unwrap(); + return Ok(()); + } Some("status") => { + #[cfg(feature = "daynaport")] + { + 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(); @@ -1259,7 +1343,7 @@ impl Device for Wd33c93a { } return Ok(()); } - _ => return Err("Usage: scsi status | scsi eject | scsi add | scsi list | scsi del | scsi next | scsi debug ".to_string()), + _ => return Err("Usage: scsi status | scsi dayna | scsi eject | scsi add | scsi list | scsi del | scsi next | scsi debug ".to_string()), } } if cmd == "cow" { @@ -1354,6 +1438,13 @@ impl Resettable for Wd33c93a { state.regs[regs::COMMAND as usize] = 0; state.advanced_mode = false; state.regs[regs::SCSI_STATUS as usize] = scsi_status::RESET; + // 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. + #[cfg(feature = "daynaport")] + for dev in state.devices.iter_mut().flatten() { + if let Some(dp) = dev.daynaport_mut() { dp.power_on(); } + } } } @@ -1824,8 +1915,15 @@ impl Wd33c93aState { _ => "UNKNOWN", }; + let is_dayna = self.devices[self.target_id].as_ref() + .map(|d| d.is_daynaport()).unwrap_or(false); let mut extra = String::new(); match cdb[0] { + // On a DaynaPort these two are packet RX/TX, not block I/O. + scsi_cmd::READ_6 | scsi_cmd::WRITE_6 if is_dayna => { + let len = ((cdb[3] as usize) << 8) | cdb[4] as usize; + extra = format!(" [DaynaPort] Bytes=0x{:x} flags={:02x}", len, cdb[5]); + } scsi_cmd::READ_6 | scsi_cmd::WRITE_6 => { let lba = (((cdb[1] & 0x1F) as u64) << 16) | ((cdb[2] as u64) << 8) | (cdb[3] as u64); let count = if cdb[4] == 0 { 256 } else { cdb[4] as usize }; @@ -1863,8 +1961,25 @@ impl Wd33c93aState { _ => ScsiDataLength::Unlimited, }; + // 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); + // For WRITE commands, receive data first (data out from host to target) let data_in = match cdb[0] { + scsi_cmd::WRITE_6 if dayna => { + self.data_direction_in = false; + let len = ((cdb[3] as usize) << 8) | cdb[4] as usize; + if len > 0 { + match self.receive_data_chunked(len, dma) { + None => return, // paused; will resume on SELECT_ATN_XFER + data => data, + } + } else { + None + } + } scsi_cmd::WRITE_6 => { self.data_direction_in = false; let count = if cdb[4] == 0 { 256 } else { cdb[4] as usize };