From 7cc3f7fe9579c29bea0dc740699d1aa4ac4490dd Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:12:16 +0900 Subject: [PATCH 01/10] a4091: Zorro III board scaffolding with nibble-wide autoboot ROM The board autoconfigs (Commodore product 84, 16M Z3, DiagArea at $0200), serves the boot ROM nibble-wide, and models the 53C710 register file with the +$40 write shadows, mirrored across the register window. Passes the driver's walking-bits hardware test; the SCRIPTS processor is still a stub. Configured via a new [a4091] section (rom + unit0..6, drives ignored for now). Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 269 ++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 65 +++++++++++ src/emulator.rs | 22 ++++ src/lib.rs | 1 + src/zorro.rs | 20 ++++ src/zorro_device.rs | 12 ++ 6 files changed, 389 insertions(+) create mode 100644 src/a4091.rs diff --git a/src/a4091.rs b/src/a4091.rs new file mode 100644 index 0000000..22ea112 --- /dev/null +++ b/src/a4091.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Commodore A4091: a Zorro III SCSI-2 controller carrying an NCR 53C710 +//! and a nibble-wide autoboot ROM. +//! +//! Board layout (offsets within the configured 16M window, matching the +//! A4091 schematics and the WinUAE/Amiberry emulation): +//! +//! - `$000000-$7FFFFF` the boot ROM, nibble-wide: ROM byte `i` presents its +//! high nibble at window offset `i*4` and its low nibble at `i*4+2` (both +//! with the low nibble of the lane forced to `$F`); odd offsets float +//! `$FF`. expansion.library reassembles the DiagArea from the nibbles +//! (DAC_NIBBLEWIDE), and the ROM's own relocator copies the driver the +//! same way. A 32K image mirrors to fill the 64K ROM space. +//! - `$800000-$87FFFF` the 53C710 registers. The board decodes only the +//! low 6 address bits, so the 64-byte register file mirrors across the +//! whole window; the driver relies on this, writing TEMP/SCRATCH through +//! the `+$40` shadow (a 68030/68040 cache write-allocate workaround) and +//! reading them back at the base offsets. Registers are plain storage for +//! now -- enough for the driver's walking-bits hardware test -- with the +//! SCRIPTS processor still to come (a DSP write warns once). +//! - `$8C0003` the DIP-switch byte (SCSI host ID, termination, sync/fast +//! negotiation enables). `$FF` means all switches off: host ID 7. +//! +//! The autoconfig identity (Commodore, product 84, 16M Zorro III, +//! er_InitDiagVec `$0200`) is supplied by [`crate::zorro::BoardSpec::a4091`]; +//! the same nibbles are also baked into the first `$60` bytes of the real +//! EPROM, which is how the physical board presents them. + +use anyhow::{bail, Result}; +use std::path::Path; + +/// The 53C710 register window within the board space. +const IO_OFFSET: u32 = 0x0080_0000; +const IO_END: u32 = 0x0088_0000; + +/// The DIP-switch readback byte. +const DIP_OFFSET: u32 = 0x008C_0003; + +/// 53C710 register-file byte offsets as the 68k sees them (the chip is +/// wired big-endian on the A4091, so these are the driver's REG_ addresses). +const REG_CTEST8: usize = 0x21; +/// DSP: writing its low byte starts the SCRIPTS processor. +const REG_DSP: usize = 0x2C; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct A4091 { + rom: Vec, + /// DIP switches as read at `$8C0003`; `$FF` = all off (host ID 7). + dip: u8, + /// The 53C710 register file, indexed by CPU-visible (big-endian) byte + /// address. Plain storage until the SCRIPTS core lands. + regs: Vec, + /// One-shot warning latch for the unimplemented SCRIPTS processor. + scripts_warned: bool, +} + +/// 53C710 register-file reset values at their big-endian byte addresses: +/// SCNTL0 (arbitration bits), SCID, DSTAT.DFE (DMA FIFO empty), CTEST2.DACK, +/// DCMD, matching the chip's documented power-on state. +fn reset_regs() -> Vec { + let mut r = vec![0u8; 0x40]; + r[0x03] = 0xC0; // SCNTL0 + r[0x07] = 0x80; // SCID + r[0x0F] = 0x80; // DSTAT: DFE + r[0x15] = 0x01; // CTEST2: DACK + r[0x24] = 0x40; // DCMD + r +} + +impl A4091 { + /// Build the board from its boot ROM image (a raw 32K or 64K byte-wide + /// EPROM dump; the board serves it nibble-wide). + pub fn new(rom: Vec) -> Result { + if !matches!(rom.len(), 0x8000 | 0x1_0000) { + bail!( + "A4091 ROM is {} bytes; expected 32K or 64K (a raw byte-wide \ + EPROM image, e.g. the open-source a4091.rom)", + rom.len() + ); + } + Ok(Self { + rom, + dip: 0xFF, + regs: reset_regs(), + scripts_warned: false, + }) + } + + pub fn load_rom(path: &Path) -> Result> { + std::fs::read(path) + .map_err(|e| anyhow::anyhow!("reading A4091 ROM {}: {e}", path.display())) + } + + /// One byte of the nibble-wide ROM as seen in the window at `off`. + fn rom_byte(&self, off: u32) -> u8 { + if off & 1 != 0 { + return 0xFF; + } + let b = self.rom[(off as usize / 4) % self.rom.len()]; + if off & 2 == 0 { + b | 0x0F + } else { + (b << 4) | 0x0F + } + } +} + +impl crate::zorro_device::ZorroDevice for A4091 { + fn read(&mut self, off: u32, size: usize, _host: &mut crate::zorro_device::DeviceHost) -> u32 { + let mut v = 0u32; + for i in 0..size { + let o = off.wrapping_add(i as u32); + let b = if o < IO_OFFSET { + self.rom_byte(o) + } else if o == DIP_OFFSET { + self.dip + } else if (IO_OFFSET..IO_END).contains(&o) { + match (o as usize) & 0x3F { + // CTEST8: chip revision 2 in the high nibble; the CLF + // (clear FIFOs) strobe bit always reads back 0. + REG_CTEST8 => (self.regs[REG_CTEST8] | 0x20) & !0x04, + r => self.regs[r], + } + } else { + 0xFF + }; + v = (v << 8) | u32::from(b); + } + v + } + + fn write( + &mut self, + off: u32, + size: usize, + value: u32, + _host: &mut crate::zorro_device::DeviceHost, + ) { + for i in 0..size { + let o = off.wrapping_add(i as u32); + if !(IO_OFFSET..IO_END).contains(&o) { + continue; + } + let r = (o as usize) & 0x3F; + self.regs[r] = (value >> (8 * (size - 1 - i))) as u8; + if (REG_DSP..REG_DSP + 4).contains(&r) && !self.scripts_warned { + self.scripts_warned = true; + log::warn!( + "a4091: DSP write ({:#04X} <- {:#04X}): SCRIPTS processor \ + not implemented yet", + r, + self.regs[r] + ); + } + } + } + + fn peek_word(&self, off: u32) -> Option { + if off < IO_OFFSET { + Some( + (u16::from(self.rom_byte(off)) << 8) + | u16::from(self.rom_byte(off.wrapping_add(1))), + ) + } else { + None + } + } + + fn tick(&mut self, _cck: u32, _host: &mut crate::zorro_device::DeviceHost) {} + + fn reset(&mut self) { + self.regs = reset_regs(); + self.scripts_warned = false; + } + + fn kind(&self) -> &'static str { + "a4091" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::Memory; + use crate::zorro::ZorroChain; + use crate::zorro_device::{DeviceHost, ZorroDevice}; + + fn board_with_rom(rom: Vec) -> A4091 { + A4091::new(rom).expect("valid ROM size") + } + + fn test_memory() -> Memory { + Memory { + chip_ram: vec![0u8; 512 * 1024], + slow_ram: Vec::new(), + rom: Vec::new(), + overlay: false, + zorro: ZorroChain::default(), + extended_rom: Vec::new(), + extended_rom_base: 0, + wcs: Vec::new(), + wcs_write_protected: false, + } + } + + fn test_rom_64k() -> Vec { + (0..0x1_0000usize).map(|i| i as u8).collect() + } + + #[test] + fn rom_appears_nibble_wide() { + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + // ROM byte $12 lives at window offset $48 (high nibble) / $4A (low). + assert_eq!(b.read(0x48, 1, &mut host), 0x1F); + assert_eq!(b.read(0x4A, 1, &mut host), 0x2F); + assert_eq!(b.read(0x49, 1, &mut host), 0xFF); + assert_eq!(b.read(0x4B, 1, &mut host), 0xFF); + } + + #[test] + fn rom_mirrors_across_the_rom_space_and_32k_images_repeat() { + let mut b = board_with_rom(vec![0xA5; 0x8000]); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + // 32K image: offset $20000 (byte $8000) wraps back to byte 0. + assert_eq!(b.read(0x0002_0000, 1, &mut host), 0xAF); + // Anywhere below the 53C710 window still decodes ROM. + assert_eq!(b.read(0x007F_FFFC, 1, &mut host), 0xAF); + } + + #[test] + fn dip_switches_read_back() { + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + assert_eq!(b.read(DIP_OFFSET, 1, &mut host), 0xFF); + } + + #[test] + fn scratch_and_temp_written_via_the_shadow_read_back_at_the_base() { + // The driver's walking-bits hardware test: write SCRATCH ($34) and + // TEMP ($1C) through the +$40 write shadows, read back at the base. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + b.write(IO_OFFSET + 0x40 + 0x34, 4, 0xF0E7_C3A5, &mut host); + b.write(IO_OFFSET + 0x40 + 0x1C, 4, 0xE1CF_874B, &mut host); + assert_eq!(b.read(IO_OFFSET + 0x34, 4, &mut host), 0xF0E7_C3A5); + assert_eq!(b.read(IO_OFFSET + 0x1C, 4, &mut host), 0xE1CF_874B); + } + + #[test] + fn ctest8_reports_chip_revision_2() { + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + assert_eq!(b.read(IO_OFFSET + 0x21, 1, &mut host) >> 4, 2); + } + + #[test] + fn peek_serves_rom_and_leaves_io_opaque() { + let b = board_with_rom(test_rom_64k()); + assert_eq!(b.peek_word(0x48), Some(0x1FFF)); + assert_eq!(b.peek_word(IO_OFFSET), None); + } +} diff --git a/src/config.rs b/src/config.rs index 10138ca..12f55ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -116,6 +116,10 @@ pub struct Config { /// drive images on SCSI IDs 0-6. Works on any machine model (the board /// autoconfigs on the Zorro chain and carries its own scsi.device). pub scsi: ScsiConfig, + /// A4091 SCSI-2 controller (`[a4091]`): boot ROM image plus up to seven + /// drive images on SCSI IDs 0-6. A Zorro III board; the 53C710 core is + /// not implemented yet, so drives are parsed but not driven. + pub a4091: A4091Config, /// A2065 Ethernet board (`[a2065]`): when set, an A2065 NIC autoconfigs on /// the Zorro chain using the named host network backend. Networking is /// non-deterministic, so a fitted A2065 breaks byte-identical replay. @@ -300,6 +304,21 @@ impl ScsiConfig { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct A4091Config { + /// A4091 boot ROM image (a raw 32K or 64K byte-wide EPROM dump). + pub rom: Option, + /// Drive images by SCSI ID (0-6; ID 7 is the controller). + pub units: [Option; 7], +} + +impl A4091Config { + /// Whether an `[a4091]` section asked for the board at all. + pub fn enabled(&self) -> bool { + self.rom.is_some() || self.units.iter().any(Option::is_some) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Emulation { /// Whether the machine starts running (powered on) at launch. When @@ -824,6 +843,7 @@ impl Default for Config { audio: AudioConfig::default(), ide: IdeConfig::default(), scsi: ScsiConfig::default(), + a4091: A4091Config::default(), a2065_net: None, floppy: FloppyConfig::default(), floppy_connected: [true, false, false, false], @@ -1071,6 +1091,8 @@ pub struct RawConfig { #[serde(default, skip_serializing_if = "is_default")] pub(crate) scsi: RawScsi, #[serde(default, skip_serializing_if = "is_default")] + pub(crate) a4091: RawA4091, + #[serde(default, skip_serializing_if = "is_default")] pub(crate) a2065: RawA2065, #[serde(default, skip_serializing_if = "is_default")] pub(crate) floppy: RawFloppy, @@ -1263,6 +1285,29 @@ pub(crate) struct RawScsi { pub(crate) unit6: Option, } +/// `[a4091]` SCSI-2 controller: boot ROM image plus drive images by SCSI ID. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RawA4091 { + /// A4091 boot ROM image (raw 32K or 64K byte-wide EPROM dump). + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) rom: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit0: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit3: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit4: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit5: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) unit6: Option, +} + /// `[a2065]` Ethernet board. Fitting the board enables host networking, which /// is non-deterministic. #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] @@ -1702,6 +1747,25 @@ impl TryFrom for Config { errors.push(anyhow!("[scsi] rom_odd needs rom (the even EPROM half)")); } + let a4091 = A4091Config { + rom: raw.a4091.rom.map(PathBuf::from), + units: [ + raw.a4091.unit0.map(drive_image).transpose()?, + raw.a4091.unit1.map(drive_image).transpose()?, + raw.a4091.unit2.map(drive_image).transpose()?, + raw.a4091.unit3.map(drive_image).transpose()?, + raw.a4091.unit4.map(drive_image).transpose()?, + raw.a4091.unit5.map(drive_image).transpose()?, + raw.a4091.unit6.map(drive_image).transpose()?, + ], + }; + if a4091.enabled() && a4091.rom.is_none() { + errors.push(anyhow!( + "[a4091] drives need the boot ROM: set [a4091] rom = \"...\" \ + (a raw A4091 EPROM image, e.g. the open-source a4091.rom)" + )); + } + let a2065_net = match &raw.a2065.net { None => None, Some(s) => Some(crate::net::parse_net_config(s).ok_or_else(|| { @@ -1825,6 +1889,7 @@ impl TryFrom for Config { audio, ide, scsi, + a4091, a2065_net, floppy, floppy_connected, diff --git a/src/emulator.rs b/src/emulator.rs index b557312..b12acb1 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -1776,6 +1776,28 @@ pub fn build_machine( ); devices.push(crate::zorro_device::BoardDevice::A2091(board)); } + if cfg.a4091.enabled() { + let rom_path = cfg + .a4091 + .rom + .as_ref() + .expect("config validated [a4091] rom"); + let rom = crate::a4091::A4091::load_rom(rom_path)?; + let board = crate::a4091::A4091::new(rom)?; + if cfg.a4091.units.iter().any(Option::is_some) { + log::warn!( + "a4091: the 53C710 SCSI core is not implemented yet; \ + [a4091] drive images are ignored" + ); + } + let slot = devices.len(); + zorro.add_board(crate::zorro::BoardSpec::a4091(slot))?; + info!( + "a4091: SCSI controller on the Zorro chain (slot {slot}), ROM {}", + rom_path.display() + ); + devices.push(crate::zorro_device::BoardDevice::A4091(board)); + } // WASM plugin boards: assign each a device slot, put its autoconfig // identity on the chain, and instantiate the module. for wb in &cfg.wasm_boards { diff --git a/src/lib.rs b/src/lib.rs index 8761e76..c56be8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod a2065; pub mod a2091; +pub mod a4091; pub mod akiko; pub mod amigaos; pub mod audio; diff --git a/src/zorro.rs b/src/zorro.rs index 22411a9..05b64f1 100644 --- a/src/zorro.rs +++ b/src/zorro.rs @@ -190,6 +190,26 @@ impl BoardSpec { } } + /// The A4091 SCSI-2 controller board: Commodore (manufacturer 514), + /// product 84, a 16M Zorro III window with the DiagArea vector at $0200 + /// in the nibble-wide boot ROM. The same identity is baked into the + /// first bytes of the physical EPROM; here the chain supplies it, as it + /// does for every board. `slot` is the index of the matching `A4091` + /// device in `Bus::devices`. + pub fn a4091(slot: usize) -> Self { + Self { + name: "A4091 SCSI".into(), + version: ZorroVersion::III, + manufacturer: 514, + product: 84, + serial: 0, + size_bytes: 0x0100_0000, + backing: BoardBacking::Device(slot), + memlist: false, + diag_vec: Some(0x0200), + } + } + fn validate(&self) -> Result<()> { match self.version { ZorroVersion::II => { diff --git a/src/zorro_device.rs b/src/zorro_device.rs index 79c023d..b257a46 100644 --- a/src/zorro_device.rs +++ b/src/zorro_device.rs @@ -262,6 +262,7 @@ pub trait ZorroDevice { #[derive(serde::Serialize, serde::Deserialize)] pub enum BoardDevice { A2091(crate::a2091::A2091), + A4091(crate::a4091::A4091), A2065(crate::a2065::A2065), Wasm(crate::wasmboard::WasmBoard), } @@ -270,6 +271,7 @@ impl ZorroDevice for BoardDevice { fn read(&mut self, off: u32, size: usize, host: &mut DeviceHost) -> u32 { match self { BoardDevice::A2091(d) => ZorroDevice::read(d, off, size, host), + BoardDevice::A4091(d) => ZorroDevice::read(d, off, size, host), BoardDevice::A2065(d) => ZorroDevice::read(d, off, size, host), BoardDevice::Wasm(d) => ZorroDevice::read(d, off, size, host), } @@ -278,6 +280,7 @@ impl ZorroDevice for BoardDevice { fn write(&mut self, off: u32, size: usize, value: u32, host: &mut DeviceHost) { match self { BoardDevice::A2091(d) => ZorroDevice::write(d, off, size, value, host), + BoardDevice::A4091(d) => ZorroDevice::write(d, off, size, value, host), BoardDevice::A2065(d) => ZorroDevice::write(d, off, size, value, host), BoardDevice::Wasm(d) => ZorroDevice::write(d, off, size, value, host), } @@ -286,6 +289,7 @@ impl ZorroDevice for BoardDevice { fn peek_word(&self, off: u32) -> Option { match self { BoardDevice::A2091(d) => ZorroDevice::peek_word(d, off), + BoardDevice::A4091(d) => ZorroDevice::peek_word(d, off), BoardDevice::A2065(d) => ZorroDevice::peek_word(d, off), BoardDevice::Wasm(d) => ZorroDevice::peek_word(d, off), } @@ -294,6 +298,7 @@ impl ZorroDevice for BoardDevice { fn tick(&mut self, cck: u32, host: &mut DeviceHost) { match self { BoardDevice::A2091(d) => ZorroDevice::tick(d, cck, host), + BoardDevice::A4091(d) => ZorroDevice::tick(d, cck, host), BoardDevice::A2065(d) => ZorroDevice::tick(d, cck, host), BoardDevice::Wasm(d) => ZorroDevice::tick(d, cck, host), } @@ -302,6 +307,7 @@ impl ZorroDevice for BoardDevice { fn int2_line(&self) -> bool { match self { BoardDevice::A2091(d) => ZorroDevice::int2_line(d), + BoardDevice::A4091(d) => ZorroDevice::int2_line(d), BoardDevice::A2065(d) => ZorroDevice::int2_line(d), BoardDevice::Wasm(d) => ZorroDevice::int2_line(d), } @@ -310,6 +316,7 @@ impl ZorroDevice for BoardDevice { fn int6_line(&self) -> bool { match self { BoardDevice::A2091(d) => ZorroDevice::int6_line(d), + BoardDevice::A4091(d) => ZorroDevice::int6_line(d), BoardDevice::A2065(d) => ZorroDevice::int6_line(d), BoardDevice::Wasm(d) => ZorroDevice::int6_line(d), } @@ -318,6 +325,7 @@ impl ZorroDevice for BoardDevice { fn is_idle(&self) -> bool { match self { BoardDevice::A2091(d) => ZorroDevice::is_idle(d), + BoardDevice::A4091(d) => ZorroDevice::is_idle(d), BoardDevice::A2065(d) => ZorroDevice::is_idle(d), BoardDevice::Wasm(d) => ZorroDevice::is_idle(d), } @@ -326,6 +334,7 @@ impl ZorroDevice for BoardDevice { fn next_event_cck(&self) -> Option { match self { BoardDevice::A2091(d) => ZorroDevice::next_event_cck(d), + BoardDevice::A4091(d) => ZorroDevice::next_event_cck(d), BoardDevice::A2065(d) => ZorroDevice::next_event_cck(d), BoardDevice::Wasm(d) => ZorroDevice::next_event_cck(d), } @@ -334,6 +343,7 @@ impl ZorroDevice for BoardDevice { fn take_activity(&mut self) -> bool { match self { BoardDevice::A2091(d) => ZorroDevice::take_activity(d), + BoardDevice::A4091(d) => ZorroDevice::take_activity(d), BoardDevice::A2065(d) => ZorroDevice::take_activity(d), BoardDevice::Wasm(d) => ZorroDevice::take_activity(d), } @@ -342,6 +352,7 @@ impl ZorroDevice for BoardDevice { fn reset(&mut self) { match self { BoardDevice::A2091(d) => ZorroDevice::reset(d), + BoardDevice::A4091(d) => ZorroDevice::reset(d), BoardDevice::A2065(d) => ZorroDevice::reset(d), BoardDevice::Wasm(d) => ZorroDevice::reset(d), } @@ -350,6 +361,7 @@ impl ZorroDevice for BoardDevice { fn kind(&self) -> &'static str { match self { BoardDevice::A2091(d) => ZorroDevice::kind(d), + BoardDevice::A4091(d) => ZorroDevice::kind(d), BoardDevice::A2065(d) => ZorroDevice::kind(d), BoardDevice::Wasm(d) => ZorroDevice::kind(d), } From 2aeae068a8b71dfbdd1a9969f48757da5fdfffb7 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:16:14 +0900 Subject: [PATCH 02/10] a4091: CTEST5 ADCK/BBCK test strobes Increment DNAD / decrement DBC by the bus width and self-clear, as validated by the ncr7xx tool's register test. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/a4091.rs b/src/a4091.rs index 22ea112..5193357 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -40,6 +40,14 @@ const DIP_OFFSET: u32 = 0x008C_0003; /// 53C710 register-file byte offsets as the 68k sees them (the chip is /// wired big-endian on the A4091, so these are the driver's REG_ addresses). const REG_CTEST8: usize = 0x21; +/// CTEST5 carries the ADCK/BBCK self-clearing test strobes. +const REG_CTEST5: usize = 0x1A; +const CTEST5_ADCK: u8 = 0x80; +const CTEST5_BBCK: u8 = 0x40; +/// DNAD: 32-bit DMA next address; ADCK increments it by the bus width. +const REG_DNAD: usize = 0x28; +/// DBC: 24-bit DMA byte counter (below DCMD at $24); BBCK decrements it. +const REG_DBC: usize = 0x25; /// DSP: writing its low byte starts the SCRIPTS processor. const REG_DSP: usize = 0x2C; @@ -92,6 +100,34 @@ impl A4091 { .map_err(|e| anyhow::anyhow!("reading A4091 ROM {}: {e}", path.display())) } + fn reg32(&self, r: usize) -> u32 { + u32::from_be_bytes(self.regs[r..r + 4].try_into().unwrap()) + } + + fn set_reg32(&mut self, r: usize, v: u32) { + self.regs[r..r + 4].copy_from_slice(&v.to_be_bytes()); + } + + /// CTEST5 ADCK/BBCK test strobes: increment DNAD / decrement DBC by the + /// bus width, then self-clear. + fn ctest5_strobes(&mut self) { + let v = self.regs[REG_CTEST5]; + if v & CTEST5_ADCK != 0 { + let dnad = self.reg32(REG_DNAD).wrapping_add(4); + self.set_reg32(REG_DNAD, dnad); + } + if v & CTEST5_BBCK != 0 { + let dbc = (u32::from(self.regs[REG_DBC]) << 16) + | (u32::from(self.regs[REG_DBC + 1]) << 8) + | u32::from(self.regs[REG_DBC + 2]); + let dbc = dbc.wrapping_sub(4) & 0x00FF_FFFF; + self.regs[REG_DBC] = (dbc >> 16) as u8; + self.regs[REG_DBC + 1] = (dbc >> 8) as u8; + self.regs[REG_DBC + 2] = dbc as u8; + } + self.regs[REG_CTEST5] = v & !(CTEST5_ADCK | CTEST5_BBCK); + } + /// One byte of the nibble-wide ROM as seen in the window at `off`. fn rom_byte(&self, off: u32) -> u8 { if off & 1 != 0 { @@ -144,6 +180,9 @@ impl crate::zorro_device::ZorroDevice for A4091 { } let r = (o as usize) & 0x3F; self.regs[r] = (value >> (8 * (size - 1 - i))) as u8; + if r == REG_CTEST5 { + self.ctest5_strobes(); + } if (REG_DSP..REG_DSP + 4).contains(&r) && !self.scripts_warned { self.scripts_warned = true; log::warn!( @@ -252,6 +291,24 @@ mod tests { assert_eq!(b.read(IO_OFFSET + 0x1C, 4, &mut host), 0xE1CF_874B); } + #[test] + fn ctest5_adck_and_bbck_strobe_the_counters_and_self_clear() { + // The ncr7xx tool's register test: DBC=0x10 via a 32-bit DCMD write, + // DNAD=0, then ADCK bumps DNAD to 4 (DBC untouched) and BBCK drops + // DBC to 0xC; both bits read back clear. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + b.write(IO_OFFSET + 0x24, 4, 0x0000_0010, &mut host); + b.write(IO_OFFSET + 0x28, 4, 0, &mut host); + b.write(IO_OFFSET + 0x1A, 1, 0x80, &mut host); + assert_eq!(b.read(IO_OFFSET + 0x28, 4, &mut host), 4); + assert_eq!(b.read(IO_OFFSET + 0x24, 4, &mut host), 0x10); + b.write(IO_OFFSET + 0x1A, 1, 0x40, &mut host); + assert_eq!(b.read(IO_OFFSET + 0x24, 4, &mut host), 0xC); + assert_eq!(b.read(IO_OFFSET + 0x1A, 1, &mut host) & 0xC0, 0); + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); From 478a8ee8222a3af3edc5858fbd31a68c0c0ac3e4 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:26:46 +0900 Subject: [PATCH 03/10] serial: bidirectional TCP sink (mode = "tcp") Bridges Paula's serial port to a host TCP listener, like UAE's TCP: serial device (same default port 1234; override with [serial] listen). With an AUX: shell on the Amiga side this gives a remote AmigaDOS console. Co-Authored-By: Claude Fable 5 --- src/config.rs | 15 ++++- src/emulator.rs | 3 + src/serial.rs | 149 ++++++++++++++++++++++++++++++++++++++++++ src/video/launcher.rs | 8 ++- 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 12f55ce..6403dae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -245,6 +245,10 @@ pub enum SerialMode { /// Serial in/out is bridged to host MIDI endpoints. Requires a build /// with the `midi` feature; without it, resolving this mode is an error. Midi, + /// Serial in/out is bridged to a host TCP port, like UAE's `TCP:` + /// serial device. With an `AUX:` shell on the Amiga side, a connected + /// client gets a remote AmigaDOS console. + Tcp, } impl SerialMode { @@ -254,6 +258,7 @@ impl SerialMode { Self::Off => "off", Self::Stdout => "stdout", Self::Midi => "midi", + Self::Tcp => "tcp", } } } @@ -267,6 +272,9 @@ pub struct SerialConfig { pub mode: SerialMode, pub midi_out: Option, pub midi_in: Option, + /// TCP listen address for [`SerialMode::Tcp`]; `None` means the + /// default `127.0.0.1:1234` (the port UAE's `TCP:` serial uses). + pub listen: Option, } /// A configured hard-drive image: the host path plus an optional volume-name @@ -1157,6 +1165,9 @@ pub(crate) struct RawSerial { /// Host MIDI input endpoint name (substring match); MIDI mode only. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) midi_in: Option, + /// TCP listen address; tcp mode only. Defaults to 127.0.0.1:1234. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) listen: Option, } /// A drive image entry in `[ide]`/`[scsi]`. Accepts either a bare path string @@ -1712,6 +1723,7 @@ impl TryFrom for Config { }, midi_out: raw.serial.midi_out.clone(), midi_in: raw.serial.midi_in.clone(), + listen: raw.serial.listen.clone(), }; let ide = IdeConfig { @@ -1938,8 +1950,9 @@ pub(crate) fn parse_serial_mode(s: &str) -> Result { "off" | "none" => Ok(SerialMode::Off), "stdout" | "terminal" => Ok(SerialMode::Stdout), "midi" => Ok(SerialMode::Midi), + "tcp" => Ok(SerialMode::Tcp), _ => Err(anyhow!( - "unknown [serial] mode {:?}: expected \"off\", \"stdout\", or \"midi\"", + "unknown [serial] mode {:?}: expected \"off\", \"stdout\", \"midi\", or \"tcp\"", s )), } diff --git a/src/emulator.rs b/src/emulator.rs index b12acb1..27f0f5a 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -1742,6 +1742,9 @@ fn build_serial_sink(cfg: &Config) -> Result> SerialMode::Midi => Err(anyhow!( "[serial] mode = \"midi\" needs a build with --features midi" )), + SerialMode::Tcp => Ok(Box::new(crate::serial::TcpSerialSink::listen( + cfg.serial.listen.as_deref().unwrap_or("127.0.0.1:1234"), + )?)), } } diff --git a/src/serial.rs b/src/serial.rs index bf37c76..1d78758 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -69,6 +69,155 @@ pub trait SerialSink: Send { fn flush(&mut self); } +/// Bidirectional TCP bridge, like the UAE "TCP:" serial device (port 1234 +/// there too): listens on a host port, and the connected client talks to +/// Paula's serial port -- with an +/// `AUX:` shell on the Amiga side, a full remote AmigaDOS console. One +/// client at a time; a new connection replaces a finished one. Output with +/// no client connected is dropped, like an unplugged serial cable. +/// +/// A background thread owns the accept loop and the read half (pushing +/// bytes into a channel), so Paula's idle fast path polls a channel probe, +/// never a socket syscall. +pub struct TcpSerialSink { + rx: std::sync::mpsc::Receiver, + /// Write half of the current client, shared with the acceptor thread + /// (which installs/clears it as clients come and go). + writer: std::sync::Arc>>, + /// Bytes queued in `rx`: the reader thread increments, `read_byte` + /// decrements. Lets `has_pending_input` (`&self`) probe the channel + /// without consuming from it. + buffered: std::sync::Arc, + /// The bound listen address (resolves port 0 to the real port). + local_addr: std::net::SocketAddr, +} + +impl TcpSerialSink { + pub fn listen(addr: &str) -> anyhow::Result { + let listener = std::net::TcpListener::bind(addr) + .map_err(|e| anyhow::anyhow!("[serial] tcp: binding {addr}: {e}"))?; + let local_addr = listener.local_addr()?; + log::info!( + "serial: listening on tcp://{local_addr} (connect with e.g. \"nc {}\" \ + or \"socat -,raw,echo=0 tcp:{}\")", + addr.replace(':', " "), + addr, + ); + let (tx, rx) = std::sync::mpsc::channel(); + let writer = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let acceptor_writer = std::sync::Arc::clone(&writer); + let buffered = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reader_buffered = std::sync::Arc::clone(&buffered); + std::thread::Builder::new() + .name("serial-tcp".into()) + .spawn(move || loop { + let Ok((stream, peer)) = listener.accept() else { + return; + }; + log::info!("serial: client connected from {peer}"); + let _ = stream.set_nodelay(true); + match stream.try_clone() { + Ok(w) => *acceptor_writer.lock().unwrap() = Some(w), + Err(e) => { + log::warn!("serial: cloning client stream: {e}"); + continue; + } + } + let mut buf = [0u8; 512]; + let mut stream = stream; + loop { + use std::io::Read; + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + for &b in &buf[..n] { + if tx.send(b).is_err() { + return; + } + reader_buffered.fetch_add(1, std::sync::atomic::Ordering::Release); + } + } + } + } + log::info!("serial: client {peer} disconnected"); + *acceptor_writer.lock().unwrap() = None; + })?; + Ok(Self { + rx, + writer, + buffered, + local_addr, + }) + } + + /// The bound listen address (a port of 0 in the config resolves here). + #[cfg_attr(not(test), allow(dead_code))] + pub fn local_addr(&self) -> std::net::SocketAddr { + self.local_addr + } +} + +impl SerialSink for TcpSerialSink { + fn write_byte(&mut self, b: u8, _at_cck: u64) { + let mut guard = self.writer.lock().unwrap(); + if let Some(w) = guard.as_mut() { + if w.write_all(&[b]).is_err() { + *guard = None; + } + } + } + + fn read_byte(&mut self) -> Option { + let b = self.rx.try_recv().ok(); + if b.is_some() { + self.buffered + .fetch_sub(1, std::sync::atomic::Ordering::Release); + } + b + } + + fn has_pending_input(&self) -> bool { + self.buffered.load(std::sync::atomic::Ordering::Acquire) > 0 + } + + fn flush(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn tcp_sink_round_trips_input_and_output() { + let mut sink = TcpSerialSink::listen("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(sink.local_addr()).unwrap(); + client.write_all(b"ab").unwrap(); + // Input: wait for the reader thread to stage the bytes. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !sink.has_pending_input() { + assert!(std::time::Instant::now() < deadline, "input never arrived"); + std::thread::yield_now(); + } + assert_eq!(sink.read_byte(), Some(b'a')); + while !sink.has_pending_input() { + assert!(std::time::Instant::now() < deadline, "second byte lost"); + std::thread::yield_now(); + } + assert_eq!(sink.read_byte(), Some(b'b')); + assert!(!sink.has_pending_input()); + + // Output: bytes written to the sink arrive at the client. + sink.write_byte(b'x', 0); + let mut got = [0u8; 1]; + client + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + client.read_exact(&mut got).unwrap(); + assert_eq!(&got, b"x"); + } +} + /// Inert sink: discards output and never produces input. Placeholder used /// where a `Box` must exist before the host wires the real /// one (serde-skipped fields during save-state deserialization). diff --git a/src/video/launcher.rs b/src/video/launcher.rs index d67c3b4..5abe041 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -491,7 +491,12 @@ const WARPS: [WarpSpeed; 5] = [ const JOYSTICK_MODES: [JoystickInputMode; 2] = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; #[cfg(feature = "midi")] -const SERIAL_MODES: [SerialMode; 3] = [SerialMode::Off, SerialMode::Stdout, SerialMode::Midi]; +const SERIAL_MODES: [SerialMode; 4] = [ + SerialMode::Off, + SerialMode::Stdout, + SerialMode::Midi, + SerialMode::Tcp, +]; /// A fully-typed, editable mirror of a configurable machine. See the module /// docs for how it round-trips through [`RawConfig`]. @@ -1143,6 +1148,7 @@ impl MachineSetup { SerialMode::Off => "Off".to_string(), SerialMode::Stdout => "Stdout".to_string(), SerialMode::Midi => "MIDI".to_string(), + SerialMode::Tcp => "TCP".to_string(), }, #[cfg(feature = "midi")] F::MidiOut => self.midi_out.clone().unwrap_or_else(|| "None".to_string()), From be31c3df0a5a319679ceadbefe551e69716ff827 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:27:06 +0900 Subject: [PATCH 04/10] a4091: CTEST1 reads FIFO-empty at reset FMT nibble = $F (all DMA FIFO byte lanes empty), per the ncr7xx DMA FIFO test. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/a4091.rs b/src/a4091.rs index 5193357..1e3219d 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -72,6 +72,7 @@ fn reset_regs() -> Vec { r[0x07] = 0x80; // SCID r[0x0F] = 0x80; // DSTAT: DFE r[0x15] = 0x01; // CTEST2: DACK + r[0x16] = 0xF0; // CTEST1: FMT, all DMA FIFO byte lanes empty r[0x24] = 0x40; // DCMD r } From 7f7632d10dd84a97878be8df029fe86c382d8b81 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:41:07 +0900 Subject: [PATCH 05/10] a4091: DMA FIFO model and ISTAT software reset Four 16-deep byte lanes with parity behind CTEST6/CTEST4.FBL2; CTEST1 and DSTAT.DFE computed from fill state; parity via CTEST7.3 in, CTEST2.3 out. ISTAT.RST restores reset state, as a4091_reset() in the driver does. Passes the ncr7xx DMA FIFO test sequence. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/src/a4091.rs b/src/a4091.rs index 1e3219d..b8921df 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -40,6 +40,25 @@ const DIP_OFFSET: u32 = 0x008C_0003; /// 53C710 register-file byte offsets as the 68k sees them (the chip is /// wired big-endian on the A4091, so these are the driver's REG_ addresses). const REG_CTEST8: usize = 0x21; +/// ISTAT: bit 6 is the software-reset strobe. +const REG_ISTAT: usize = 0x22; +const ISTAT_RST: u8 = 0x40; +/// DSTAT: bit 7 (DFE) tracks "all DMA FIFO lanes empty". +const REG_DSTAT: usize = 0x0F; +const DSTAT_DFE: u8 = 0x80; +/// CTEST1: FMT lane-empty flags in the high nibble, FFL lane-full in the low. +const REG_CTEST1: usize = 0x16; +/// CTEST2: bit 3 carries the parity bit of the last DMA FIFO pop. +const REG_CTEST2: usize = 0x15; +const CTEST2_DFP: u8 = 0x08; +/// CTEST4: FBL2 (bit 2) routes CTEST6 to the DMA FIFO lane in bits 1:0. +const REG_CTEST4: usize = 0x1B; +const CTEST4_FBL2: u8 = 0x04; +/// CTEST6: the DMA FIFO data window (with FBL2 set). +const REG_CTEST6: usize = 0x19; +/// CTEST7: bit 3 supplies the parity bit pushed with a DMA FIFO write. +const REG_CTEST7: usize = 0x18; +const CTEST7_DFP: u8 = 0x08; /// CTEST5 carries the ADCK/BBCK self-clearing test strobes. const REG_CTEST5: usize = 0x1A; const CTEST5_ADCK: u8 = 0x80; @@ -50,6 +69,8 @@ const REG_DNAD: usize = 0x28; const REG_DBC: usize = 0x25; /// DSP: writing its low byte starts the SCRIPTS processor. const REG_DSP: usize = 0x2C; +/// DMA FIFO depth per byte lane. +const DMA_FIFO_DEPTH: usize = 16; #[derive(serde::Serialize, serde::Deserialize)] pub struct A4091 { @@ -59,6 +80,10 @@ pub struct A4091 { /// The 53C710 register file, indexed by CPU-visible (big-endian) byte /// address. Plain storage until the SCRIPTS core lands. regs: Vec, + /// The DMA FIFO: four byte lanes, 16 entries deep, 8 data bits plus a + /// parity bit per entry. CTEST6 pushes/pops the lane selected by + /// CTEST4; CTEST1 and DSTAT.DFE report the fill state. + dma_fifo: [Vec; 4], /// One-shot warning latch for the unimplemented SCRIPTS processor. scripts_warned: bool, } @@ -70,9 +95,8 @@ fn reset_regs() -> Vec { let mut r = vec![0u8; 0x40]; r[0x03] = 0xC0; // SCNTL0 r[0x07] = 0x80; // SCID - r[0x0F] = 0x80; // DSTAT: DFE + // DSTAT.DFE and CTEST1 are computed from the DMA FIFO state on read. r[0x15] = 0x01; // CTEST2: DACK - r[0x16] = 0xF0; // CTEST1: FMT, all DMA FIFO byte lanes empty r[0x24] = 0x40; // DCMD r } @@ -92,10 +116,40 @@ impl A4091 { rom, dip: 0xFF, regs: reset_regs(), + dma_fifo: Default::default(), scripts_warned: false, }) } + /// The DMA FIFO lane CTEST6 currently addresses, when CTEST4.FBL2 + /// routes it to the FIFO at all. + fn fifo_lane(&self) -> Option { + (self.regs[REG_CTEST4] & CTEST4_FBL2 != 0).then_some((self.regs[REG_CTEST4] & 3) as usize) + } + + /// CTEST1 computed from the FIFO: FMT empty flags high, FFL full low. + fn ctest1(&self) -> u8 { + let mut v = 0u8; + for (lane, fifo) in self.dma_fifo.iter().enumerate() { + if fifo.is_empty() { + v |= 0x10 << lane; + } + if fifo.len() >= DMA_FIFO_DEPTH { + v |= 1 << lane; + } + } + v + } + + /// Software reset (power-on, /RST, or the ISTAT RST strobe): registers + /// to documented reset values, FIFOs drained. ROM and switches keep. + fn chip_reset(&mut self) { + self.regs = reset_regs(); + for fifo in &mut self.dma_fifo { + fifo.clear(); + } + } + pub fn load_rom(path: &Path) -> Result> { std::fs::read(path) .map_err(|e| anyhow::anyhow!("reading A4091 ROM {}: {e}", path.display())) @@ -157,6 +211,22 @@ impl crate::zorro_device::ZorroDevice for A4091 { // CTEST8: chip revision 2 in the high nibble; the CLF // (clear FIFOs) strobe bit always reads back 0. REG_CTEST8 => (self.regs[REG_CTEST8] | 0x20) & !0x04, + REG_CTEST1 => self.ctest1(), + REG_DSTAT => { + let dfe = self.dma_fifo.iter().all(Vec::is_empty); + (self.regs[REG_DSTAT] & !DSTAT_DFE) | if dfe { DSTAT_DFE } else { 0 } + } + // CTEST6 pops the addressed DMA FIFO lane; the entry's + // parity bit lands in CTEST2.DFP. + REG_CTEST6 => match self.fifo_lane() { + Some(lane) if !self.dma_fifo[lane].is_empty() => { + let entry = self.dma_fifo[lane].remove(0); + self.regs[REG_CTEST2] = (self.regs[REG_CTEST2] & !CTEST2_DFP) + | if entry & 0x100 != 0 { CTEST2_DFP } else { 0 }; + entry as u8 + } + _ => self.regs[REG_CTEST6], + }, r => self.regs[r], } } else { @@ -180,10 +250,25 @@ impl crate::zorro_device::ZorroDevice for A4091 { continue; } let r = (o as usize) & 0x3F; - self.regs[r] = (value >> (8 * (size - 1 - i))) as u8; + let b = (value >> (8 * (size - 1 - i))) as u8; + // CTEST6 with the FIFO addressed pushes instead of storing. + if r == REG_CTEST6 { + if let Some(lane) = self.fifo_lane() { + if self.dma_fifo[lane].len() < DMA_FIFO_DEPTH { + let parity = u16::from(self.regs[REG_CTEST7] & CTEST7_DFP != 0); + self.dma_fifo[lane].push((parity << 8) | u16::from(b)); + } + continue; + } + } + self.regs[r] = b; if r == REG_CTEST5 { self.ctest5_strobes(); } + if r == REG_ISTAT && b & ISTAT_RST != 0 { + self.chip_reset(); + self.regs[REG_ISTAT] = ISTAT_RST; + } if (REG_DSP..REG_DSP + 4).contains(&r) && !self.scripts_warned { self.scripts_warned = true; log::warn!( @@ -210,7 +295,7 @@ impl crate::zorro_device::ZorroDevice for A4091 { fn tick(&mut self, _cck: u32, _host: &mut crate::zorro_device::DeviceHost) {} fn reset(&mut self) { - self.regs = reset_regs(); + self.chip_reset(); self.scripts_warned = false; } @@ -310,6 +395,47 @@ mod tests { assert_eq!(b.read(IO_OFFSET + 0x1A, 1, &mut host) & 0xC0, 0); } + #[test] + fn dma_fifo_pushes_pops_with_parity_and_tracks_status() { + // The ncr7xx DMA FIFO test: fill all four lanes through CTEST6 + // (parity from CTEST7.3), watching CTEST1/DSTAT.DFE, then drain + // verifying data, parity in CTEST2.3, and status flags. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + + // ISTAT.RST software reset leaves the FIFO empty. + b.write(io(REG_ISTAT), 1, 0x40, &mut host); + b.write(io(REG_ISTAT), 1, 0, &mut host); + assert_eq!(b.read(io(REG_CTEST1), 1, &mut host), 0xF0); + assert_ne!(b.read(io(REG_DSTAT), 1, &mut host) & 0x80, 0); + + for lane in 0..4u32 { + b.write(io(REG_CTEST4), 1, 0x04 | lane, &mut host); + for byte in 0..16u32 { + let parity = (lane + byte) & 1; + b.write(io(REG_CTEST7), 1, parity << 3, &mut host); + b.write(io(REG_CTEST6), 1, (0xA0 + byte) ^ lane, &mut host); + } + } + // All lanes full, FIFO not empty. + assert_eq!(b.read(io(REG_CTEST1), 1, &mut host), 0x0F); + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x80, 0); + + for lane in 0..4u32 { + b.write(io(REG_CTEST4), 1, 0x04 | lane, &mut host); + for byte in 0..16u32 { + let v = b.read(io(REG_CTEST6), 1, &mut host); + assert_eq!(v, (0xA0 + byte) ^ lane, "lane {lane} byte {byte}"); + let parity = (b.read(io(REG_CTEST2), 1, &mut host) >> 3) & 1; + assert_eq!(parity, (lane + byte) & 1, "parity lane {lane} byte {byte}"); + } + } + assert_eq!(b.read(io(REG_CTEST1), 1, &mut host), 0xF0); + assert_ne!(b.read(io(REG_DSTAT), 1, &mut host) & 0x80, 0); + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); From b88d63557f4d007034e34056d64a273e55b90a21 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:52:17 +0900 Subject: [PATCH 06/10] a4091: SCSI FIFO model with generated parity 8-deep FIFO behind SODL/CTEST4.SFWR pushes and CTEST3 pops; odd parity generated, even under SCNTL1.AESP; count in SSTAT2, parity out via CTEST2.SFP. Passes the ncr7xx SCSI FIFO test. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/a4091.rs b/src/a4091.rs index b8921df..a755003 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -71,6 +71,21 @@ const REG_DBC: usize = 0x25; const REG_DSP: usize = 0x2C; /// DMA FIFO depth per byte lane. const DMA_FIFO_DEPTH: usize = 16; +/// SCNTL1: bit 2 asserts even (instead of odd) generated parity. +const REG_SCNTL1: usize = 0x02; +const SCNTL1_AESP: u8 = 0x04; +/// SODL: writes push the SCSI FIFO when CTEST4.SFWR routes them there. +const REG_SODL: usize = 0x05; +/// SSTAT2: SCSI FIFO fill count in the high nibble. +const REG_SSTAT2: usize = 0x0C; +/// CTEST3: reads pop the SCSI FIFO. +const REG_CTEST3: usize = 0x14; +/// CTEST4 bit 3: route SODL writes to the SCSI FIFO. +const CTEST4_SFWR: u8 = 0x08; +/// CTEST2 bit 4: parity bit of the last SCSI FIFO pop. +const CTEST2_SFP: u8 = 0x10; +/// SCSI FIFO depth. +const SCSI_FIFO_DEPTH: usize = 8; #[derive(serde::Serialize, serde::Deserialize)] pub struct A4091 { @@ -84,6 +99,9 @@ pub struct A4091 { /// parity bit per entry. CTEST6 pushes/pops the lane selected by /// CTEST4; CTEST1 and DSTAT.DFE report the fill state. dma_fifo: [Vec; 4], + /// The SCSI FIFO: 8 entries, 8 data bits plus parity. SODL pushes it + /// (with CTEST4.SFWR), CTEST3 pops it, SSTAT2 counts it. + scsi_fifo: Vec, /// One-shot warning latch for the unimplemented SCRIPTS processor. scripts_warned: bool, } @@ -117,10 +135,25 @@ impl A4091 { dip: 0xFF, regs: reset_regs(), dma_fifo: Default::default(), + scsi_fifo: Vec::new(), scripts_warned: false, }) } + /// The parity bit the chip generates for a pushed SCSI FIFO byte: odd + /// parity normally, even when SCNTL1.AESP is set. (SCNTL0.EPG gates + /// generation on the real chip; without it parity would come from the + /// bus, which has no meaning here, so the generated bit is used + /// regardless.) + fn scsi_parity(&self, b: u8) -> u16 { + let even = u16::from(b.count_ones() as u8 & 1); + if self.regs[REG_SCNTL1] & SCNTL1_AESP != 0 { + even + } else { + even ^ 1 + } + } + /// The DMA FIFO lane CTEST6 currently addresses, when CTEST4.FBL2 /// routes it to the FIFO at all. fn fifo_lane(&self) -> Option { @@ -148,6 +181,7 @@ impl A4091 { for fifo in &mut self.dma_fifo { fifo.clear(); } + self.scsi_fifo.clear(); } pub fn load_rom(path: &Path) -> Result> { @@ -216,6 +250,16 @@ impl crate::zorro_device::ZorroDevice for A4091 { let dfe = self.dma_fifo.iter().all(Vec::is_empty); (self.regs[REG_DSTAT] & !DSTAT_DFE) | if dfe { DSTAT_DFE } else { 0 } } + REG_SSTAT2 => { + ((self.scsi_fifo.len() as u8) << 4) | (self.regs[REG_SSTAT2] & 0x0F) + } + // CTEST3 pops the SCSI FIFO; parity lands in CTEST2.SFP. + REG_CTEST3 if !self.scsi_fifo.is_empty() => { + let entry = self.scsi_fifo.remove(0); + self.regs[REG_CTEST2] = (self.regs[REG_CTEST2] & !CTEST2_SFP) + | if entry & 0x100 != 0 { CTEST2_SFP } else { 0 }; + entry as u8 + } // CTEST6 pops the addressed DMA FIFO lane; the entry's // parity bit lands in CTEST2.DFP. REG_CTEST6 => match self.fifo_lane() { @@ -251,6 +295,14 @@ impl crate::zorro_device::ZorroDevice for A4091 { } let r = (o as usize) & 0x3F; let b = (value >> (8 * (size - 1 - i))) as u8; + // SODL with SFWR set pushes the SCSI FIFO as well as storing. + if r == REG_SODL + && self.regs[REG_CTEST4] & CTEST4_SFWR != 0 + && self.scsi_fifo.len() < SCSI_FIFO_DEPTH + { + let parity = self.scsi_parity(b); + self.scsi_fifo.push((parity << 8) | u16::from(b)); + } // CTEST6 with the FIFO addressed pushes instead of storing. if r == REG_CTEST6 { if let Some(lane) = self.fifo_lane() { @@ -436,6 +488,39 @@ mod tests { assert_ne!(b.read(io(REG_DSTAT), 1, &mut host) & 0x80, 0); } + #[test] + fn scsi_fifo_pushes_via_sodl_pops_via_ctest3_with_generated_parity() { + // The ncr7xx SCSI FIFO test: SFWR routes SODL pushes to the FIFO, + // parity generated odd (or even under SCNTL1.AESP), count in + // SSTAT2's high nibble, pops through CTEST3 with parity in + // CTEST2.SFP. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + + assert_eq!(b.read(io(REG_SSTAT2), 1, &mut host), 0x00); + b.write(io(0x1B), 1, 0x08, &mut host); // CTEST4.SFWR + let data = [0x00u8, 0x01, 0xFF, 0x5A, 0x81, 0x7E, 0x33, 0xC4]; + for (i, &d) in data.iter().enumerate() { + // Alternate even/odd generation via SCNTL1.AESP. + b.write(io(REG_SCNTL1), 1, ((i as u32) & 1) << 2, &mut host); + assert_eq!(b.read(io(REG_SSTAT2), 1, &mut host) >> 4, i as u32); + b.write(io(REG_SODL), 1, u32::from(d), &mut host); + } + assert_eq!(b.read(io(REG_SSTAT2), 1, &mut host), 0x80); + + for (i, &d) in data.iter().enumerate() { + let v = b.read(io(REG_CTEST3), 1, &mut host); + assert_eq!(v, u32::from(d), "byte {i}"); + let sfp = (b.read(io(REG_CTEST2), 1, &mut host) >> 4) & 1; + let even = u32::from(d.count_ones() & 1); + let expect = if i & 1 == 1 { even } else { even ^ 1 }; + assert_eq!(sfp, expect, "parity byte {i}"); + assert_eq!(b.read(io(REG_SSTAT2), 1, &mut host) >> 4, (7 - i) as u32); + } + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); From 9dcfe539a8197c1c5de991de0a03a4a9ddd1d4b9 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Sun, 5 Jul 2026 23:59:31 +0900 Subject: [PATCH 07/10] a4091: interrupt plumbing (ISTAT/DSTAT/SSTAT0/DIEN/SIEN) ISTAT.ABRT latches DSTAT.ABRT; DIP/SIP computed from pending causes; DSTAT/SSTAT0 clear on read; INT2 asserted for DIEN/SIEN-enabled causes, QEMU lsi53c710 semantics. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/src/a4091.rs b/src/a4091.rs index a755003..0c8ef1c 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -40,9 +40,21 @@ const DIP_OFFSET: u32 = 0x008C_0003; /// 53C710 register-file byte offsets as the 68k sees them (the chip is /// wired big-endian on the A4091, so these are the driver's REG_ addresses). const REG_CTEST8: usize = 0x21; -/// ISTAT: bit 6 is the software-reset strobe. +/// ISTAT: bit 7 abort strobe, bit 6 software reset; bits 1/0 are the +/// computed SIP/DIP interrupt-pending flags. const REG_ISTAT: usize = 0x22; +const ISTAT_ABRT: u8 = 0x80; const ISTAT_RST: u8 = 0x40; +const ISTAT_SIP: u8 = 0x02; +const ISTAT_DIP: u8 = 0x01; +/// DIEN: enable mask for DSTAT interrupt causes. +const REG_DIEN: usize = 0x3A; +/// DSTAT bit 4: aborted. +const DSTAT_ABRT: u8 = 0x10; +/// SIEN: enable mask for SSTAT0 interrupt causes. +const REG_SIEN: usize = 0x00; +/// SSTAT0: SCSI interrupt causes (read-clear). +const REG_SSTAT0: usize = 0x0E; /// DSTAT: bit 7 (DFE) tracks "all DMA FIFO lanes empty". const REG_DSTAT: usize = 0x0F; const DSTAT_DFE: u8 = 0x80; @@ -174,6 +186,14 @@ impl A4091 { v } + /// Whether the chip's interrupt output is asserted: any enabled DMA + /// cause (DSTAT masked by DIEN) or SCSI cause (SSTAT0 masked by SIEN). + /// DSTAT.DFE is status, not a cause, and never contributes. + fn irq_line(&self) -> bool { + (self.regs[REG_DSTAT] & self.regs[REG_DIEN] & !DSTAT_DFE) != 0 + || (self.regs[REG_SSTAT0] & self.regs[REG_SIEN]) != 0 + } + /// Software reset (power-on, /RST, or the ISTAT RST strobe): registers /// to documented reset values, FIFOs drained. ROM and switches keep. fn chip_reset(&mut self) { @@ -246,9 +266,27 @@ impl crate::zorro_device::ZorroDevice for A4091 { // (clear FIFOs) strobe bit always reads back 0. REG_CTEST8 => (self.regs[REG_CTEST8] | 0x20) & !0x04, REG_CTEST1 => self.ctest1(), + // DSTAT: cause bits clear on read; DFE is live status. REG_DSTAT => { let dfe = self.dma_fifo.iter().all(Vec::is_empty); - (self.regs[REG_DSTAT] & !DSTAT_DFE) | if dfe { DSTAT_DFE } else { 0 } + let v = + (self.regs[REG_DSTAT] & !DSTAT_DFE) | if dfe { DSTAT_DFE } else { 0 }; + self.regs[REG_DSTAT] = 0; + v + } + // SSTAT0: cause bits clear on read. + REG_SSTAT0 => { + let v = self.regs[REG_SSTAT0]; + self.regs[REG_SSTAT0] = 0; + v + } + // ISTAT: stored strobe bits plus computed DIP/SIP. + REG_ISTAT => { + let dip = self.regs[REG_DSTAT] & !DSTAT_DFE != 0; + let sip = self.regs[REG_SSTAT0] != 0; + (self.regs[REG_ISTAT] & 0xF0) + | if sip { ISTAT_SIP } else { 0 } + | if dip { ISTAT_DIP } else { 0 } } REG_SSTAT2 => { ((self.scsi_fifo.len() as u8) << 4) | (self.regs[REG_SSTAT2] & 0x0F) @@ -313,14 +351,23 @@ impl crate::zorro_device::ZorroDevice for A4091 { continue; } } + if r == REG_ISTAT { + // DIP/SIP (low nibble) are computed status, not writable. + self.regs[REG_ISTAT] = b & 0xF0; + if b & ISTAT_RST != 0 { + self.chip_reset(); + self.regs[REG_ISTAT] = ISTAT_RST; + } + // Abort: latch the DSTAT cause; DIEN gates only the line. + if b & ISTAT_ABRT != 0 { + self.regs[REG_DSTAT] |= DSTAT_ABRT; + } + continue; + } self.regs[r] = b; if r == REG_CTEST5 { self.ctest5_strobes(); } - if r == REG_ISTAT && b & ISTAT_RST != 0 { - self.chip_reset(); - self.regs[REG_ISTAT] = ISTAT_RST; - } if (REG_DSP..REG_DSP + 4).contains(&r) && !self.scripts_warned { self.scripts_warned = true; log::warn!( @@ -346,6 +393,10 @@ impl crate::zorro_device::ZorroDevice for A4091 { fn tick(&mut self, _cck: u32, _host: &mut crate::zorro_device::DeviceHost) {} + fn int2_line(&self) -> bool { + self.irq_line() + } + fn reset(&mut self) { self.chip_reset(); self.scripts_warned = false; @@ -521,6 +572,39 @@ mod tests { } } + #[test] + fn istat_abort_latches_dstat_and_raises_int2_until_dstat_read() { + // The ncr7xx bus access test, stage 1: DIEN.ABRT + ISTAT.ABRT must + // pull INT2 with ISTAT showing DIP|ABRT; the ISR's DSTAT read + // returns ABRT|DFE and drops the line. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + + b.write(io(REG_DIEN), 1, 0x10, &mut host); + assert!(!ZorroDevice::int2_line(&b)); + b.write(io(REG_ISTAT), 1, 0x80, &mut host); + assert!(ZorroDevice::int2_line(&b)); + assert_eq!(b.read(io(REG_ISTAT), 1, &mut host), 0x81); // ABRT|DIP + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host), 0x90); // DFE|ABRT + assert!(!ZorroDevice::int2_line(&b)); + assert_eq!(b.read(io(REG_ISTAT), 1, &mut host), 0x80); // DIP gone + b.write(io(REG_ISTAT), 1, 0, &mut host); + assert_eq!(b.read(io(REG_ISTAT), 1, &mut host), 0x00); + } + + #[test] + fn disabled_abort_still_sets_dip_but_not_the_line() { + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + b.write(io(REG_ISTAT), 1, 0x80, &mut host); + assert!(!ZorroDevice::int2_line(&b)); + assert_eq!(b.read(io(REG_ISTAT), 1, &mut host) & 1, 1); + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); From 73b41afffdd16f2c5b5418931a6a133dbc2036dc Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Mon, 6 Jul 2026 00:06:45 +0900 Subject: [PATCH 08/10] a4091: minimal SCRIPTS engine (register write, RETURN, INT) DSP writes start execution: instructions fetched by DMA from host memory (incl. Z3 space), MOVE-immediate-to-register with native register numbering, RETURN via TEMP, INT loads DSPS and raises DSTAT.SIR; undecodable fetches stop with DSTAT.IID. Enough for the ncr7xx bus access test's address-pin probes. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 112 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 13 deletions(-) diff --git a/src/a4091.rs b/src/a4091.rs index 0c8ef1c..f786b95 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -81,6 +81,13 @@ const REG_DNAD: usize = 0x28; const REG_DBC: usize = 0x25; /// DSP: writing its low byte starts the SCRIPTS processor. const REG_DSP: usize = 0x2C; +/// DSPS: the operand of the last INT instruction. +const REG_DSPS: usize = 0x30; +/// TEMP: the SCRIPTS return-address register. +const REG_TEMP: usize = 0x1C; +/// DSTAT causes the SCRIPTS engine raises. +const DSTAT_SIR: u8 = 0x04; +const DSTAT_IID: u8 = 0x01; /// DMA FIFO depth per byte lane. const DMA_FIFO_DEPTH: usize = 16; /// SCNTL1: bit 2 asserts even (instead of odd) generated parity. @@ -114,8 +121,6 @@ pub struct A4091 { /// The SCSI FIFO: 8 entries, 8 data bits plus parity. SODL pushes it /// (with CTEST4.SFWR), CTEST3 pops it, SSTAT2 counts it. scsi_fifo: Vec, - /// One-shot warning latch for the unimplemented SCRIPTS processor. - scripts_warned: bool, } /// 53C710 register-file reset values at their big-endian byte addresses: @@ -148,7 +153,6 @@ impl A4091 { regs: reset_regs(), dma_fifo: Default::default(), scsi_fifo: Vec::new(), - scripts_warned: false, }) } @@ -237,6 +241,60 @@ impl A4091 { self.regs[REG_CTEST5] = v & !(CTEST5_ADCK | CTEST5_BBCK); } + /// Map a chip-native (little-endian) register number to its big-endian + /// byte address in `regs`. SCRIPTS register operands use the native + /// numbering; the 68k byte lanes are swapped within each longword. + fn le_to_be(le: usize) -> usize { + (le & !3) | (3 - (le & 3)) + } + + /// Run the SCRIPTS processor from DSP until it interrupts. Enough of + /// the instruction set for the boot ROM's self-tests: register-write + /// (MOVE immediate to register), RETURN, and INT; anything else stops + /// with an illegal-instruction interrupt, as the real chip does for + /// garbage fetches. Block moves and the full transfer-control set come + /// with the SCSI phase engine. + fn run_scripts(&mut self, host: &mut crate::zorro_device::DeviceHost) { + for _ in 0..64 { + let dsp = self.reg32(REG_DSP); + let mut insn = [0u8; 8]; + host.dma_read(dsp, &mut insn); + self.set_reg32(REG_DSP, dsp.wrapping_add(8)); + let dcmd = insn[0]; + match dcmd >> 6 { + // Register read-modify-write; only the "move immediate to + // register" form (0x78) is implemented. + 0b01 if dcmd == 0x78 => { + let reg = Self::le_to_be((insn[1] & 0x3F) as usize); + self.regs[reg] = insn[2]; + } + // Transfer control: RETURN and INT. + 0b10 => match (dcmd >> 3) & 7 { + 2 => { + let temp = self.reg32(REG_TEMP); + self.set_reg32(REG_DSP, temp); + } + 3 => { + let dsps = u32::from_be_bytes(insn[4..8].try_into().unwrap()); + self.set_reg32(REG_DSPS, dsps); + self.regs[REG_DSTAT] |= DSTAT_SIR; + return; + } + _ => { + self.regs[REG_DSTAT] |= DSTAT_IID; + return; + } + }, + _ => { + self.regs[REG_DSTAT] |= DSTAT_IID; + return; + } + } + } + // Runaway script (e.g. a RETURN loop): stop as illegal. + self.regs[REG_DSTAT] |= DSTAT_IID; + } + /// One byte of the nibble-wide ROM as seen in the window at `off`. fn rom_byte(&self, off: u32) -> u8 { if off & 1 != 0 { @@ -324,7 +382,7 @@ impl crate::zorro_device::ZorroDevice for A4091 { off: u32, size: usize, value: u32, - _host: &mut crate::zorro_device::DeviceHost, + host: &mut crate::zorro_device::DeviceHost, ) { for i in 0..size { let o = off.wrapping_add(i as u32); @@ -368,14 +426,9 @@ impl crate::zorro_device::ZorroDevice for A4091 { if r == REG_CTEST5 { self.ctest5_strobes(); } - if (REG_DSP..REG_DSP + 4).contains(&r) && !self.scripts_warned { - self.scripts_warned = true; - log::warn!( - "a4091: DSP write ({:#04X} <- {:#04X}): SCRIPTS processor \ - not implemented yet", - r, - self.regs[r] - ); + // Writing DSP's low byte starts the SCRIPTS processor. + if r == REG_DSP + 3 { + self.run_scripts(host); } } } @@ -399,7 +452,6 @@ impl crate::zorro_device::ZorroDevice for A4091 { fn reset(&mut self) { self.chip_reset(); - self.scripts_warned = false; } fn kind(&self) -> &'static str { @@ -605,6 +657,40 @@ mod tests { assert_eq!(b.read(io(REG_ISTAT), 1, &mut host) & 1, 1); } + #[test] + fn scripts_write_register_then_interrupt() { + // The ncr7xx bus access test's probe script: MOVE $A5 to DWT + // (register $3A chip-native = $39 as the 68k sees it), then + // INT $00000012. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + mem.chip_ram[0x1000..0x1010].copy_from_slice(&[ + 0x78, 0x3A, 0xA5, 0x00, 0x00, 0x00, 0x00, 0x00, // MOVE $A5 to DWT + 0x98, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, // INT $12 + ]); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + b.write(io(REG_DIEN), 1, 0x04, &mut host); // enable SIR + b.write(io(REG_DSP), 4, 0x1000, &mut host); + assert_eq!(b.read(io(0x39), 1, &mut host), 0xA5); // DWT written + assert!(ZorroDevice::int2_line(&b)); + assert_eq!(b.read(io(REG_DSPS), 4, &mut host), 0x12); + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x04, 0x04); // SIR + assert!(!ZorroDevice::int2_line(&b)); + } + + #[test] + fn scripts_garbage_fetch_raises_illegal_instruction() { + // Fetching from unmapped space yields $FF bytes; the engine must + // stop with DSTAT.IID like the real chip on a garbage opcode. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + let mut host = DeviceHost::new(&mut mem); + let io = |r: usize| IO_OFFSET + r as u32; + b.write(io(REG_DSP), 4, 0x0F00_0000, &mut host); + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x01, 0x01); // IID + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); From 81c16b3f977961eab8bb150b1296a44d5bcbd77f Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Mon, 6 Jul 2026 00:16:12 +0900 Subject: [PATCH 09/10] a4091: SCRIPTS Memory Move, incl. the board's own register window DeviceHost learns which slot it serves so a bus master can recognize DMA addresses inside its own configured window; the A4091 self-test Memory Moves the chip's own SCRATCH to TEMP through board space. Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 95 +++++++++++++++++++++++++++++++++++++++++++-- src/bus.rs | 4 +- src/cpu.rs | 4 +- src/zorro_device.rs | 26 +++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/a4091.rs b/src/a4091.rs index f786b95..1206b4a 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -248,12 +248,39 @@ impl A4091 { (le & !3) | (3 - (le & 3)) } + /// One byte as the SCRIPTS DMA engine sees the bus: RAM through the + /// host decode, the board's own window (register file / ROM / DIP) + /// directly -- the self-test DMAs the chip's own SCRATCH and TEMP. + /// Register bytes are accessed as plain storage, without read side + /// effects; the self-tests only move SCRATCH/TEMP this way. + fn dma_byte(&self, host: &crate::zorro_device::DeviceHost, addr: u32) -> u8 { + match host.own_window_offset(addr) { + Some(off) if (IO_OFFSET..IO_END).contains(&off) => self.regs[(off as usize) & 0x3F], + Some(off) if off < IO_OFFSET => self.rom_byte(off), + Some(off) if off == DIP_OFFSET => self.dip, + Some(_) => 0xFF, + None => host.dma_read_byte(addr).unwrap_or(0xFF), + } + } + + fn dma_write_byte(&mut self, host: &mut crate::zorro_device::DeviceHost, addr: u32, b: u8) { + match host.own_window_offset(addr) { + Some(off) if (IO_OFFSET..IO_END).contains(&off) => { + self.regs[(off as usize) & 0x3F] = b; + } + Some(_) => {} // ROM and switches are not writable + None => { + host.dma_write(addr, &[b]); + } + } + } + /// Run the SCRIPTS processor from DSP until it interrupts. Enough of /// the instruction set for the boot ROM's self-tests: register-write - /// (MOVE immediate to register), RETURN, and INT; anything else stops - /// with an illegal-instruction interrupt, as the real chip does for - /// garbage fetches. Block moves and the full transfer-control set come - /// with the SCSI phase engine. + /// (MOVE immediate to register), Memory Move, RETURN, and INT; anything + /// else stops with an illegal-instruction interrupt, as the real chip + /// does for garbage fetches. Block moves and the full transfer-control + /// set come with the SCSI phase engine. fn run_scripts(&mut self, host: &mut crate::zorro_device::DeviceHost) { for _ in 0..64 { let dsp = self.reg32(REG_DSP); @@ -285,6 +312,24 @@ impl A4091 { return; } }, + // Memory Move: 24-bit byte count, then source and + // destination addresses in a third fetched longword. + 0b11 if dcmd & 0x38 == 0 => { + let mut dst = [0u8; 4]; + host.dma_read(dsp.wrapping_add(8), &mut dst); + self.set_reg32(REG_DSP, dsp.wrapping_add(12)); + let count = u32::from_be_bytes(insn[0..4].try_into().unwrap()) & 0x00FF_FFFF; + let src = u32::from_be_bytes(insn[4..8].try_into().unwrap()); + let dst = u32::from_be_bytes(dst); + for i in 0..count { + let b = self.dma_byte(host, src.wrapping_add(i)); + self.dma_write_byte(host, dst.wrapping_add(i), b); + } + // The byte counter runs down to zero. + self.regs[REG_DBC] = 0; + self.regs[REG_DBC + 1] = 0; + self.regs[REG_DBC + 2] = 0; + } _ => { self.regs[REG_DSTAT] |= DSTAT_IID; return; @@ -691,6 +736,48 @@ mod tests { assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x01, 0x01); // IID } + #[test] + fn scripts_memory_move_copies_own_scratch_to_temp() { + // ncr7xx DMA test 1: Memory Move of the chip's own SCRATCH to TEMP + // through the configured board window, then INT. + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + mem.zorro + .add_board_configured_at(crate::zorro::BoardSpec::a4091(0), 0x4200_0000) + .unwrap(); + mem.chip_ram[0x1000..0x1014].copy_from_slice(&[ + 0xC0, 0x00, 0x00, 0x04, // Memory Move, 4 bytes + 0x42, 0x80, 0x00, 0x34, // src: SCRATCH via the board window + 0x42, 0x80, 0x00, 0x1C, // dst: TEMP via the board window + 0x98, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // INT + ]); + let mut host = DeviceHost::for_slot(&mut mem, 0); + let io = |r: usize| IO_OFFSET + r as u32; + b.write(io(0x34), 4, 0xCAE5_92F7, &mut host); // SCRATCH + b.write(io(REG_TEMP), 4, !0xCAE5_92F7, &mut host); + b.write(io(REG_DSP), 4, 0x1000, &mut host); + assert_eq!(b.read(io(REG_TEMP), 4, &mut host), 0xCAE5_92F7); + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x04, 0x04); // SIR + } + + #[test] + fn scripts_memory_move_ram_to_ram() { + let mut b = board_with_rom(test_rom_64k()); + let mut mem = test_memory(); + mem.chip_ram[0x2000..0x2008].copy_from_slice(b"COPPRLNE"); + mem.chip_ram[0x1000..0x1014].copy_from_slice(&[ + 0xC0, 0x00, 0x00, 0x08, // Memory Move, 8 bytes + 0x00, 0x00, 0x20, 0x00, // src + 0x00, 0x00, 0x30, 0x00, // dst + 0x98, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // INT + ]); + let mut host = DeviceHost::for_slot(&mut mem, 0); + let io = |r: usize| IO_OFFSET + r as u32; + b.write(io(REG_DSP), 4, 0x1000, &mut host); + assert_eq!(b.read(io(REG_DSTAT), 1, &mut host) & 0x04, 0x04); + assert_eq!(&host.memory_mut().chip_ram[0x3000..0x3008], b"COPPRLNE"); + } + #[test] fn ctest8_reports_chip_revision_2() { let mut b = board_with_rom(test_rom_64k()); diff --git a/src/bus.rs b/src/bus.rs index 1ccc3bd..6b720d5 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -3682,8 +3682,8 @@ impl Bus { paula, .. } = self; - for dev in devices.iter_mut() { - let mut host = crate::zorro_device::DeviceHost::new(&mut *mem); + for (slot, dev) in devices.iter_mut().enumerate() { + let mut host = crate::zorro_device::DeviceHost::for_slot(&mut *mem, slot); crate::zorro_device::ZorroDevice::tick(dev, cck, &mut host); if crate::zorro_device::ZorroDevice::int2_line(dev) { paula.intreq |= INT_PORTS; diff --git a/src/cpu.rs b/src/cpu.rs index 749f832..2e2e5f8 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -2200,7 +2200,7 @@ impl CpuBus { let (value, activity) = { let mem = &mut self.bus.mem; let dev = &mut self.bus.devices[slot]; - let mut host = crate::zorro_device::DeviceHost::new(mem); + let mut host = crate::zorro_device::DeviceHost::for_slot(mem, slot); let v = crate::zorro_device::ZorroDevice::read(dev, off, size, &mut host); (v, crate::zorro_device::ZorroDevice::take_activity(dev)) }; @@ -2403,7 +2403,7 @@ impl CpuBus { let activity = { let mem = &mut self.bus.mem; let dev = &mut self.bus.devices[slot]; - let mut host = crate::zorro_device::DeviceHost::new(mem); + let mut host = crate::zorro_device::DeviceHost::for_slot(mem, slot); crate::zorro_device::ZorroDevice::write(dev, off, size, value, &mut host); crate::zorro_device::ZorroDevice::take_activity(dev) }; diff --git a/src/zorro_device.rs b/src/zorro_device.rs index b257a46..c6a938e 100644 --- a/src/zorro_device.rs +++ b/src/zorro_device.rs @@ -110,6 +110,10 @@ pub struct DeviceHost<'a> { mem: &'a mut Memory, /// Paula's CD-audio ring, available only on a host built for the CDTV tick. cd_audio: Option<&'a mut CdAudioRing>, + /// The device slot this host was built for, so a bus-mastering board + /// can recognize DMA addresses inside its own configured window (the + /// A4091 self-test DMAs its own registers) without re-entering itself. + self_slot: Option, } impl<'a> DeviceHost<'a> { @@ -117,6 +121,27 @@ impl<'a> DeviceHost<'a> { Self { mem, cd_audio: None, + self_slot: None, + } + } + + /// A host view that knows which device slot it serves. See `self_slot`. + pub fn for_slot(mem: &'a mut Memory, slot: usize) -> Self { + Self { + mem, + cd_audio: None, + self_slot: Some(slot), + } + } + + /// The window offset when `addr` falls inside the calling device's own + /// configured board window, `None` otherwise (or when the host was not + /// built with a slot). + pub fn own_window_offset(&self, addr: u32) -> Option { + let slot = self.self_slot?; + match self.mem.zorro.device_region_at(addr, 1) { + Some((crate::zorro::BoardBacking::Device(s), off)) if s == slot => Some(off), + _ => None, } } @@ -126,6 +151,7 @@ impl<'a> DeviceHost<'a> { Self { mem, cd_audio: Some(cd_audio), + self_slot: None, } } From ec17ec863767e2c0e529ed23c3055848e7d21145 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Mon, 6 Jul 2026 01:02:28 +0900 Subject: [PATCH 10/10] a4091: SCRIPTS fetch/move diagnostics (COPPERLINE_DIAG_A4091) Co-Authored-By: Claude Fable 5 --- src/a4091.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/a4091.rs b/src/a4091.rs index 1206b4a..ccc52c8 100644 --- a/src/a4091.rs +++ b/src/a4091.rs @@ -288,6 +288,9 @@ impl A4091 { host.dma_read(dsp, &mut insn); self.set_reg32(REG_DSP, dsp.wrapping_add(8)); let dcmd = insn[0]; + if crate::envcfg::flag("COPPERLINE_DIAG_A4091") { + log::info!("a4091 scripts: fetch {dsp:#010X}: {insn:02X?}"); + } match dcmd >> 6 { // Register read-modify-write; only the "move immediate to // register" form (0x78) is implemented. @@ -321,9 +324,17 @@ impl A4091 { let count = u32::from_be_bytes(insn[0..4].try_into().unwrap()) & 0x00FF_FFFF; let src = u32::from_be_bytes(insn[4..8].try_into().unwrap()); let dst = u32::from_be_bytes(dst); + let diag = crate::envcfg::flag("COPPERLINE_DIAG_A4091"); for i in 0..count { let b = self.dma_byte(host, src.wrapping_add(i)); self.dma_write_byte(host, dst.wrapping_add(i), b); + if diag && i < 8 { + log::info!( + "a4091 scripts: move [{:#010X}] {b:02X} -> [{:#010X}]", + src.wrapping_add(i), + dst.wrapping_add(i) + ); + } } // The byte counter runs down to zero. self.regs[REG_DBC] = 0;