From cb2772cef8ceab0f4da1ffd54d056948f9083db7 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 00:44:46 +0700 Subject: [PATCH] feat(io): model disabled sound and disconnected serial state --- crates/gba-core/src/io.rs | 23 +++- crates/gba-core/src/io/inactive.rs | 120 ++++++++++++++++++ crates/gba-core/src/memory.rs | 33 ++++- crates/gba-core/tests/inactive_devices.rs | 142 ++++++++++++++++++++++ crates/gba-core/tests/keypad.rs | 6 +- 5 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 crates/gba-core/src/io/inactive.rs create mode 100644 crates/gba-core/tests/inactive_devices.rs diff --git a/crates/gba-core/src/io.rs b/crates/gba-core/src/io.rs index 96e23cb..81bad14 100644 --- a/crates/gba-core/src/io.rs +++ b/crates/gba-core/src/io.rs @@ -1,7 +1,13 @@ -//! GBA I/O subset: DMA, timers, interrupts, WAITCNT, display, and keypad input. +//! GBA I/O subset: DMA, timers, interrupts, WAITCNT, display, keypad input, +//! disabled sound state, and disconnected serial initialization. //! Timers share a free-running prescaler phase. Hardware startup/write delays, //! audio events, and interrupt delivery delays are not modeled. +mod inactive; +pub use inactive::{ + JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8, SOUNDBIAS, SOUNDCNT_H, + SOUNDCNT_X, SOUND_START, WAVE_RAM, +}; mod timer_step; pub(crate) use timer_step::TimerStep; use timer_step::TIMER_IRQ_MASK; @@ -150,6 +156,7 @@ enum PowerState { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(crate) struct Io { + inactive: inactive::Inactive, timers: [Timer; 4], // Low ten system-clock bits cover every supported divider. STOP freezes this phase. timer_phase: u16, @@ -174,10 +181,18 @@ pub(crate) struct Io { impl Io { pub(crate) fn mapped(address: u32) -> bool { - matches!(address, 0x0400_0000..=0x0400_0057 | DMA_BASE..=DMA_END | 0x0400_0100..=0x0400_010f | 0x0400_0130..=0x0400_0133 | 0x0400_0200..=0x0400_020b | POSTFLG..=0x0400_0303) + inactive::Inactive::mapped(address) + || matches!(address, 0x0400_0000..=0x0400_0057 | DMA_BASE..=DMA_END | 0x0400_0100..=0x0400_010f | 0x0400_0130..=0x0400_0133 | 0x0400_0200..=0x0400_020b | POSTFLG..=0x0400_0303) + } + + pub(crate) fn unsupported_write(address: u32, value: u8) -> Option<&'static str> { + inactive::Inactive::unsupported(address, value) } pub(crate) fn read8(&self, address: u32) -> Option { + if inactive::Inactive::mapped(address) { + return self.inactive.read8(address); + } if (DMA_BASE..=DMA_END).contains(&address) { return Some(self.dma.read8(address)); } @@ -216,6 +231,10 @@ impl Io { pub(crate) fn write8(&mut self, address: u32, value: u8) { debug_assert!(Self::mapped(address)); debug_assert!(!(KEYINPUT..=KEYCNT + 1).contains(&address)); + if inactive::Inactive::mapped(address) { + self.inactive.write8(address, value); + return; + } if (DMA_BASE..=DMA_END).contains(&address) { self.dma.write8(address, value); return; diff --git a/crates/gba-core/src/io/inactive.rs b/crates/gba-core/src/io/inactive.rs new file mode 100644 index 0000000..5cb75a8 --- /dev/null +++ b/crates/gba-core/src/io/inactive.rs @@ -0,0 +1,120 @@ +//! Disabled sound and disconnected serial initialization state. +//! Sound enable, FIFO writes, serial transfers, UART/multiplayer/Joybus modes, +//! and GPIO interrupts remain explicit diagnostics, not silent device stubs. + +use super::replace_byte; + +pub const SOUND_START: u32 = 0x0400_0060; +pub const SOUNDCNT_H: u32 = 0x0400_0082; +pub const SOUNDCNT_X: u32 = 0x0400_0084; +pub const SOUNDBIAS: u32 = 0x0400_0088; +pub const WAVE_RAM: u32 = 0x0400_0090; +pub const SIODATA32: u32 = 0x0400_0120; +pub const SIOCNT: u32 = 0x0400_0128; +pub const SIODATA8: u32 = 0x0400_012a; +pub const RCNT: u32 = 0x0400_0134; +pub const JOYCNT: u32 = 0x0400_0140; +pub const JOY_RECV: u32 = 0x0400_0150; +pub const JOY_TRANS: u32 = 0x0400_0154; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct Inactive { + sound_control: u16, + bias: u16, + // With sound disabled, SOUND3CNT_L is zero. The CPU accesses bank 1; + // bank 0 stays zero because enabling sound/bank selection is unsupported. + wave: [u8; 16], + serial_data: [u8; 4], + serial_send: u8, + serial_control: u16, + rcnt: u16, +} + +impl Inactive { + pub(super) fn mapped(address: u32) -> bool { + matches!(address, + SOUND_START..=0x0400_008b | WAVE_RAM..=0x0400_009f | + SIODATA32..=0x0400_0123 | SIOCNT..=0x0400_012b | + RCNT..=0x0400_0135 | JOYCNT..=0x0400_0141 | + JOY_RECV..=0x0400_0157) + } + + /// Value-dependent rejection is independent of device state, so batch + /// stores can validate all values before committing any RAM/I/O writes. + pub(super) fn unsupported(address: u32, value: u8) -> Option<&'static str> { + match address { + SOUNDCNT_X if value & 0x80 != 0 => { + Some("sound master enable (audio engine not implemented)") + } + SIOCNT if value & 0x80 != 0 => Some("serial transfer start"), + 0x0400_0129 if value & 0x20 != 0 => Some("multiplayer/UART serial mode"), + 0x0400_0135 if value & 0x40 != 0 => Some("Joybus serial mode"), + 0x0400_0135 if value & 1 != 0 => Some("GPIO serial interrupt enable"), + JOYCNT if value & 0x40 != 0 => Some("Joybus interrupt enable"), + JOY_RECV..=0x0400_0157 if value != 0 => Some("Joybus data access beyond reset"), + _ => None, + } + } + + fn gpio_pins(&self) -> u16 { + let outputs = (self.rcnt >> 4) & 15; + (self.rcnt & outputs & 15) | (!outputs & 15) // Disconnected inputs have pull-ups. + } + + pub(super) fn read8(&self, address: u32) -> Option { + if (WAVE_RAM..=WAVE_RAM + 15).contains(&address) { + return Some(self.wave[(address - WAVE_RAM) as usize]); + } + if (SIODATA32..=SIODATA32 + 3).contains(&address) { + return Some(self.serial_data[(address - SIODATA32) as usize]); + } + let value = match address & !1 { + SOUNDCNT_H => self.sound_control, + SOUNDBIAS => self.bias, + SOUND_START..=0x0400_008a => 0, // PSG stays reset while master enable is clear. + SIOCNT => { + let si = if self.rcnt & 0x8000 != 0 { + self.gpio_pins() & 4 + } else { + 4 + }; + self.serial_control | si + } + SIODATA8 => u16::from(self.serial_send), + RCNT => { + // Normal-mode RCNT low pin samples are outside this subset. + if self.rcnt & 0x8000 == 0 && address & 1 == 0 { + return None; + } + (self.rcnt & !15) | self.gpio_pins() + } + JOYCNT | JOY_RECV..=0x0400_0156 => 0, // Only reset-state Joybus accesses are supported. + _ => return None, + }; + Some(value.to_le_bytes()[(address & 1) as usize]) + } + + pub(super) fn write8(&mut self, address: u32, value: u8) { + debug_assert!(Self::unsupported(address, value).is_none()); + if (WAVE_RAM..=WAVE_RAM + 15).contains(&address) { + self.wave[(address - WAVE_RAM) as usize] = value; + return; + } + if (SIODATA32..=SIODATA32 + 3).contains(&address) { + self.serial_data[(address - SIODATA32) as usize] = value; + return; + } + match address & !1 { + SOUNDCNT_H => { + self.sound_control = replace_byte(self.sound_control, address, value) & 0x770f + } + SOUNDBIAS => self.bias = replace_byte(self.bias, address, value) & 0xc3fe, + SIOCNT => { + self.serial_control = replace_byte(self.serial_control, address, value) & 0x500b + } + SIODATA8 if address & 1 == 0 => self.serial_send = value, + RCNT => self.rcnt = replace_byte(self.rcnt, address, value) & 0x80ff, + _ => {} // Disabled PSG writes and unused/read-only bits have no effect. + } + } +} diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index dbca33e..1130362 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -43,11 +43,24 @@ pub enum MemoryError { Unaligned(u32), RomTooLarge(usize), InvalidBiosSize(usize), + UnsupportedIo { + address: u32, + value: u8, + operation: &'static str, + }, } impl fmt::Display for MemoryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::UnsupportedIo { + address, + value, + operation, + } => write!( + f, + "unsupported {operation}: write {value:#04x} at {address:#010x}" + ), Self::Unmapped(address) => write!(f, "unmapped memory at {address:#010x}"), Self::ReadOnly(address) => write!(f, "read-only memory at {address:#010x}"), Self::Unaligned(address) => write!(f, "unaligned memory access at {address:#010x}"), @@ -714,6 +727,7 @@ impl Memory { bytes: [u8; N], ) -> Result<(), MemoryError> { let index = self.write_index::(address)?; + Self::validate_io_values(address, &bytes)?; self.record_access( address, match N { @@ -829,12 +843,29 @@ impl Memory { } } + fn validate_io_values(address: u32, bytes: &[u8]) -> Result<(), MemoryError> { + if address >> 24 == 0x04 { + for (offset, &value) in bytes.iter().enumerate() { + let address = address + offset as u32; + if let Some(operation) = Io::unsupported_write(address, value) { + return Err(MemoryError::UnsupportedIo { + address, + value, + operation, + }); + } + } + } + Ok(()) + } + /// Validate a batch before any RAM or I/O writes. Diagnostic errors cannot /// leave a partial block store. This is not a model of hardware data aborts. /// Mapped I/O reads currently have no side effects; writes cannot fail after validation. pub(crate) fn write_words(&mut self, writes: &[(u32, u32)]) -> Result<(), MemoryError> { - for &(address, _) in writes { + for &(address, value) in writes { self.write_index::<4>(address)?; + Self::validate_io_values(address, &value.to_le_bytes())?; } for &(address, value) in writes { // The map cannot change between validation and these writes. diff --git a/crates/gba-core/tests/inactive_devices.rs b/crates/gba-core/tests/inactive_devices.rs new file mode 100644 index 0000000..4852298 --- /dev/null +++ b/crates/gba-core/tests/inactive_devices.rs @@ -0,0 +1,142 @@ +//! Original bus/CPU/DMA regressions for the disabled-device initialization subset. +use gba_core::{ + cpu::Cpu, + dma::DMA_BASE, + io::{ + JOYCNT, JOY_RECV, RCNT, SIOCNT, SIODATA32, SIODATA8, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, + WAVE_RAM, + }, + machine::Machine, + memory::{Memory, MemoryError, ROM_START}, +}; + +fn memory() -> Memory { + Memory::new(vec![0xfe, 0xff, 0xff, 0xea]).unwrap() +} + +#[test] +fn disabled_sound_registers_masks_lanes_and_wave_ram() { + let mut bus = memory(); + for address in (0x04000060..0x04000082).step_by(2) { + bus.write16(address, 0xffff).unwrap(); + assert_eq!(bus.read16(address).unwrap(), 0); + } + bus.write16(SOUNDCNT_H, 0xffff).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 0x770f); // Reset strobes do not latch. + bus.write8(SOUNDCNT_H, 0).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 0x7700); + bus.write32(SOUNDBIAS, u32::MAX).unwrap(); + assert_eq!(bus.read32(SOUNDBIAS).unwrap(), 0xc3fe); + for index in 0..16 { + bus.write8(WAVE_RAM + index, index as u8 * 13).unwrap(); + } + bus.write32(SOUNDCNT_X, 0x7f).unwrap(); // Status bits are read-only; master stays off. + assert_eq!(bus.read32(SOUNDCNT_X).unwrap(), 0); + for index in 0..16 { + assert_eq!(bus.read8(WAVE_RAM + index).unwrap(), index as u8 * 13); + } + assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 0x7700); + assert_eq!(bus.read16(SOUNDBIAS).unwrap(), 0xc3fe); + // FIFOs are not ordinary register latches. + assert!(bus.write32(0x040000a0, 0).is_err()); + assert!(bus.read32(0x040000a0).is_err()); +} + +#[test] +fn disconnected_gpio_inputs_pull_high_and_outputs_follow_latches() { + let mut bus = memory(); + bus.write16(RCNT, 0x8000).unwrap(); + assert_eq!(bus.read16(RCNT).unwrap(), 0x800f); + bus.write16(RCNT, 0x80f5).unwrap(); + assert_eq!(bus.read16(RCNT).unwrap(), 0x80f5); + bus.write8(RCNT, 0x20).unwrap(); // SD output low; SC/SI/SO pulled high. + assert_eq!(bus.read16(RCNT).unwrap(), 0x802d); + bus.write8(RCNT, 0x22).unwrap(); + assert_eq!(bus.read16(RCNT).unwrap(), 0x802f); + bus.write16(RCNT, 0).unwrap(); + assert!(bus.read8(RCNT).is_err()); // Unsupported normal-mode pin readback is not guessed. + assert_eq!(bus.read8(RCNT + 1).unwrap(), 0); +} + +#[test] +fn idle_normal_serial_data_and_control_have_distinct_widths() { + let mut bus = memory(); + bus.write32(SIODATA32, 0x12345678).unwrap(); + assert_eq!(bus.read32(SIODATA32).unwrap(), 0x12345678); + bus.write16(SIODATA32, 0).unwrap(); + assert_eq!(bus.read32(SIODATA32).unwrap(), 0x12340000); + bus.write16(SIODATA8, 0xabcd).unwrap(); + assert_eq!(bus.read16(SIODATA8).unwrap(), 0xcd); + bus.write16(SIOCNT, 0x500b).unwrap(); + assert_eq!(bus.read16(SIOCNT).unwrap(), 0x500f); // Disconnected SI is high. + bus.advance_cycles(1000000); + assert_eq!(bus.read16(SIOCNT).unwrap(), 0x500f); // No fabricated completion or IRQ. + assert_eq!(bus.read16(0x04000202).unwrap() & 0x80, 0); + bus.write16(JOYCNT, 7).unwrap(); // Clear empty status flags. + assert_eq!(bus.read16(JOYCNT).unwrap(), 0); +} + +#[test] +fn unsupported_operations_have_specific_retryable_diagnostics() { + let mut bus = memory(); + for (address, value, description) in [ + (SOUNDCNT_X, 0x80, "sound master enable"), + (SIOCNT, 0x80, "serial transfer start"), + (SIOCNT + 1, 0x20, "multiplayer/UART"), + (RCNT + 1, 0x40, "Joybus serial mode"), + (RCNT + 1, 1, "GPIO serial interrupt"), + (JOYCNT, 0x40, "Joybus interrupt"), + (JOY_RECV, 1, "Joybus data access"), + ] { + let error = bus.write8(address, value).unwrap_err(); + assert!(matches!(error, MemoryError::UnsupportedIo { .. })); + assert!(error.to_string().contains(description)); + assert_eq!(bus.write8(address, value), Err(error)); + } + bus.write16(RCNT, 0x8055).unwrap(); + assert!(bus.write16(RCNT, 0xc0aa).is_err()); + assert_eq!(bus.read16(RCNT).unwrap(), 0x805f); // Low byte was not committed. + bus.write16(SIOCNT, 0x4008).unwrap(); + assert!(bus.write32(SIOCNT, 0xaabb2080).is_err()); + assert_eq!(bus.read16(SIOCNT).unwrap(), 0x400c); + assert_eq!(bus.read16(SIODATA8).unwrap(), 0); +} + +#[test] +fn block_store_rejects_later_activation_before_earlier_control_writes() { + let code: [u32; 7] = [ + 0xe3a00301, 0xe2800080, 0xe59f1008, 0xe3a02080, 0xe8a00006, 0xeafffffe, 0x770f0000, + ]; + let bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + for _ in 0..4 { + machine.step().unwrap(); + } + let before = machine.cpu().clone(); + let cycles = machine.cycles(); + let error = machine.step().unwrap_err(); + assert!(error.to_string().contains("sound master enable")); + assert_eq!(machine.cpu(), &before); + assert_eq!(machine.cycles(), cycles); + assert_eq!(machine.memory().read16(SOUNDCNT_H).unwrap(), 0); + assert_eq!(machine.step(), Err(error)); +} + +#[test] +fn dma_activation_error_preserves_channel_progress_and_clocks() { + let mut bus = memory(); + bus.write32(0x02000000, 0x80).unwrap(); + bus.write32(DMA_BASE, 0x02000000).unwrap(); + bus.write32(DMA_BASE + 4, SOUNDCNT_X).unwrap(); + bus.write32(DMA_BASE + 8, 0x84000001).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + let before = machine.cpu().clone(); + let error = machine.step().unwrap_err(); + assert!(error.to_string().contains("sound master enable")); + assert_eq!(machine.cycles(), 0); + assert_eq!(machine.cpu(), &before); + assert_eq!(machine.step(), Err(error)); + machine.memory_mut().write32(0x02000000, 0).unwrap(); + machine.step().unwrap(); + assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap() & 0x8000, 0); +} diff --git a/crates/gba-core/tests/keypad.rs b/crates/gba-core/tests/keypad.rs index feb6fa6..ebd1521 100644 --- a/crates/gba-core/tests/keypad.rs +++ b/crates/gba-core/tests/keypad.rs @@ -53,7 +53,7 @@ fn register_reset_masks_byte_merges_and_combined_word_access() { assert_eq!(m.read32(KEYINPUT).unwrap(), 0x0123_01fe); assert_eq!(m.cycles(), 0); assert_eq!(m.read32(KEYCNT), Err(MemoryError::Unaligned(KEYCNT))); - for address in [KEYCNT + 2, KEYCNT + 0x400] { + for address in [KEYCNT + 4, KEYCNT + 0x400] { assert_eq!(m.read8(address), Err(MemoryError::Unmapped(address))); assert_eq!(m.write8(address, 0), Err(MemoryError::Unmapped(address))); } @@ -371,7 +371,7 @@ fn cpu_store_samples_keys_then_irq_enters_on_the_following_step() { #[test] fn failed_block_store_does_not_commit_key_control_irq_history_or_halt_wake() { - // STMIA r0!,{r1,r2}: keypad word is valid, next word (serial RCNT) is not mapped. + // STMIA r0!,{r1,r2}: keypad word is valid, but RCNT's upper padding is not mapped. let mut m = prepared(0xe8a0_0006, KEYINPUT, 0x4001_0000); press(m.memory_mut(), 1); m.memory_mut().write16(IE, IRQ).unwrap(); @@ -381,7 +381,7 @@ fn failed_block_store_does_not_commit_key_control_irq_history_or_halt_wake() { assert_eq!( m.step(), Err(MachineError::Cpu(CpuError::Memory(MemoryError::Unmapped( - KEYINPUT + 4 + KEYINPUT + 6 )))) ); assert_eq!(m.cpu(), &before);