feat(io): model disabled sound and disconnected serial state

This commit is contained in:
0xrsydn 2026-10-07 00:44:46 +07:00
commit cb2772cef8
5 changed files with 318 additions and 6 deletions

View file

@ -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<u8> {
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;

View file

@ -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<u8> {
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.
}
}
}

View file

@ -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::<N>(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.