feat(audio): implement nominal Direct Sound playback and FIFO DMA

This commit is contained in:
0xrsydn 2026-10-07 01:35:55 +07:00
commit 5888c4d292
21 changed files with 933 additions and 129 deletions

View file

@ -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) | | 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) | | 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) | | 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) | | 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) | | Backgrounds, sprites, mosaic, windows, blending, input | [docs/hardware/video.md](docs/hardware/video.md) |
| Demos and key mapping | [docs/demos.md](docs/demos.md) | | Demos and key mapping | [docs/demos.md](docs/demos.md) |

View file

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

View file

@ -92,12 +92,15 @@ fn emit_ram_reset(a: &mut ArmImage) {
a.literal(1, SOUNDCNT_X); a.literal(1, SOUNDCNT_X);
store_halfword(a, 0); // Disable sound; PSG register state is held in reset. store_halfword(a, 0); // Disable sound; PSG register state is held in reset.
a.literal(1, SOUNDCNT_H); 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); store_halfword(a, 0);
a.literal(1, SOUNDBIAS); a.literal(1, SOUNDBIAS);
a.emit(0xe3a0_3c02); // MOV r3,#0x200 (bias default) a.emit(0xe3a0_3c02); // MOV r3,#0x200 (bias default)
store_halfword(a, 0); store_halfword(a, 0);
a.emit(0xe3a0_3000); // MOV r3,#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.label("ram_reset_other");
a.emit(0xe310_0080); // TST r0,#other registers a.emit(0xe310_0080); // TST r0,#other registers

View file

@ -1,6 +1,7 @@
//! Four-channel GBA direct memory access (DMA) controller. //! Four-channel GBA direct memory access (DMA) controller.
//! Transfers are scheduled one data unit at a time. Startup/resumption delays, //! 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. //! Each channel retains known source data across blocks, including blocked reads.
use std::{error::Error, fmt}; use std::{error::Error, fmt};
@ -79,12 +80,16 @@ impl Channel {
(self.control >> 12) & 3 (self.control >> 12) & 3
} }
fn unsupported(&self) -> bool { fn unsupported(&self, index: usize) -> bool {
self.start_timing() == 3 || self.control & 0x800 != 0 || self.control & 0x180 == 0x180 (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 { fn width(&self) -> AccessWidth {
if self.control & 0x400 != 0 { if self.start_timing() == 3 || self.control & 0x400 != 0 {
AccessWidth::Word AccessWidth::Word
} else { } else {
AccessWidth::Halfword AccessWidth::Halfword
@ -92,7 +97,9 @@ impl Channel {
} }
fn initial_count(&self, index: usize) -> u32 { 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) u32::from(self.count)
} else if index == 3 { } else if index == 3 {
0x1_0000 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. /// Select the highest-priority ready channel without changing its state.
/// Unsupported enabled modes report a diagnostic rather than silently waiting. /// Unsupported enabled modes report a diagnostic rather than silently waiting.
pub(crate) fn next(&self) -> Result<Option<Transfer>, DmaError> { pub(crate) fn next(&self) -> Result<Option<Transfer>, DmaError> {
@ -190,7 +206,7 @@ impl Dma {
if !channel.enabled() { if !channel.enabled() {
continue; continue;
} }
if channel.unsupported() { if channel.unsupported(index) {
return Err(DmaError::UnsupportedControl { return Err(DmaError::UnsupportedControl {
channel: index, channel: index,
control: channel.control, control: channel.control,
@ -225,7 +241,11 @@ impl Dma {
channel.current_destination = advance_address( channel.current_destination = advance_address(
channel.current_destination, channel.current_destination,
width, width,
(channel.control >> 5) & 3, if channel.start_timing() == 3 {
2
} else {
(channel.control >> 5) & 3
},
); );
channel.remaining -= 1; channel.remaining -= 1;
channel.first = false; channel.first = false;
@ -236,7 +256,7 @@ impl Dma {
if channel.control & 0x200 != 0 && channel.start_timing() != 0 { if channel.control & 0x200 != 0 && channel.start_timing() != 0 {
channel.remaining = channel.initial_count(index); channel.remaining = channel.initial_count(index);
channel.first = true; channel.first = true;
if channel.control & 0x60 == 0x60 { if channel.start_timing() != 3 && channel.control & 0x60 == 0x60 {
channel.current_destination = channel.destination & !(width - 1); channel.current_destination = channel.destination & !(width - 1);
} }
} else { } else {

View file

@ -1,13 +1,12 @@
//! GBA I/O subset: DMA, timers, interrupts, WAITCNT, display, keypad input, //! GBA I/O subset: DMA, timers, interrupts, WAITCNT, display, keypad input,
//! disabled sound state, and disconnected serial initialization. //! Direct Sound, and disconnected serial initialization.
//! Timers share a free-running prescaler phase. Hardware startup/write delays, //! Timers share a free-running prescaler phase. Hardware startup/write delays
//! audio events, and interrupt delivery delays are not modeled. //! and interrupt delivery delays are not modeled.
mod inactive; mod inactive;
pub use inactive::{ use crate::audio::Audio;
JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8, SOUNDBIAS, SOUNDCNT_H, pub use crate::audio::{FIFO_A, FIFO_B, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, SOUND_START, WAVE_RAM};
SOUNDCNT_X, SOUND_START, WAVE_RAM, pub use inactive::{JOYCNT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8};
};
mod timer_step; mod timer_step;
pub(crate) use timer_step::TimerStep; pub(crate) use timer_step::TimerStep;
use timer_step::TIMER_IRQ_MASK; use timer_step::TIMER_IRQ_MASK;
@ -157,6 +156,7 @@ enum PowerState {
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Io { pub(crate) struct Io {
inactive: inactive::Inactive, inactive: inactive::Inactive,
pub(crate) audio: Audio,
timers: [Timer; 4], timers: [Timer; 4],
// Low ten system-clock bits cover every supported divider. STOP freezes this phase. // Low ten system-clock bits cover every supported divider. STOP freezes this phase.
timer_phase: u16, timer_phase: u16,
@ -181,7 +181,8 @@ pub(crate) struct Io {
impl Io { impl Io {
pub(crate) fn mapped(address: u32) -> bool { 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) || 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<u8> { pub(crate) fn read8(&self, address: u32) -> Option<u8> {
if Audio::mapped(address) {
return self.audio.read8(address);
}
if inactive::Inactive::mapped(address) { if inactive::Inactive::mapped(address) {
return self.inactive.read8(address); return self.inactive.read8(address);
} }
@ -420,17 +424,25 @@ impl Io {
} }
pub(crate) fn commit_timer_step(&mut self, step: TimerStep) { 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.timers = step.timers;
self.timer_phase = step.phase; self.timer_phase = step.phase;
self.pending = (self.pending & !TIMER_IRQ_MASK) | step.pending; self.pending = (self.pending & !TIMER_IRQ_MASK) | step.pending;
self.wake_if_requested(); 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 pending = 0;
let mut overflows = 0; let mut overflows = 0;
for (index, timer) in timers.iter_mut().enumerate() { for (index, timer) in timers.iter_mut().enumerate() {
overflows = timer.advance(cycles, overflows, *phase); overflows = timer.advance(cycles, overflows, *phase);
audio.timer_overflows(index, overflows);
if overflows != 0 && timer.control & 0x40 != 0 { if overflows != 0 && timer.control & 0x40 != 0 {
pending |= 1 << (3 + index); pending |= 1 << (3 + index);
} }
@ -473,8 +485,13 @@ impl Io {
self.dma.trigger(vblank, hblank); self.dma.trigger(vblank, hblank);
self.pending |= self.display.advance(cycles); self.pending |= self.display.advance(cycles);
if advance_timers { if advance_timers {
self.pending |= self.pending |= Self::advance_timer_bank(
Self::advance_timer_bank(&mut self.timers, &mut self.timer_phase, cycles); &mut self.timers,
&mut self.timer_phase,
&mut self.audio,
cycles,
);
self.dma.trigger_sound(self.audio.take_requests());
} }
self.wake_if_requested(); self.wake_if_requested();
} }

View file

@ -1,14 +1,7 @@
//! Disabled sound and disconnected serial initialization state. //! Disconnected serial initialization state. Active transfers remain diagnostic.
//! Sound enable, FIFO writes, serial transfers, UART/multiplayer/Joybus modes,
//! and GPIO interrupts remain explicit diagnostics, not silent device stubs.
use super::replace_byte; 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 SIODATA32: u32 = 0x0400_0120;
pub const SIOCNT: u32 = 0x0400_0128; pub const SIOCNT: u32 = 0x0400_0128;
pub const SIODATA8: u32 = 0x0400_012a; pub const SIODATA8: u32 = 0x0400_012a;
@ -19,11 +12,6 @@ pub const JOY_TRANS: u32 = 0x0400_0154;
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(super) struct Inactive { 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_data: [u8; 4],
serial_send: u8, serial_send: u8,
serial_control: u16, serial_control: u16,
@ -33,7 +21,6 @@ pub(super) struct Inactive {
impl Inactive { impl Inactive {
pub(super) fn mapped(address: u32) -> bool { pub(super) fn mapped(address: u32) -> bool {
matches!(address, matches!(address,
SOUND_START..=0x0400_008b | WAVE_RAM..=0x0400_009f |
SIODATA32..=0x0400_0123 | SIOCNT..=0x0400_012b | SIODATA32..=0x0400_0123 | SIOCNT..=0x0400_012b |
RCNT..=0x0400_0135 | JOYCNT..=0x0400_0141 | RCNT..=0x0400_0135 | JOYCNT..=0x0400_0141 |
JOY_RECV..=0x0400_0157) JOY_RECV..=0x0400_0157)
@ -43,9 +30,6 @@ impl Inactive {
/// stores can validate all values before committing any RAM/I/O writes. /// stores can validate all values before committing any RAM/I/O writes.
pub(super) fn unsupported(address: u32, value: u8) -> Option<&'static str> { pub(super) fn unsupported(address: u32, value: u8) -> Option<&'static str> {
match address { 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"), SIOCNT if value & 0x80 != 0 => Some("serial transfer start"),
0x0400_0129 if value & 0x20 != 0 => Some("multiplayer/UART serial mode"), 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 & 0x40 != 0 => Some("Joybus serial mode"),
@ -62,16 +46,10 @@ impl Inactive {
} }
pub(super) fn read8(&self, address: u32) -> Option<u8> { 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) { if (SIODATA32..=SIODATA32 + 3).contains(&address) {
return Some(self.serial_data[(address - SIODATA32) as usize]); return Some(self.serial_data[(address - SIODATA32) as usize]);
} }
let value = match address & !1 { 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 => { SIOCNT => {
let si = if self.rcnt & 0x8000 != 0 { let si = if self.rcnt & 0x8000 != 0 {
self.gpio_pins() & 4 self.gpio_pins() & 4
@ -96,25 +74,17 @@ impl Inactive {
pub(super) fn write8(&mut self, address: u32, value: u8) { pub(super) fn write8(&mut self, address: u32, value: u8) {
debug_assert!(Self::unsupported(address, value).is_none()); 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) { if (SIODATA32..=SIODATA32 + 3).contains(&address) {
self.serial_data[(address - SIODATA32) as usize] = value; self.serial_data[(address - SIODATA32) as usize] = value;
return; return;
} }
match address & !1 { 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 => { SIOCNT => {
self.serial_control = replace_byte(self.serial_control, address, value) & 0x500b self.serial_control = replace_byte(self.serial_control, address, value) & 0x500b
} }
SIODATA8 if address & 1 == 0 => self.serial_send = value, SIODATA8 if address & 1 == 0 => self.serial_send = value,
RCNT => self.rcnt = replace_byte(self.rcnt, address, value) & 0x80ff, 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.
} }
} }
} }

View file

@ -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. //! Other devices remain on the instruction-boundary scheduler.
use super::{Io, Timer, IF, TIMER_BASE}; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerStep { pub(crate) struct TimerStep {
pub(crate) audio: crate::audio::Audio,
pub(super) timers: [Timer; 4], pub(super) timers: [Timer; 4],
pub(super) phase: u16, pub(super) phase: u16,
pub(super) pending: u16, pub(super) pending: u16,
@ -15,6 +16,7 @@ pub(crate) struct TimerStep {
impl TimerStep { impl TimerStep {
pub(super) fn new(io: &Io) -> Self { pub(super) fn new(io: &Io) -> Self {
Self { Self {
audio: io.audio,
timers: io.timers, timers: io.timers,
phase: io.timer_phase, phase: io.timer_phase,
pending: io.pending & TIMER_IRQ_MASK, pending: io.pending & TIMER_IRQ_MASK,
@ -24,13 +26,20 @@ impl TimerStep {
pub(crate) fn advance_to(&mut self, elapsed: u32) { pub(crate) fn advance_to(&mut self, elapsed: u32) {
assert!(elapsed >= self.elapsed, "timer step time must be monotonic"); assert!(elapsed >= self.elapsed, "timer step time must be monotonic");
self.pending |= self.pending |= Io::advance_timer_bank(
Io::advance_timer_bank(&mut self.timers, &mut self.phase, elapsed - self.elapsed); &mut self.timers,
&mut self.phase,
&mut self.audio,
elapsed - self.elapsed,
);
self.elapsed = elapsed; self.elapsed = elapsed;
} }
/// All lanes of a bus access observe the same staged time. /// All lanes of a bus access observe the same staged time.
pub(crate) fn read8(&self, address: u32, committed: u8) -> Option<u8> { pub(crate) fn read8(&self, address: u32, committed: u8) -> Option<u8> {
if crate::audio::Audio::mapped(address) {
return self.audio.read8(address);
}
match address { match address {
TIMER_BASE..=0x0400_010f => { TIMER_BASE..=0x0400_010f => {
Some(self.timers[((address - TIMER_BASE) / 4) as usize].read8(address)) Some(self.timers[((address - TIMER_BASE) / 4) as usize].read8(address))

View file

@ -1,5 +1,6 @@
//! An educational GBA starting point, not a game-compatible emulator. //! An educational GBA starting point, not a game-compatible emulator.
pub mod audio;
pub mod bios; pub mod bios;
pub mod cpu; pub mod cpu;
pub mod display; pub mod display;

View file

@ -1,5 +1,7 @@
use std::{cell::Cell, error::Error, fmt}; use std::{cell::Cell, error::Error, fmt};
#[cfg(test)]
mod audio_step_tests;
mod fetch; mod fetch;
#[cfg(test)] #[cfg(test)]
mod fetch_tests; mod fetch_tests;
@ -18,6 +20,7 @@ pub(crate) use fetch::InstructionFetch;
use iwram_bus::IwramBus; use iwram_bus::IwramBus;
use crate::{ use crate::{
audio::{Audio, StereoLevel},
cpu::InstructionSet, cpu::InstructionSet,
display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES}, display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{DmaError, DMA_BASE, DMA_END}, dma::{DmaError, DMA_BASE, DMA_END},
@ -97,7 +100,7 @@ struct CpuAccess {
/// Successful DMA accesses drive local lanes even before the first CPU instruction. /// 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. /// Channel data remains separate; general and sub-instruction CPU bus handoff is unmodeled.
/// Instruction buffering and supported bus history consume shared fetch samples. /// 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 { pub struct Memory {
external_ram: Vec<u8>, external_ram: Vec<u8>,
internal_ram: Vec<u8>, internal_ram: Vec<u8>,
@ -162,6 +165,17 @@ impl Memory {
Ok(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 /// Total elapsed emulated clock cycles, wrapping at u64::MAX. Reads/writes
/// alone consume no cycles. STOP discards externally supplied clock advances. /// alone consume no cycles. STOP discards externally supplied clock advances.
pub fn cycles(&self) -> u64 { pub fn cycles(&self) -> u64 {
@ -727,7 +741,7 @@ impl Memory {
bytes: [u8; N], bytes: [u8; N],
) -> Result<(), MemoryError> { ) -> Result<(), MemoryError> {
let index = self.write_index::<N>(address)?; let index = self.write_index::<N>(address)?;
Self::validate_io_values(address, &bytes)?; Self::validate_io_values(&mut self.audio_state(), address, &bytes)?;
self.record_access( self.record_access(
address, address,
match N { match N {
@ -753,6 +767,15 @@ impl Memory {
self.iwram_bus.access(address, width, value); self.iwram_bus.access(address, width, value);
} }
if address >> 24 == 0x04 { 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 { if address & !3 == KEYINPUT {
self.io.write_keypad(address, &bytes); self.io.write_keypad(address, &bytes);
return Ok(()); 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 { if address >> 24 == 0x04 {
for (offset, &value) in bytes.iter().enumerate() { for (offset, &value) in bytes.iter().enumerate() {
let address = address + offset as u32; 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 { return Err(MemoryError::UnsupportedIo {
address, address,
value, value,
@ -855,6 +884,9 @@ impl Memory {
}); });
} }
} }
if Audio::mapped(address) {
audio.write(address, bytes);
}
} }
Ok(()) Ok(())
} }
@ -863,9 +895,10 @@ impl Memory {
/// leave a partial block store. This is not a model of hardware data aborts. /// 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. /// 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> { pub(crate) fn write_words(&mut self, writes: &[(u32, u32)]) -> Result<(), MemoryError> {
let mut audio = self.audio_state();
for &(address, value) in writes { for &(address, value) in writes {
self.write_index::<4>(address)?; 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 { for &(address, value) in writes {
// The map cannot change between validation and these writes. // The map cannot change between validation and these writes.

View file

@ -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);
}

View file

@ -19,6 +19,32 @@ const REGIONS: [(u32, u32, u32); 5] = [
]; ];
const MARKER: u32 = 0xa55a_c33c; 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<u32>, register: u32, value: u32) { fn literal(code: &mut Vec<u32>, register: u32, value: u32) {
code.extend([0xe59f_0000 | register << 12, 0xea00_0000, value]); code.extend([0xe59f_0000 | register << 12, 0xea00_0000, value]);
} }

View file

@ -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));
}
}

View file

@ -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(SOUNDCNT_H).unwrap(), 0x7700);
assert_eq!(bus.read16(SOUNDBIAS).unwrap(), 0xc3fe); assert_eq!(bus.read16(SOUNDBIAS).unwrap(), 0xc3fe);
// FIFOs are not ordinary register latches. // FIFOs are not ordinary register latches.
assert!(bus.write32(0x040000a0, 0).is_err()); bus.write32(0x040000a0, 0).unwrap();
assert!(bus.read32(0x040000a0).is_err()); 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() { fn unsupported_operations_have_specific_retryable_diagnostics() {
let mut bus = memory(); let mut bus = memory();
for (address, value, description) in [ for (address, value, description) in [
(SOUNDCNT_X, 0x80, "sound master enable"),
(SIOCNT, 0x80, "serial transfer start"), (SIOCNT, 0x80, "serial transfer start"),
(SIOCNT + 1, 0x20, "multiplayer/UART"), (SIOCNT + 1, 0x20, "multiplayer/UART"),
(RCNT + 1, 0x40, "Joybus serial mode"), (RCNT + 1, 0x40, "Joybus serial mode"),
@ -105,9 +104,10 @@ fn unsupported_operations_have_specific_retryable_diagnostics() {
#[test] #[test]
fn block_store_rejects_later_activation_before_earlier_control_writes() { fn block_store_rejects_later_activation_before_earlier_control_writes() {
let code: [u32; 7] = [ 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); let mut machine = Machine::new(Cpu::new(ROM_START), bus);
for _ in 0..4 { for _ in 0..4 {
machine.step().unwrap(); machine.step().unwrap();
@ -115,24 +115,25 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() {
let before = machine.cpu().clone(); let before = machine.cpu().clone();
let cycles = machine.cycles(); let cycles = machine.cycles();
let error = machine.step().unwrap_err(); 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.cpu(), &before);
assert_eq!(machine.cycles(), cycles); 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)); assert_eq!(machine.step(), Err(error));
} }
#[test] #[test]
fn dma_activation_error_preserves_channel_progress_and_clocks() { fn dma_activation_error_preserves_channel_progress_and_clocks() {
let mut bus = memory(); 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, 0x02000000).unwrap();
bus.write32(DMA_BASE + 4, SOUNDCNT_X).unwrap(); bus.write32(DMA_BASE + 4, 0x04000064).unwrap();
bus.write32(DMA_BASE + 8, 0x84000001).unwrap(); bus.write32(DMA_BASE + 8, 0x84000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus); let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let before = machine.cpu().clone(); let before = machine.cpu().clone();
let error = machine.step().unwrap_err(); 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.cycles(), 0);
assert_eq!(machine.cpu(), &before); assert_eq!(machine.cpu(), &before);
assert_eq!(machine.step(), Err(error)); assert_eq!(machine.step(), Err(error));

View file

@ -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_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/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.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/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/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/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 | | `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs |

View file

@ -1,49 +1,112 @@
# Sound initialization subset # Direct Sound subset
Sound synthesis and host audio output are not implemented. The core models timer-driven Direct Sound A/B playback and a bounded sound-DMA configuration.
The core now models disabled sound-register state so firmware can perform a functional sound reset. It does not produce desktop audio. Programmable sound generator (PSG) synthesis remains unsupported.
This is not a silent-audio compatibility mode. Enabling sound returns an explicit diagnostic. `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 | | Address | Behavior |
| --- | --- | | --- | --- |
| `0x04000060..0x04000081` | Programmable sound generator (PSG) registers remain zero; writes have no effect while master sound is disabled | | `0x04000060..0x04000081` | Idle PSG configuration and documented read masks; writes are ignored while master sound is disabled |
| `0x04000082` (`SOUNDCNT_H`) | Retains mixing/routing/timer configuration with readback mask `0x770f` | | `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` |
| `0x04000084` (`SOUNDCNT_X`) | Reads zero; status flags are read-only; a write setting bit 7 fails | | `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only PSG activity flags remain zero |
| `0x04000088` (`SOUNDBIAS`) | Retains bias/resolution configuration with mask `0xc3fe`; no analog output is generated | | `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` |
| `0x04000090..0x0400009f` | Read/write wave RAM in the CPU-accessible inactive bank | | `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. Unused mapped register bytes 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 reads and the `0x0400008c..0x0400008f` gap remain explicit unmapped-access errors; no FIFO open-bus value is invented.
FIFO addresses and the remaining sound-region holes stay unmapped. Raw memory starts with zero sound state. RegisterRamReset bit 6 sets the functional bias default to `0x0200`.
All sound configuration starts at zero in raw memory. RegisterRamReset bit 6 sets the functional bias default to `0x0200`.
With master sound disabled, `SOUND3CNT_L` stays zero. The CPU accesses wave bank 1. Master disable clears PSG configuration and the FIFO queues, but retains wave RAM and Direct Sound routing/bias.
Bank 0 cannot be selected or played and remains zero. Disabling master sound does not erase wave RAM. The CPU then accesses wave bank 1. Idle bank selection can expose bank 0 after master enable.
The BIOS reset explicitly clears the accessible wave RAM through ordinary stores. The original BIOS reset clears bank 1 and both FIFO queues. It retains bank 0 and any separate in-flight playback word.
Future bank switching and channel playback need a complete sound engine, not a larger register array. 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. ## FIFO and timer behavior
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.
Original tests cover disabled-register writes, masks, byte lanes, wave retention, and BIOS reset selection. Each channel has a seven-word queue, a separate four-byte playback word, and a held signed sample.
They also check rejected sound activation through byte/word accesses, ARM block stores, and DMA without partial state changes. 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. 1. Request sound DMA if the queue contains at most three words, before loading a playback word.
Add a host-independent sample interface before connecting a desktop audio backend. 2. Load a queued word if the playback word is empty.
Do not acknowledge active sound requests without implementing their device behavior. 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 ## 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 Direct Sound](https://problemkaputt.de/gbatek-gba-sound-channel-a-and-b-dma-sound.htm): registers, timer selection, signed samples, and DMA blocks.
- [GBATEK](https://mgba-emu.github.io/gbatek/): channel 3 wave-bank access and register layout. - [GBATEK sound control](https://problemkaputt.de/gbatek-gba-sound-control-registers.htm): master enable, read masks, reset strobes, routing, and bias.
- [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 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.

View file

@ -216,11 +216,12 @@ Supported flags can be combined:
| 3 | Clear all 96 KiB of video RAM: `0x06000000..0x06017fff` | | 3 | Clear all 96 KiB of video RAM: `0x06000000..0x06017fff` |
| 4 | Clear sprite attribute memory (OAM): `0x07000000..0x070003ff` | | 4 | Clear sprite attribute memory (OAM): `0x07000000..0x070003ff` |
| 5 | Reset supported idle serial state and select general-purpose inputs | | 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 | | 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. 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. 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. 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. 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. 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. 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. This subset is not sufficient for Pokémon Emerald compatibility.

View file

@ -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. 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. 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 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. 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. Other accesses use the existing memory map, alignment checks, mirrors, and I/O write rules.

View file

@ -7,7 +7,7 @@
Otherwise it services one ready DMA data unit, keeping the CPU paused. 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. 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. 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. `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. `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. 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. 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. 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. 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::Dma { channel }` identifies a DMA unit. `StepKind::HaltIdle` identifies one bounded HALT clock advance.
`StepKind::StopIdle` identifies a stopped system with no clock progress. `StepKind::StopIdle` identifies a stopped system with no clock progress.
The other step kinds remain `Instruction` and `IrqEntry`. 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. 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. 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. Successful steps commit timers, the shared divider phase, timer IF bits, and Direct Sound state.
Failed steps discard staged counters, divider progress, reload/control writes, and requests. 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. 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. 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. 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. 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. 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. `--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`. 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. 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. 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. 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. Its IRQ callback acknowledges VBlank and updates the BIOS RAM flag before the service returns.

View file

@ -9,10 +9,11 @@ No ROM bytes, extracted code, or assets were added to the repository.
## General correction ## 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. 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 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. 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. 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 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 ```text
PC=0x082e0518 Steps: 321159; instructions: 320647; IRQ entries: 0; DMA units: 512; HALT idle: 0
unsupported sound master enable (audio engine not implemented): write 0x8f at 0x04000084 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 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. The window run does not reach its requested frame limit and correctly exits with failure.
Terminal execution is also diagnostic, not a compatibility pass. 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. Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match. The pinned Pong debug/release reports match.
The next observed startup requirement is active audio-device behavior, starting at SOUNDCNT_X master enable. The next observed startup requirement is PSG channel playback, beginning with pulse channel 1.
Implement channel/FIFO clocks, state, DMA requests, and an output interface before claiming sound support. Direct Sound now has FIFO clocks, nominal DMA requests, and an instantaneous digital-level inspection interface.
Do not make enable succeed solely to suppress this diagnostic. 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. 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. Hardware and emulator references are recorded in the linked subsystem documents.

View file

@ -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. - 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. - 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. - 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. - 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. - 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. - 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. 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. 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. 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. There is no PSG 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. 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. 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. 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. 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 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. 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. 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). 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. 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). The [ROM suite runner](rom-tests.md) has original regressions and a [pinned public ARM baseline](public-arm-tests.md).

View file

@ -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. 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. 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. 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. A local Emerald run now passes its initial all-device reset and master sound enable, then stops at a PSG channel 1 trigger.
See [the runtime result](research/emerald-reset.md). Active audio-device behavior is the next demonstrated startup requirement. 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. 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. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.