From c90da3a1b109801b275e87445aadfe804b5814d0 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Mon, 18 May 2026 07:07:04 -0400 Subject: [PATCH 1/3] Add CHD support for SCSI disks and CD-ROMs Wraps libchdman-rs to mount .chd files anywhere a regular disk image is accepted. HD CHDs are writable (in-place for uncompressed, MAME-style .diff.chd sidecar for compressed parents); CD CHDs expose cooked 2048-byte sectors read-only. Vibe coded this PR. Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 1 + src/chd_disk.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/scsi.rs | 25 +++++++-- src/wd33c93a.rs | 16 +++++- 5 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/chd_disk.rs diff --git a/Cargo.toml b/Cargo.toml index 1ce4d98..377eb85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ cranelift-jit = { version = "0.116", optional = true } cranelift-module = { version = "0.116", optional = true } cranelift-native = { version = "0.116", optional = true } target-lexicon = { version = "0.13", optional = true } +libchdman-rs = { git = "https://github.com/danifunker/libchdman-rs" } [target.'cfg(not(windows))'.dependencies] libc = "0.2" diff --git a/src/chd_disk.rs b/src/chd_disk.rs new file mode 100644 index 0000000..f89e1be --- /dev/null +++ b/src/chd_disk.rs @@ -0,0 +1,139 @@ +//! CHD-backed disk implementations for the SCSI subsystem. +//! +//! Two flavors: +//! * [`ChdHd`] — hard-disk CHD as a writable block device. Uncompressed CHDs +//! are written in place; compressed CHDs get an uncompressed `.diff.chd` +//! sidecar so the parent stays untouched (MAME's strategy). +//! * [`ChdCd`] — single-track MODE1 CD CHD exposed as a 2048-byte/sector +//! read-only stream via libchdman-rs's `CdCookedReader`. + +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use libchdman_rs::cd::CdCookedReader; +use libchdman_rs::hd::HdImage; +use libchdman_rs::Chd; + +fn map_err(e: E) -> io::Error { + io::Error::new(io::ErrorKind::Other, format!("{:?}", e)) +} + +/// Writable hard-disk CHD backend. +pub struct ChdHd { + img: HdImage, + sector_size: u32, + total_bytes: u64, +} + +// The underlying MAME chd_file holds a raw pointer (`*mut ChdFile`), making it +// !Send by default. We only ever own these from the SCSI worker thread (the +// backend is moved in once and never shared), so transferring ownership across +// threads is safe — we just don't share refs (no Sync). +unsafe impl Send for ChdHd {} +unsafe impl Send for ChdCd {} + +impl ChdHd { + pub fn open(path: &str) -> io::Result { + let p = Path::new(path); + let diff = diff_path_for(p); + + // If a diff sidecar already exists, reattach to it (so previously-written + // sectors are preserved across runs, like a COW overlay). + let img = if diff.exists() { + HdImage::reopen_diff(p, &diff).map_err(map_err)? + } else { + // Try in-place first (works for uncompressed CHDs). On failure, + // fall back to creating an uncompressed diff alongside the parent. + match HdImage::open(p) { + Ok(img) => img, + Err(_) => HdImage::open_with_diff(p, &diff).map_err(map_err)?, + } + }; + + let sector_size = img.sector_size(); + let total_bytes = img.sector_count() * u64::from(sector_size); + Ok(Self { img, sector_size, total_bytes }) + } + + pub fn size(&self) -> u64 { + self.total_bytes + } + + pub fn read_blocks(&mut self, lba: u64, count: usize, block_size: u64) -> io::Result> { + let ss = u64::from(self.sector_size); + if block_size != ss { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("CHD HD sector size {} != requested block size {}", ss, block_size), + )); + } + let mut buf = vec![0u8; count * ss as usize]; + for i in 0..count { + let off = i * ss as usize; + self.img + .read_sector(lba + i as u64, &mut buf[off..off + ss as usize]) + .map_err(map_err)?; + } + Ok(buf) + } + + pub fn write_sectors(&mut self, lba: u64, data: &[u8]) -> io::Result<()> { + let ss = self.sector_size as usize; + if !data.len().is_multiple_of(ss) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("CHD HD write length {} not a multiple of sector {}", data.len(), ss), + )); + } + let count = data.len() / ss; + for i in 0..count { + let off = i * ss; + self.img + .write_sector(lba + i as u64, &data[off..off + ss]) + .map_err(map_err)?; + } + Ok(()) + } +} + +/// Read-only CD CHD backend. +pub struct ChdCd { + reader: CdCookedReader, + total_bytes: u64, +} + +impl ChdCd { + pub fn open(path: &str) -> io::Result { + let chd = Chd::open(path, false, None).map_err(map_err)?; + let reader = CdCookedReader::open(chd).map_err(map_err)?; + let total_bytes = reader.len(); + Ok(Self { reader, total_bytes }) + } + + pub fn size(&self) -> u64 { + self.total_bytes + } + + pub fn read_blocks(&mut self, lba: u64, count: usize, block_size: u64) -> io::Result> { + let byte_offset = lba * block_size; + let byte_count = (count as u64) * block_size; + self.reader.seek(SeekFrom::Start(byte_offset))?; + let mut buf = vec![0u8; byte_count as usize]; + self.reader.read_exact(&mut buf)?; + Ok(buf) + } +} + +fn diff_path_for(parent: &Path) -> PathBuf { + let mut s = parent.as_os_str().to_owned(); + s.push(".diff.chd"); + PathBuf::from(s) +} + +pub fn is_chd(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("chd")) + .unwrap_or(false) +} diff --git a/src/lib.rs b/src/lib.rs index 210d77c..bb46101 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod pit8254; pub mod net; pub mod seeq8003; pub mod cow_disk; +pub mod chd_disk; pub mod scsi; pub mod wd33c93a; pub mod hal2; diff --git a/src/scsi.rs b/src/scsi.rs index a70dbc7..8a00d24 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -67,6 +67,12 @@ pub enum DiskBackend { Direct(File), /// Copy-on-write: base image is read-only, writes go to overlay file. Cow(CowDisk), + /// Hard-disk CHD. Writable; compressed parents get an uncompressed + /// `.diff.chd` sidecar (MAME-style), so the parent stays untouched. + ChdHd(crate::chd_disk::ChdHd), + /// CD CHD (single-track MODE1) exposed as a 2048-byte/sector read-only + /// stream. Writes return an error. + ChdCd(crate::chd_disk::ChdCd), } impl DiskBackend { @@ -86,6 +92,8 @@ impl DiskBackend { DiskBackend::Cow(cow) => { cow.read_sectors(lba, count) } + DiskBackend::ChdHd(hd) => hd.read_blocks(lba, count, block_size), + DiskBackend::ChdCd(cd) => cd.read_blocks(lba, count, block_size), } } @@ -98,6 +106,11 @@ impl DiskBackend { Ok(()) } DiskBackend::Cow(cow) => cow.write_sectors(lba, data), + DiskBackend::ChdHd(hd) => hd.write_sectors(lba, data), + DiskBackend::ChdCd(_) => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "CD CHD is read-only", + )), } } @@ -105,6 +118,8 @@ impl DiskBackend { match self { DiskBackend::Direct(file) => file.metadata().map(|m| m.len()).unwrap_or(0), DiskBackend::Cow(cow) => cow.size(), + DiskBackend::ChdHd(hd) => hd.size(), + DiskBackend::ChdCd(cd) => cd.size(), } } } @@ -155,7 +170,7 @@ impl ScsiDevice { pub fn cow_commit(&mut self) -> io::Result { match &mut self.backend { DiskBackend::Cow(cow) => cow.commit(), - DiskBackend::Direct(_) => Ok(0), + DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(0), } } @@ -163,7 +178,7 @@ impl ScsiDevice { pub fn cow_reset(&mut self) -> io::Result<()> { match &mut self.backend { DiskBackend::Cow(cow) => cow.reset_overlay(), - DiskBackend::Direct(_) => Ok(()), + DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), } } @@ -172,7 +187,7 @@ impl ScsiDevice { pub fn cow_export(&mut self, dest: &std::path::Path) -> io::Result> { match &mut self.backend { DiskBackend::Cow(cow) => cow.export_overlay(dest), - DiskBackend::Direct(_) => Ok(Vec::new()), + DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(Vec::new()), } } @@ -181,7 +196,7 @@ impl ScsiDevice { pub fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { match &mut self.backend { DiskBackend::Cow(cow) => cow.import_overlay(source, dirty), - DiskBackend::Direct(_) => Ok(()), + DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), } } @@ -189,7 +204,7 @@ impl ScsiDevice { pub fn cow_dirty_count(&self) -> usize { match &self.backend { DiskBackend::Cow(cow) => cow.dirty_count(), - DiskBackend::Direct(_) => 0, + DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => 0, } } diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index bf3e23b..f373aa7 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -248,10 +248,24 @@ impl Wd33c93a { overlay: bool, overlay_path_override: Option<&str>, ) -> std::io::Result<()> { + use crate::chd_disk::{is_chd, ChdCd, ChdHd}; use crate::cow_disk::CowDisk; use crate::scsi::DiskBackend; - let (backend, size) = if overlay && !is_cdrom { + let (backend, size) = if is_chd(path) { + if is_cdrom { + let cd = ChdCd::open(path)?; + let sz = cd.size(); + (DiskBackend::ChdCd(cd), sz) + } else { + // HD CHDs are inherently writable via the libchdman-rs HdImage + // surface (in-place for uncompressed, diff sidecar for + // compressed), so the `overlay` flag is not applicable here. + let hd = ChdHd::open(path)?; + let sz = hd.size(); + (DiskBackend::ChdHd(hd), sz) + } + } else if overlay && !is_cdrom { let overlay_path = overlay_path_override .map(|s| s.to_string()) .unwrap_or_else(|| format!("{}.overlay", path)); From c42163242140fcc3c8529ce477a06f6c99c6f8f4 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Tue, 19 May 2026 23:15:10 -0400 Subject: [PATCH 2/3] changed libchdman-rs from git to crate (saves a lot of time) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 377eb85..2dd3945 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ cranelift-jit = { version = "0.116", optional = true } cranelift-module = { version = "0.116", optional = true } cranelift-native = { version = "0.116", optional = true } target-lexicon = { version = "0.13", optional = true } -libchdman-rs = { git = "https://github.com/danifunker/libchdman-rs" } +libchdman-rs = { version = "0.287.0-l7", features = ["prebuilt"] } [target.'cfg(not(windows))'.dependencies] libc = "0.2" From 2dba10fef32d2e539865bdbe293021d365167817 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Wed, 20 May 2026 09:43:20 -0400 Subject: [PATCH 3/3] Make CHD image support an optional cargo feature CHD support pulls in libchdman-rs (and a prebuilt native chdman blob), which is unnecessary for users who only use raw disk images. Gate the chd_disk module, DiskBackend::ChdHd/ChdCd variants, and the wd33c93a mount path behind a new `chd` feature, off by default. Mounting a `.chd` path without the feature returns an Unsupported error pointing the user at `--features chd`. Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 5 ++++- README.md | 15 +++++++++++++++ src/lib.rs | 1 + src/scsi.rs | 28 +++++++++++++++++++++++----- src/wd33c93a.rs | 44 +++++++++++++++++++++++++++++++------------- 5 files changed, 74 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2dd3945..e40b42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ rex-jit = ["cranelift-codegen", "cranelift-frontend", "cranelift-jit", "cranelif # Use WindowEvent::CursorMoved for mouse motion instead of DeviceEvent::MouseMotion. # Useful for testing absolute cursor position tracking. May cause doubled moves on some platforms. mouseabs = [] +# CHD disk image support (SCSI HDDs and CD-ROMs) via libchdman-rs. +# Off by default — enable with `cargo build --features chd`. +chd = ["dep:libchdman-rs"] [dependencies] clap = { version = "4", features = ["derive"] } @@ -58,7 +61,7 @@ cranelift-jit = { version = "0.116", optional = true } cranelift-module = { version = "0.116", optional = true } cranelift-native = { version = "0.116", optional = true } target-lexicon = { version = "0.13", optional = true } -libchdman-rs = { version = "0.287.0-l7", features = ["prebuilt"] } +libchdman-rs = { version = "0.287.0-l7", features = ["prebuilt"], optional = true } [target.'cfg(not(windows))'.dependencies] libc = "0.2" diff --git a/README.md b/README.md index 09f722e..e5aacb4 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,24 @@ cargo run --release --features jit # enable Cranelift MIPS JIT cargo run --release --features rex-jit # enable REX3 graphics JIT compiler cargo run --release --features tlbvmap # enable 8k slot to tlb entry map (increases cache use but may help depending on host cpu arch) cargo run --release --features ci_clock # synthetic deterministic CP0 Compare clock (CI/snapshot validator only; loses realtime desktop timing) +cargo run --release --features chd # mount .chd disk/CD-ROM images directly (via libchdman-rs); off by default to keep builds light cargo run --release --features lightning,rex-jit,tlbvmap # recommended for best speed right now ``` +### CHD image support (`--features chd`) + +Off by default. When enabled, IRIS can mount `.chd` hard-disk and CD-ROM +images directly without first extracting to raw. Compressed parent CHDs +stay untouched — writes go to a MAME-style `.diff.chd` sidecar. + +``` +cargo build --release --features chd +``` + +Without this feature, attempting to mount a `.chd` path returns an +`Unsupported` error; raw images and COW overlays continue to work as +before. + See [HELP.md](HELP.md) for the full rundown: serial ports, monitor console, NVRAM/MAC address setup, disk image prep, and more. diff --git a/src/lib.rs b/src/lib.rs index bb46101..9488320 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod pit8254; pub mod net; pub mod seeq8003; pub mod cow_disk; +#[cfg(feature = "chd")] pub mod chd_disk; pub mod scsi; pub mod wd33c93a; diff --git a/src/scsi.rs b/src/scsi.rs index 8a00d24..fa41bb3 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -69,9 +69,11 @@ pub enum DiskBackend { Cow(CowDisk), /// Hard-disk CHD. Writable; compressed parents get an uncompressed /// `.diff.chd` sidecar (MAME-style), so the parent stays untouched. + #[cfg(feature = "chd")] ChdHd(crate::chd_disk::ChdHd), /// CD CHD (single-track MODE1) exposed as a 2048-byte/sector read-only /// stream. Writes return an error. + #[cfg(feature = "chd")] ChdCd(crate::chd_disk::ChdCd), } @@ -92,7 +94,9 @@ impl DiskBackend { DiskBackend::Cow(cow) => { cow.read_sectors(lba, count) } + #[cfg(feature = "chd")] DiskBackend::ChdHd(hd) => hd.read_blocks(lba, count, block_size), + #[cfg(feature = "chd")] DiskBackend::ChdCd(cd) => cd.read_blocks(lba, count, block_size), } } @@ -106,7 +110,9 @@ impl DiskBackend { Ok(()) } DiskBackend::Cow(cow) => cow.write_sectors(lba, data), + #[cfg(feature = "chd")] DiskBackend::ChdHd(hd) => hd.write_sectors(lba, data), + #[cfg(feature = "chd")] DiskBackend::ChdCd(_) => Err(io::Error::new( io::ErrorKind::PermissionDenied, "CD CHD is read-only", @@ -118,7 +124,9 @@ impl DiskBackend { match self { DiskBackend::Direct(file) => file.metadata().map(|m| m.len()).unwrap_or(0), DiskBackend::Cow(cow) => cow.size(), + #[cfg(feature = "chd")] DiskBackend::ChdHd(hd) => hd.size(), + #[cfg(feature = "chd")] DiskBackend::ChdCd(cd) => cd.size(), } } @@ -170,7 +178,9 @@ impl ScsiDevice { pub fn cow_commit(&mut self) -> io::Result { match &mut self.backend { DiskBackend::Cow(cow) => cow.commit(), - DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(0), + DiskBackend::Direct(_) => Ok(0), + #[cfg(feature = "chd")] + DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(0), } } @@ -178,7 +188,9 @@ impl ScsiDevice { pub fn cow_reset(&mut self) -> io::Result<()> { match &mut self.backend { DiskBackend::Cow(cow) => cow.reset_overlay(), - DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), + DiskBackend::Direct(_) => Ok(()), + #[cfg(feature = "chd")] + DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), } } @@ -187,7 +199,9 @@ impl ScsiDevice { pub fn cow_export(&mut self, dest: &std::path::Path) -> io::Result> { match &mut self.backend { DiskBackend::Cow(cow) => cow.export_overlay(dest), - DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(Vec::new()), + DiskBackend::Direct(_) => Ok(Vec::new()), + #[cfg(feature = "chd")] + DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(Vec::new()), } } @@ -196,7 +210,9 @@ impl ScsiDevice { pub fn cow_import(&mut self, source: &std::path::Path, dirty: Vec) -> io::Result<()> { match &mut self.backend { DiskBackend::Cow(cow) => cow.import_overlay(source, dirty), - DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), + DiskBackend::Direct(_) => Ok(()), + #[cfg(feature = "chd")] + DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => Ok(()), } } @@ -204,7 +220,9 @@ impl ScsiDevice { pub fn cow_dirty_count(&self) -> usize { match &self.backend { DiskBackend::Cow(cow) => cow.dirty_count(), - DiskBackend::Direct(_) | DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => 0, + DiskBackend::Direct(_) => 0, + #[cfg(feature = "chd")] + DiskBackend::ChdHd(_) | DiskBackend::ChdCd(_) => 0, } } diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index f373aa7..d985e9a 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -248,22 +248,40 @@ impl Wd33c93a { overlay: bool, overlay_path_override: Option<&str>, ) -> std::io::Result<()> { - use crate::chd_disk::{is_chd, ChdCd, ChdHd}; use crate::cow_disk::CowDisk; use crate::scsi::DiskBackend; - let (backend, size) = if is_chd(path) { - if is_cdrom { - let cd = ChdCd::open(path)?; - let sz = cd.size(); - (DiskBackend::ChdCd(cd), sz) - } else { - // HD CHDs are inherently writable via the libchdman-rs HdImage - // surface (in-place for uncompressed, diff sidecar for - // compressed), so the `overlay` flag is not applicable here. - let hd = ChdHd::open(path)?; - let sz = hd.size(); - (DiskBackend::ChdHd(hd), sz) + #[cfg(feature = "chd")] + let is_chd_path = crate::chd_disk::is_chd(path); + #[cfg(not(feature = "chd"))] + let is_chd_path = { + let p = path.to_ascii_lowercase(); + p.ends_with(".chd") + }; + + let (backend, size) = if is_chd_path { + #[cfg(feature = "chd")] + { + use crate::chd_disk::{ChdCd, ChdHd}; + if is_cdrom { + let cd = ChdCd::open(path)?; + let sz = cd.size(); + (DiskBackend::ChdCd(cd), sz) + } else { + // HD CHDs are inherently writable via the libchdman-rs HdImage + // surface (in-place for uncompressed, diff sidecar for + // compressed), so the `overlay` flag is not applicable here. + let hd = ChdHd::open(path)?; + let sz = hd.size(); + (DiskBackend::ChdHd(hd), sz) + } + } + #[cfg(not(feature = "chd"))] + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "CHD image support not compiled in (rebuild with --features chd)", + )); } } else if overlay && !is_cdrom { let overlay_path = overlay_path_override