From c27d5f1ef8e56f13addfeb0b8060c69962a18dba Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Tue, 14 Jul 2026 22:03:56 +0900 Subject: [PATCH 1/6] scsi: put drives on the A3000 motherboard SCSI bus [scsi] controller = "a3000" fits drives to the Super DMAC's WD33C93 instead of a Zorro board. It is the default on a machine that has one, and it takes no boot ROM: Kickstart's own scsi.device drives it and autoboots from an RDB disk. Co-Authored-By: Claude Opus 4.8 --- copperline.example.toml | 36 ++++++------ src/config.rs | 121 +++++++++++++++++++++++++++++++++++----- src/emulator.rs | 25 ++++++++- src/video/launcher.rs | 56 +++++++++++++++---- 4 files changed, 194 insertions(+), 44 deletions(-) diff --git a/copperline.example.toml b/copperline.example.toml index 2120b20d..2fa6740c 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -104,11 +104,11 @@ model = "68000" # A500 (ECS 8372A Agnus, OCS 8362 Denise, 512 KiB chip + 512 KiB trapdoor # slow RAM) -- the most common and most-targeted Amiga. # The big-box profiles (A3000, A4000) are new and incomplete, but both boot. -# The A3000 has its motherboard SCSI (Super DMAC + WD33C93 at $DD0000, driven by -# Kickstart's own scsi.device) with no drives attached yet; the A4000's IDE at -# $DD2020 is not emulated at all, so Kickstart spends several seconds probing for -# it on every boot. Give either machine a Zorro controller or a [[filesys]] mount -# to boot from. +# The A3000 boots off its motherboard SCSI (Super DMAC + WD33C93 at $DD0000, +# driven by Kickstart's own scsi.device): put drives on it with [scsi]. The +# A4000's IDE at $DD2020 is not emulated at all, so Kickstart spends several +# seconds probing for it on every boot; give that machine a Zorro controller or +# a [[filesys]] mount to boot from. # [machine] # profile = "A600" # A1000 | A500 | A500OCS | A500Plus | A600 | A1200 | A3000 | A4000 | CDTV | CD32 # rtc = true # add a $DC0000 battery RTC (default: only A500+/CDTV/A3000/A4000 have one) @@ -189,18 +189,22 @@ slow = "512K" # metadata = "boards/megaram.toml" -# SCSI controller: a Zorro board with up to seven drives, on any machine -# model. Preferred over [ide] for multiple disks: the board's own boot ROM -# carries scsi.device and autoboots on Kickstart 1.3+, so it does not depend -# on the Kickstart IDE driver (which only probes the master on stock 3.1). -# Drive paths accept the same images as [ide]: RDB HDFs, bare partition -# hardfiles, or host directories. +# SCSI bus with up to seven drives, on any machine model. Preferred over [ide] +# for multiple disks: a Zorro board's own boot ROM carries scsi.device and +# autoboots on Kickstart 1.3+, so it does not depend on the Kickstart IDE +# driver (which only probes the master on stock 3.1). Drive paths accept the +# same images as [ide]: RDB HDFs, bare partition hardfiles, or host +# directories. # -# controller picks the board: "a2091" (Zorro II, WD33C93; the default) or -# "a4091" (Zorro III, NCR 53C710). The A2091 needs an A590/A2091 boot ROM -# (6.6+, 16K/32K; rom_odd takes the odd half of a split even/odd EPROM dump); -# the A4091 needs a raw A4091 EPROM image (e.g. the open-source a4091.rom) and -# has a single ROM (no rom_odd). +# controller picks the host adapter: +# "a2091" - Zorro II, WD33C93. The default on machines without onboard SCSI. +# Needs an A590/A2091 boot ROM (6.6+, 16K/32K; rom_odd takes the +# odd half of a split even/odd EPROM dump). +# "a4091" - Zorro III, NCR 53C710. Needs a raw A4091 EPROM image (e.g. the +# open-source a4091.rom); single ROM, no rom_odd. +# "a3000" - the A3000's motherboard SCSI (Super DMAC + WD33C93 at $DD0000), +# and the default on that machine. No ROM: Kickstart's own +# scsi.device drives it and autoboots from an RDB drive. # [scsi] # controller = "a2091" # rom = "a2091-v6.6.rom" diff --git a/src/config.rs b/src/config.rs index 038c2cd2..00dc21d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,9 +93,9 @@ pub struct Config { /// ROM probes enough empty space to make this a firehose, so it is meant /// to be pointed at one window (e.g. the A4000 IDE at $DD2020). pub log_unmapped: Option>, - /// Super DMAC fitted (A3000 profile): the SCSI DMA controller at $DD0000. - /// No WD33C93 behind it yet, so the machine boots with an empty SCSI - /// socket -- but Kickstart hangs outright if nothing answers at all. + /// Super DMAC fitted (A3000 profile): the SCSI DMA controller at $DD0000 + /// and the WD33C93 behind it. Kickstart hangs outright if nothing answers + /// there. Drives go on its bus through `[scsi] controller = "a3000"`. pub sdmac: bool, /// Akiko gate array fitted (CD32 profile): ID + C2P port at $B80000. pub akiko: bool, @@ -127,10 +127,10 @@ pub struct Config { /// Gayle IDE drive images (raw flat HDF, RDB inside), opened /// read/write. Only valid on machines with a Gayle gate array. pub ide: IdeConfig, - /// SCSI controller (`[scsi]`): the `controller` selects an A2091 (Zorro II) - /// or A4091 (Zorro III), plus a boot ROM image and up to seven drive images - /// on SCSI IDs 0-6. The board autoconfigs on the Zorro chain and carries its - /// own scsi.device. + /// SCSI controller (`[scsi]`): the `controller` selects an A2091 (Zorro II), + /// an A4091 (Zorro III), or the A3000's motherboard SCSI, plus up to seven + /// drive images on SCSI IDs 0-6. The Zorro boards autoconfig on the chain + /// and carry their own boot ROM and scsi.device; the A3000's does not. pub scsi: ScsiConfig, /// A2065 Ethernet board (`[a2065]`): when set, an A2065 NIC autoconfigs on /// the Zorro chain using the named host network backend. Networking is @@ -311,8 +311,10 @@ pub struct IdeConfig { pub slave: Option, } -/// Which SCSI host adapter the `[scsi]` section fits. Both are Zorro -/// autoconfig boards carrying their own boot ROM and scsi.device. +/// Which SCSI host adapter the `[scsi]` section fits: one of the two Zorro +/// autoconfig boards, which carry their own boot ROM and scsi.device, or the +/// A3000's motherboard SCSI, which has neither (Kickstart's own scsi.device +/// drives it) and is only there on a machine with a Super DMAC. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ScsiController { /// Commodore A2091/A590: Zorro II, WD33C93. The default. @@ -320,6 +322,17 @@ pub enum ScsiController { A2091, /// Commodore A4091: Zorro III, NCR 53C710. A4091, + /// A3000 motherboard SCSI: Super DMAC + WD33C93 at $DD0000. The default on + /// a machine that has one. + A3000, +} + +impl ScsiController { + /// Whether the controller is a Zorro board (it autoconfigs and needs a boot + /// ROM) rather than motherboard silicon. + pub fn is_zorro_board(self) -> bool { + !matches!(self, ScsiController::A3000) + } } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -1958,14 +1971,18 @@ impl TryFrom for Config { } let scsi_controller = match raw.scsi.controller.as_deref() { + // A machine with a Super DMAC already has a SCSI bus, so drives go + // on it unless the config asks for a Zorro board instead. + None if defaults.sdmac => ScsiController::A3000, None => ScsiController::A2091, Some(raw_ctrl) => match raw_ctrl.trim().to_ascii_lowercase().as_str() { "a2091" => ScsiController::A2091, "a4091" => ScsiController::A4091, + "a3000" => ScsiController::A3000, _ => { errors.push(anyhow!( "[scsi] controller = {raw_ctrl:?} is not known \ - (expected \"a2091\" or \"a4091\")" + (expected \"a2091\", \"a4091\", or \"a3000\")" )); ScsiController::A2091 } @@ -1985,17 +2002,33 @@ impl TryFrom for Config { raw.scsi.unit6.map(drive_image).transpose()?, ], }; - if scsi.enabled() && scsi.rom.is_none() { + if scsi.enabled() && scsi.rom.is_none() && scsi.controller.is_zorro_board() { let hint = match scsi.controller { - ScsiController::A2091 => { - "an A590/A2091 6.x ROM image; its scsi.device drives the disks" - } ScsiController::A4091 => "a raw A4091 EPROM image, e.g. the open-source a4091.rom", + _ => "an A590/A2091 6.x ROM image; its scsi.device drives the disks", }; errors.push(anyhow!( "[scsi] drives need the boot ROM: set [scsi] rom = \"...\" ({hint})" )); } + // The motherboard SCSI is silicon, not a card: it has no boot ROM (the + // Kickstart carries its driver), and it only exists where the Super + // DMAC does. + if scsi.controller == ScsiController::A3000 { + if !defaults.sdmac { + errors.push(anyhow!( + "[scsi] controller = \"a3000\" is the motherboard SCSI: set \ + [machine] profile = \"A3000\", or fit a Zorro board with \ + controller = \"a2091\" (or \"a4091\")" + )); + } + if scsi.rom.is_some() { + errors.push(anyhow!( + "[scsi] rom does not apply to the A3000 motherboard SCSI: it has no \ + boot ROM, Kickstart's own scsi.device drives it" + )); + } + } if scsi.rom_odd.is_some() && scsi.controller != ScsiController::A2091 { errors.push(anyhow!( "[scsi] rom_odd is an A2091 split-EPROM option; the A4091 has a single rom" @@ -3823,6 +3856,66 @@ mod tests { Ok(()) } + /// The A3000's SCSI is motherboard silicon: its drives need no boot ROM, + /// they are the default on that machine, and they fit nowhere else. + #[test] + fn the_a3000_scsi_bus_takes_drives_without_a_boot_rom() -> Result<()> { + let cfg = parse_config( + r#" + [machine] + profile = "A3000" + [scsi] + unit0 = "workbench.hdf" + "#, + )?; + assert!(cfg.sdmac); + assert_eq!(cfg.scsi.controller, ScsiController::A3000); + assert_eq!( + cfg.scsi.units[0].as_ref().map(|d| d.path.as_path()), + Some(Path::new("workbench.hdf")) + ); + + // A Zorro board still fits an A3000, and there it does need its ROM. + let cfg = parse_config( + r#" + [machine] + profile = "A3000" + [scsi] + controller = "a2091" + rom = "a2091.rom" + unit0 = "workbench.hdf" + "#, + )?; + assert_eq!(cfg.scsi.controller, ScsiController::A2091); + + // No Super DMAC, no motherboard SCSI. + let err = parse_config( + r#" + [machine] + profile = "A1200" + [scsi] + controller = "a3000" + unit0 = "workbench.hdf" + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("motherboard SCSI"), "{err:#}"); + + // And there is no ROM to give it. + let err = parse_config( + r#" + [machine] + profile = "A3000" + [scsi] + rom = "a2091.rom" + unit0 = "workbench.hdf" + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("no boot ROM"), "{err:#}"); + Ok(()) + } + #[test] fn drive_entries_accept_a_volume_name_override() -> Result<()> { // IDE and SCSI drives take either a bare path or a table carrying an diff --git a/src/emulator.rs b/src/emulator.rs index 32a4ff63..70117368 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -1785,12 +1785,15 @@ pub fn build_machine( // the chain (mapping its window to a device slot) while the device object // is attached to the bus after it is built; the slot index ties them. let mut devices: Vec = Vec::new(); - if cfg.scsi.enabled() { + // The A3000's motherboard SCSI is not a Zorro board: its drives are fitted + // to the Super DMAC further down, once the bus exists. + if cfg.scsi.enabled() && cfg.scsi.controller.is_zorro_board() { use crate::config::ScsiController; let rom_path = cfg.scsi.rom.as_ref().expect("config validated [scsi] rom"); let slot = devices.len(); // The controller picks the board; the drive plumbing is identical. let device = match cfg.scsi.controller { + ScsiController::A3000 => unreachable!("not a Zorro board"), ScsiController::A2091 => { let rom = crate::a2091::A2091::load_rom(rom_path, cfg.scsi.rom_odd.as_deref())?; let mut board = crate::a2091::A2091::new(rom)?; @@ -1962,8 +1965,24 @@ pub fn build_machine( bus.log_unmapped = Some(range); } if cfg.sdmac { - bus.attach_sdmac(crate::sdmac::Sdmac::new()); - info!("sdmac: Super DMAC + WD33C93 at $DD0000 (no drives)"); + let mut sdmac = crate::sdmac::Sdmac::new(); + let mut drives = 0; + if cfg.scsi.controller == crate::config::ScsiController::A3000 { + for (unit, drive) in cfg.scsi.units.iter().enumerate() { + let Some(drive) = drive else { continue }; + sdmac.attach_drive( + unit, + crate::scsi::ScsiDisk::open(&drive.path, unit, drive.volume_name.as_deref())?, + ); + info!("scsi: unit {unit} {}", drive.path.display()); + drives += 1; + } + } + bus.attach_sdmac(sdmac); + match drives { + 0 => info!("sdmac: Super DMAC + WD33C93 at $DD0000 (no drives)"), + n => info!("sdmac: Super DMAC + WD33C93 at $DD0000, {n} drive(s)"), + } } if let Some(revision) = cfg.mem_controller.ramsey_revision() { // TODO(codewiz): pass the real bank size once motherboard fast RAM diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 09f86c15..13a5818c 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -554,10 +554,11 @@ const JOYSTICK_MODES: [JoystickInputMode; 2] = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; // `None` = no SCSI board fitted; the two boards are mutually exclusive here even // though the engine could run both, so a config round-trips through this picker. -const SCSI_CONTROLLERS: [Option; 3] = [ +const SCSI_CONTROLLERS: [Option; 4] = [ None, Some(ScsiController::A2091), Some(ScsiController::A4091), + Some(ScsiController::A3000), ]; #[cfg(feature = "midi")] const SERIAL_MODES: [SerialMode; 5] = [ @@ -911,12 +912,21 @@ impl MachineSetup { // Only emit `[scsi]` when a controller is fitted, so an unset board // leaves the section absent rather than writing dangling ROM/units. if let Some(controller) = self.scsi_controller { - // "a2091" is the default, so omit it; name only "a4091". - raw.scsi.controller = match controller { - ScsiController::A2091 => None, - ScsiController::A4091 => Some("a4091".to_string()), - }; - raw.scsi.rom = self.scsi_rom.as_deref().map(path_string); + // Name every controller: which one a bare [scsi] means depends on + // the machine (an A3000 defaults to its motherboard SCSI). + raw.scsi.controller = Some( + match controller { + ScsiController::A2091 => "a2091", + ScsiController::A4091 => "a4091", + ScsiController::A3000 => "a3000", + } + .to_string(), + ); + // The motherboard SCSI has no boot ROM of its own. + raw.scsi.rom = controller + .is_zorro_board() + .then(|| self.scsi_rom.as_deref().map(path_string)) + .flatten(); // rom_odd is an A2091 split-EPROM option; the A4091 has one ROM. raw.scsi.rom_odd = (controller == ScsiController::A2091) .then(|| self.scsi_rom_odd.as_deref().map(path_string)) @@ -1126,12 +1136,21 @@ impl MachineSetup { if model != Some(MachineModel::Cd32) { self.cd32_nvram = None; } + // The motherboard SCSI leaves with the motherboard; the drives stay and + // land on the default Zorro board instead. + if !self.has_sdmac() && self.scsi_controller == Some(ScsiController::A3000) { + self.scsi_controller = Some(ScsiController::A2091); + } } fn has_gayle(&self) -> bool { matches!(self.model, Some(MachineModel::A600 | MachineModel::A1200)) } + fn has_sdmac(&self) -> bool { + self.model == Some(MachineModel::A3000) + } + fn has_cd(&self) -> bool { matches!(self.model, Some(MachineModel::Cdtv | MachineModel::Cd32)) } @@ -1157,9 +1176,18 @@ impl MachineSetup { F::Z3Ram => reason(cpu_is_32bit(self.cpu), "needs 32-bit CPU"), F::IdeMaster | F::IdeSlave => reason(self.has_gayle(), "needs A600/A1200"), // The ROM and drives belong to the fitted controller; greyed with - // none. rom_odd is an A2091 split-EPROM option only. - F::ScsiRom - | F::ScsiUnit0 + // none. The A3000's motherboard SCSI has no ROM of its own, and + // rom_odd is an A2091 split-EPROM option only. + F::ScsiRom => reason( + self.scsi_controller + .is_some_and(ScsiController::is_zorro_board), + if self.scsi_controller.is_some() { + "Zorro boards only" + } else { + "no controller" + }, + ), + F::ScsiUnit0 | F::ScsiUnit1 | F::ScsiUnit2 | F::ScsiUnit3 @@ -1371,6 +1399,7 @@ impl MachineSetup { None => "None".to_string(), Some(ScsiController::A2091) => "A2091 (Z2)".to_string(), Some(ScsiController::A4091) => "A4091 (Z3)".to_string(), + Some(ScsiController::A3000) => "A3000 (onboard)".to_string(), }, #[cfg(feature = "midi")] F::SerialMode => match self.serial_mode { @@ -1476,7 +1505,12 @@ impl MachineSetup { cycle_slice(&JOYSTICK_MODES, self.joystick_input_mode, forward) } F::ScsiController => { - self.scsi_controller = cycle_slice(&SCSI_CONTROLLERS, self.scsi_controller, forward) + // The motherboard SCSI is only on offer where the silicon is. + let choices: Vec> = SCSI_CONTROLLERS + .into_iter() + .filter(|c| self.has_sdmac() || *c != Some(ScsiController::A3000)) + .collect(); + self.scsi_controller = cycle_slice(&choices, self.scsi_controller, forward) } #[cfg(feature = "midi")] F::SerialMode => { From 6206c40264e11c74e73b5453d2fccb29a3f10e12 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 00:59:39 +0900 Subject: [PATCH 2/6] launcher: do not write scsi rom_odd without rom rom_odd is the odd half of a split EPROM dump, so it is meaningless alone and the config rejects it; clearing the even half left it behind. Co-Authored-By: Claude Opus 4.8 --- src/video/launcher.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 13a5818c..3bb6d991 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -928,7 +928,9 @@ impl MachineSetup { .then(|| self.scsi_rom.as_deref().map(path_string)) .flatten(); // rom_odd is an A2091 split-EPROM option; the A4091 has one ROM. - raw.scsi.rom_odd = (controller == ScsiController::A2091) + // It is the odd half OF rom, so without rom there is nothing for it + // to complete and the config would not validate. + raw.scsi.rom_odd = (controller == ScsiController::A2091 && raw.scsi.rom.is_some()) .then(|| self.scsi_rom_odd.as_deref().map(path_string)) .flatten(); raw.scsi.unit0 = drive_raw( From d21fec0f5cdd3c64aa6312be36b24329adb6475e Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 02:08:20 +0900 Subject: [PATCH 3/6] scsi: end the WD33C93 register file at the data register Registers $1A-$1E are not registers and float $FF; $1F is the auxiliary status, aliased into the file read-only. Both were plain RAM, so cdhooper's sdmac tool failed its WDC test and reported the chip as not detected. Co-Authored-By: Claude Opus 4.8 --- src/scsi.rs | 15 +++++++++++++++ src/sdmac.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/scsi.rs b/src/scsi.rs index 6a8486fd..6330850c 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -38,6 +38,12 @@ pub const WD_DESTINATION_ID: u8 = 0x15; pub const WD_SCSI_STATUS: u8 = 0x17; pub const WD_COMMAND: u8 = 0x18; pub const WD_DATA: u8 = 0x19; +/// The register file ends at the data register: 0x1A-0x1E are not registers at +/// all and float, and 0x1F is the auxiliary status, which the chip also aliases +/// into the register file as read-only. cdhooper's sdmac tool identifies the +/// WD33C93 by exactly this: 0x1E must read $FF, and 0x1F must not be writable. +const WD_LAST_REG: u8 = WD_DATA; +pub const WD_AUX_STATUS: u8 = 0x1F; // ----- auxiliary status bits ------------------------------------------------ @@ -617,6 +623,12 @@ impl Wd33c93 { pub fn read_data_port(&mut self) -> u8 { match self.sasr { WD_DATA => self.pio_read_byte(), + WD_AUX_STATUS => self.read_aux_status(), + r if r > WD_LAST_REG => { + // Not a register: the chip does not drive the bus. + self.incr_sasr(); + 0xFF + } WD_SCSI_STATUS => { let v = self.regs[usize::from(WD_SCSI_STATUS)]; // Reading the status register acknowledges the interrupt; @@ -637,6 +649,9 @@ impl Wd33c93 { match self.sasr { WD_COMMAND => self.execute_command(v), WD_DATA => self.pio_write_byte(v), + // The auxiliary status is read-only, and past it there is nothing + // to write to. + r if r > WD_LAST_REG => self.incr_sasr(), r => { self.regs[usize::from(r)] = v; self.incr_sasr(); diff --git a/src/sdmac.rs b/src/sdmac.rs index 488534cc..0f88a29b 100644 --- a/src/sdmac.rs +++ b/src/sdmac.rs @@ -431,6 +431,32 @@ mod tests { assert_eq!(aux & (ASR_CIP | ASR_BSY | ASR_INT), 0, "aux {aux:#04X}"); } + /// The register file ends at the data register: $1F is the auxiliary status + /// aliased in read-only, and $1A-$1E are not registers and float. This is + /// how cdhooper's sdmac tool identifies the chip -- with both writable, it + /// reported "SCSI Controller: Not detected: INVALID" and failed its WDC + /// test. + #[test] + fn the_registers_past_the_data_register_are_read_only_or_float() { + use crate::scsi::WD_AUX_STATUS; + let mut s = sdmac(); + let aux = s.read_byte(SDMAC_BASE + SASR); + + for value in [0xFFu8, 0xA5, 0x5A] { + // The auxiliary status ignores writes and keeps reading the status. + s.write_byte(SDMAC_BASE + SASR, WD_AUX_STATUS); + s.write_byte(SDMAC_BASE + SCMD, value); + s.write_byte(SDMAC_BASE + SASR, WD_AUX_STATUS); + assert_eq!(s.read_byte(SDMAC_BASE + SCMD), aux, "wrote {value:#04X}"); + + // An undefined register never drives the bus. + s.write_byte(SDMAC_BASE + SASR, 0x1E); + s.write_byte(SDMAC_BASE + SCMD, value); + s.write_byte(SDMAC_BASE + SASR, 0x1E); + assert_eq!(s.read_byte(SDMAC_BASE + SCMD), 0xFF, "wrote {value:#04X}"); + } + } + /// The WD33C93's registers are reached by writing a register number to the /// select latch and reading or writing the data port. Both addresses have /// an alias four bytes below (the SDMAC decodes them loosely) -- SysInfo From 9b11fa92900419370aa1375ca2a32510efb5cad3 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 03:52:24 +0900 Subject: [PATCH 4/6] bus: light the HDD LED for the SDMAC and the A4091 The front panel only offered an LED to a Gayle machine or an A2091, so an A3000 (motherboard SCSI) had none -- and neither did any machine whose only controller was an A4091. Co-Authored-By: Claude Opus 4.8 --- src/bus.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/bus.rs b/src/bus.rs index 137dda79..ecbb69c5 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -2890,11 +2890,17 @@ impl Bus { self.cdtv.is_some() || self.akiko.is_some() } - /// Whether a SCSI controller board is fitted (lights the HDD LED). + /// Whether the machine has a hard-disk controller, which is what gives it + /// an HDD LED: a Zorro board, or the A3000's motherboard SCSI. fn has_scsi_device(&self) -> bool { - self.devices - .iter() - .any(|d| matches!(d, crate::zorro_device::BoardDevice::A2091(_))) + self.sdmac.is_some() + || self.devices.iter().any(|d| { + matches!( + d, + crate::zorro_device::BoardDevice::A2091(_) + | crate::zorro_device::BoardDevice::A4091(_) + ) + }) } /// Whether a disc is mounted (or, on CDTV, waiting in the tray). From c4617a58784214d577b04cd869354eeb93054305 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 00:57:22 +0900 Subject: [PATCH 5/6] ide: emulate the A4000 motherboard IDE at $DD2020 The A4000 has the same ATA cable as Gayle behind a different decode, so the drives, task file, and command engine move to a shared AtaBus; Gayle keeps its ID, interrupt, and PCMCIA registers, and the A4000 interface is a second front-end. [ide] now fits drives to either machine. Task file at $DD2020 with Gayle stride, control block at $DD303A, and an interrupt status register at $DD3020 that follows INTRQ -- Kickstart polls it after every INT2 and spins there if nothing answers. Co-Authored-By: Claude Opus 4.8 --- copperline.example.toml | 23 +- src/ata.rs | 746 ++++++++++++++++++++++++++++++++++++++++ src/bus.rs | 24 +- src/config.rs | 45 ++- src/cpu.rs | 20 ++ src/emulator.rs | 14 + src/gayle.rs | 720 ++------------------------------------ src/ide_a4000.rs | 191 ++++++++++ src/lib.rs | 2 + src/video/launcher.rs | 9 +- 10 files changed, 1083 insertions(+), 711 deletions(-) create mode 100644 src/ata.rs create mode 100644 src/ide_a4000.rs diff --git a/copperline.example.toml b/copperline.example.toml index 2fa6740c..699b7a65 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -103,12 +103,9 @@ model = "68000" # Without any [machine] section the default machine is that same Rev 6A # A500 (ECS 8372A Agnus, OCS 8362 Denise, 512 KiB chip + 512 KiB trapdoor # slow RAM) -- the most common and most-targeted Amiga. -# The big-box profiles (A3000, A4000) are new and incomplete, but both boot. -# The A3000 boots off its motherboard SCSI (Super DMAC + WD33C93 at $DD0000, -# driven by Kickstart's own scsi.device): put drives on it with [scsi]. The -# A4000's IDE at $DD2020 is not emulated at all, so Kickstart spends several -# seconds probing for it on every boot; give that machine a Zorro controller or -# a [[filesys]] mount to boot from. +# The big-box profiles (A3000, A4000) are new and incomplete, but both boot, +# each off its own built-in disk controller: put drives on the A3000's SCSI +# with [scsi], and on the A4000's IDE with [ide]. # [machine] # profile = "A600" # A1000 | A500 | A500OCS | A500Plus | A600 | A1200 | A3000 | A4000 | CDTV | CD32 # rtc = true # add a $DC0000 battery RTC (default: only A500+/CDTV/A3000/A4000 have one) @@ -202,9 +199,9 @@ slow = "512K" # odd half of a split even/odd EPROM dump). # "a4091" - Zorro III, NCR 53C710. Needs a raw A4091 EPROM image (e.g. the # open-source a4091.rom); single ROM, no rom_odd. -# "a3000" - the A3000's motherboard SCSI (Super DMAC + WD33C93 at $DD0000), -# and the default on that machine. No ROM: Kickstart's own -# scsi.device drives it and autoboots from an RDB drive. +# "a3000" - the A3000's built-in SCSI, and the default on that machine. +# Needs no ROM: the Kickstart carries its driver and autoboots +# from an RDB drive. # [scsi] # controller = "a2091" # rom = "a2091-v6.6.rom" @@ -235,10 +232,10 @@ slow = "512K" video = "PAL" -# Gayle IDE hard-disk images (A600/A1200 machine profiles only). Raw flat -# HDF images opened read/write; both full images with their own RDB and -# bare partition hardfiles (boot block "DOS\x..", size a multiple of -# 256 KiB) work - the latter get a synthesized RDB on the fly. A path may +# IDE hard-disk images, on the machines with a built-in IDE port: A600, A1200, +# and A4000. Raw flat HDF images opened read/write; both full images with their +# own RDB and bare partition hardfiles (boot block "DOS\x..", size a multiple +# of 256 KiB) work - the latter get a synthesized RDB on the fly. A path may # also name a host directory, mounted as an in-memory FFS volume (guest # writes are not synced back to the host). # [ide] diff --git a/src/ata.rs b/src/ata.rs new file mode 100644 index 00000000..ed3ba36b --- /dev/null +++ b/src/ata.rs @@ -0,0 +1,746 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! ATA (IDE) task file and command engine, shared by the machines that have an +//! IDE port: the A600/A1200's Gayle ([`crate::gayle`]) and the A4000's +//! motherboard interface ([`crate::ide_a4000`]). +//! +//! Both are the same 16-bit ATA-1 cable with the same eight task-file registers +//! and the same control block; only the address decode and the gate array's own +//! registers differ, so the front-ends keep those and hand every register access +//! to [`AtaBus`]. +//! +//! Transfers complete within the access that triggers them: a command reads or +//! writes its sectors immediately and BSY is never observable. + +use crate::harddrive::{HardDriveImage, RDB_HEADS, RDB_SPT}; +use std::path::Path; + +pub use crate::harddrive::SECTOR_SIZE; +/// Maximum sectors per READ/WRITE MULTIPLE block we advertise in IDENTIFY +/// word 47 and accept from SET MULTIPLE. +pub const MAX_MULTIPLE: u8 = 16; + +// ATA status bits. BSY is defined for completeness: transfers complete +// within the access in this model, so it is never observable. +#[allow(dead_code)] +pub(crate) const ST_BSY: u8 = 0x80; +pub(crate) const ST_DRDY: u8 = 0x40; +pub(crate) const ST_DSC: u8 = 0x10; +pub(crate) const ST_DRQ: u8 = 0x08; +pub(crate) const ST_ERR: u8 = 0x01; +// ATA error bits. +pub(crate) const ERR_ABRT: u8 = 0x04; +pub(crate) const ERR_IDNF: u8 = 0x10; +// Device control bits. +pub(crate) const CTL_NIEN: u8 = 0x02; +pub(crate) const CTL_SRST: u8 = 0x04; +// Device/head bits. +pub(crate) const DH_LBA: u8 = 0x40; +pub(crate) const DH_DRV: u8 = 0x10; + +/// A register in the task file, or the control block's shared +/// alternate-status/device-control address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdeReg { + Data, + ErrorFeature, + SectorCount, + SectorNumber, + CylLow, + CylHigh, + DriveHead, + StatusCommand, + AltStatusDevCtl, +} + +/// The task-file register at `offset` bytes from the base of the file. Both +/// Amiga interfaces space the eight registers four bytes apart and answer on +/// either byte of the word, so a register covers offsets 4n and 4n+2. +pub fn task_file_reg(offset: u32) -> Option { + Some(match offset & !0x02 { + 0x00 => IdeReg::Data, + 0x04 => IdeReg::ErrorFeature, + 0x08 => IdeReg::SectorCount, + 0x0C => IdeReg::SectorNumber, + 0x10 => IdeReg::CylLow, + 0x14 => IdeReg::CylHigh, + 0x18 => IdeReg::DriveHead, + 0x1C => IdeReg::StatusCommand, + _ => return None, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +enum Transfer { + None, + /// Device-to-host PIO (READ SECTORS / READ MULTIPLE / IDENTIFY). + PioIn { + /// Sectors still owed after the words currently in the buffer. + remaining: u32, + /// Sectors per DRQ block (1, or the SET MULTIPLE count). + block: u32, + }, + /// Host-to-device PIO (WRITE SECTORS / WRITE MULTIPLE). + PioOut { + remaining: u32, + block: u32, + }, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct IdeDrive { + /// The sector store (shared with the SCSI targets): HDF file, + /// directory-built FFS volume, synthesized-RDB overlay handling. + pub disk: HardDriveImage, + // Default geometry from the image size; INITIALIZE DEVICE PARAMETERS + // (0x91) overrides the current translation. + default_heads: u8, + default_spt: u8, + cylinders: u16, + heads: u8, + spt: u8, + multiple: u8, +} + +impl IdeDrive { + /// Open an IDE unit (0 = master, 1 = slave; this picks the DHn device + /// name a synthesized RDB advertises). The path may be a raw HDF image + /// file, or a host directory, which is built into an in-memory FFS + /// volume at open time; `volume_name` labels that volume (directory + /// mounts only). + pub fn open(path: &Path, unit: usize, volume_name: Option<&str>) -> anyhow::Result { + let disk = HardDriveImage::open( + path, + &format!("DH{unit}"), + "ide", + "COPPERLINE IDE DISK", + volume_name, + )?; + // The classic Amiga HDF geometry: 16 surfaces, 32 sectors per track + // (what HDToolBox/RDB tooling defaults to), so the CHS the host + // computes from an RDB's physical-drive block agrees with what the + // drive decodes. + let heads = RDB_HEADS as u8; + let spt = RDB_SPT as u8; + let cylinders = + (disk.total_sectors() / (u64::from(heads) * u64::from(spt))).clamp(1, 65535) as u16; + Ok(Self { + disk, + default_heads: heads, + default_spt: spt, + cylinders, + heads, + spt, + multiple: 0, + }) + } + + /// IDENTIFY DEVICE data. The Amiga IDE ports wire the drive's data bus + /// byte-swapped relative to the 68000 (IDE D7-D0 land on CPU D15-D8), so + /// the CPU reads every ATA word with its bytes exchanged. The ROM driver's + /// scsi.device depends on this: it parses the stored block assuming PC byte + /// order per word (its word helper at $FB788C and string helper at $FB7B22 + /// swap each pair back). Sector data is unaffected because the swap puts + /// file bytes back in natural memory order. We therefore store each ATA + /// word low-byte-first here, since the data port read returns + /// `buf[2i] << 8 | buf[2i+1]`. + fn identify_block(&self) -> Vec { + let mut buf = vec![0u8; SECTOR_SIZE]; + let mut word = |idx: usize, val: u16| { + buf[idx * 2] = (val & 0xFF) as u8; + buf[idx * 2 + 1] = (val >> 8) as u8; + }; + // Word 0 mirrors the Conner drives the A600HD shipped with + // (soft-sectored, fixed, MFM-encoded transfer-rate bits). + word(0, 0x045A); + word(1, self.cylinders); + word(3, u16::from(self.default_heads)); + // ATA-1 unformatted bytes per track/sector: vintage drivers + // (ROM scsi.device) read these for the block size. + word(4, u16::from(self.default_spt) * 512); + word(5, 512); + word(6, u16::from(self.default_spt)); + word(20, 3); // dual-ported buffer with read caching + word(21, 64); // buffer size in sectors + word(22, 4); // ECC bytes for READ/WRITE LONG + word(48, 1); // can perform doubleword I/O (32-bit host transfers) + word(51, 0x0200); // PIO data transfer timing mode 2 + word(52, 0x0200); // DMA data transfer timing mode (legacy field) + word(47, 0x8000 | u16::from(MAX_MULTIPLE)); + word(49, 0x0200); // LBA supported + word(53, 0x0001); // words 54-58 valid + word(54, self.cylinders); + word(55, u16::from(self.heads)); + word(56, u16::from(self.spt)); + let current = u32::from(self.cylinders) * u32::from(self.heads) * u32::from(self.spt); + word(57, (current & 0xFFFF) as u16); + word(58, (current >> 16) as u16); + let lba = self.disk.total_sectors().min(u64::from(u32::MAX)) as u32; + word(60, (lba & 0xFFFF) as u16); + word(61, (lba >> 16) as u16); + word( + 59, + if self.multiple > 0 { + 0x0100 | u16::from(self.multiple) + } else { + 0 + }, + ); + + // ATA strings carry the first character of each pair in bits 15-8, + // so with the low-byte-first storage above the pair lands swapped. + let mut string = |start: usize, len_words: usize, text: &str| { + let mut bytes = text.as_bytes().to_vec(); + bytes.resize(len_words * 2, b' '); + for (i, pair) in bytes.chunks(2).enumerate() { + buf[(start + i) * 2] = pair[1]; + buf[(start + i) * 2 + 1] = pair[0]; + } + }; + string(10, 10, "CPRLN-0000000000"); + string(23, 4, "1.0 "); + string(27, 20, "COPPERLINE IDE DISK"); + buf + } +} + +/// One ATA cable: the master/slave pair, the task file they share, and the +/// command engine. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct AtaBus { + drives: [Option; 2], + // Shared task file (one register file per bus, like the real cable). + feature: u8, + error: u8, + sector_count: u8, + sector_number: u8, + cyl_low: u8, + cyl_high: u8, + drive_head: u8, + status: u8, + devctl: u8, + /// INTRQ, the drive's interrupt line: raised on command completion and on + /// each DRQ block, dropped when the host reads the status register. + intrq: bool, + /// INTRQ went high since the front-end last looked. Gayle latches this in + /// its own interrupt-change register. + irq_edge: bool, + + buf: Vec, + buf_pos: usize, + transfer: Transfer, + /// Set whenever the drive does real work (command issued or data port + /// moved during a transfer); drained by the bus for the HDD LED. + activity: bool, +} + +impl Default for AtaBus { + fn default() -> Self { + Self::new() + } +} + +impl AtaBus { + pub fn new() -> Self { + Self { + drives: [None, None], + feature: 0, + error: 0x01, // diagnostics passed + sector_count: 0x01, + sector_number: 0x01, + cyl_low: 0, + cyl_high: 0, + drive_head: 0, + status: ST_DRDY | ST_DSC, + devctl: 0, + intrq: false, + irq_edge: false, + buf: Vec::new(), + buf_pos: 0, + transfer: Transfer::None, + activity: false, + } + } + + pub fn attach_drive(&mut self, slot: usize, drive: IdeDrive) { + self.drives[slot.min(1)] = Some(drive); + } + + /// System reset: clear the register file and any in-flight transfer but + /// keep the mounted drives. + pub fn reset(&mut self) { + self.feature = 0; + self.sector_count = 0x01; + self.sector_number = 0x01; + self.cyl_low = 0; + self.cyl_high = 0; + self.drive_head = 0; + self.devctl = 0; + self.soft_reset(); + } + + /// Drain the activity latch set by command issue and data-port traffic. + /// The bus polls this after each access to time the HDD LED. + pub fn take_activity(&mut self) -> bool { + std::mem::take(&mut self.activity) + } + + /// The INTRQ line as the host sees it: masked by the control block's + /// interrupt disable. + pub fn irq_level(&self) -> bool { + self.intrq && self.devctl & CTL_NIEN == 0 + } + + /// Drain the "INTRQ went high" edge, for a front-end that latches it. + pub fn take_irq_edge(&mut self) -> bool { + std::mem::take(&mut self.irq_edge) + } + + fn selected(&self) -> usize { + usize::from(self.drive_head & DH_DRV != 0) + } + + fn drive(&mut self) -> Option<&mut IdeDrive> { + self.drives[self.selected()].as_mut() + } + + fn pair_present(&self) -> bool { + self.drives[1 - self.selected().min(1)].is_some() + } + + fn raise_irq(&mut self) { + self.intrq = true; + if self.devctl & CTL_NIEN == 0 { + self.irq_edge = true; + } + } + + fn clear_irq(&mut self) { + self.intrq = false; + } + + // ----- register access --------------------------------------------------- + + pub fn read_reg(&mut self, reg: Option, size: usize) -> u32 { + // Selected device absent: the status register reads 0x01 (ERR set, + // not ready) when the other device is present and 0xFF when the + // cable is empty; every other task-file register reads zero, and a + // status read drops a pending interrupt (the INTRQ line is shared). + // This is how the ROM probe concludes a unit does not exist + // instead of classifying it as a pre-ATA drive (matches WinUAE). + if self.drives[self.selected()].is_none() { + return match reg { + Some(IdeReg::StatusCommand) | Some(IdeReg::AltStatusDevCtl) => { + self.clear_irq(); + if self.pair_present() { + 0x01 + } else { + 0xFF + } + } + _ => 0, + }; + } + match reg { + Some(IdeReg::Data) => { + let word = self.data_read_word(); + if size == 1 { + u32::from(word >> 8) + } else { + u32::from(word) + } + } + Some(IdeReg::ErrorFeature) => u32::from(self.error), + Some(IdeReg::SectorCount) => u32::from(self.sector_count), + Some(IdeReg::SectorNumber) => u32::from(self.sector_number), + Some(IdeReg::CylLow) => u32::from(self.cyl_low), + Some(IdeReg::CylHigh) => u32::from(self.cyl_high), + Some(IdeReg::DriveHead) => u32::from(self.drive_head), + Some(IdeReg::StatusCommand) => { + let v = self.status; + self.clear_irq(); + u32::from(v) + } + Some(IdeReg::AltStatusDevCtl) => u32::from(self.status), + None => 0, + } + } + + pub fn write_reg(&mut self, reg: Option, size: usize, value: u32) { + let byte = value as u8; + match reg { + Some(IdeReg::Data) => { + let word = if size == 1 { + (value as u16) << 8 + } else { + value as u16 + }; + self.data_write_word(word); + } + Some(IdeReg::ErrorFeature) => self.feature = byte, + Some(IdeReg::SectorCount) => self.sector_count = byte, + Some(IdeReg::SectorNumber) => self.sector_number = byte, + Some(IdeReg::CylLow) => self.cyl_low = byte, + Some(IdeReg::CylHigh) => self.cyl_high = byte, + Some(IdeReg::DriveHead) => self.drive_head = byte, + Some(IdeReg::StatusCommand) => self.command(byte), + Some(IdeReg::AltStatusDevCtl) => { + let was_reset = self.devctl & CTL_SRST != 0; + self.devctl = byte; + if byte & CTL_SRST != 0 && !was_reset { + self.soft_reset(); + } + } + None => {} + } + } + + fn soft_reset(&mut self) { + self.status = ST_DRDY | ST_DSC; + self.error = 0x01; + self.transfer = Transfer::None; + self.buf.clear(); + self.buf_pos = 0; + self.clear_irq(); + } + + // ----- data port ------------------------------------------------------- + + fn data_read_word(&mut self) -> u16 { + if !matches!(self.transfer, Transfer::PioIn { .. }) || self.buf_pos + 1 >= self.buf.len() { + return 0; + } + let word = (u16::from(self.buf[self.buf_pos]) << 8) | u16::from(self.buf[self.buf_pos + 1]); + self.buf_pos += 2; + self.activity = true; + if self.buf_pos >= self.buf.len() { + self.pio_in_block_consumed(); + } + word + } + + fn data_write_word(&mut self, word: u16) { + if !matches!(self.transfer, Transfer::PioOut { .. }) || self.buf_pos + 1 >= self.buf.len() { + return; + } + self.buf[self.buf_pos] = (word >> 8) as u8; + self.buf[self.buf_pos + 1] = (word & 0xFF) as u8; + self.buf_pos += 2; + self.activity = true; + if self.buf_pos >= self.buf.len() { + self.pio_out_block_filled(); + } + } + + fn pio_in_block_consumed(&mut self) { + let Transfer::PioIn { remaining, block } = self.transfer else { + // IDENTIFY-style single buffer: transfer complete. + self.status = ST_DRDY | ST_DSC; + self.transfer = Transfer::None; + return; + }; + if remaining == 0 { + self.status = ST_DRDY | ST_DSC; + self.transfer = Transfer::None; + return; + } + let chunk = remaining.min(block); + if self.fill_read_buffer(chunk).is_ok() { + self.transfer = Transfer::PioIn { + remaining: remaining - chunk, + block, + }; + self.status = ST_DRDY | ST_DSC | ST_DRQ; + self.raise_irq(); + } + } + + fn pio_out_block_filled(&mut self) { + let Transfer::PioOut { remaining, block } = self.transfer else { + return; + }; + // Commit the buffered sectors at the current task-file position. + if self.commit_write_buffer().is_err() { + return; + } + if remaining == 0 { + if let Some(drive) = self.drive() { + drive.disk.flush(); + } + self.status = ST_DRDY | ST_DSC; + self.transfer = Transfer::None; + self.raise_irq(); + return; + } + let chunk = remaining.min(block); + self.buf.clear(); + self.buf.resize(chunk as usize * SECTOR_SIZE, 0); + self.buf_pos = 0; + self.transfer = Transfer::PioOut { + remaining: remaining - chunk, + block, + }; + self.status = ST_DRDY | ST_DSC | ST_DRQ; + self.raise_irq(); + } + + // ----- addressing ------------------------------------------------------- + + /// Current LBA from the task file (LBA28 or CHS translation). + fn current_lba(&mut self) -> Option { + let lba_mode = self.drive_head & DH_LBA != 0; + let head = u64::from(self.drive_head & 0x0F); + let sector = u64::from(self.sector_number); + let cyl = (u64::from(self.cyl_high) << 8) | u64::from(self.cyl_low); + let drive = self.drive()?; + if lba_mode { + Some((head << 24) | (cyl << 8) | sector) + } else { + if sector == 0 { + return None; + } + let heads = u64::from(drive.heads); + let spt = u64::from(drive.spt); + Some((cyl * heads + head) * spt + (sector - 1)) + } + } + + /// Advance the task-file position by one sector, as real drives do, so + /// software can resume after a partial transfer. + fn advance_lba(&mut self) { + if self.drive_head & DH_LBA != 0 { + let lba = ((u32::from(self.drive_head & 0x0F) << 24) + | (u32::from(self.cyl_high) << 16) + | (u32::from(self.cyl_low) << 8) + | u32::from(self.sector_number)) + .wrapping_add(1); + self.sector_number = (lba & 0xFF) as u8; + self.cyl_low = ((lba >> 8) & 0xFF) as u8; + self.cyl_high = ((lba >> 16) & 0xFF) as u8; + self.drive_head = (self.drive_head & 0xF0) | ((lba >> 24) & 0x0F) as u8; + return; + } + let (heads, spt) = match self.drive() { + Some(d) => (d.heads, d.spt), + None => return, + }; + if self.sector_number < spt { + self.sector_number += 1; + return; + } + self.sector_number = 1; + let head = self.drive_head & 0x0F; + if head + 1 < heads { + self.drive_head = (self.drive_head & 0xF0) | (head + 1); + return; + } + self.drive_head &= 0xF0; + let cyl = ((u16::from(self.cyl_high) << 8) | u16::from(self.cyl_low)).wrapping_add(1); + self.cyl_low = (cyl & 0xFF) as u8; + self.cyl_high = (cyl >> 8) as u8; + } + + fn fill_read_buffer(&mut self, sectors: u32) -> Result<(), ()> { + self.buf.clear(); + self.buf_pos = 0; + for _ in 0..sectors { + let Some(lba) = self.current_lba() else { + self.command_error(ERR_IDNF); + return Err(()); + }; + let total = self.drive().map(|d| d.disk.total_sectors()).unwrap_or(0); + if lba >= total { + self.command_error(ERR_IDNF); + return Err(()); + } + let mut sector = [0u8; SECTOR_SIZE]; + let res = self + .drive() + .map(|d| d.disk.read_sector(lba, &mut sector)) + .unwrap_or_else(|| Err(std::io::ErrorKind::NotFound.into())); + if let Err(e) = res { + log::warn!("IDE read lba {lba}: {e}"); + self.command_error(ERR_ABRT); + return Err(()); + } + self.buf.extend_from_slice(§or); + self.advance_lba(); + } + Ok(()) + } + + fn commit_write_buffer(&mut self) -> Result<(), ()> { + let sectors = self.buf.len() / SECTOR_SIZE; + for i in 0..sectors { + let Some(lba) = self.current_lba() else { + self.command_error(ERR_IDNF); + return Err(()); + }; + let total = self.drive().map(|d| d.disk.total_sectors()).unwrap_or(0); + if lba >= total { + self.command_error(ERR_IDNF); + return Err(()); + } + let start = i * SECTOR_SIZE; + let sector: [u8; SECTOR_SIZE] = + self.buf[start..start + SECTOR_SIZE].try_into().unwrap(); + let res = self + .drive() + .map(|d| d.disk.write_sector(lba, §or)) + .unwrap_or_else(|| Err(std::io::ErrorKind::NotFound.into())); + if let Err(e) = res { + log::warn!("IDE write lba {lba}: {e}"); + self.command_error(ERR_ABRT); + return Err(()); + } + self.advance_lba(); + } + Ok(()) + } + + fn command_error(&mut self, error_bits: u8) { + self.error = error_bits; + self.status = ST_DRDY | ST_DSC | ST_ERR; + self.transfer = Transfer::None; + self.buf.clear(); + self.buf_pos = 0; + self.raise_irq(); + } + + // ----- command dispatch -------------------------------------------------- + + fn command(&mut self, cmd: u8) { + if crate::envcfg::flag("COPPERLINE_DIAG_GAYLE") { + let lba = self.drive_head & DH_LBA != 0; + log::info!( + "ide cmd {cmd:#04X} drv={} lba={} chs/lba=({:02X} {:02X} {:02X} {:02X}) n={}", + self.selected(), + lba, + self.drive_head & 0x0F, + self.cyl_high, + self.cyl_low, + self.sector_number, + self.sector_count + ); + } + self.clear_irq(); + if self.drives[self.selected()].is_none() { + // Every command addressed to an absent device fails with + // command-aborted and raises the completion interrupt, so the + // host's probe finishes promptly (matches WinUAE; the ROM's + // INITIALIZE DEVICE PARAMETERS arrives with the DEV bit set + // and must complete one way or the other). + self.command_error(ERR_ABRT); + return; + } + self.error = 0; + self.status = ST_DRDY | ST_DSC; + self.activity = true; + let count = if self.sector_count == 0 { + 256u32 + } else { + u32::from(self.sector_count) + }; + match cmd { + // IDENTIFY DEVICE + 0xEC => { + self.buf = self.drive().map(|d| d.identify_block()).unwrap_or_default(); + self.buf_pos = 0; + self.transfer = Transfer::PioIn { + remaining: 0, + block: 1, + }; + self.status = ST_DRDY | ST_DSC | ST_DRQ; + self.raise_irq(); + } + // READ SECTORS (with/without retry) and READ MULTIPLE. + 0x20 | 0x21 | 0xC4 => { + let block = if cmd == 0xC4 { + let m = self.drive().map(|d| d.multiple).unwrap_or(0); + if m == 0 { + self.command_error(ERR_ABRT); + return; + } + u32::from(m) + } else { + 1 + }; + let chunk = count.min(block); + self.transfer = Transfer::PioIn { + remaining: count - chunk, + block, + }; + if self.fill_read_buffer(chunk).is_ok() { + self.status = ST_DRDY | ST_DSC | ST_DRQ; + self.raise_irq(); + } + } + // WRITE SECTORS (with/without retry) and WRITE MULTIPLE. + 0x30 | 0x31 | 0xC5 => { + let block = if cmd == 0xC5 { + let m = self.drive().map(|d| d.multiple).unwrap_or(0); + if m == 0 { + self.command_error(ERR_ABRT); + return; + } + u32::from(m) + } else { + 1 + }; + let chunk = count.min(block); + self.buf.clear(); + self.buf.resize(chunk as usize * SECTOR_SIZE, 0); + self.buf_pos = 0; + self.transfer = Transfer::PioOut { + remaining: count - chunk, + block, + }; + // First DRQ block is ready without an interrupt (ATA PIO out). + self.status = ST_DRDY | ST_DSC | ST_DRQ; + } + // SET MULTIPLE MODE + 0xC6 => { + let requested = self.sector_count; + let ok = + requested <= MAX_MULTIPLE && (requested == 0 || requested.is_power_of_two()); + if let (true, Some(drive)) = (ok, self.drive()) { + drive.multiple = requested; + self.status = ST_DRDY | ST_DSC; + self.raise_irq(); + } else { + self.command_error(ERR_ABRT); + } + } + // INITIALIZE DEVICE PARAMETERS: set current CHS translation. + // A zero sector count is invalid and aborts, as on real drives. + 0x91 => { + let heads = (self.drive_head & 0x0F) + 1; + let spt = self.sector_count; + if spt == 0 { + self.command_error(ERR_ABRT); + return; + } + if let Some(drive) = self.drive() { + drive.heads = heads; + drive.spt = spt; + let total = drive.disk.total_sectors(); + drive.cylinders = + (total / (u64::from(heads) * u64::from(spt)).max(1)).clamp(1, 65535) as u16; + } + self.status = ST_DRDY | ST_DSC; + self.raise_irq(); + } + // RECALIBRATE + 0x10..=0x1F => { + self.status = ST_DRDY | ST_DSC; + self.raise_irq(); + } + // NOP: per ATA-2 always aborts. + 0x00 => self.command_error(ERR_ABRT), + _ => { + log::warn!("IDE: unimplemented command {cmd:#04X}"); + self.command_error(ERR_ABRT); + } + } + } +} diff --git a/src/bus.rs b/src/bus.rs index ecbb69c5..7e003786 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -673,6 +673,10 @@ pub struct Bus { /// Kickstart's scsi.device hangs during init if nothing answers here. #[serde(default)] pub sdmac: Option, + /// A4000 motherboard IDE (A4000 machine profile): the ATA task file at + /// $DD2020. + #[serde(default)] + pub ide_a4000: Option, /// `[debug] log_unmapped`: log CPU accesses in this range that no device /// decodes, to find the registers a guest expects and we do not provide. #[serde(default)] @@ -2105,6 +2109,7 @@ impl Bus { ramsey: None, gary: None, sdmac: None, + ide_a4000: None, log_unmapped: None, akiko: None, cdtv: None, @@ -2543,6 +2548,10 @@ impl Bus { self.sdmac = Some(sdmac); } + pub fn attach_ide_a4000(&mut self, ide: crate::ide_a4000::IdeA4000) { + self.ide_a4000 = Some(ide); + } + pub fn attach_akiko(&mut self, akiko: crate::akiko::Akiko) { self.akiko = Some(akiko); } @@ -2766,6 +2775,9 @@ impl Bus { if let Some(sdmac) = self.sdmac.as_mut() { sdmac.reset(); } + if let Some(ide) = self.ide_a4000.as_mut() { + ide.reset(); + } if let Some(akiko) = self.akiko.as_mut() { akiko.reset(); } @@ -2874,7 +2886,7 @@ impl Bus { power_led_on: pra & 0x02 == 0, fdd_led_on: self.floppy.activity_led_on(), fdd_track: self.floppy.selected_track(), - hdd_led: (self.gayle.is_some() || self.has_scsi_device()) + hdd_led: (self.gayle.is_some() || self.ide_a4000.is_some() || self.has_scsi_device()) .then_some(self.emulated_cck < self.hdd_led_until_cck), cd_led: self .cdtv @@ -4002,6 +4014,16 @@ impl Bus { self.paula.intreq |= INT_PORTS; } + // The A4000's IDE has no interrupt latch of its own: the drive's INTRQ + // is the INT2 line, and the driver drops it by reading the status. + if self + .ide_a4000 + .as_ref() + .is_some_and(crate::ide_a4000::IdeA4000::int2_line) + { + self.paula.intreq |= INT_PORTS; + } + // SDMAC (A3000 motherboard SCSI): advance the WD33C93 and its DMA, and // level-feed its INT2 line like Gayle's. Kickstart's own scsi.device // drives it, so nothing boots until this interrupt arrives. diff --git a/src/config.rs b/src/config.rs index 00dc21d3..e25886ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,10 @@ pub struct Config { /// ROM probes enough empty space to make this a firehose, so it is meant /// to be pointed at one window (e.g. the A4000 IDE at $DD2020). pub log_unmapped: Option>, + /// A4000 motherboard IDE fitted (A4000 profile): the ATA task file at + /// $DD2020, driven by Kickstart's own scsi.device. Takes its drives from + /// `[ide]`, like Gayle's. + pub ide_a4000: bool, /// Super DMAC fitted (A3000 profile): the SCSI DMA controller at $DD0000 /// and the WD33C93 behind it. Kickstart hangs outright if nothing answers /// there. Drives go on its bus through `[scsi] controller = "a3000"`. @@ -967,6 +971,7 @@ impl Default for Config { gate_array: GateArray::None, mem_controller: MemController::None, log_unmapped: None, + ide_a4000: false, sdmac: false, akiko: false, cdtv_cd: false, @@ -1960,13 +1965,14 @@ impl TryFrom for Config { master: raw.ide.master.map(drive_image).transpose()?, slave: raw.ide.slave.map(drive_image).transpose()?, }; - // Only Gayle carries an IDE interface. A Fat Gary machine has one too - // (the A4000's, at $DD2020), but it is not emulated yet and it is not - // Gayle's, so [ide] cannot drive it. - if (ide.master.is_some() || ide.slave.is_some()) && defaults.gate_array.gayle_id().is_none() - { + // Two machines have an IDE port: a Gayle one (A600/A1200) and the + // A4000's, which is the same ATA cable off the Fat Gary bus. `[ide]` + // fits either; nothing else has anywhere to put the drives. + let has_ide_port = defaults.gate_array.gayle_id().is_some() || defaults.ide_a4000; + if (ide.master.is_some() || ide.slave.is_some()) && !has_ide_port { errors.push(anyhow!( - "[ide] images need a Gayle machine: set [machine] profile = \"A600\" (or A1200)" + "[ide] images need a machine with an IDE port: set [machine] profile = \"A600\" \ + (or A1200, or A4000)" )); } @@ -2156,6 +2162,7 @@ impl TryFrom for Config { denise_revision, machine, gate_array: defaults.gate_array, + ide_a4000: defaults.ide_a4000, sdmac: defaults.sdmac, akiko: defaults.akiko, cdtv_cd: defaults.cdtv_cd, @@ -2422,7 +2429,8 @@ pub(crate) fn machine_profile_defaults(model: MachineModel) -> Config { d.sdmac = true; } // The A4000: the same board a generation later -- AGA, a 25 MHz 68040, - // and Ramsey-07. Its IDE lives at $DD2020, which is not emulated yet. + // and Ramsey-07. Its IDE at $DD2020 is Gayle's ATA cable without the + // gate array; `[ide]` fits drives to it. MachineModel::A4000 => { d.chipset = Chipset::Aga; d.chip_ram_bytes = 2 * 1024 * 1024; @@ -2432,6 +2440,7 @@ pub(crate) fn machine_profile_defaults(model: MachineModel) -> Config { d.mem_controller = MemController::Ramsey7; d.gate_array = GateArray::FatGary; d.rtc_present = true; + d.ide_a4000 = true; } // CDTV: A500-class board with the 1 MB ECS Agnus and 1 MB chip // RAM, plus the 256 KiB extended ROM at $F00000 (configure it via @@ -3509,7 +3518,8 @@ mod tests { } #[test] - fn ide_images_require_a_gayle_machine() { + fn ide_images_require_a_machine_with_an_ide_port() { + // The default A500 has nowhere to put them. let err = parse_config( r#" [ide] @@ -3517,7 +3527,7 @@ mod tests { "#, ) .unwrap_err(); - assert!(err.to_string().contains("Gayle machine"), "{err:#}"); + assert!(err.to_string().contains("IDE port"), "{err:#}"); let cfg = parse_config( r#" @@ -3533,6 +3543,23 @@ mod tests { Some(Path::new("disk.hdf")) ); assert_eq!(cfg.ide.slave, None); + + // The A4000's port is not Gayle's, but it takes the same drives. + let cfg = parse_config( + r#" + [machine] + profile = "A4000" + [ide] + master = "disk.hdf" + "#, + ) + .unwrap(); + assert!(cfg.ide_a4000); + assert!(cfg.gate_array.gayle_id().is_none()); + assert_eq!( + cfg.ide.master.as_ref().map(|d| d.path.as_path()), + Some(Path::new("disk.hdf")) + ); } #[test] diff --git a/src/cpu.rs b/src/cpu.rs index 7c0312d9..75a37d4f 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -2563,6 +2563,16 @@ impl CpuBus { return value; } } + if self.bus.ide_a4000.is_some() && crate::ide_a4000::IdeA4000::decodes(addr) { + self.bus.cpu_slow_external_access(Self::access_words(size)); + if let Some(ide) = self.bus.ide_a4000.as_mut() { + let value = ide.read(addr, size); + if ide.take_activity() { + self.bus.note_hdd_activity(); + } + return value; + } + } // A configured functional Zorro board window (registers, boot ROM, DMA // strobes): the chain maps it to a device slot. Off the chip bus like // Gayle. @@ -2794,6 +2804,16 @@ impl CpuBus { } return; } + if self.bus.ide_a4000.is_some() && crate::ide_a4000::IdeA4000::decodes(addr) { + self.bus.cpu_slow_external_access(Self::access_words(size)); + if let Some(ide) = self.bus.ide_a4000.as_mut() { + ide.write(addr, size, value); + if ide.take_activity() { + self.bus.note_hdd_activity(); + } + } + return; + } if let Some((crate::zorro::BoardBacking::Device(slot), off)) = self.bus.mem.zorro.device_region_at(addr, size) { diff --git a/src/emulator.rs b/src/emulator.rs index 70117368..0ff48160 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -1956,6 +1956,20 @@ pub fn build_machine( } bus.attach_gayle(gayle); } + if cfg.ide_a4000 { + let mut ide = crate::ide_a4000::IdeA4000::new(); + for (slot, drive) in [(0, &cfg.ide.master), (1, &cfg.ide.slave)] { + let Some(drive) = drive else { continue }; + ide.attach_drive( + slot, + crate::ata::IdeDrive::open(&drive.path, slot, drive.volume_name.as_deref())?, + ); + let which = if slot == 0 { "master" } else { "slave" }; + info!("ide: {which} {}", drive.path.display()); + } + bus.attach_ide_a4000(ide); + info!("ide: A4000 motherboard interface at $DD2020"); + } if let Some(range) = cfg.log_unmapped.clone() { info!( "debug: logging unmapped CPU accesses in {:#08X}-{:#08X}", diff --git a/src/gayle.rs b/src/gayle.rs index 4aeb350e..79e5ad18 100644 --- a/src/gayle.rs +++ b/src/gayle.rs @@ -6,190 +6,24 @@ //! //! Decode and register layout follow the Commodore schematics as captured by //! the Linux `gayle.c` IDE driver and the ROM scsi.device: the IDE task -//! file lives at $DA0000 with a 4-byte stride (byte registers on the odd +//! file lives at $DA2000 with a 4-byte stride (byte registers on the odd //! word half, offset base+4*reg+2), and the control block register at //! base+$101A. None of this is on the chip bus; the CPU reaches it through //! `cpu_external_access`. +//! +//! The drives, the task file, and the command engine are the shared ATA core +//! in [`crate::ata`]; Gayle is the front-end that decodes for it and adds its +//! own ID, interrupt, and PCMCIA registers. -use crate::harddrive::{HardDriveImage, RDB_HEADS, RDB_SPT}; -use std::path::Path; - -pub use crate::harddrive::SECTOR_SIZE; -/// Maximum sectors per READ/WRITE MULTIPLE block we advertise in IDENTIFY -/// word 47 and accept from SET MULTIPLE. -pub const MAX_MULTIPLE: u8 = 16; +use crate::ata::{task_file_reg, AtaBus, IdeReg}; -// ATA status bits. BSY is defined for completeness: transfers complete -// within the access in this model, so it is never observable. -#[allow(dead_code)] -const ST_BSY: u8 = 0x80; -const ST_DRDY: u8 = 0x40; -const ST_DSC: u8 = 0x10; -const ST_DRQ: u8 = 0x08; -const ST_ERR: u8 = 0x01; -// ATA error bits. -const ERR_ABRT: u8 = 0x04; -const ERR_IDNF: u8 = 0x10; -// Device control bits. -const CTL_NIEN: u8 = 0x02; -const CTL_SRST: u8 = 0x04; -// Device/head bits. -const DH_LBA: u8 = 0x40; -const DH_DRV: u8 = 0x10; +pub use crate::ata::{IdeDrive, MAX_MULTIPLE, SECTOR_SIZE}; // Gayle interrupt/status bit layout (shared by the status, interrupt // change, and interrupt enable registers). pub const GAYLE_IRQ_IDE: u8 = 0x80; // PCMCIA bits (CCDET/BVD1/BVD2/WR/BSY) stay clear: no card inserted. -/// IDE register selected by the task-file decode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum IdeReg { - Data, - ErrorFeature, - SectorCount, - SectorNumber, - CylLow, - CylHigh, - DriveHead, - StatusCommand, - AltStatusDevCtl, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -enum Transfer { - None, - /// Device-to-host PIO (READ SECTORS / READ MULTIPLE / IDENTIFY). - PioIn { - /// Sectors still owed after the words currently in the buffer. - remaining: u32, - /// Sectors per DRQ block (1, or the SET MULTIPLE count). - block: u32, - }, - /// Host-to-device PIO (WRITE SECTORS / WRITE MULTIPLE). - PioOut { - remaining: u32, - block: u32, - }, -} - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct IdeDrive { - /// The sector store (shared with the SCSI targets): HDF file, - /// directory-built FFS volume, synthesized-RDB overlay handling. - pub disk: HardDriveImage, - // Default geometry from the image size; INITIALIZE DEVICE PARAMETERS - // (0x91) overrides the current translation. - default_heads: u8, - default_spt: u8, - cylinders: u16, - heads: u8, - spt: u8, - multiple: u8, -} - -impl IdeDrive { - /// Open an IDE unit (0 = master, 1 = slave; this picks the DHn device - /// name a synthesized RDB advertises). The path may be a raw HDF image - /// file, or a host directory, which is built into an in-memory FFS - /// volume at open time; `volume_name` labels that volume (directory - /// mounts only). - pub fn open(path: &Path, unit: usize, volume_name: Option<&str>) -> anyhow::Result { - let disk = HardDriveImage::open( - path, - &format!("DH{unit}"), - "ide", - "COPPERLINE IDE DISK", - volume_name, - )?; - // The classic Amiga HDF geometry: 16 surfaces, 32 sectors per track - // (what HDToolBox/RDB tooling defaults to), so the CHS the host - // computes from an RDB's physical-drive block agrees with what the - // drive decodes. - let heads = RDB_HEADS as u8; - let spt = RDB_SPT as u8; - let cylinders = - (disk.total_sectors() / (u64::from(heads) * u64::from(spt))).clamp(1, 65535) as u16; - Ok(Self { - disk, - default_heads: heads, - default_spt: spt, - cylinders, - heads, - spt, - multiple: 0, - }) - } - - /// IDENTIFY DEVICE data. Gayle wires the IDE data bus byte-swapped - /// relative to the 68000 (IDE D7-D0 land on CPU D15-D8), so the CPU - /// reads every ATA word with its bytes exchanged. The ROM driver's - /// scsi.device depends on this: it parses the stored block assuming - /// PC byte order per word (its word helper at $FB788C and string - /// helper at $FB7B22 swap each pair back). Sector data is unaffected - /// because the swap puts file bytes back in natural memory order. - /// We therefore store each ATA word low-byte-first here, since the - /// data port read returns `buf[2i] << 8 | buf[2i+1]`. - fn identify_block(&self) -> Vec { - let mut buf = vec![0u8; SECTOR_SIZE]; - let mut word = |idx: usize, val: u16| { - buf[idx * 2] = (val & 0xFF) as u8; - buf[idx * 2 + 1] = (val >> 8) as u8; - }; - // Word 0 mirrors the Conner drives the A600HD shipped with - // (soft-sectored, fixed, MFM-encoded transfer-rate bits). - word(0, 0x045A); - word(1, self.cylinders); - word(3, u16::from(self.default_heads)); - // ATA-1 unformatted bytes per track/sector: vintage drivers - // (ROM scsi.device) read these for the block size. - word(4, u16::from(self.default_spt) * 512); - word(5, 512); - word(6, u16::from(self.default_spt)); - word(20, 3); // dual-ported buffer with read caching - word(21, 64); // buffer size in sectors - word(22, 4); // ECC bytes for READ/WRITE LONG - word(48, 1); // can perform doubleword I/O (32-bit host transfers) - word(51, 0x0200); // PIO data transfer timing mode 2 - word(52, 0x0200); // DMA data transfer timing mode (legacy field) - word(47, 0x8000 | u16::from(MAX_MULTIPLE)); - word(49, 0x0200); // LBA supported - word(53, 0x0001); // words 54-58 valid - word(54, self.cylinders); - word(55, u16::from(self.heads)); - word(56, u16::from(self.spt)); - let current = u32::from(self.cylinders) * u32::from(self.heads) * u32::from(self.spt); - word(57, (current & 0xFFFF) as u16); - word(58, (current >> 16) as u16); - let lba = self.disk.total_sectors().min(u64::from(u32::MAX)) as u32; - word(60, (lba & 0xFFFF) as u16); - word(61, (lba >> 16) as u16); - word( - 59, - if self.multiple > 0 { - 0x0100 | u16::from(self.multiple) - } else { - 0 - }, - ); - - // ATA strings carry the first character of each pair in bits 15-8, - // so with the low-byte-first storage above the pair lands swapped. - let mut string = |start: usize, len_words: usize, text: &str| { - let mut bytes = text.as_bytes().to_vec(); - bytes.resize(len_words * 2, b' '); - for (i, pair) in bytes.chunks(2).enumerate() { - buf[(start + i) * 2] = pair[1]; - buf[(start + i) * 2 + 1] = pair[0]; - } - }; - string(10, 10, "CPRLN-0000000000"); - string(23, 4, "1.0 "); - string(27, 20, "COPPERLINE IDE DISK"); - buf - } -} - #[derive(serde::Serialize, serde::Deserialize)] pub struct Gayle { /// $DE1000 ID shifted out MSB-first on D7: $D0 (A600) / $D1 (A1200). @@ -201,26 +35,8 @@ pub struct Gayle { intena: u8, /// $DAA000 config (PCMCIA voltage/resistor config; stored only). config: u8, - - drives: [Option; 2], - // Shared task file (one register file per bus, like the real cable). - feature: u8, - error: u8, - sector_count: u8, - sector_number: u8, - cyl_low: u8, - cyl_high: u8, - drive_head: u8, - status: u8, - devctl: u8, - intrq: bool, - - buf: Vec, - buf_pos: usize, - transfer: Transfer, - /// Set whenever the drive does real work (command issued or data port - /// moved during a transfer); drained by the bus for the HDD LED. - activity: bool, + /// The IDE cable behind the gate array. + ata: AtaBus, } impl Gayle { @@ -231,32 +47,18 @@ impl Gayle { intreq: 0, intena: 0, config: 0, - drives: [None, None], - feature: 0, - error: 0x01, // diagnostics passed - sector_count: 0x01, - sector_number: 0x01, - cyl_low: 0, - cyl_high: 0, - drive_head: 0, - status: ST_DRDY | ST_DSC, - devctl: 0, - intrq: false, - buf: Vec::new(), - buf_pos: 0, - transfer: Transfer::None, - activity: false, + ata: AtaBus::new(), } } /// Drain the activity latch set by command issue and data-port traffic. /// The bus polls this after each Gayle access to time the HDD LED. pub fn take_activity(&mut self) -> bool { - std::mem::take(&mut self.activity) + self.ata.take_activity() } pub fn attach_drive(&mut self, slot: usize, drive: IdeDrive) { - self.drives[slot.min(1)] = Some(drive); + self.ata.attach_drive(slot, drive); } /// System reset: clear the register file and any in-flight transfer but @@ -266,14 +68,7 @@ impl Gayle { self.intreq = 0; self.intena = 0; self.config = 0; - self.feature = 0; - self.sector_count = 0x01; - self.sector_number = 0x01; - self.cyl_low = 0; - self.cyl_high = 0; - self.drive_head = 0; - self.devctl = 0; - self.soft_reset(); + self.ata.reset(); } /// The INT2 line into Paula (PORTS): the latched interrupt-change bits @@ -284,29 +79,15 @@ impl Gayle { self.intreq & self.intena != 0 } - fn selected(&self) -> usize { - usize::from(self.drive_head & DH_DRV != 0) - } - - fn drive(&mut self) -> Option<&mut IdeDrive> { - self.drives[self.selected()].as_mut() - } - - fn pair_present(&self) -> bool { - self.drives[1 - self.selected().min(1)].is_some() - } - - fn raise_ide_irq(&mut self) { - self.intrq = true; - if self.devctl & CTL_NIEN == 0 { + /// Latch an IDE interrupt the cable raised during this access. Unlike the + /// A4000's interface, Gayle records the edge in a register of its own, + /// which the driver clears by writing it back. + fn latch_ide_irq(&mut self) { + if self.ata.take_irq_edge() { self.intreq |= GAYLE_IRQ_IDE; } } - fn clear_ide_irq(&mut self) { - self.intrq = false; - } - // ----- $DE1000 ID shift register ------------------------------------- fn id_read(&mut self) -> u8 { @@ -334,6 +115,7 @@ impl Gayle { return (hi << 16) | lo; } let value = self.read_inner(addr, size); + self.latch_ide_irq(); if crate::envcfg::flag("COPPERLINE_DIAG_GAYLE") { log::info!("gayle rd {addr:#08X}/{size} -> {value:#06X}"); } @@ -359,7 +141,9 @@ impl Gayle { v } } - _ if (0x00DA_0000..0x00DA_8000).contains(&addr) => self.ide_read(addr, size), + _ if (0x00DA_0000..0x00DA_8000).contains(&addr) => { + self.ata.read_reg(Self::ide_reg(addr), size) + } _ => 0, } } @@ -383,9 +167,12 @@ impl Gayle { }; self.register_write(addr, byte); } - _ if (0x00DA_0000..0x00DA_8000).contains(&addr) => self.ide_write(addr, size, value), + _ if (0x00DA_0000..0x00DA_8000).contains(&addr) => { + self.ata.write_reg(Self::ide_reg(addr), size, value); + } _ => {} } + self.latch_ide_irq(); } fn register_read(&mut self, addr: u32) -> u8 { @@ -397,7 +184,7 @@ impl Gayle { // would tell card.resource a card is inserted and wedge boot // waiting for it to become ready. let pcmcia_empty = 0x7C; - if self.intrq && self.devctl & CTL_NIEN == 0 { + if self.ata.irq_level() { GAYLE_IRQ_IDE | pcmcia_empty } else { pcmcia_empty @@ -427,455 +214,15 @@ impl Gayle { } } - // ----- IDE task file --------------------------------------------------- - /// A600/A1200 IDE decode, as the ROM scsi.device drives it (verified /// against ROM 40.063 boot probes): task file at $DA2000 with a 4-byte /// stride, byte registers on the even (D15-D8) byte, the 16-bit data /// port at $DA2000, and the control block one A12 page up ($DA3018). - fn ide_reg(addr: u32, write: bool) -> Option { - let off = addr & 0x7FFF; - Some(match off { - 0x2000 | 0x2002 => IdeReg::Data, - 0x2004 | 0x2006 => IdeReg::ErrorFeature, - 0x2008 | 0x200A => IdeReg::SectorCount, - 0x200C | 0x200E => IdeReg::SectorNumber, - 0x2010 | 0x2012 => IdeReg::CylLow, - 0x2014 | 0x2016 => IdeReg::CylHigh, - 0x2018 | 0x201A => IdeReg::DriveHead, - 0x201C | 0x201E => IdeReg::StatusCommand, - 0x3018 | 0x301A => IdeReg::AltStatusDevCtl, - _ => { - let _ = write; - return None; - } - }) - } - - fn ide_read(&mut self, addr: u32, size: usize) -> u32 { - // Selected device absent: the status register reads 0x01 (ERR set, - // not ready) when the other device is present and 0xFF when the - // cable is empty; every other task-file register reads zero, and a - // status read drops a pending interrupt (the INTRQ line is shared). - // This is how the ROM probe concludes a unit does not exist - // instead of classifying it as a pre-ATA drive (matches WinUAE). - if self.drives[self.selected()].is_none() { - return match Self::ide_reg(addr, false) { - Some(IdeReg::StatusCommand) | Some(IdeReg::AltStatusDevCtl) => { - self.clear_ide_irq(); - if self.pair_present() { - 0x01 - } else { - 0xFF - } - } - _ => 0, - }; - } - match Self::ide_reg(addr, false) { - Some(IdeReg::Data) => { - let word = self.data_read_word(); - if size == 1 { - u32::from(word >> 8) - } else { - u32::from(word) - } - } - Some(IdeReg::ErrorFeature) => u32::from(self.error), - Some(IdeReg::SectorCount) => u32::from(self.sector_count), - Some(IdeReg::SectorNumber) => u32::from(self.sector_number), - Some(IdeReg::CylLow) => u32::from(self.cyl_low), - Some(IdeReg::CylHigh) => u32::from(self.cyl_high), - Some(IdeReg::DriveHead) => u32::from(self.drive_head), - Some(IdeReg::StatusCommand) => { - let v = self.status_read(); - self.clear_ide_irq(); - u32::from(v) - } - Some(IdeReg::AltStatusDevCtl) => u32::from(self.status_read()), - None => 0, - } - } - - fn ide_write(&mut self, addr: u32, size: usize, value: u32) { - let byte = value as u8; - match Self::ide_reg(addr, true) { - Some(IdeReg::Data) => { - let word = if size == 1 { - (value as u16) << 8 - } else { - value as u16 - }; - self.data_write_word(word); - } - Some(IdeReg::ErrorFeature) => self.feature = byte, - Some(IdeReg::SectorCount) => self.sector_count = byte, - Some(IdeReg::SectorNumber) => self.sector_number = byte, - Some(IdeReg::CylLow) => self.cyl_low = byte, - Some(IdeReg::CylHigh) => self.cyl_high = byte, - Some(IdeReg::DriveHead) => self.drive_head = byte, - Some(IdeReg::StatusCommand) => self.command(byte), - Some(IdeReg::AltStatusDevCtl) => { - let was_reset = self.devctl & CTL_SRST != 0; - self.devctl = byte; - if byte & CTL_SRST != 0 && !was_reset { - self.soft_reset(); - } - } - None => {} - } - } - - fn status_read(&self) -> u8 { - self.status - } - - fn soft_reset(&mut self) { - self.status = ST_DRDY | ST_DSC; - self.error = 0x01; - self.transfer = Transfer::None; - self.buf.clear(); - self.buf_pos = 0; - self.clear_ide_irq(); - } - - // ----- data port ------------------------------------------------------- - - fn data_read_word(&mut self) -> u16 { - if !matches!(self.transfer, Transfer::PioIn { .. }) || self.buf_pos + 1 >= self.buf.len() { - return 0; - } - let word = (u16::from(self.buf[self.buf_pos]) << 8) | u16::from(self.buf[self.buf_pos + 1]); - self.buf_pos += 2; - self.activity = true; - if self.buf_pos >= self.buf.len() { - self.pio_in_block_consumed(); - } - word - } - - fn data_write_word(&mut self, word: u16) { - if !matches!(self.transfer, Transfer::PioOut { .. }) || self.buf_pos + 1 >= self.buf.len() { - return; - } - self.buf[self.buf_pos] = (word >> 8) as u8; - self.buf[self.buf_pos + 1] = (word & 0xFF) as u8; - self.buf_pos += 2; - self.activity = true; - if self.buf_pos >= self.buf.len() { - self.pio_out_block_filled(); - } - } - - fn pio_in_block_consumed(&mut self) { - let Transfer::PioIn { remaining, block } = self.transfer else { - // IDENTIFY-style single buffer: transfer complete. - self.status = ST_DRDY | ST_DSC; - self.transfer = Transfer::None; - return; - }; - if remaining == 0 { - self.status = ST_DRDY | ST_DSC; - self.transfer = Transfer::None; - return; - } - let chunk = remaining.min(block); - if self.fill_read_buffer(chunk).is_ok() { - self.transfer = Transfer::PioIn { - remaining: remaining - chunk, - block, - }; - self.status = ST_DRDY | ST_DSC | ST_DRQ; - self.raise_ide_irq(); - } - } - - fn pio_out_block_filled(&mut self) { - let Transfer::PioOut { remaining, block } = self.transfer else { - return; - }; - // Commit the buffered sectors at the current task-file position. - if self.commit_write_buffer().is_err() { - return; - } - if remaining == 0 { - if let Some(drive) = self.drive() { - drive.disk.flush(); - } - self.status = ST_DRDY | ST_DSC; - self.transfer = Transfer::None; - self.raise_ide_irq(); - return; - } - let chunk = remaining.min(block); - self.buf.clear(); - self.buf.resize(chunk as usize * SECTOR_SIZE, 0); - self.buf_pos = 0; - self.transfer = Transfer::PioOut { - remaining: remaining - chunk, - block, - }; - self.status = ST_DRDY | ST_DSC | ST_DRQ; - self.raise_ide_irq(); - } - - // ----- addressing ------------------------------------------------------- - - /// Current LBA from the task file (LBA28 or CHS translation). - fn current_lba(&mut self) -> Option { - let lba_mode = self.drive_head & DH_LBA != 0; - let head = u64::from(self.drive_head & 0x0F); - let sector = u64::from(self.sector_number); - let cyl = (u64::from(self.cyl_high) << 8) | u64::from(self.cyl_low); - let drive = self.drive()?; - if lba_mode { - Some((head << 24) | (cyl << 8) | sector) - } else { - if sector == 0 { - return None; - } - let heads = u64::from(drive.heads); - let spt = u64::from(drive.spt); - Some((cyl * heads + head) * spt + (sector - 1)) - } - } - - /// Advance the task-file position by one sector, as real drives do, so - /// software can resume after a partial transfer. - fn advance_lba(&mut self) { - if self.drive_head & DH_LBA != 0 { - let lba = ((u32::from(self.drive_head & 0x0F) << 24) - | (u32::from(self.cyl_high) << 16) - | (u32::from(self.cyl_low) << 8) - | u32::from(self.sector_number)) - .wrapping_add(1); - self.sector_number = (lba & 0xFF) as u8; - self.cyl_low = ((lba >> 8) & 0xFF) as u8; - self.cyl_high = ((lba >> 16) & 0xFF) as u8; - self.drive_head = (self.drive_head & 0xF0) | ((lba >> 24) & 0x0F) as u8; - return; - } - let (heads, spt) = match self.drive() { - Some(d) => (d.heads, d.spt), - None => return, - }; - if self.sector_number < spt { - self.sector_number += 1; - return; - } - self.sector_number = 1; - let head = self.drive_head & 0x0F; - if head + 1 < heads { - self.drive_head = (self.drive_head & 0xF0) | (head + 1); - return; - } - self.drive_head &= 0xF0; - let cyl = ((u16::from(self.cyl_high) << 8) | u16::from(self.cyl_low)).wrapping_add(1); - self.cyl_low = (cyl & 0xFF) as u8; - self.cyl_high = (cyl >> 8) as u8; - } - - fn fill_read_buffer(&mut self, sectors: u32) -> Result<(), ()> { - self.buf.clear(); - self.buf_pos = 0; - for _ in 0..sectors { - let Some(lba) = self.current_lba() else { - self.command_error(ERR_IDNF); - return Err(()); - }; - let total = self.drive().map(|d| d.disk.total_sectors()).unwrap_or(0); - if lba >= total { - self.command_error(ERR_IDNF); - return Err(()); - } - let mut sector = [0u8; SECTOR_SIZE]; - let res = self - .drive() - .map(|d| d.disk.read_sector(lba, &mut sector)) - .unwrap_or_else(|| Err(std::io::ErrorKind::NotFound.into())); - if let Err(e) = res { - log::warn!("IDE read lba {lba}: {e}"); - self.command_error(ERR_ABRT); - return Err(()); - } - self.buf.extend_from_slice(§or); - self.advance_lba(); - } - Ok(()) - } - - fn commit_write_buffer(&mut self) -> Result<(), ()> { - let sectors = self.buf.len() / SECTOR_SIZE; - for i in 0..sectors { - let Some(lba) = self.current_lba() else { - self.command_error(ERR_IDNF); - return Err(()); - }; - let total = self.drive().map(|d| d.disk.total_sectors()).unwrap_or(0); - if lba >= total { - self.command_error(ERR_IDNF); - return Err(()); - } - let start = i * SECTOR_SIZE; - let sector: [u8; SECTOR_SIZE] = - self.buf[start..start + SECTOR_SIZE].try_into().unwrap(); - let res = self - .drive() - .map(|d| d.disk.write_sector(lba, §or)) - .unwrap_or_else(|| Err(std::io::ErrorKind::NotFound.into())); - if let Err(e) = res { - log::warn!("IDE write lba {lba}: {e}"); - self.command_error(ERR_ABRT); - return Err(()); - } - self.advance_lba(); - } - Ok(()) - } - - fn command_error(&mut self, error_bits: u8) { - self.error = error_bits; - self.status = ST_DRDY | ST_DSC | ST_ERR; - self.transfer = Transfer::None; - self.buf.clear(); - self.buf_pos = 0; - self.raise_ide_irq(); - } - - // ----- command dispatch -------------------------------------------------- - - fn command(&mut self, cmd: u8) { - if crate::envcfg::flag("COPPERLINE_DIAG_GAYLE") { - let lba = self.drive_head & DH_LBA != 0; - log::info!( - "ide cmd {cmd:#04X} drv={} lba={} chs/lba=({:02X} {:02X} {:02X} {:02X}) n={}", - self.selected(), - lba, - self.drive_head & 0x0F, - self.cyl_high, - self.cyl_low, - self.sector_number, - self.sector_count - ); - } - self.clear_ide_irq(); - if self.drives[self.selected()].is_none() { - // Every command addressed to an absent device fails with - // command-aborted and raises the completion interrupt, so the - // host's probe finishes promptly (matches WinUAE; the ROM's - // INITIALIZE DEVICE PARAMETERS arrives with the DEV bit set - // and must complete one way or the other). - self.command_error(ERR_ABRT); - return; - } - self.error = 0; - self.status = ST_DRDY | ST_DSC; - self.activity = true; - let count = if self.sector_count == 0 { - 256u32 - } else { - u32::from(self.sector_count) - }; - match cmd { - // IDENTIFY DEVICE - 0xEC => { - self.buf = self.drive().map(|d| d.identify_block()).unwrap_or_default(); - self.buf_pos = 0; - self.transfer = Transfer::PioIn { - remaining: 0, - block: 1, - }; - self.status = ST_DRDY | ST_DSC | ST_DRQ; - self.raise_ide_irq(); - } - // READ SECTORS (with/without retry) and READ MULTIPLE. - 0x20 | 0x21 | 0xC4 => { - let block = if cmd == 0xC4 { - let m = self.drive().map(|d| d.multiple).unwrap_or(0); - if m == 0 { - self.command_error(ERR_ABRT); - return; - } - u32::from(m) - } else { - 1 - }; - let chunk = count.min(block); - self.transfer = Transfer::PioIn { - remaining: count - chunk, - block, - }; - if self.fill_read_buffer(chunk).is_ok() { - self.status = ST_DRDY | ST_DSC | ST_DRQ; - self.raise_ide_irq(); - } - } - // WRITE SECTORS (with/without retry) and WRITE MULTIPLE. - 0x30 | 0x31 | 0xC5 => { - let block = if cmd == 0xC5 { - let m = self.drive().map(|d| d.multiple).unwrap_or(0); - if m == 0 { - self.command_error(ERR_ABRT); - return; - } - u32::from(m) - } else { - 1 - }; - let chunk = count.min(block); - self.buf.clear(); - self.buf.resize(chunk as usize * SECTOR_SIZE, 0); - self.buf_pos = 0; - self.transfer = Transfer::PioOut { - remaining: count - chunk, - block, - }; - // First DRQ block is ready without an interrupt (ATA PIO out). - self.status = ST_DRDY | ST_DSC | ST_DRQ; - } - // SET MULTIPLE MODE - 0xC6 => { - let requested = self.sector_count; - let ok = - requested <= MAX_MULTIPLE && (requested == 0 || requested.is_power_of_two()); - if let (true, Some(drive)) = (ok, self.drive()) { - drive.multiple = requested; - self.status = ST_DRDY | ST_DSC; - self.raise_ide_irq(); - } else { - self.command_error(ERR_ABRT); - } - } - // INITIALIZE DEVICE PARAMETERS: set current CHS translation. - // A zero sector count is invalid and aborts, as on real drives. - 0x91 => { - let heads = (self.drive_head & 0x0F) + 1; - let spt = self.sector_count; - if spt == 0 { - self.command_error(ERR_ABRT); - return; - } - if let Some(drive) = self.drive() { - drive.heads = heads; - drive.spt = spt; - let total = drive.disk.total_sectors(); - drive.cylinders = - (total / (u64::from(heads) * u64::from(spt)).max(1)).clamp(1, 65535) as u16; - } - self.status = ST_DRDY | ST_DSC; - self.raise_ide_irq(); - } - // RECALIBRATE - 0x10..=0x1F => { - self.status = ST_DRDY | ST_DSC; - self.raise_ide_irq(); - } - // NOP: per ATA-2 always aborts. - 0x00 => self.command_error(ERR_ABRT), - _ => { - log::warn!("IDE: unimplemented command {cmd:#04X}"); - self.command_error(ERR_ABRT); - } + fn ide_reg(addr: u32) -> Option { + match addr & 0x7FFF { + off @ 0x2000..=0x201F => task_file_reg(off - 0x2000), + 0x3018 | 0x301A => Some(IdeReg::AltStatusDevCtl), + _ => None, } } } @@ -883,7 +230,8 @@ impl Gayle { #[cfg(test)] mod tests { use super::*; - use crate::harddrive::CYL_SECTORS; + use crate::ata::{DH_LBA, ERR_ABRT, ST_DRDY, ST_DRQ, ST_DSC, ST_ERR}; + use crate::harddrive::{CYL_SECTORS, RDB_HEADS, RDB_SPT}; use std::path::PathBuf; fn temp_image(sectors: u64) -> PathBuf { diff --git a/src/ide_a4000.rs b/src/ide_a4000.rs new file mode 100644 index 00000000..c7dc5f66 --- /dev/null +++ b/src/ide_a4000.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! A4000 motherboard IDE: an ATA task file ([`crate::ata`]) decoded at $DD2020, +//! with no gate array in front of it. +//! +//! The layout is the one Kickstart's own scsi.device probes (confirmed with +//! `[debug] log_unmapped` on an A4000 boot: it writes the drive/head register at +//! $DD203A and polls status at $DD203E). The task file has the same 4-byte +//! stride as Gayle's, based at $DD2020, and the control block sits one A12 page +//! up at $DD303A -- matching the A4000T and the Linux `buddha`/`gayle` style +//! decode. Unlike Gayle there is no interrupt-change latch: INTRQ feeds INT2 +//! directly, and the driver clears it by reading the status register. + +use crate::ata::{task_file_reg, AtaBus, IdeDrive, IdeReg}; + +/// Base of the IDE window. The task file runs to $DD203F and the control block +/// lives one A12 page up. +pub const IDE_BASE: u32 = 0x00DD_2020; +const IDE_TASKFILE_END: u32 = 0x00DD_2040; +/// Alternate status (read) / device control (write), on either byte of the word. +const IDE_CONTROL: u32 = 0x00DD_3038; +/// Interrupt status: bit 7 is the drive's INTRQ. Kickstart's scsi.device polls +/// this to decide whether the INT2 it took belongs to the IDE port, and spins +/// here forever if nothing answers (2.8M reads a boot with the window undecoded). +/// The interface has no latch: the bit follows the line, which the driver drops +/// by reading the status register. +const IDE_IRQ: u32 = 0x00DD_3020; +/// INTRQ, in the interrupt status register. +const IRQ_IDE: u8 = 0x80; + +#[derive(Default, serde::Serialize, serde::Deserialize)] +pub struct IdeA4000 { + ata: AtaBus, +} + +impl IdeA4000 { + pub fn new() -> Self { + Self { ata: AtaBus::new() } + } + + pub fn attach_drive(&mut self, slot: usize, drive: IdeDrive) { + self.ata.attach_drive(slot, drive); + } + + pub fn reset(&mut self) { + self.ata.reset(); + } + + pub fn take_activity(&mut self) -> bool { + self.ata.take_activity() + } + + /// INT2 (PORTS). The interface has no interrupt register of its own, so the + /// drive's INTRQ is the line, gated only by the control block's nIEN. + pub fn int2_line(&self) -> bool { + self.ata.irq_level() + } + + /// Whether the interface drives the bus at `addr`. Everything else in the + /// $DD0000 page belongs to the SDMAC or floats. + pub fn decodes(addr: u32) -> bool { + Self::ide_reg(addr).is_some() || Self::is_irq_reg(addr) + } + + fn is_irq_reg(addr: u32) -> bool { + addr == IDE_IRQ || addr == IDE_IRQ + 1 + } + + fn ide_reg(addr: u32) -> Option { + match addr { + _ if (IDE_BASE..IDE_TASKFILE_END).contains(&addr) => task_file_reg(addr - IDE_BASE), + IDE_CONTROL | 0x00DD_303A => Some(IdeReg::AltStatusDevCtl), + _ => None, + } + } + + pub fn read(&mut self, addr: u32, size: usize) -> u32 { + if size == 4 { + let hi = self.read(addr, 2); + let lo = self.read(addr.wrapping_add(2), 2); + return (hi << 16) | lo; + } + let value = if Self::is_irq_reg(addr) { + let byte = u32::from(if self.ata.irq_level() { IRQ_IDE } else { 0 }); + // A word read puts the register on D15-D8, as the byte lanes wire it. + if size == 2 && addr == IDE_IRQ { + byte << 8 + } else { + byte + } + } else { + self.ata.read_reg(Self::ide_reg(addr), size) + }; + // The edge latch is Gayle's business; drop it so it cannot go stale. + self.ata.take_irq_edge(); + if crate::envcfg::flag("COPPERLINE_DIAG_GAYLE") { + log::info!("a4000 ide rd {addr:#08X}/{size} -> {value:#06X}"); + } + value + } + + pub fn write(&mut self, addr: u32, size: usize, value: u32) { + if size == 4 { + self.write(addr, 2, value >> 16); + self.write(addr.wrapping_add(2), 2, value & 0xFFFF); + return; + } + if crate::envcfg::flag("COPPERLINE_DIAG_GAYLE") { + log::info!("a4000 ide wr {addr:#08X}/{size} <- {value:#06X}"); + } + // The interrupt status register has nothing to write: the line is the + // drive's, and reading the status register is what drops it. + if !Self::is_irq_reg(addr) { + self.ata.write_reg(Self::ide_reg(addr), size, value); + } + self.ata.take_irq_edge(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The decode the ROM's probe walks: drive/head at $DD203A, status at + /// $DD203E, cylinder low/high at $DD2032/$DD2036 -- Gayle's 4-byte stride, + /// based at $DD2020. + #[test] + fn the_task_file_decodes_where_kickstart_probes_it() { + assert_eq!(IdeA4000::ide_reg(0x00DD_2020), Some(IdeReg::Data)); + assert_eq!(IdeA4000::ide_reg(0x00DD_2032), Some(IdeReg::CylLow)); + assert_eq!(IdeA4000::ide_reg(0x00DD_2036), Some(IdeReg::CylHigh)); + assert_eq!(IdeA4000::ide_reg(0x00DD_203A), Some(IdeReg::DriveHead)); + assert_eq!(IdeA4000::ide_reg(0x00DD_203E), Some(IdeReg::StatusCommand)); + assert_eq!( + IdeA4000::ide_reg(0x00DD_303A), + Some(IdeReg::AltStatusDevCtl) + ); + // The SDMAC's registers are in the same page and are not ours. + assert!(!IdeA4000::decodes(0x00DD_0043)); + assert!(!IdeA4000::decodes(0x00DD_2000)); + assert!(!IdeA4000::decodes(0x00DD_2040)); + } + + /// An empty cable floats the status register, which is how the ROM's probe + /// concludes there is no drive rather than waiting on one. + #[test] + fn an_empty_cable_floats_the_status_register() { + let mut ide = IdeA4000::new(); + ide.write(0x00DD_203A, 1, 0xA0); + assert_eq!(ide.read(0x00DD_203E, 1) as u8, 0xFF); + assert!(!ide.int2_line()); + } + + /// The interrupt status register follows INTRQ, and reading the drive's + /// status drops it. Kickstart's scsi.device polls $DD3020 after every INT2 + /// and spins there forever if it never sees the bit (2.8M reads a boot). + #[test] + fn the_interrupt_register_follows_intrq_and_the_status_read_clears_it() { + let path = std::env::temp_dir().join(format!( + "copperline-a4000-ide-{}-{}.hdf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::write(&path, vec![0u8; 64 * crate::ata::SECTOR_SIZE]).unwrap(); + let mut ide = IdeA4000::new(); + ide.attach_drive(0, IdeDrive::open(&path, 0, None).unwrap()); + + assert_eq!( + ide.read(IDE_IRQ, 1) as u8 & IRQ_IDE, + 0, + "idle: no interrupt" + ); + + // IDENTIFY completes and raises INTRQ. + ide.write(0x00DD_203A, 1, 0xA0); + ide.write(0x00DD_203E, 1, 0xEC); + assert!(ide.int2_line()); + assert_eq!(ide.read(IDE_IRQ, 1) as u8 & IRQ_IDE, IRQ_IDE); + // The register sits on D15-D8 of a word access. + assert_eq!(ide.read(IDE_IRQ, 2) as u16, u16::from(IRQ_IDE) << 8); + + // Reading the status register drops the line, and the register with it. + ide.read(0x00DD_203E, 1); + assert_eq!(ide.read(IDE_IRQ, 1) as u8 & IRQ_IDE, 0); + assert!(!ide.int2_line()); + std::fs::remove_file(&path).ok(); + } +} diff --git a/src/lib.rs b/src/lib.rs index e8f2a1fd..d4e3d05a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod a2091; pub mod a4091; pub mod akiko; pub mod amigaos; +pub mod ata; pub mod audio; pub mod bus; pub mod cache; @@ -38,6 +39,7 @@ pub mod gary; pub mod gayle; pub mod gdbstub; pub mod harddrive; +pub mod ide_a4000; pub mod inputrec; pub mod inputsched; pub mod memory; diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 3bb6d991..7a56a795 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -1125,7 +1125,7 @@ impl MachineSetup { self.realtime_priority = base.emulation.realtime_priority; self.warp = base.emulation.warp_speed; self.joystick_input_mode = base.joystick_input_mode; - if !self.has_gayle() { + if !self.has_ide() { self.ide_master = None; self.ide_master_name = None; self.ide_slave = None; @@ -1153,6 +1153,11 @@ impl MachineSetup { self.model == Some(MachineModel::A3000) } + /// Machines with an IDE port: Gayle's, and the A4000's at $DD2020. + fn has_ide(&self) -> bool { + self.has_gayle() || self.model == Some(MachineModel::A4000) + } + fn has_cd(&self) -> bool { matches!(self.model, Some(MachineModel::Cdtv | MachineModel::Cd32)) } @@ -1176,7 +1181,7 @@ impl MachineSetup { F::Icache => reason(self.cpu.has_instruction_cache(), "needs 68020+"), F::Dcache => reason(self.cpu.has_data_cache(), "needs 68030/040"), F::Z3Ram => reason(cpu_is_32bit(self.cpu), "needs 32-bit CPU"), - F::IdeMaster | F::IdeSlave => reason(self.has_gayle(), "needs A600/A1200"), + F::IdeMaster | F::IdeSlave => reason(self.has_ide(), "needs A600/A1200/A4000"), // The ROM and drives belong to the fitted controller; greyed with // none. The A3000's motherboard SCSI has no ROM of its own, and // rom_odd is an A2091 split-EPROM option only. From a8177cb7b79124700a3bc26d6fe1863c8b538716 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 10:46:48 +0900 Subject: [PATCH 6/6] ide: clarify the task-file/control halfword-aliasing comments The decode folds the two 16-bit halves of each 4-byte slot together; the old wording ("either byte of the word") wrongly suggested byte-lane aliasing. Co-Authored-By: Claude Opus 4.8 --- src/ata.rs | 5 +++-- src/ide_a4000.rs | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ata.rs b/src/ata.rs index ed3ba36b..d6aaceb8 100644 --- a/src/ata.rs +++ b/src/ata.rs @@ -54,8 +54,9 @@ pub enum IdeReg { } /// The task-file register at `offset` bytes from the base of the file. Both -/// Amiga interfaces space the eight registers four bytes apart and answer on -/// either byte of the word, so a register covers offsets 4n and 4n+2. +/// Amiga interfaces space the eight registers four bytes apart, and each +/// register occupies both 16-bit halves of its slot, so it answers at offsets +/// 4n and 4n+2 (the `& !0x02` folds the two halfword addresses together). pub fn task_file_reg(offset: u32) -> Option { Some(match offset & !0x02 { 0x00 => IdeReg::Data, diff --git a/src/ide_a4000.rs b/src/ide_a4000.rs index c7dc5f66..f0f062c6 100644 --- a/src/ide_a4000.rs +++ b/src/ide_a4000.rs @@ -17,7 +17,8 @@ use crate::ata::{task_file_reg, AtaBus, IdeDrive, IdeReg}; /// lives one A12 page up. pub const IDE_BASE: u32 = 0x00DD_2020; const IDE_TASKFILE_END: u32 = 0x00DD_2040; -/// Alternate status (read) / device control (write), on either byte of the word. +/// Alternate status (read) / device control (write). Like the task file, it +/// answers at both halfword addresses of its slot ($DD3038 and $DD303A). const IDE_CONTROL: u32 = 0x00DD_3038; /// Interrupt status: bit 7 is the drive's INTRQ. Kickstart's scsi.device polls /// this to decide whether the INT2 it took belongs to the IDE port, and spins