From 5888c4d29279f60ab91e5cd156d20073a0106955 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 01:35:55 +0700 Subject: [PATCH] feat(audio): implement nominal Direct Sound playback and FIFO DMA --- AGENTS.md | 1 + crates/gba-core/src/audio.rs | 219 ++++++++++++ crates/gba-core/src/bios/reset.rs | 5 +- crates/gba-core/src/dma.rs | 36 +- crates/gba-core/src/io.rs | 39 ++- crates/gba-core/src/io/inactive.rs | 34 +- crates/gba-core/src/io/timer_step.rs | 15 +- crates/gba-core/src/lib.rs | 1 + crates/gba-core/src/memory.rs | 43 ++- .../gba-core/src/memory/audio_step_tests.rs | 104 ++++++ crates/gba-core/tests/bios/ram_reset.rs | 26 ++ crates/gba-core/tests/direct_sound.rs | 318 ++++++++++++++++++ crates/gba-core/tests/inactive_devices.rs | 19 +- docs/architecture.md | 7 +- docs/hardware/audio.md | 129 +++++-- docs/hardware/bios.md | 7 +- docs/hardware/dma.md | 4 +- docs/hardware/timers-irq.md | 16 +- docs/research/emerald-reset.md | 23 +- docs/status.md | 11 +- docs/usability-roadmap.md | 5 +- 21 files changed, 933 insertions(+), 129 deletions(-) create mode 100644 crates/gba-core/src/audio.rs create mode 100644 crates/gba-core/src/memory/audio_step_tests.rs create mode 100644 crates/gba-core/tests/direct_sound.rs diff --git a/AGENTS.md b/AGENTS.md index ba96e46..a4097f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Window smoke tests (`--*-smoke-test`) need a logged-in desktop session. Details | BIOS services (SWI) | [docs/hardware/bios.md](docs/hardware/bios.md) | | Timers, IE/IF/IME, HALT/STOP | [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | | DMA | [docs/hardware/dma.md](docs/hardware/dma.md) | +| Direct Sound, FIFOs, PSG limits, and mixing | [docs/hardware/audio.md](docs/hardware/audio.md) | | VCOUNT/DISPSTAT, HBlank/VBlank, row capture | [docs/hardware/display-timing.md](docs/hardware/display-timing.md) | | Backgrounds, sprites, mosaic, windows, blending, input | [docs/hardware/video.md](docs/hardware/video.md) | | Demos and key mapping | [docs/demos.md](docs/demos.md) | diff --git a/crates/gba-core/src/audio.rs b/crates/gba-core/src/audio.rs new file mode 100644 index 0000000..f19e636 --- /dev/null +++ b/crates/gba-core/src/audio.rs @@ -0,0 +1,219 @@ +//! Nominal Direct Sound device state. No host audio or PSG synthesis. + +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 FIFO_A: u32 = 0x0400_00a0; +pub const FIFO_B: u32 = 0x0400_00a4; + +/// Instantaneous digital mixer levels, not a sampled audio stream. +/// Bias is applied, clipped to ten bits, then centered at 512: -512..=511. +/// Master disable returns zero. PWM quantization and analog filtering are absent. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct StereoLevel { + pub left: i16, + pub right: i16, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct Fifo { + words: [u32; 7], + read: usize, + len: usize, + playback: u32, + remaining: u8, + sample: i8, +} + +impl Fifo { + fn reset_queue(&mut self) { + self.words = [0; 7]; + self.read = 0; + self.len = 0; + } + + fn write(&mut self, address: u32, bytes: &[u8]) { + if self.len == 7 { + self.reset_queue(); + return; + } + let slot = (self.read + self.len) % 7; + for (offset, &byte) in bytes.iter().enumerate() { + let shift = ((address & 3) as usize + offset) * 8; + self.words[slot] = (self.words[slot] & !(255 << shift)) | (u32::from(byte) << shift); + } + self.len += 1; + } + + fn clock(&mut self, overflows: u64) -> bool { + let mut request = false; + // At most 32 queued/in-flight bytes, then one empty sample. This bounds + // device-only advances even with a timer overflow on every clock. + for _ in 0..overflows.min(33) { + request |= self.len <= 3; + if self.remaining == 0 && self.len != 0 { + self.playback = self.words[self.read]; + self.read = (self.read + 1) % 7; + self.len -= 1; + self.remaining = 4; + } + self.sample = self.playback as i8; + if self.remaining != 0 { + self.playback >>= 8; + self.remaining -= 1; + } + } + request + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Audio { + enabled: bool, + psg: [u16; 17], + control: u16, + bias: u16, + wave: [[u8; 16]; 2], + fifo: [Fifo; 2], + requests: [bool; 2], +} + +impl Audio { + pub(crate) fn mapped(address: u32) -> bool { + matches!(address, SOUND_START..=0x0400_008b | WAVE_RAM..=0x0400_00a7) + } + + pub(crate) fn unsupported(&self, address: u32, value: u8) -> Option<&'static str> { + if self.enabled + && matches!( + address, + 0x0400_0065 | 0x0400_006d | 0x0400_0075 | 0x0400_007d + ) + && value & 0x80 != 0 + { + Some("PSG channel trigger (synthesis not implemented)") + } else { + None + } + } + + fn wave_bank(&self) -> usize { + 1 ^ usize::from((self.psg[8] >> 6) & 1) + } + + pub(crate) fn read8(&self, address: u32) -> Option { + if (WAVE_RAM..FIFO_A).contains(&address) { + return Some(self.wave[self.wave_bank()][(address - WAVE_RAM) as usize]); + } + let value = match address & !1 { + SOUND_START..=0x0400_0080 => { + let mask = match address & !1 { + 0x0400_0060 => 0x007f, + 0x0400_0062 | 0x0400_0068 => 0xffc0, + 0x0400_0064 | 0x0400_006c | 0x0400_0074 => 0x4000, + 0x0400_0070 => 0x00e0, + 0x0400_0072 => 0xe000, + 0x0400_0078 => 0xff00, + 0x0400_007c => 0x40ff, + 0x0400_0080 => 0xff77, + _ => 0, + }; + self.psg[((address - SOUND_START) / 2) as usize] & mask + } + SOUNDCNT_H => self.control, + SOUNDCNT_X => u16::from(self.enabled) << 7, + SOUNDBIAS => self.bias, + 0x0400_0086 | 0x0400_008a => 0, + _ => return None, // FIFO is write-only; open-bus readback is not modeled. + }; + Some(value.to_le_bytes()[(address & 1) as usize]) + } + + /// One bus access. Partial FIFO writes advance a whole word, not one byte. + pub(crate) fn write(&mut self, address: u32, bytes: &[u8]) { + if (FIFO_A..=FIFO_B + 3).contains(&address) { + self.fifo[((address - FIFO_A) / 4) as usize].write(address, bytes); + return; + } + for (offset, &value) in bytes.iter().enumerate() { + let address = address + offset as u32; + if (WAVE_RAM..FIFO_A).contains(&address) { + self.wave[self.wave_bank()][(address - WAVE_RAM) as usize] = value; + continue; + } + let shift = (address & 1) * 8; + let merge = |old: u16| (old & !(255 << shift)) | (u16::from(value) << shift); + match address & !1 { + SOUND_START..=0x0400_0080 if self.enabled => { + let register = &mut self.psg[((address - SOUND_START) / 2) as usize]; + *register = merge(*register); + } + SOUNDCNT_H => { + self.control = merge(self.control) & 0x770f; + if address & 1 != 0 { + for (index, mask) in [8, 128].into_iter().enumerate() { + if value & mask != 0 { + self.fifo[index].reset_queue(); + } + } + } + } + SOUNDCNT_X if address & 1 == 0 => { + if self.enabled && value & 0x80 == 0 { + self.psg = [0; 17]; + for fifo in &mut self.fifo { + fifo.reset_queue(); + } + } + self.enabled = value & 0x80 != 0; + } + SOUNDBIAS => self.bias = merge(self.bias) & 0xc3fe, + _ => {} + } + } + } + + pub(crate) fn timer_overflows(&mut self, timer: usize, count: u64) { + if !self.enabled || timer > 1 { + return; + } + for index in 0..2 { + let selected = usize::from((self.control >> (10 + index * 4)) & 1); + if selected == timer { + self.requests[index] |= self.fifo[index].clock(count); + } + } + } + + pub(crate) fn take_requests(&mut self) -> [bool; 2] { + std::mem::take(&mut self.requests) + } + + pub(crate) fn level(&self) -> StereoLevel { + if !self.enabled { + return StereoLevel::default(); + } + let mut sides = [0i16; 2]; // right, left + for index in 0..2 { + let volume = if self.control & (4 << index) != 0 { + 4 + } else { + 2 + }; + for (side, level) in sides.iter_mut().enumerate() { + if self.control & (1 << (8 + index * 4 + side)) != 0 { + *level += i16::from(self.fifo[index].sample) * volume; + } + } + } + for level in &mut sides { + *level = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512; + } + StereoLevel { + left: sides[1], + right: sides[0], + } + } +} diff --git a/crates/gba-core/src/bios/reset.rs b/crates/gba-core/src/bios/reset.rs index 30559a6..1ca12fb 100644 --- a/crates/gba-core/src/bios/reset.rs +++ b/crates/gba-core/src/bios/reset.rs @@ -92,12 +92,15 @@ fn emit_ram_reset(a: &mut ArmImage) { a.literal(1, SOUNDCNT_X); store_halfword(a, 0); // Disable sound; PSG register state is held in reset. a.literal(1, SOUNDCNT_H); + a.emit(0xe3a0_3c88); // MOV r3,#0x8800 (clear both FIFO queues) + store_halfword(a, 0); + a.emit(0xe3a0_3000); // MOV r3,#0 store_halfword(a, 0); a.literal(1, SOUNDBIAS); a.emit(0xe3a0_3c02); // MOV r3,#0x200 (bias default) store_halfword(a, 0); a.emit(0xe3a0_3000); // MOV r3,#0 - clear_words(a, WAVE_RAM, 16); // Accessible inactive bank; other bank cannot be enabled yet. + clear_words(a, WAVE_RAM, 16); // CPU-accessible inactive bank 1; bank 0 is retained. a.label("ram_reset_other"); a.emit(0xe310_0080); // TST r0,#other registers diff --git a/crates/gba-core/src/dma.rs b/crates/gba-core/src/dma.rs index 98e96d7..28166a6 100644 --- a/crates/gba-core/src/dma.rs +++ b/crates/gba-core/src/dma.rs @@ -1,6 +1,7 @@ //! Four-channel GBA direct memory access (DMA) controller. //! Transfers are scheduled one data unit at a time. Startup/resumption delays, -//! CPU bus handoff, sound FIFO, video capture, and Game Pak DRQ are not modeled. +//! CPU bus handoff, video capture, and Game Pak DRQ are not modeled. +//! Sound requests support DMA1/FIFO A and DMA2/FIFO B; other pairings are diagnostic. //! Each channel retains known source data across blocks, including blocked reads. use std::{error::Error, fmt}; @@ -79,12 +80,16 @@ impl Channel { (self.control >> 12) & 3 } - fn unsupported(&self) -> bool { - self.start_timing() == 3 || self.control & 0x800 != 0 || self.control & 0x180 == 0x180 + fn unsupported(&self, index: usize) -> bool { + (self.start_timing() == 3 + && (!(1..=2).contains(&index) + || self.destination != crate::audio::FIFO_A + (index as u32 - 1) * 4)) + || self.control & 0x800 != 0 + || self.control & 0x180 == 0x180 } fn width(&self) -> AccessWidth { - if self.control & 0x400 != 0 { + if self.start_timing() == 3 || self.control & 0x400 != 0 { AccessWidth::Word } else { AccessWidth::Halfword @@ -92,7 +97,9 @@ impl Channel { } fn initial_count(&self, index: usize) -> u32 { - if self.count != 0 { + if self.start_timing() == 3 { + 4 + } else if self.count != 0 { u32::from(self.count) } else if index == 3 { 0x1_0000 @@ -183,6 +190,15 @@ impl Dma { } } + pub(crate) fn trigger_sound(&mut self, requests: [bool; 2]) { + for (index, requested) in requests.into_iter().enumerate() { + let channel = &mut self.channels[index + 1]; + if requested && channel.enabled() && channel.start_timing() == 3 && !channel.active { + channel.active = true; + } + } + } + /// Select the highest-priority ready channel without changing its state. /// Unsupported enabled modes report a diagnostic rather than silently waiting. pub(crate) fn next(&self) -> Result, DmaError> { @@ -190,7 +206,7 @@ impl Dma { if !channel.enabled() { continue; } - if channel.unsupported() { + if channel.unsupported(index) { return Err(DmaError::UnsupportedControl { channel: index, control: channel.control, @@ -225,7 +241,11 @@ impl Dma { channel.current_destination = advance_address( channel.current_destination, width, - (channel.control >> 5) & 3, + if channel.start_timing() == 3 { + 2 + } else { + (channel.control >> 5) & 3 + }, ); channel.remaining -= 1; channel.first = false; @@ -236,7 +256,7 @@ impl Dma { if channel.control & 0x200 != 0 && channel.start_timing() != 0 { channel.remaining = channel.initial_count(index); channel.first = true; - if channel.control & 0x60 == 0x60 { + if channel.start_timing() != 3 && channel.control & 0x60 == 0x60 { channel.current_destination = channel.destination & !(width - 1); } } else { diff --git a/crates/gba-core/src/io.rs b/crates/gba-core/src/io.rs index 81bad14..85ed8e2 100644 --- a/crates/gba-core/src/io.rs +++ b/crates/gba-core/src/io.rs @@ -1,13 +1,12 @@ //! 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. +//! Direct Sound, and disconnected serial initialization. +//! Timers share a free-running prescaler phase. Hardware startup/write delays +//! 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, -}; +use crate::audio::Audio; +pub use crate::audio::{FIFO_A, FIFO_B, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, SOUND_START, WAVE_RAM}; +pub use inactive::{JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8}; mod timer_step; pub(crate) use timer_step::TimerStep; use timer_step::TIMER_IRQ_MASK; @@ -157,6 +156,7 @@ enum PowerState { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(crate) struct Io { inactive: inactive::Inactive, + pub(crate) audio: Audio, timers: [Timer; 4], // Low ten system-clock bits cover every supported divider. STOP freezes this phase. timer_phase: u16, @@ -181,7 +181,8 @@ pub(crate) struct Io { impl Io { pub(crate) fn mapped(address: u32) -> bool { - inactive::Inactive::mapped(address) + Audio::mapped(address) + || 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) } @@ -190,6 +191,9 @@ impl Io { } pub(crate) fn read8(&self, address: u32) -> Option { + if Audio::mapped(address) { + return self.audio.read8(address); + } if inactive::Inactive::mapped(address) { return self.inactive.read8(address); } @@ -420,17 +424,25 @@ impl Io { } pub(crate) fn commit_timer_step(&mut self, step: TimerStep) { + self.audio = step.audio; + self.dma.trigger_sound(self.audio.take_requests()); self.timers = step.timers; self.timer_phase = step.phase; self.pending = (self.pending & !TIMER_IRQ_MASK) | step.pending; self.wake_if_requested(); } - fn advance_timer_bank(timers: &mut [Timer; 4], phase: &mut u16, cycles: u32) -> u16 { + fn advance_timer_bank( + timers: &mut [Timer; 4], + phase: &mut u16, + audio: &mut Audio, + cycles: u32, + ) -> u16 { let mut pending = 0; let mut overflows = 0; for (index, timer) in timers.iter_mut().enumerate() { overflows = timer.advance(cycles, overflows, *phase); + audio.timer_overflows(index, overflows); if overflows != 0 && timer.control & 0x40 != 0 { pending |= 1 << (3 + index); } @@ -473,8 +485,13 @@ impl Io { self.dma.trigger(vblank, hblank); self.pending |= self.display.advance(cycles); if advance_timers { - self.pending |= - Self::advance_timer_bank(&mut self.timers, &mut self.timer_phase, cycles); + self.pending |= Self::advance_timer_bank( + &mut self.timers, + &mut self.timer_phase, + &mut self.audio, + cycles, + ); + self.dma.trigger_sound(self.audio.take_requests()); } self.wake_if_requested(); } diff --git a/crates/gba-core/src/io/inactive.rs b/crates/gba-core/src/io/inactive.rs index 5cb75a8..145c053 100644 --- a/crates/gba-core/src/io/inactive.rs +++ b/crates/gba-core/src/io/inactive.rs @@ -1,14 +1,7 @@ -//! 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. +//! Disconnected serial initialization state. Active transfers remain diagnostic. 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; @@ -19,11 +12,6 @@ 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, @@ -33,7 +21,6 @@ pub(super) struct Inactive { 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) @@ -43,9 +30,6 @@ impl Inactive { /// 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"), @@ -62,16 +46,10 @@ impl Inactive { } 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 @@ -96,25 +74,17 @@ impl Inactive { 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. + _ => {} // Unused/read-only bits have no effect. } } } diff --git a/crates/gba-core/src/io/timer_step.rs b/crates/gba-core/src/io/timer_step.rs index d77e4e0..b2d1c65 100644 --- a/crates/gba-core/src/io/timer_step.rs +++ b/crates/gba-core/src/io/timer_step.rs @@ -1,4 +1,4 @@ -//! Staged timer clocks for one machine instruction, IRQ entry, or DMA unit. +//! Staged timer and Direct Sound clocks for one instruction, IRQ entry, or DMA unit. //! Other devices remain on the instruction-boundary scheduler. use super::{Io, Timer, IF, TIMER_BASE}; @@ -6,6 +6,7 @@ pub(super) const TIMER_IRQ_MASK: u16 = 0x78; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct TimerStep { + pub(crate) audio: crate::audio::Audio, pub(super) timers: [Timer; 4], pub(super) phase: u16, pub(super) pending: u16, @@ -15,6 +16,7 @@ pub(crate) struct TimerStep { impl TimerStep { pub(super) fn new(io: &Io) -> Self { Self { + audio: io.audio, timers: io.timers, phase: io.timer_phase, pending: io.pending & TIMER_IRQ_MASK, @@ -24,13 +26,20 @@ impl TimerStep { pub(crate) fn advance_to(&mut self, elapsed: u32) { assert!(elapsed >= self.elapsed, "timer step time must be monotonic"); - self.pending |= - Io::advance_timer_bank(&mut self.timers, &mut self.phase, elapsed - self.elapsed); + self.pending |= Io::advance_timer_bank( + &mut self.timers, + &mut self.phase, + &mut self.audio, + elapsed - self.elapsed, + ); self.elapsed = elapsed; } /// All lanes of a bus access observe the same staged time. pub(crate) fn read8(&self, address: u32, committed: u8) -> Option { + if crate::audio::Audio::mapped(address) { + return self.audio.read8(address); + } match address { TIMER_BASE..=0x0400_010f => { Some(self.timers[((address - TIMER_BASE) / 4) as usize].read8(address)) diff --git a/crates/gba-core/src/lib.rs b/crates/gba-core/src/lib.rs index 4498308..1e86759 100644 --- a/crates/gba-core/src/lib.rs +++ b/crates/gba-core/src/lib.rs @@ -1,5 +1,6 @@ //! An educational GBA starting point, not a game-compatible emulator. +pub mod audio; pub mod bios; pub mod cpu; pub mod display; diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index 1130362..cec496e 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -1,5 +1,7 @@ use std::{cell::Cell, error::Error, fmt}; +#[cfg(test)] +mod audio_step_tests; mod fetch; #[cfg(test)] mod fetch_tests; @@ -18,6 +20,7 @@ pub(crate) use fetch::InstructionFetch; use iwram_bus::IwramBus; use crate::{ + audio::{Audio, StereoLevel}, cpu::InstructionSet, display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES}, dma::{DmaError, DMA_BASE, DMA_END}, @@ -97,7 +100,7 @@ struct CpuAccess { /// Successful DMA accesses drive local lanes even before the first CPU instruction. /// Channel data remains separate; general and sub-instruction CPU bus handoff is unmodeled. /// Instruction buffering and supported bus history consume shared fetch samples. -/// Timer data accesses use staged machine-step time; other per-access device timing remains unmodeled. +/// Timer and sound accesses use staged machine-step time; other per-access device timing remains unmodeled. pub struct Memory { external_ram: Vec, internal_ram: Vec, @@ -162,6 +165,17 @@ impl Memory { Ok(memory) } + /// Current committed Direct Sound mixer level. This is not a host sample stream. + pub fn audio_level(&self) -> StereoLevel { + self.io.audio.level() + } + + fn audio_state(&self) -> Audio { + self.timer_step + .get() + .map_or(self.io.audio, |step| step.audio) + } + /// Total elapsed emulated clock cycles, wrapping at u64::MAX. Reads/writes /// alone consume no cycles. STOP discards externally supplied clock advances. pub fn cycles(&self) -> u64 { @@ -727,7 +741,7 @@ impl Memory { bytes: [u8; N], ) -> Result<(), MemoryError> { let index = self.write_index::(address)?; - Self::validate_io_values(address, &bytes)?; + Self::validate_io_values(&mut self.audio_state(), address, &bytes)?; self.record_access( address, match N { @@ -753,6 +767,15 @@ impl Memory { self.iwram_bus.access(address, width, value); } if address >> 24 == 0x04 { + if Audio::mapped(address) { + if let Some(mut step) = self.timer_step.get() { + step.audio.write(address, &bytes); + self.timer_step.set(Some(step)); + } else { + self.io.audio.write(address, &bytes); + } + return Ok(()); + } if address & !3 == KEYINPUT { self.io.write_keypad(address, &bytes); return Ok(()); @@ -843,11 +866,17 @@ impl Memory { } } - fn validate_io_values(address: u32, bytes: &[u8]) -> Result<(), MemoryError> { + fn validate_io_values( + audio: &mut Audio, + 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) { + if let Some(operation) = Io::unsupported_write(address, value) + .or_else(|| audio.unsupported(address, value)) + { return Err(MemoryError::UnsupportedIo { address, value, @@ -855,6 +884,9 @@ impl Memory { }); } } + if Audio::mapped(address) { + audio.write(address, bytes); + } } Ok(()) } @@ -863,9 +895,10 @@ impl Memory { /// 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> { + let mut audio = self.audio_state(); for &(address, value) in writes { self.write_index::<4>(address)?; - Self::validate_io_values(address, &value.to_le_bytes())?; + Self::validate_io_values(&mut audio, 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/src/memory/audio_step_tests.rs b/crates/gba-core/src/memory/audio_step_tests.rs new file mode 100644 index 0000000..4e907fc --- /dev/null +++ b/crates/gba-core/src/memory/audio_step_tests.rs @@ -0,0 +1,104 @@ +//! Original checks of audio bus phases and transactional timer/audio ownership. +use super::{timing_event_tests::prepared, *}; +use crate::{ + io::{FIFO_A, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE}, + machine::Machine, +}; + +fn sound(memory: &mut Memory, period: u16) { + memory.write16(SOUNDCNT_X, 0x80).unwrap(); + memory.write16(SOUNDBIAS, 0x200).unwrap(); + memory.write16(SOUNDCNT_H, 0x0304).unwrap(); + memory + .write32( + TIMER_BASE, + 0x00800000 | u32::from(0u16.wrapping_sub(period)), + ) + .unwrap(); +} + +#[test] +fn fifo_store_becomes_visible_after_its_bus_completion_not_at_instruction_start() { + let (cpu, mut memory) = prepared( + false, + &[0xe5810000, 0xe1a00000], + &[(0, 0x04030201), (1, FIFO_A)], + ); + sound(&mut memory, 1); + let mut machine = Machine::new(cpu, memory); + machine.step().unwrap(); + assert_eq!(machine.cycles(), 7); + assert_eq!(machine.memory().audio_level().left, 0); // Earlier seven empty overflows. + machine.memory_mut().advance_cycles(1); + assert_eq!(machine.memory().audio_level().left, 4); +} + +#[test] +fn master_enable_store_does_not_replay_earlier_timer_edges() { + let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x80), (1, SOUNDCNT_X)]); + sound(&mut memory, 1); + memory.write16(SOUNDCNT_X, 0).unwrap(); + memory.write32(FIFO_A, 0x04030201).unwrap(); + let mut machine = Machine::new(cpu, memory); + machine.step().unwrap(); + assert_eq!(machine.memory().audio_level().left, 0); + machine.memory_mut().advance_cycles(1); + assert_eq!(machine.memory().audio_level().left, 4); +} + +#[test] +fn failed_cpu_load_rolls_back_timer_audio_and_pending_refill_requests() { + let (cpu, mut memory) = prepared(false, &[0xe8910005], &[(1, SOUNDBIAS)]); // LDM: bias then unmapped 8c. + sound(&mut memory, 1); + memory.write32(FIFO_A, 0x04030201).unwrap(); + let audio = memory.io.audio; + let mut machine = Machine::new(cpu, memory); + let cpu = machine.cpu().clone(); + for _ in 0..2 { + assert!(machine.step().is_err()); + assert_eq!(machine.memory().io.audio, audio); + assert_eq!(machine.cycles(), 0); + assert_eq!(machine.cpu(), &cpu); + assert!(machine.memory().timer_step.get().is_none()); + } +} + +#[test] +fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges() { + for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x04000064, 0x8000)] { + let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]); + sound(&mut memory, 1); + memory.write32(FIFO_A, 0x04030201).unwrap(); + memory.write32(0x02000000, word).unwrap(); + memory.write32(DMA_BASE, source).unwrap(); + memory.write32(DMA_BASE + 4, destination).unwrap(); + memory.write32(DMA_BASE + 8, 0x84000001).unwrap(); + let audio = memory.io.audio; + let mut machine = Machine::new(cpu, memory); + for _ in 0..2 { + assert!(machine.step().is_err()); + assert_eq!(machine.memory().io.audio, audio); + assert_eq!(machine.cycles(), 0); + } + } +} + +#[test] +fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_write() { + let mut memory = Memory::new(vec![]).unwrap(); + let original = memory.io.audio; + assert!(memory + .write_words(&[ + (0x02000000, 0x11223344), + (SOUNDCNT_X, 0x80), + (0x04000064, 0x8000), + ]) + .is_err()); + assert_eq!(memory.read32(0x02000000).unwrap(), 0); + assert_eq!(memory.io.audio, original); + memory + .write_words(&[(SOUNDCNT_X, 0x80), (SOUNDCNT_X, 0), (0x04000064, 0x8000)]) + .unwrap(); + assert_eq!(memory.read16(0x04000064).unwrap(), 0); + assert_eq!(memory.read16(SOUNDCNT_X).unwrap(), 0); +} diff --git a/crates/gba-core/tests/bios/ram_reset.rs b/crates/gba-core/tests/bios/ram_reset.rs index 1adfa8a..bc20c37 100644 --- a/crates/gba-core/tests/bios/ram_reset.rs +++ b/crates/gba-core/tests/bios/ram_reset.rs @@ -19,6 +19,32 @@ const REGIONS: [(u32, u32, u32); 5] = [ ]; const MARKER: u32 = 0xa55a_c33c; +#[test] +fn sound_reset_clears_disabled_fifo_queues_and_only_accessible_wave_bank() { + use gba_core::io::{FIFO_A, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE, WAVE_RAM}; + for thumb in [false, true] { + let (mut machine, return_pc) = prepare(0x40, thumb, 0x1f); + let bus = machine.memory_mut(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(0x04000070, 0x40).unwrap(); + bus.write32(WAVE_RAM, MARKER).unwrap(); // CPU bank 0. + bus.write16(SOUNDCNT_X, 0).unwrap(); + bus.write32(WAVE_RAM, MARKER).unwrap(); // CPU bank 1. + bus.write32(FIFO_A, 0x7f7f7f7f).unwrap(); + complete(&mut machine, return_pc); + let bus = machine.memory_mut(); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(0x04000070, 0x40).unwrap(); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), MARKER); + bus.write16(SOUNDCNT_H, 0x0304).unwrap(); + bus.write32(TIMER_BASE, 0x0080ffff).unwrap(); + bus.advance_cycles(1); + assert_eq!(bus.audio_level().left, 0); + } +} + fn literal(code: &mut Vec, register: u32, value: u32) { code.extend([0xe59f_0000 | register << 12, 0xea00_0000, value]); } diff --git a/crates/gba-core/tests/direct_sound.rs b/crates/gba-core/tests/direct_sound.rs new file mode 100644 index 0000000..41600b0 --- /dev/null +++ b/crates/gba-core/tests/direct_sound.rs @@ -0,0 +1,318 @@ +//! Original Direct Sound tests. No game data or upstream test programs. +use gba_core::{ + audio::StereoLevel, + cpu::Cpu, + dma::{DMA_BASE, DMA_STRIDE}, + io::{FIFO_A, FIFO_B, HALTCNT, IF, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE, WAVE_RAM}, + machine::{Machine, StepKind}, + memory::{Memory, ROM_START}, +}; + +fn bus() -> Memory { + let mut bus = Memory::new(0xeaff_fffeu32.to_le_bytes().to_vec()).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(SOUNDBIAS, 0x200).unwrap(); + bus.write16(SOUNDCNT_H, 0x0304).unwrap(); // A, both sides, 100%, timer 0. + bus +} + +fn timer(bus: &mut Memory, index: u32, period: u16, control: u16) { + bus.write32( + TIMER_BASE + index * 4, + u32::from(0u16.wrapping_sub(period)) | u32::from(control) << 16, + ) + .unwrap(); +} + +fn stereo(value: i16) -> StereoLevel { + StereoLevel { + left: value, + right: value, + } +} + +fn sound_dma(bus: &mut Memory, channel: u32, control: u16) { + let base = DMA_BASE + channel * DMA_STRIDE; + bus.write32(base, 0x0200_0000).unwrap(); + bus.write32(base + 4, FIFO_A + (channel - 1) * 4).unwrap(); + bus.write32(base + 8, u32::from(control | 0xb000) << 16 | 1) + .unwrap(); +} + +#[test] +fn signed_little_endian_samples_hold_between_edges_then_underflow_to_zero() { + let mut bus = bus(); + bus.write32(FIFO_A, 0x7f80ff01).unwrap(); + timer(&mut bus, 0, 10, 0x80); + assert_eq!(bus.audio_level(), stereo(0)); + for sample in [4, -4, -512, 508, 0, 0] { + bus.advance_cycles(9); + let previous = bus.audio_level(); + bus.advance_cycles(0); + assert_eq!(bus.audio_level(), previous); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(sample)); + } + assert!(bus.read32(FIFO_A).is_err()); +} + +#[test] +fn independent_timer_selection_cascade_routing_volume_and_clipping() { + let mut bus = bus(); + bus.write16(SOUNDCNT_H, 0x610c).unwrap(); // A right/timer0, B left/timer1, both 100%. + bus.write32(FIFO_A, 0x7f7f7f7f).unwrap(); + bus.write32(FIFO_B, 0x80808080).unwrap(); + timer(&mut bus, 0, 2, 0x80); + timer(&mut bus, 1, 2, 0x84); // Cascade: one B sample per two A samples. + bus.advance_cycles(2); + assert_eq!( + bus.audio_level(), + StereoLevel { + left: 0, + right: 508 + } + ); + bus.advance_cycles(2); + assert_eq!( + bus.audio_level(), + StereoLevel { + left: -512, + right: 508 + } + ); + bus.write16(SOUNDCNT_H, 0x3300).unwrap(); // Both sides, both 50%, retain held samples. + assert_eq!(bus.audio_level(), stereo(-2)); + bus.write16(SOUNDBIAS, 0).unwrap(); + assert_eq!(bus.audio_level(), stereo(-512)); + bus.write16(SOUNDBIAS, 0x3fe).unwrap(); + bus.write16(SOUNDCNT_H, 0x0304).unwrap(); + assert_eq!(bus.audio_level(), stereo(511)); +} + +#[test] +fn muted_routes_still_consume_but_master_disable_freezes_playback() { + let mut bus = bus(); + bus.write16(SOUNDCNT_H, 4).unwrap(); + bus.write32(FIFO_A, 0x04030201).unwrap(); + timer(&mut bus, 0, 1, 0x80); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(0)); + bus.write16(SOUNDCNT_H, 0x0304).unwrap(); + assert_eq!(bus.audio_level(), stereo(4)); + bus.write16(SOUNDCNT_X, 0).unwrap(); + bus.advance_cycles(100); + assert_eq!(bus.audio_level(), stereo(0)); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(8)); // In-flight word is separate from the reset queue. +} + +#[test] +fn reset_strobes_clear_queues_not_in_flight_word_or_held_level() { + let mut bus = bus(); + bus.write32(FIFO_A, 0x04030201).unwrap(); + bus.write32(FIFO_A, 0x7f7f7f7f).unwrap(); + timer(&mut bus, 0, 1, 0x80); + bus.advance_cycles(1); + bus.write16(SOUNDCNT_H, 0x8b04).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 0x0304); + assert_eq!(bus.audio_level(), stereo(4)); + for sample in [8, 12, 16, 0] { + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(sample)); + } +} + +#[test] +fn partial_writes_enqueue_words_and_preserve_other_lanes_on_reused_slots() { + let mut bus = bus(); + timer(&mut bus, 0, 1, 0x80); + // Visit each of the seven queue slots. Reuse the first with a byte write. + for _ in 0..7 { + bus.write32(FIFO_A, 0x04030201).unwrap(); + bus.advance_cycles(4); + } + bus.write8(FIFO_A + 1, 9).unwrap(); + for sample in [4, 36, 12, 16] { + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(sample)); + } + bus.write16(FIFO_A + 2, 0x0807).unwrap(); + for sample in [4, 8, 28, 32] { + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(sample)); + } +} + +#[test] +fn full_queue_overflow_clears_queued_words() { + let mut bus = bus(); + for _ in 0..8 { + bus.write32(FIFO_A, 0x7f7f7f7f).unwrap(); + } + timer(&mut bus, 0, 1, 0x80); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(0)); + bus.write32(FIFO_A, 1).unwrap(); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(4)); +} + +#[test] +fn wave_banks_and_idle_psg_masks_work_without_claiming_psg_playback() { + let mut bus = bus(); + bus.write16(0x04000062, 0xffff).unwrap(); + assert_eq!(bus.read16(0x04000062).unwrap(), 0xffc0); + bus.write32(WAVE_RAM, 0x11223344).unwrap(); // CPU bank 1. + bus.write16(0x04000070, 0x40).unwrap(); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0); + bus.write32(WAVE_RAM, 0xaabbccdd).unwrap(); + for address in [0x04000064, 0x0400006c, 0x04000074, 0x0400007c] { + let previous = bus.read16(address).unwrap(); + assert!(bus + .write16(address, 0xffff) + .unwrap_err() + .to_string() + .contains("PSG channel trigger")); + assert_eq!(bus.read16(address).unwrap(), previous); + } + bus.write16(SOUNDCNT_X, 0).unwrap(); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0x11223344); + assert_eq!(bus.read16(0x04000062).unwrap(), 0); + bus.write16(0x04000064, 0xffff).unwrap(); // Disabled PSG writes are ignored. + bus.write16(SOUNDCNT_X, 0xff).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + bus.write16(0x04000070, 0x40).unwrap(); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0xaabbccdd); +} + +#[test] +fn sound_dma_forces_four_words_and_fixed_destination_then_repeats_and_interrupts() { + for channel in [1, 2] { + let mut bus = bus(); + let fifo = FIFO_A + (channel - 1) * 4; + let route = if channel == 1 { 0x0304 } else { 0x3008 }; + bus.write16(SOUNDCNT_H, route).unwrap(); + for index in 0..8 { + bus.write32(0x02000000 + index * 4, 0x01010101 * (index + 1)) + .unwrap(); + } + sound_dma(&mut bus, channel, 0x4200); // IRQ/repeat, programmed count1, halfword/increment destination. + timer(&mut bus, 0, 100, 0x80); + bus.advance_cycles(100); // Empty playback, request. + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + for _ in 0..4 { + assert_eq!( + machine.step().unwrap(), + StepKind::Dma { + channel: channel as usize + } + ); + } + assert_ne!( + machine.memory().read16(IF).unwrap() & (1 << (8 + channel)), + 0 + ); + assert_ne!( + machine + .memory() + .read16(DMA_BASE + channel * DMA_STRIDE + 10) + .unwrap() + & 0x8000, + 0 + ); + // No fifth unit. Four queued words suppress the next request BEFORE the playback word loads. + assert!(matches!(machine.step().unwrap(), StepKind::Instruction)); + let remaining = 100 - (machine.cycles() as u32 % 100); + machine.memory_mut().advance_cycles(remaining); + assert_eq!(machine.memory().audio_level(), stereo(4)); + assert!(matches!(machine.step().unwrap(), StepKind::Instruction)); + let remaining = 100 - (machine.cycles() as u32 % 100); + machine.memory_mut().advance_cycles(remaining); + for _ in 0..4 { + assert_eq!( + machine.step().unwrap(), + StepKind::Dma { + channel: channel as usize + } + ); + } + // Drain without servicing further requests. The repeated block must continue the source, + // use four-byte strides, and never advance the FIFO destination into the other channel. + for offset in 0..30 { + let remaining = 100 - (machine.cycles() as u32 % 100); + machine.memory_mut().advance_cycles(remaining); + assert_eq!( + machine.memory().audio_level(), + stereo(((offset + 2) / 4 + 1) * 4) + ); + } + assert!(machine.memory().read32(fifo).is_err()); + } +} + +#[test] +fn nonrepeat_sound_dma_disables_after_four_words() { + let mut bus = bus(); + sound_dma(&mut bus, 1, 0); + timer(&mut bus, 0, 1000, 0x80); + bus.advance_cycles(1000); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + for _ in 0..4 { + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 1 }); + } + assert_eq!( + machine.memory().read16(DMA_BASE + DMA_STRIDE + 10).unwrap() & 0x8000, + 0 + ); +} + +#[test] +fn halt_clocks_samples_and_stop_freezes_them() { + for stop in [false, true] { + let mut bus = bus(); + bus.write32(FIFO_A, 0x04030201).unwrap(); + timer(&mut bus, 0, 10, 0x80); + bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + machine.step().unwrap(); + assert_eq!( + machine.memory().audio_level(), + stereo(if stop { 0 } else { 4 }) + ); + if stop { + machine.memory_mut().advance_cycles(u32::MAX); + assert_eq!(machine.cycles(), 0); + } + } +} + +#[test] +fn large_device_batches_match_single_clocks_with_and_without_row_capture() { + for capture in [false, true] { + let mut bulk = bus(); + let mut small = bus(); + for bus in [&mut bulk, &mut small] { + if capture { + bus.set_scanline_rendering(true); + } + bus.write16(SOUNDCNT_H, 0x730c).unwrap(); + for index in 1..=7 { + bus.write32(FIFO_A, index * 0x01010101).unwrap(); + bus.write32(FIFO_B, (index + 8) * 0x01010101).unwrap(); + } + timer(bus, 0, 2, 0x80); + timer(bus, 1, 3, 0x80); + } + for cycles in [1, 2, 3, 17, 39, 1000] { + bulk.advance_cycles(cycles); + for _ in 0..cycles { + small.advance_cycles(1); + } + assert_eq!(bulk.audio_level(), small.audio_level()); + } + bulk.set_scanline_rendering(false); + bulk.advance_cycles(u32::MAX); + assert_eq!(bulk.audio_level(), stereo(0)); + } +} diff --git a/crates/gba-core/tests/inactive_devices.rs b/crates/gba-core/tests/inactive_devices.rs index 4852298..cfaa4b6 100644 --- a/crates/gba-core/tests/inactive_devices.rs +++ b/crates/gba-core/tests/inactive_devices.rs @@ -38,7 +38,7 @@ fn disabled_sound_registers_masks_lanes_and_wave_ram() { 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()); + bus.write32(0x040000a0, 0).unwrap(); assert!(bus.read32(0x040000a0).is_err()); } @@ -80,7 +80,6 @@ fn idle_normal_serial_data_and_control_have_distinct_widths() { 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"), @@ -105,9 +104,10 @@ fn unsupported_operations_have_specific_retryable_diagnostics() { #[test] fn block_store_rejects_later_activation_before_earlier_control_writes() { let code: [u32; 7] = [ - 0xe3a00301, 0xe2800080, 0xe59f1008, 0xe3a02080, 0xe8a00006, 0xeafffffe, 0x770f0000, + 0xe3a00301, 0xe2800060, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678, ]; - let bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); + let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); let mut machine = Machine::new(Cpu::new(ROM_START), bus); for _ in 0..4 { machine.step().unwrap(); @@ -115,24 +115,25 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() { let before = machine.cpu().clone(); let cycles = machine.cycles(); let error = machine.step().unwrap_err(); - assert!(error.to_string().contains("sound master enable")); + assert!(error.to_string().contains("PSG channel trigger")); assert_eq!(machine.cpu(), &before); assert_eq!(machine.cycles(), cycles); - assert_eq!(machine.memory().read16(SOUNDCNT_H).unwrap(), 0); + assert_eq!(machine.memory().read16(0x04000060).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.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write32(0x02000000, 0x8000).unwrap(); bus.write32(DMA_BASE, 0x02000000).unwrap(); - bus.write32(DMA_BASE + 4, SOUNDCNT_X).unwrap(); + bus.write32(DMA_BASE + 4, 0x04000064).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!(error.to_string().contains("PSG channel trigger")); assert_eq!(machine.cycles(), 0); assert_eq!(machine.cpu(), &before); assert_eq!(machine.step(), Err(error)); diff --git a/docs/architecture.md b/docs/architecture.md index 7f82d05..2670fa6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,9 +53,12 @@ The repository is a Cargo workspace with three crates: | `crates/gba-core/src/cpu/fetch_history_tests.rs` | Shared sample consumption, no execution-entry rereads, and failed-step retry | | `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing | | `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches | -| `crates/gba-core/src/io/inactive.rs` | Disabled sound and disconnected serial initialization with explicit unsupported-activity validation | +| `crates/gba-core/src/audio.rs` | Direct Sound FIFO/playback state, idle PSG/wave banks, and instantaneous stereo mixing | +| `crates/gba-core/tests/direct_sound.rs` | FIFO lanes/reset, signed mixing, timer selection, DMA refill, and HALT/STOP | +| `crates/gba-core/src/memory/audio_step_tests.rs` | Audio access phases, shadow validation, and CPU/DMA failure rollback | +| `crates/gba-core/src/io/inactive.rs` | Disconnected serial initialization with explicit unsupported-activity validation | | `crates/gba-core/tests/inactive_devices.rs` | Device masks, wave RAM, GPIO pull-ups, serial data, and failed CPU/DMA write isolation | -| `crates/gba-core/src/io/timer_step.rs` | Staged timer bank, shared divider phase, and timer IF bits at ordered CPU/DMA bus phases | +| `crates/gba-core/src/io/timer_step.rs` | Staged timer/audio state, divider phase, timer IF bits, and sound refill requests at ordered CPU/DMA bus phases | | `crates/gba-core/src/memory/timer_step_tests.rs` | Timer access phases, coherent lanes, cascades, IF ordering, rollback, DMA, idle, and clock ownership | | `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events | | `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs | diff --git a/docs/hardware/audio.md b/docs/hardware/audio.md index eb772ac..dfdab10 100644 --- a/docs/hardware/audio.md +++ b/docs/hardware/audio.md @@ -1,49 +1,112 @@ -# Sound initialization subset +# Direct Sound subset -Sound synthesis and host audio output are not implemented. -The core now models disabled sound-register state so firmware can perform a functional sound reset. -This is not a silent-audio compatibility mode. Enabling sound returns an explicit diagnostic. +The core models timer-driven Direct Sound A/B playback and a bounded sound-DMA configuration. +It does not produce desktop audio. Programmable sound generator (PSG) synthesis remains unsupported. +`Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream. -## Supported state +## Registers and inactive PSG state | Address | Behavior | | --- | --- | -| `0x04000060..0x04000081` | Programmable sound generator (PSG) registers remain zero; writes have no effect while master sound is disabled | -| `0x04000082` (`SOUNDCNT_H`) | Retains mixing/routing/timer configuration with readback mask `0x770f` | -| `0x04000084` (`SOUNDCNT_X`) | Reads zero; status flags are read-only; a write setting bit 7 fails | -| `0x04000088` (`SOUNDBIAS`) | Retains bias/resolution configuration with mask `0xc3fe`; no analog output is generated | -| `0x04000090..0x0400009f` | Read/write wave RAM in the CPU-accessible inactive bank | +| `0x04000060..0x04000081` | Idle PSG configuration and documented read masks; writes are ignored while master sound is disabled | +| `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` | +| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only PSG activity flags remain zero | +| `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` | +| `0x04000090..0x0400009f` | CPU wave RAM window, opposite the bank selected by `SOUND3CNT_L` bit 6 | +| `0x040000a0..0x040000a7` | FIFO A/B writes with byte, halfword, and word access boundaries preserved | -Unused mapped bytes through `0x0400008b` read zero and ignore writes. -The FIFO reset strobes in `SOUNDCNT_H` do not latch. No FIFO data can be queued in this subset. -FIFO addresses and the remaining sound-region holes stay unmapped. -All sound configuration starts at zero in raw memory. RegisterRamReset bit 6 sets the functional bias default to `0x0200`. +Unused mapped register bytes read zero and ignore writes. +FIFO reads and the `0x0400008c..0x0400008f` gap remain explicit unmapped-access errors; no FIFO open-bus value is invented. +Raw memory starts with zero sound state. RegisterRamReset bit 6 sets the functional bias default to `0x0200`. -With master sound disabled, `SOUND3CNT_L` stays zero. The CPU accesses wave bank 1. -Bank 0 cannot be selected or played and remains zero. Disabling master sound does not erase wave RAM. -The BIOS reset explicitly clears the accessible wave RAM through ordinary stores. -Future bank switching and channel playback need a complete sound engine, not a larger register array. +Master disable clears PSG configuration and the FIFO queues, but retains wave RAM and Direct Sound routing/bias. +The CPU then accesses wave bank 1. Idle bank selection can expose bank 0 after master enable. +The original BIOS reset clears bank 1 and both FIFO queues. It retains bank 0 and any separate in-flight playback word. +This is a functional reset subset, not a claim of complete firmware ordering or sound-reset equivalence. -## Diagnostics and atomicity +An enabled PSG channel trigger returns `MemoryError::UnsupportedIo` with the exact address and value. +This applies even when the configured channel would be inaudible. Disabled PSG trigger writes are ignored. +Length, envelope, sweep, frequency, and wave-bank playback clocks are not implemented. +Do not interpret successful idle configuration writes as PSG synthesis. -A write setting `SOUNDCNT_X` bit 7 returns `MemoryError::UnsupportedIo`, including the address, byte value, and operation. -Status bits and unused bits alone do not enable sound. -Validation occurs before any bytes in the access are written. -Block stores validate every register value before committing earlier writes. Failed CPU and DMA steps remain retryable. +## FIFO and timer behavior -Original tests cover disabled-register writes, masks, byte lanes, wave retention, and BIOS reset selection. -They also check rejected sound activation through byte/word accesses, ARM block stores, and DMA without partial state changes. +Each channel has a seven-word queue, a separate four-byte playback word, and a held signed sample. +Timer 0 or timer 1 overflow clocks the selected channel while master sound is enabled. +Timer 1 cascade overflows work. Timer IRQ enable and stereo routing do not gate sample consumption. +Timers 2 and 3 cannot clock Direct Sound. -## Remaining work +On each selected overflow: -Implement channel clocks, length/envelope/sweep state, wave banking, FIFO consumption, timer-triggered sound DMA, and mixing. -Add a host-independent sample interface before connecting a desktop audio backend. -Do not acknowledge active sound requests without implementing their device behavior. +1. Request sound DMA if the queue contains at most three words, before loading a playback word. +2. Load a queued word if the playback word is empty. +3. Latch the next signed eight-bit sample in little-endian order. + +The held sample remains unchanged between overflows. An empty playback word supplies zero on the next overflow. +This underflow policy follows both consulted emulator implementations; the cited hardware report leaves it uncertain. +A bulk advance consumes all available samples and then reaches zero without looping over every empty overflow. + +A partial FIFO write updates the addressed lanes of the next queue slot and enqueues that whole word. +Other lanes retain that slot's previous bytes. A write to a full queue clears the queue. +Reset strobes clear queues without changing the held sample or remaining playback bytes. +Queue clearing also zeros stored queue slots in this nominal model. +Exact stale-lane behavior across overflow/reset remains unverified on hardware. + +HALT continues timer/audio progress. STOP freezes it. +Master disable stops sample consumption; route disable only mutes the mixer path. + +## Sound DMA + +Special timing supports DMA1 to FIFO A and DMA2 to FIFO B. +Each request transfers four 32-bit words with a fixed destination, regardless of programmed count, width, or destination mode. +Source address control, channel priority, repeat enable, completion IRQ, and retained DMA data still apply. +Without repeat, the channel disables after the fourth word. With repeat, it waits for another timer request. + +Other channel/FIFO pairings and non-FIFO destinations remain explicit `DmaError::UnsupportedControl` cases. +The references disagree about request routing outside the supported pairing; hardware reports permit non-FIFO destinations. +The core does not silently guess those configurations. Video capture and Game Pak DRQ remain unsupported. + +Requests become visible at successful machine-step boundaries. Active DMA blocks ignore further requests. +Device-only bulk advances coalesce unserviced requests; they do not execute or replay DMA blocks. +Sub-instruction DMA arbitration and exact refill latency are not modeled. + +## Mixing and timing ownership + +Each routed sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume. +The mixer adds the ten-bit bias, clips each side to `0..1023`, and subtracts 512. +`StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero. +Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization. +There is no resampling, analog filtering, sample queue, or host output backend. +Polling once per video frame will not reconstruct audio. + +Timer and sound state share the staged CPU/DMA clock. +Reads and writes observe their nominal bus-completion phase, after earlier timer overflows. +Successful steps commit once. Failed steps discard FIFO consumption, held samples, register writes, and refill requests. +Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes. +Other devices retain their existing scheduling limits. + +## Validation and remaining work + +Original tests cover signed samples, byte order, queue capacity, partial writes, resets, underflow, stereo routing, and clipping. +They also cover cascaded timers, DMA block/repeat behavior, source progress, completion IRQs, HALT/STOP, and clock batching. +CPU/DMA failure tests verify transactional rollback and sound writes at bus completion. +BIOS tests cover disabled FIFO reset and the supported wave-bank policy. + +These tests validate the documented nominal model, not hardware audio fidelity. +Next work: PSG clocks and synthesis, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend. +The local Emerald startup now passes master enable and stops at a PSG channel 1 trigger. +See [the local runtime result](../research/emerald-reset.md). ## References -- [GBATEK sound control registers](https://problemkaputt.de/gbatek-gba-sound-control-registers.htm): disabled PSG behavior, master enable, masks, FIFO reset strobes, and bias. -- [GBATEK](https://mgba-emu.github.io/gbatek/): channel 3 wave-bank access and register layout. -- [mGBA BIOS service implementation at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/bios.c): functional reset defaults. No upstream implementation was copied. +- [GBATEK Direct Sound](https://problemkaputt.de/gbatek-gba-sound-channel-a-and-b-dma-sound.htm): registers, timer selection, signed samples, and DMA blocks. +- [GBATEK sound control](https://problemkaputt.de/gbatek-gba-sound-control-registers.htm): master enable, read masks, reset strobes, routing, and bias. +- [GBATEK mirror](https://mgba-emu.github.io/gbatek/): wave-bank access and register layout. +- [gbadev Direct Sound overview](https://gbadev.net/gbadoc/audio/directsound.html): introductory FIFO/timer/DMA behavior; not authority for queue edge cases. +- [Gericom's hardware findings, mGBA issue 1847](https://github.com/mgba-emu/mgba/issues/1847): seven-word queue, separate playback buffer, request order, partial writes, and overflow observations. +- [mGBA audio at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/audio.c): playback, reset, mixing, and destination-based request routing comparison. +- [NanoBoyAdvance APU at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/apu.cc): master gating, playback word, underflow, and mixing. +- [NanoBoyAdvance FIFO](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/fifo.hh), [registers](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/registers.cc), and [DMA](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/dma/dma.cc): queue/reset policy and channel-based request routing comparison. +- [mGBA BIOS at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/bios.c): functional reset defaults. -Reset values are source-backed. Exact Nintendo firmware ordering, timing, and analog behavior are not established. +The Rust implementation and regression programs are original. No upstream implementation or external test ROM was copied into the repository. diff --git a/docs/hardware/bios.md b/docs/hardware/bios.md index 8638033..c37582a 100644 --- a/docs/hardware/bios.md +++ b/docs/hardware/bios.md @@ -216,11 +216,12 @@ Supported flags can be combined: | 3 | Clear all 96 KiB of video RAM: `0x06000000..0x06017fff` | | 4 | Clear sprite attribute memory (OAM): `0x07000000..0x070003ff` | | 5 | Reset supported idle serial state and select general-purpose inputs | -| 6 | Reset disabled sound state, mixing control, bias, and accessible wave RAM | +| 6 | Disable sound, reset FIFO queues and mixing control, set bias, and clear accessible wave bank 1 | | 7 | Reset supported display, DMA, timer, and interrupt registers as described below | All low-byte flag combinations, including `r0=0xff`, are accepted within the supported device subset. -This is not full audio or serial support. [Sound activation](audio.md) and [serial transfers](serial.md) still fail explicitly. +This is not full audio or serial support. [PSG playback](audio.md) and [serial transfers](serial.md) still fail explicitly. +Sound reset retains wave bank 0 and the separate in-flight playback word; exact firmware sound-reset equivalence is not established. The firmware always clears the low halfword of SIODATA32, even when bit 5 is clear; its upper halfword is preserved. Every supported request first writes `DISPCNT=0x0080`, including requests with no selected flags. @@ -509,5 +510,5 @@ Unsupported SWIs, unsupported exception vectors, and null/misaligned IRQ callbac The CPU reports `CpuError::UnsupportedInstruction` with `bios::UNSUPPORTED_TRAP`, rather than silently treating a service as a no-op. Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction. Prior boot/service steps remain committed on failure. -Active sound, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. +PSG playback, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. This subset is not sufficient for Pokémon Emerald compatibility. diff --git a/docs/hardware/dma.md b/docs/hardware/dma.md index dccce33..b475e18 100644 --- a/docs/hardware/dma.md +++ b/docs/hardware/dma.md @@ -59,7 +59,9 @@ Exact startup/completion placement remains nominal, not independently verified p See [CPU resume timing](cpu.md#nominal-cpu-resume-after-dma) for failure, idle, and refill rules. Startup scheduling delays, channel-resumption costs, a full CPU fetch pipeline, and display-bus contention remain unmodeled. -Special timing modes (sound FIFO/video capture), Game Pak DRQ, and prohibited source mode 3 return `DmaError::UnsupportedControl`. +Special timing supports DMA1/FIFO A and DMA2/FIFO B with four-word blocks, forced word width, and fixed destinations. +Timer-driven requests, repeat, and completion IRQs are described in [Direct Sound](audio.md). +Other special-mode pairings, video capture, Game Pak DRQ, and prohibited source mode 3 return `DmaError::UnsupportedControl`. DMA reads below work RAM reuse known channel data as described below. They never expose BIOS bytes. DMA writes to DMA registers return `DmaError::RegisterDestination`; self-modifying transfers are not supported. Other accesses use the existing memory map, alignment checks, mirrors, and I/O write rules. diff --git a/docs/hardware/timers-irq.md b/docs/hardware/timers-irq.md index cda9ed2..41aecf2 100644 --- a/docs/hardware/timers-irq.md +++ b/docs/hardware/timers-irq.md @@ -7,7 +7,7 @@ Otherwise it services one ready DMA data unit, keeping the CPU paused. If HALT is waiting and no DMA is ready, it advances device clocks to the next event without executing CPU code. Otherwise, it samples the IRQ line, including the CPU's interrupt mask, then enters IRQ mode or executes one instruction. -Each successful step advances display and timer clocks by its nominal cost. +Each successful step advances display, timer, and Direct Sound clocks by its nominal cost. `Machine::last_timing()` reports separate code, data, internal, and idle cycle totals for the last successful step. `StepTiming::idle_cycles` counts device-clock cycles during HALT; it does not count CPU work. IRQ entry is a separate step; the vector instruction runs on the following step. @@ -15,7 +15,7 @@ Entry charges the discarded incoming-state PC+8/PC+4 fetch and the ARM vector pa DMA resume can make the discarded fetch non-sequential. Missing discarded bytes do not prevent IRQ entry. See [refill timing](../research/refill-fetch-timing.md) for history effects and remaining limits. A device event during a step can trigger IRQ entry once no DMA is ready and CPU masks allow delivery. -Timer accesses use ordered bus-completion phases. Other device writes still take effect before their bulk clock update. +Timer and sound accesses use ordered bus-completion phases. Other device writes still take effect before their bulk clock update. `StepKind::Dma { channel }` identifies a DMA unit. `StepKind::HaltIdle` identifies one bounded HALT clock advance. `StepKind::StopIdle` identifies a stopped system with no clock progress. The other step kinds remain `Instruction` and `IrqEntry`. @@ -34,10 +34,11 @@ The transaction also owns timer IF bits. Reads see overflows through the current A later internal cycle can raise an acknowledged timer request again. Non-timer IF bits retain their existing behavior. IRQ delivery still waits until the next machine step. A committed timer request can wake HALT, but not STOP. -Successful steps commit timers, the shared divider phase, and timer IF bits. -Failed steps discard staged counters, divider progress, reload/control writes, and requests. +Successful steps commit timers, the shared divider phase, timer IF bits, and Direct Sound state. +Failed steps discard staged counters, divider progress, timer/audio writes, FIFO consumption, and requests. Display and other device clocks then advance once, without advancing timers again. -Those devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled. +Display and remaining devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled. +[Direct Sound](audio.md) consumes selected timer overflows in the timer transaction; sound DMA requests commit at step boundaries. Host clock advances and HALT idle batches retain ordinary bulk timer advancement; STOP idle does not advance timers. The unchanged [prefetch cancellation probe](../research/prefetch-cancellation.md) now matches both published interval totals and actual timer samples. @@ -92,7 +93,8 @@ See [prescaler evidence and original tests](../research/timer-prescaler.md). Hardware startup and register-write delays are still not modeled. Writes retain the immediate bus-completion policy. The shared-phase tests validate this bounded model, not absolute enable/reload edges on hardware. -Audio events and interrupt sources other than timers, display events, DMA completion, and keypad input are not implemented. +Timers 0 and 1 can clock Direct Sound playback and request FIFO DMA independently of timer IRQ enable. +Interrupt sources other than timers, display events, DMA completion, and keypad input are not implemented. `--timer-demo` executes original ARM code that configures Timer 0, IE, and IME. The timer overflows after 16 supplied cycles and enters the original handler through vector `0x18`. @@ -228,7 +230,7 @@ The current desktop demos do not enter STOP; the frontend does not yet provide a Only keypad wake-up is implemented. General-purpose serial and Game Pak wake sources require their missing external devices. Oscillator restart delays, exact entry/wake edges, and hardware-specific IF behavior remain unverified. -There is no host-thread sleep, wall-clock accounting, or audio clock implementation. +There is no host-thread sleep or wall-clock accounting. STOP freezes the emulated timer-driven audio clock. The graphics demo now uses the optional original BIOS replacement's VBlankIntrWait service. Its IRQ callback acknowledges VBlank and updates the BIOS RAM flag before the service returns. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index afc914c..f6f5258 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -9,10 +9,11 @@ No ROM bytes, extracted code, or assets were added to the repository. ## General correction -The core now provides a bounded [disabled sound](../hardware/audio.md) and [disconnected serial](../hardware/serial.md) initialization model. +The reset correction introduced bounded sound and [disconnected serial](../hardware/serial.md) initialization models. +The [sound model](../hardware/audio.md) now also supports timer-driven Direct Sound and bounded FIFO DMA. The BIOS performs reset through original ARM stores against that model. Bit 5 selects general-purpose input pins and clears supported serial reset registers. -Bit 6 disables sound, clears mixing configuration and accessible wave RAM, and sets the functional bias default. +Bit 6 disables sound, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default. The low halfword of SIODATA32 is cleared unconditionally, matching the documented BIOS side effect. This is not a game-specific bypass. Original ARM and Thumb callers exercise selected flags and the all-device request. @@ -28,13 +29,18 @@ direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --s direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --window --frames 120 ``` -Both runs now pass the BIOS reset and stop at the game's sound activation write: +The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`). +After the Direct Sound increment, both runs pass that write and stop at an enabled PSG channel 1 trigger: ```text -PC=0x082e0518 -unsupported sound master enable (audio engine not implemented): write 0x8f at 0x04000084 +Steps: 321159; instructions: 320647; IRQ entries: 0; DMA units: 512; HALT idle: 0 +Nominal cycles: 1038738 +PC=0x082e03bc +unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x04000065 ``` +The native run captures three startup frames. Direct Sound behavior is validated by original tests, not by audible game output. + The native run captures startup frames before this diagnostic. These are not evidence of a working title screen or gameplay. The window run does not reach its requested frame limit and correctly exits with failure. Terminal execution is also diagnostic, not a compatibility pass. @@ -46,9 +52,10 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check Native ROM-window tests and graphics smoke modes pass on Darwin arm64. The pinned Pong debug/release reports match. -The next observed startup requirement is active audio-device behavior, starting at SOUNDCNT_X master enable. -Implement channel/FIFO clocks, state, DMA requests, and an output interface before claiming sound support. -Do not make enable succeed solely to suppress this diagnostic. +The next observed startup requirement is PSG channel playback, beginning with pulse channel 1. +Direct Sound now has FIFO clocks, nominal DMA requests, and an instantaneous digital-level inspection interface. +A continuous sample stream and desktop output are still missing. Do not treat digital-level tests as audible-game validation. +Do not suppress the PSG trigger diagnostic without implementing the requested device behavior. Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol. Hardware and emulator references are recorded in the linked subsystem documents. diff --git a/docs/status.md b/docs/status.md index 4af7735..78dcc7e 100644 --- a/docs/status.md +++ b/docs/status.md @@ -78,7 +78,8 @@ Update this file when a feature lands or a limit is removed. - Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports. - Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices. - RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request. -- Sound activation and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback. +- Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks. +- PSG triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback. - ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP. - Optional ROM-window frame limits and an original input-test ROM generator. - Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports. @@ -134,8 +135,9 @@ Internal affine origins, vertical window flags, and vertical mosaic counters tra Horizontal window comparators retain four-cycle event history. Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits. Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented. -There is no sound synthesis, host audio output, or cartridge save support. -The [sound initialization subset](hardware/audio.md) supports disabled register state and wave RAM; master sound enable remains diagnostic. +There is no PSG synthesis, host audio output, or cartridge save support. +The [Direct Sound subset](hardware/audio.md) supports FIFO/timer/DMA behavior, idle PSG state, wave banking, and instantaneous digital mixing. +It does not yet provide a continuous sample stream. PSG playback and unverified sound-DMA pairings remain diagnostic. The [serial initialization subset](hardware/serial.md) supports idle normal data and disconnected general-purpose pins, not active transfers. Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT. Keypad IRQs use a documented polling model; hardware retrigger edges and asynchronous input timing remain unverified. @@ -145,7 +147,8 @@ All named graphics demos connect desktop input and rendering to the emulated CPU ROM files can run in bounded terminal mode or a window with the original BIOS replacement. ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices. Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility. -A local Emerald run now completes its all-device reset and reaches sound activation at `0x04000084`, where it stops explicitly. +A local Emerald run now completes its all-device reset and master sound enable. +It stops explicitly at a PSG channel 1 trigger at `0x04000065`; no title-screen or gameplay compatibility is established. Its captured startup frames do not establish title-screen or gameplay compatibility. See [the startup result](research/emerald-reset.md). See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status. The [ROM suite runner](rom-tests.md) has original regressions and a [pinned public ARM baseline](public-arm-tests.md). diff --git a/docs/usability-roadmap.md b/docs/usability-roadmap.md index 89910e2..fabbba1 100644 --- a/docs/usability-roadmap.md +++ b/docs/usability-roadmap.md @@ -39,8 +39,9 @@ The [pinned Pong homebrew](public-pong.md) passes scripted menus, ball movement, The native Darwin window also starts and presents frames without a diagnostic. This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch. Frame-based scenarios and the preparation adapter now make the result repeatable. -A local Emerald run now passes its initial all-device reset and stops at sound master enable. -See [the runtime result](research/emerald-reset.md). Active audio-device behavior is the next demonstrated startup requirement. +A local Emerald run now passes its initial all-device reset and master sound enable, then stops at a PSG channel 1 trigger. +The core has a tested Direct Sound subset, but no continuous sample stream or desktop audio output. +See [the runtime result](research/emerald-reset.md). PSG playback is the next demonstrated startup requirement. The separate save requirement remains cartridge SRAM, followed by safe host save persistence. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.