diff --git a/crates/gba-core/src/audio.rs b/crates/gba-core/src/audio.rs index f19e636..2309ebc 100644 --- a/crates/gba-core/src/audio.rs +++ b/crates/gba-core/src/audio.rs @@ -1,4 +1,9 @@ -//! Nominal Direct Sound device state. No host audio or PSG synthesis. +//! Nominal Direct Sound and PSG pulse channel 1. No host audio output. + +mod pulse; +use pulse::Pulse; + +const SEQUENCER_PERIOD: u32 = 32768; // 16,777,216 Hz / 512 Hz. pub const SOUND_START: u32 = 0x0400_0060; pub const SOUNDCNT_H: u32 = 0x0400_0082; @@ -73,6 +78,9 @@ impl Fifo { pub(crate) struct Audio { enabled: bool, psg: [u16; 17], + pulse: Pulse, + sequencer_phase: u16, + next_step: u8, control: u16, bias: u16, wave: [[u8; 16]; 2], @@ -86,11 +94,13 @@ impl Audio { } pub(crate) fn unsupported(&self, address: u32, value: u8) -> Option<&'static str> { + if (self.enabled && address == SOUNDCNT_H && value & 3 == 3) + || (address == SOUNDCNT_X && value & 0x80 != 0 && self.control & 3 == 3) + { + return Some("reserved PSG volume selection"); + } if self.enabled - && matches!( - address, - 0x0400_0065 | 0x0400_006d | 0x0400_0075 | 0x0400_007d - ) + && matches!(address, 0x0400_006d | 0x0400_0075 | 0x0400_007d) && value & 0x80 != 0 { Some("PSG channel trigger (synthesis not implemented)") @@ -104,6 +114,9 @@ impl Audio { } pub(crate) fn read8(&self, address: u32) -> Option { + if (SOUND_START..=SOUND_START + 5).contains(&address) { + return Some(self.pulse.read(address - SOUND_START)); + } if (WAVE_RAM..FIFO_A).contains(&address) { return Some(self.wave[self.wave_bank()][(address - WAVE_RAM) as usize]); } @@ -123,7 +136,7 @@ impl Audio { self.psg[((address - SOUND_START) / 2) as usize] & mask } SOUNDCNT_H => self.control, - SOUNDCNT_X => u16::from(self.enabled) << 7, + SOUNDCNT_X => (u16::from(self.enabled) << 7) | u16::from(self.pulse.active), SOUNDBIAS => self.bias, 0x0400_0086 | 0x0400_008a => 0, _ => return None, // FIFO is write-only; open-bus readback is not modeled. @@ -143,6 +156,11 @@ impl Audio { self.wave[self.wave_bank()][(address - WAVE_RAM) as usize] = value; continue; } + if self.enabled && (SOUND_START..=SOUND_START + 5).contains(&address) { + self.pulse + .write(address - SOUND_START, value, self.next_step); + continue; + } let shift = (address & 1) * 8; let merge = |old: u16| (old & !(255 << shift)) | (u16::from(value) << shift); match address & !1 { @@ -163,10 +181,14 @@ impl Audio { SOUNDCNT_X if address & 1 == 0 => { if self.enabled && value & 0x80 == 0 { self.psg = [0; 17]; + self.pulse = Pulse::default(); for fifo in &mut self.fifo { fifo.reset_queue(); } } + if !self.enabled && value & 0x80 != 0 { + self.next_step = 0; + } self.enabled = value & 0x80 != 0; } SOUNDBIAS => self.bias = merge(self.bias) & 0xc3fe, @@ -175,6 +197,29 @@ impl Audio { } } + /// Free-running 512 Hz divider; master enable resets the sequencer step, + /// not the divider phase. STOP is gated by the memory bus before this call. + pub(crate) fn advance(&mut self, mut cycles: u32) { + if !self.enabled { + self.sequencer_phase = ((u64::from(self.sequencer_phase) + u64::from(cycles)) + % u64::from(SEQUENCER_PERIOD)) as u16; + return; + } + while cycles != 0 { + let count = cycles.min(SEQUENCER_PERIOD - u32::from(self.sequencer_phase)); + self.pulse.advance(count); + let phase = u32::from(self.sequencer_phase) + count; + if phase == SEQUENCER_PERIOD { + self.sequencer_phase = 0; + self.pulse.clock(self.next_step); + self.next_step = (self.next_step + 1) & 7; + } else { + self.sequencer_phase = phase as u16; + } + cycles -= count; + } + } + pub(crate) fn timer_overflows(&mut self, timer: usize, count: u64) { if !self.enabled || timer > 1 { return; @@ -208,7 +253,13 @@ impl Audio { } } } - for level in &mut sides { + let psg_control = self.psg[16]; + let ratio = [1, 2, 4, 0][usize::from(self.control & 3)]; + for (side, level) in sides.iter_mut().enumerate() { + if psg_control & (1 << (8 + side * 4)) != 0 { + let volume = ((psg_control >> (side * 4)) & 7) as i16 + 1; + *level += (self.pulse.sample() * volume * ratio) >> 2; + } *level = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512; } StereoLevel { diff --git a/crates/gba-core/src/audio/pulse.rs b/crates/gba-core/src/audio/pulse.rs new file mode 100644 index 0000000..1a1ad8a --- /dev/null +++ b/crates/gba-core/src/audio/pulse.rs @@ -0,0 +1,207 @@ +//! Nominal channel 1 oscillator and modulation units. No host-clock dependency. + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Pulse { + sweep: u8, + duty: u8, + envelope: u8, + frequency: u16, + length_enabled: bool, + length: u8, + pub(super) active: bool, + phase: u8, + remaining: u32, + volume: u8, + envelope_ticks: u8, + envelope_running: bool, + sweep_shadow: u16, + sweep_ticks: u8, + sweep_running: bool, + subtracted: bool, +} + +impl Pulse { + pub(super) fn read(&self, offset: u32) -> u8 { + match offset { + 0 => self.sweep, + 2 => self.duty << 6, + 3 => self.envelope, + 5 => u8::from(self.length_enabled) << 6, + _ => 0, + } + } + + fn period(&self) -> u32 { + 16 * (2048 - u32::from(self.frequency)) + } + + fn dac_enabled(&self) -> bool { + self.envelope & 0xf8 != 0 + } + + fn envelope_can_run(&self) -> bool { + self.envelope & 7 != 0 + && if self.envelope & 8 != 0 { + self.volume < 15 + } else { + self.volume > 0 + } + } + + fn sweep_period(&self) -> u8 { + let period = (self.sweep >> 4) & 7; + if period == 0 { + 8 + } else { + period + } + } + + fn calculate_sweep(&mut self) -> u16 { + let delta = self.sweep_shadow >> (self.sweep & 7); + if self.sweep & 8 != 0 { + self.subtracted = true; + self.sweep_shadow - delta + } else { + self.sweep_shadow + delta + } + } + + pub(super) fn write(&mut self, offset: u32, value: u8, next_step: u8) { + match offset { + 0 => { + if self.subtracted && self.sweep & 8 != 0 && value & 8 == 0 { + self.active = false; + } + self.sweep = value & 0x7f; + } + 2 => { + self.duty = value >> 6; + self.length = 64 - (value & 63); + } + 3 => { + let was_running = self.envelope_running; + self.envelope = value; + if !self.dac_enabled() { + self.active = false; + } + self.envelope_running = self.envelope_can_run(); + if !was_running && self.envelope_running { + self.envelope_ticks = value & 7; + } + } + 4 => self.frequency = (self.frequency & 0x700) | u16::from(value), + 5 => { + self.frequency = (self.frequency & 255) | (u16::from(value & 7) << 8); + let extra_length_clock = next_step & 1 != 0; + if !self.length_enabled && value & 0x40 != 0 && extra_length_clock { + self.clock_length(); + } + self.length_enabled = value & 0x40 != 0; + if value & 0x80 != 0 { + self.active = self.dac_enabled(); + if self.length == 0 { + self.length = 64; + if self.length_enabled && extra_length_clock { + self.length -= 1; + } + } + // Retrigger reloads the oscillator timer but preserves duty position. + // Sub-divider alignment and hardware startup latency are not modeled. + self.remaining = self.period(); + self.volume = self.envelope >> 4; + self.envelope_ticks = if self.envelope & 7 == 0 { + 8 + } else { + self.envelope & 7 + }; + self.envelope_running = self.envelope_can_run(); + self.sweep_shadow = self.frequency; + self.sweep_ticks = self.sweep_period(); + self.sweep_running = self.sweep & 0x77 != 0; + self.subtracted = false; + if self.sweep & 7 != 0 && self.calculate_sweep() > 2047 { + self.active = false; + } + } + } + _ => {} + } + } + + /// Batch oscillator edges until the next modulation event or bus access. + pub(super) fn advance(&mut self, cycles: u32) { + if !self.active || cycles == 0 { + return; + } + if cycles < self.remaining { + self.remaining -= cycles; + return; + } + let rest = cycles - self.remaining; + let period = self.period(); + self.phase = ((u32::from(self.phase) + 1 + rest / period) & 7) as u8; + self.remaining = period - rest % period; + } + + fn clock_length(&mut self) { + if self.length != 0 { + self.length -= 1; + if self.length == 0 { + self.active = false; + } + } + } + + pub(super) fn clock(&mut self, step: u8) { + if step & 1 == 0 && self.length_enabled { + self.clock_length(); + } + if step & 3 == 2 && self.sweep_running { + self.sweep_ticks -= 1; + if self.sweep_ticks == 0 { + self.sweep_ticks = self.sweep_period(); + if self.sweep & 0x70 != 0 { + let next = self.calculate_sweep(); + if next > 2047 { + self.active = false; + } else if self.sweep & 7 != 0 { + self.frequency = next; + self.sweep_shadow = next; + if self.calculate_sweep() > 2047 { + self.active = false; + } + } + } + } + } + if step == 7 && self.active && self.envelope_running { + self.envelope_ticks -= 1; + if self.envelope_ticks == 0 { + if self.envelope & 8 != 0 { + self.volume += 1; + } else { + self.volume -= 1; + } + self.envelope_ticks = self.envelope & 7; + self.envelope_running = self.envelope_can_run(); + } + } + } + + /// Signed pulse amplitude before PSG master volume and stereo routing. + pub(super) fn sample(&self) -> i16 { + if !self.active { + return 0; + } + let high = [0x80u8, 0x81, 0xe1, 0x7e][usize::from(self.duty)] & (1 << self.phase) != 0; + if high { + i16::from(self.volume) + } else { + -i16::from(self.volume) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/gba-core/src/audio/pulse/tests.rs b/crates/gba-core/src/audio/pulse/tests.rs new file mode 100644 index 0000000..5fbcdee --- /dev/null +++ b/crates/gba-core/src/audio/pulse/tests.rs @@ -0,0 +1,205 @@ +//! Original arithmetic and state-transition regressions, not imported hardware tests. +use super::*; + +fn start(frequency: u16, duty: u8, envelope: u8, sweep: u8) -> Pulse { + let mut pulse = Pulse::default(); + pulse.write(0, sweep, 0); + pulse.write(2, duty << 6, 0); + pulse.write(3, envelope, 0); + pulse.write(4, frequency as u8, 0); + pulse.write(5, 0x80 | (frequency >> 8) as u8, 0); + pulse +} + +#[test] +fn all_frequencies_and_duties_match_independent_edge_arithmetic() { + let high_phases: [&[u8]; 4] = [&[7], &[0, 7], &[0, 5, 6, 7], &[1, 2, 3, 4, 5, 6]]; + for frequency in 0..2048 { + let period = 16 * (2048 - u32::from(frequency)); + for duty in 0..4 { + for cycles in [0, period - 1, period, period * 8, u32::MAX] { + let mut pulse = start(frequency, duty, 0xf0, 0); + pulse.advance(cycles); + let phase = ((cycles / period) % 8) as u8; + assert_eq!(pulse.phase, phase); + assert_eq!(pulse.remaining, period - cycles % period); + assert_eq!( + pulse.sample(), + if high_phases[duty as usize].contains(&phase) { + 15 + } else { + -15 + } + ); + } + } + } +} + +#[test] +fn frequency_writes_preserve_current_interval_and_trigger_preserves_duty_phase() { + let mut pulse = start(2046, 2, 0xf0, 0); // 32 clocks per edge. + pulse.advance(31); + pulse.write(4, 255, 0); // Next period is 16 clocks; current interval has one left. + pulse.advance(1); + assert_eq!(pulse.phase, 1); + assert_eq!(pulse.remaining, 16); + pulse.advance(7); + pulse.write(5, 0x87, 0); + assert_eq!(pulse.phase, 1); + assert_eq!(pulse.remaining, 16); + pulse.advance(15); + assert_eq!(pulse.phase, 1); + pulse.advance(1); + assert_eq!(pulse.phase, 2); +} + +#[test] +fn length_load_enable_extra_clock_expiry_and_zero_reload() { + let mut pulse = start(1000, 2, 0xf0, 0); + pulse.write(2, 63, 0); // One remaining length tick. + pulse.clock(0); // Disabled length does not expire. + assert!(pulse.active); + pulse.write(5, 0x43, 1); // Enable before a step that does not clock length. + assert_eq!(pulse.length, 0); + assert!(!pulse.active); + pulse.write(5, 0xc3, 1); // Trigger with empty length reloads 63 in this phase. + assert_eq!(pulse.length, 63); + assert!(pulse.active); + for _ in 0..62 { + pulse.clock(0); + } + assert!(pulse.active); + pulse.clock(0); + assert!(!pulse.active); + pulse.clock(0); + assert_eq!(pulse.length, 0); + pulse.write(5, 0xc3, 0); + assert_eq!(pulse.length, 64); +} + +#[test] +fn simultaneous_length_enable_and_trigger_reload_after_extra_expiry() { + let mut pulse = start(1000, 0, 0xf0, 0); + pulse.write(2, 63, 0); + pulse.write(5, 0xc3, 1); + assert_eq!(pulse.length, 63); + assert!(pulse.active); + pulse.write(2, 62, 0); + pulse.write(5, 0xc3, 1); // Already enabled: do not add another extra decrement. + assert_eq!(pulse.length, 2); +} + +#[test] +fn envelopes_step_at_selected_period_saturate_and_do_not_clear_status() { + for envelope in [0x39, 0xc1] { + let mut pulse = start(0, 1, envelope, 0); + for tick in 1..=20 { + pulse.clock(7); + let expected = if envelope & 8 != 0 { + (3 + tick).min(15) + } else { + 12u8.saturating_sub(tick) + }; + assert_eq!(pulse.volume, expected); + assert!(pulse.active); + } + } + let mut pulse = start(0, 1, 0x53, 0); + pulse.clock(7); + pulse.clock(7); + assert_eq!(pulse.volume, 5); + pulse.clock(7); + assert_eq!(pulse.volume, 4); + pulse.write(3, 0xa0, 0); // Period zero holds current volume until retrigger. + for _ in 0..16 { + pulse.clock(7); + } + assert_eq!(pulse.volume, 4); + pulse.write(5, 0x80, 0); + assert_eq!(pulse.volume, 10); +} + +#[test] +fn live_envelope_configuration_retains_volume_and_dac_off_requires_retrigger() { + let mut pulse = start(0, 1, 0x52, 0); + pulse.clock(7); + pulse.write(3, 0xa9, 0); // Change direction/period, not current volume or remaining tick. + assert_eq!(pulse.volume, 5); + pulse.clock(7); + assert_eq!(pulse.volume, 6); + pulse.write(3, 0, 0); + assert!(!pulse.active); + assert_eq!(pulse.sample(), 0); + pulse.write(3, 0x08, 0); // Rising envelope with initial zero has DAC enabled. + assert!(!pulse.active); + pulse.write(5, 0x80, 0); + assert!(pulse.active); + assert_eq!(pulse.volume, 0); + pulse.write(3, 7, 0); // Period alone cannot enable DAC. + pulse.write(5, 0x80, 0); + assert!(!pulse.active); +} + +#[test] +fn sweep_checks_trigger_overflow_and_second_calculation_after_update() { + assert!(!start(1500, 1, 0xf0, 0x11).active); + assert!(!start(1500, 1, 0xf0, 1).active); // Time zero still checks at trigger. + let mut pulse = start(1000, 1, 0xf0, 0x11); + assert!(pulse.active); + pulse.clock(2); + assert_eq!(pulse.frequency, 1500); + assert!(!pulse.active); // Next calculated 2250 disables before another sweep tick. +} + +#[test] +fn sweep_uses_shadow_frequency_and_preserves_live_write_until_next_update() { + let mut pulse = start(400, 1, 0xf0, 0x21); + pulse.write(4, 200, 0); + assert_eq!(pulse.frequency, 456); + pulse.clock(2); + assert_eq!(pulse.frequency, 456); + pulse.clock(6); + assert_eq!(pulse.frequency, 600); // 400 + 400/2, not based on 456. + assert_eq!(pulse.read(4), 0); // Frequency is write-only. + assert_eq!(pulse.read(5), 0); +} + +#[test] +fn sweep_zero_period_shift_zero_and_subtraction_direction_rules() { + let mut pulse = start(500, 1, 0xf0, 1); + for _ in 0..32 { + pulse.clock(2); + } + assert_eq!(pulse.frequency, 500); // Time zero suppresses periodic changes. + let mut pulse = start(1200, 1, 0xf0, 0x10); + assert!(pulse.active); // Shift zero skips trigger-time overflow check. + pulse.clock(2); + assert!(!pulse.active); // Timed overflow check still applies. + let mut pulse = start(1000, 1, 0xf0, 0x19); + pulse.clock(2); + assert_eq!(pulse.frequency, 500); + pulse.write(0, 0x11, 0); + assert!(!pulse.active); // Clearing negate after subtraction disables the channel. + let mut pulse = start(1000, 1, 0xf0, 0x18); + pulse.write(0, 0x10, 0); // No subtraction calculation has happened. + assert!(pulse.active); +} + +#[test] +fn split_clock_advances_match_one_batch() { + let mut random = 0x5a17u32; + let mut bulk = start(1783, 3, 0xa2, 0x29); + let mut split = bulk; + for step in 0..1000u32 { + random = random.wrapping_mul(1664525).wrapping_add(1013904223); + let cycles = random & 0xffff; + bulk.advance(cycles); + for part in [cycles / 3, cycles / 3, cycles - (cycles / 3) * 2] { + split.advance(part); + } + bulk.clock((step & 7) as u8); + split.clock((step & 7) as u8); + assert_eq!(bulk, split); + } +} diff --git a/crates/gba-core/src/io.rs b/crates/gba-core/src/io.rs index 85ed8e2..c28afc6 100644 --- a/crates/gba-core/src/io.rs +++ b/crates/gba-core/src/io.rs @@ -438,6 +438,7 @@ impl Io { audio: &mut Audio, cycles: u32, ) -> u16 { + audio.advance(cycles); let mut pending = 0; let mut overflows = 0; for (index, timer) in timers.iter_mut().enumerate() { diff --git a/crates/gba-core/src/io/timer_step.rs b/crates/gba-core/src/io/timer_step.rs index b2d1c65..d5c401f 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 and Direct Sound clocks for one instruction, IRQ entry, or DMA unit. +//! Staged timer and audio clocks for one instruction, IRQ entry, or DMA unit. //! Other devices remain on the instruction-boundary scheduler. use super::{Io, Timer, IF, TIMER_BASE}; diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index cec496e..9b49552 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -165,7 +165,7 @@ impl Memory { Ok(memory) } - /// Current committed Direct Sound mixer level. This is not a host sample stream. + /// Current committed audio mixer level. This is not a host sample stream. pub fn audio_level(&self) -> StereoLevel { self.io.audio.level() } diff --git a/crates/gba-core/src/memory/audio_step_tests.rs b/crates/gba-core/src/memory/audio_step_tests.rs index 4e907fc..ed0c94d 100644 --- a/crates/gba-core/src/memory/audio_step_tests.rs +++ b/crates/gba-core/src/memory/audio_step_tests.rs @@ -9,6 +9,8 @@ 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.write16(0x04000062, 0xf080).unwrap(); + memory.write16(0x04000064, 0x87ff).unwrap(); // Running pulse, unrouted; rollback must include it. memory .write32( TIMER_BASE, @@ -17,6 +19,34 @@ fn sound(memory: &mut Memory, period: u16) { .unwrap(); } +#[test] +fn pulse_status_load_observes_length_expiry_at_bus_completion() { + let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]); + sound(&mut memory, 1000); + memory.write16(0x04000062, 0xf03f).unwrap(); + memory.write16(0x04000064, 0xc7ff).unwrap(); + memory.advance_cycles(32768 - 7); + let mut machine = Machine::new(cpu, memory); + machine.step().unwrap(); + assert_eq!(machine.cpu().registers()[0], 0x80); + assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), 0x80); +} + +#[test] +fn pulse_trigger_reloads_timer_at_store_completion() { + let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x87ff), (1, 0x04000064)]); + sound(&mut memory, 1000); + memory.write16(0x04000080, 0x1177).unwrap(); + memory.write16(SOUNDCNT_H, 2).unwrap(); + let mut machine = Machine::new(cpu, memory); + machine.step().unwrap(); + assert_eq!(machine.cycles(), 7); + machine.memory_mut().advance_cycles(15); + assert_eq!(machine.memory().audio_level().left, 120); + machine.memory_mut().advance_cycles(1); + assert_eq!(machine.memory().audio_level().left, -120); +} + #[test] fn fifo_store_becomes_visible_after_its_bus_completion_not_at_instruction_start() { let (cpu, mut memory) = prepared( @@ -65,7 +95,7 @@ fn failed_cpu_load_rolls_back_timer_audio_and_pending_refill_requests() { #[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)] { + for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x0400006c, 0x8000)] { let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]); sound(&mut memory, 1); memory.write32(FIFO_A, 0x04030201).unwrap(); @@ -91,7 +121,7 @@ fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_wri .write_words(&[ (0x02000000, 0x11223344), (SOUNDCNT_X, 0x80), - (0x04000064, 0x8000), + (0x0400006c, 0x8000), ]) .is_err()); assert_eq!(memory.read32(0x02000000).unwrap(), 0); diff --git a/crates/gba-core/tests/bios/ram_reset.rs b/crates/gba-core/tests/bios/ram_reset.rs index bc20c37..3358d17 100644 --- a/crates/gba-core/tests/bios/ram_reset.rs +++ b/crates/gba-core/tests/bios/ram_reset.rs @@ -19,6 +19,25 @@ const REGIONS: [(u32, u32, u32); 5] = [ ]; const MARKER: u32 = 0xa55a_c33c; +#[test] +fn sound_reset_disables_running_pulse_only_when_selected() { + use gba_core::io::SOUNDCNT_X; + for thumb in [false, true] { + for flags in [0, 0x40] { + let (mut machine, return_pc) = prepare(flags, thumb, 0x1f); + let bus = machine.memory_mut(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(0x04000062, 0xf080).unwrap(); + bus.write16(0x04000064, 0x87ff).unwrap(); + complete(&mut machine, return_pc); + assert_eq!( + machine.memory().read16(SOUNDCNT_X).unwrap(), + if flags == 0 { 0x81 } else { 0 } + ); + } + } +} + #[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}; diff --git a/crates/gba-core/tests/direct_sound.rs b/crates/gba-core/tests/direct_sound.rs index 41600b0..ef36935 100644 --- a/crates/gba-core/tests/direct_sound.rs +++ b/crates/gba-core/tests/direct_sound.rs @@ -159,7 +159,7 @@ fn full_queue_overflow_clears_queued_words() { } #[test] -fn wave_banks_and_idle_psg_masks_work_without_claiming_psg_playback() { +fn wave_banks_and_idle_channel_masks_preserve_explicit_limits() { let mut bus = bus(); bus.write16(0x04000062, 0xffff).unwrap(); assert_eq!(bus.read16(0x04000062).unwrap(), 0xffc0); @@ -167,7 +167,7 @@ fn wave_banks_and_idle_psg_masks_work_without_claiming_psg_playback() { 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] { + for address in [0x0400006c, 0x04000074, 0x0400007c] { let previous = bus.read16(address).unwrap(); assert!(bus .write16(address, 0xffff) diff --git a/crates/gba-core/tests/inactive_devices.rs b/crates/gba-core/tests/inactive_devices.rs index cfaa4b6..745135b 100644 --- a/crates/gba-core/tests/inactive_devices.rs +++ b/crates/gba-core/tests/inactive_devices.rs @@ -104,7 +104,7 @@ fn unsupported_operations_have_specific_retryable_diagnostics() { #[test] fn block_store_rejects_later_activation_before_earlier_control_writes() { let code: [u32; 7] = [ - 0xe3a00301, 0xe2800060, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678, + 0xe3a00301, 0xe2800068, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678, ]; let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); bus.write16(SOUNDCNT_X, 0x80).unwrap(); @@ -118,7 +118,7 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() { assert!(error.to_string().contains("PSG channel trigger")); assert_eq!(machine.cpu(), &before); assert_eq!(machine.cycles(), cycles); - assert_eq!(machine.memory().read16(0x04000060).unwrap(), 0); + assert_eq!(machine.memory().read16(0x04000068).unwrap(), 0); assert_eq!(machine.step(), Err(error)); } @@ -128,7 +128,7 @@ fn dma_activation_error_preserves_channel_progress_and_clocks() { bus.write16(SOUNDCNT_X, 0x80).unwrap(); bus.write32(0x02000000, 0x8000).unwrap(); bus.write32(DMA_BASE, 0x02000000).unwrap(); - bus.write32(DMA_BASE + 4, 0x04000064).unwrap(); + bus.write32(DMA_BASE + 4, 0x0400006c).unwrap(); bus.write32(DMA_BASE + 8, 0x84000001).unwrap(); let mut machine = Machine::new(Cpu::new(ROM_START), bus); let before = machine.cpu().clone(); diff --git a/crates/gba-core/tests/pulse_sound.rs b/crates/gba-core/tests/pulse_sound.rs new file mode 100644 index 0000000..fd7e205 --- /dev/null +++ b/crates/gba-core/tests/pulse_sound.rs @@ -0,0 +1,213 @@ +//! Original public-bus tests for PSG channel 1; no game code or audio assets. +use gba_core::{ + audio::StereoLevel, + cpu::Cpu, + io::{FIFO_A, HALTCNT, IF, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE}, + machine::Machine, + memory::{Memory, ROM_START}, +}; + +const SWEEP: u32 = 0x04000060; +const DUTY: u32 = 0x04000062; +const FREQUENCY: u32 = 0x04000064; +const MIX: u32 = 0x04000080; +const SEQUENCE: u32 = 32768; + +fn bus() -> Memory { + let mut bus = Memory::new(0xeafffffeu32.to_le_bytes().to_vec()).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(SOUNDBIAS, 0x200).unwrap(); + bus.write16(MIX, 0x1177).unwrap(); // Channel 1, both sides, maximum volume. + bus.write16(SOUNDCNT_H, 2).unwrap(); // PSG 100%. + bus +} + +fn start(bus: &mut Memory) { + bus.write16(DUTY, 0xf080).unwrap(); // 50% duty, volume15, no envelope, length64. + bus.write16(FREQUENCY, 0x87ff).unwrap(); // Frequency2047, trigger; length disabled. +} + +fn stereo(value: i16) -> StereoLevel { + StereoLevel { + left: value, + right: value, + } +} + +#[test] +fn register_widths_masks_status_and_wave_edges_without_general_timers() { + let mut bus = bus(); + bus.write32(SWEEP, 0xf0bf0080).unwrap(); // Sweep bit7 ignored; duty50%, length1. + assert_eq!(bus.read32(SWEEP).unwrap(), 0xf0800000); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + bus.write8(FREQUENCY, 255).unwrap(); + bus.write8(FREQUENCY + 1, 0x87).unwrap(); + assert_eq!(bus.read32(FREQUENCY).unwrap(), 0); + assert_eq!(bus.read32(SOUNDCNT_X).unwrap(), 0x81); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); // Status cannot be cleared by a write. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + assert_eq!(bus.audio_level(), stereo(120)); + bus.advance_cycles(15); + assert_eq!(bus.audio_level(), stereo(120)); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(-120)); + bus.advance_cycles(64); + assert_eq!(bus.audio_level(), stereo(120)); + assert_eq!(bus.read16(IF).unwrap(), 0); // PSG clocks do not generate IRQs. +} + +#[test] +fn psg_stereo_volume_ratios_and_direct_sound_share_bias_and_clipping() { + let mut bus = bus(); + start(&mut bus); + for (ratio, expected) in [(0, 30), (1, 60), (2, 120)] { + bus.write16(SOUNDCNT_H, ratio).unwrap(); + assert_eq!(bus.audio_level(), stereo(expected)); + } + bus.write16(MIX, 0x1070).unwrap(); + assert_eq!( + bus.audio_level(), + StereoLevel { + left: 120, + right: 0 + } + ); + bus.write16(MIX, 0x0100).unwrap(); // Volume field zero means one, not mute. + assert_eq!(bus.audio_level(), StereoLevel { left: 0, right: 15 }); + bus.write16(MIX, 0x1177).unwrap(); + bus.write16(SOUNDCNT_H, 0x0306).unwrap(); + bus.write32(FIFO_A, 0x7f7f7f7f).unwrap(); + bus.write32(TIMER_BASE, 0x0080ffff).unwrap(); + bus.advance_cycles(1); + assert_eq!(bus.audio_level(), stereo(511)); // 508 + 120 clips after mixing. + bus.write8(DUTY + 1, 0).unwrap(); + assert_eq!(bus.audio_level(), stereo(508)); // DAC off removes only pulse contribution. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); +} + +#[test] +fn length_clocks_at_256_hz_and_status_expires_independently_of_routing() { + let mut bus = bus(); + bus.write16(MIX, 0).unwrap(); + bus.write16(DUTY, 0xf0be).unwrap(); // Two length ticks. + bus.write16(FREQUENCY, 0xc7ff).unwrap(); + bus.advance_cycles(SEQUENCE - 1); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + bus.advance_cycles(1); // Step0, first length tick. + bus.advance_cycles(SEQUENCE * 2 - 1); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + bus.advance_cycles(1); // Step2, second length tick. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + assert_eq!(bus.read16(FREQUENCY).unwrap(), 0x4000); +} + +#[test] +fn envelope_64_hz_and_sweep_128_hz_use_distinct_sequencer_steps() { + let mut bus = bus(); + bus.write16(DUTY, 0x3980).unwrap(); // Volume3, increase every envelope tick. + bus.write16(FREQUENCY, 0x87ff).unwrap(); + bus.advance_cycles(SEQUENCE * 8 - 1); + assert_eq!(bus.audio_level().left.abs(), 24); + bus.advance_cycles(1); // Step7. + assert_eq!(bus.audio_level().left.abs(), 32); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + + let mut bus = self::bus(); + bus.write16(SWEEP, 0x11).unwrap(); + bus.write16(DUTY, 0xf080).unwrap(); + bus.write16(FREQUENCY, 0x83e8).unwrap(); // 1000 ->1500; second check overflows2250. + bus.advance_cycles(SEQUENCE * 3 - 1); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + bus.advance_cycles(1); // Step2, first sweep clock. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + assert_eq!(bus.audio_level(), stereo(0)); +} + +#[test] +fn sequencer_divider_runs_while_master_off_and_enable_resets_only_step() { + let mut bus = Memory::new(vec![]).unwrap(); + bus.advance_cycles(SEQUENCE - 1); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(DUTY, 0xf03f).unwrap(); + bus.write16(FREQUENCY, 0xc000).unwrap(); + bus.advance_cycles(1); // Not one whole new interval after enable. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + bus.write16(SOUNDCNT_X, 0).unwrap(); + assert_eq!(bus.read16(DUTY).unwrap(), 0); + bus.write16(DUTY, 0xffff).unwrap(); + bus.write16(FREQUENCY, 0xffff).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + assert_eq!(bus.read16(DUTY).unwrap(), 0); +} + +#[test] +fn reserved_psg_ratio_errors_are_atomic_including_enable_after_disabled_configuration() { + let mut bus = bus(); + start(&mut bus); + let before = bus.audio_level(); + let error = bus.write32(MIX, 0x00030000).unwrap_err(); + assert!(error.to_string().contains("reserved PSG volume selection")); + assert_eq!(bus.read16(MIX).unwrap(), 0x1177); + assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 2); + assert_eq!(bus.audio_level(), before); + bus.write16(SOUNDCNT_X, 0).unwrap(); + bus.write16(SOUNDCNT_H, 3).unwrap(); // Disabled sound still retains these bits. + assert!(bus.write16(SOUNDCNT_X, 0x80).is_err()); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0); +} + +#[test] +fn halt_keeps_pulse_running_and_stop_freezes_it() { + for stop in [false, true] { + let mut bus = bus(); + start(&mut bus); + bus.write16(FREQUENCY, 0x87fd).unwrap(); // 48 clocks per duty step. + bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + let cpu = machine.cpu().clone(); + machine.step().unwrap(); + assert_eq!(machine.cpu(), &cpu); + assert_eq!( + machine.memory().audio_level(), + stereo(if stop { 120 } else { -120 }) + ); + if stop { + machine.memory_mut().advance_cycles(u32::MAX); + assert_eq!(machine.cycles(), 0); + assert_eq!(machine.memory().audio_level(), stereo(120)); + } + } +} + +#[test] +fn clock_batching_and_row_capture_do_not_change_psg_result() { + let mut bulk = bus(); + let mut rows = bus(); + let mut split = bus(); + rows.set_scanline_rendering(true); + for bus in [&mut bulk, &mut rows, &mut split] { + bus.write16(SWEEP, 0x29).unwrap(); + bus.write16(DUTY, 0xa340).unwrap(); + bus.write16(FREQUENCY, 0x85ad).unwrap(); + } + for cycles in [1, 15, 32751, 262145, 77777] { + bulk.advance_cycles(cycles); + rows.advance_cycles(cycles); + for _ in 0..cycles { + split.advance_cycles(1); + } + assert_eq!(bulk.audio_level(), split.audio_level()); + assert_eq!(bulk.audio_level(), rows.audio_level()); + assert_eq!( + bulk.read16(SOUNDCNT_X).unwrap(), + split.read16(SOUNDCNT_X).unwrap() + ); + } + // Maximum batch must remain bounded and preserve an indefinite channel. + let mut bus = bus(); + start(&mut bus); + bus.advance_cycles(u32::MAX); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81); + assert_eq!(bus.audio_level(), stereo(120)); // floor(MAX/16) modulo8 = 7. +} diff --git a/docs/architecture.md b/docs/architecture.md index 2670fa6..2062c12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,10 @@ 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/audio.rs` | Direct Sound FIFO/playback state, idle PSG/wave banks, and instantaneous stereo mixing | +| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, idle channel/wave banks, and instantaneous stereo mixing | +| `crates/gba-core/src/audio/pulse.rs` | Pulse channel 1 oscillator, length, envelope, sweep, and status | +| `crates/gba-core/src/audio/pulse/tests.rs` | Exhaustive frequency/duty arithmetic and original modulation edge tests | +| `crates/gba-core/tests/pulse_sound.rs` | Register masks, stereo mixing, sequencer clocks, idle modes, batching, and explicit limits | | `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 | diff --git a/docs/hardware/audio.md b/docs/hardware/audio.md index dfdab10..32f1a0b 100644 --- a/docs/hardware/audio.md +++ b/docs/hardware/audio.md @@ -1,16 +1,17 @@ -# Direct Sound subset +# Audio device subset -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. +The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) pulse channel 1. +It does not produce desktop audio. PSG channels 2–4 remain unsupported. `Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream. -## Registers and inactive PSG state +## Registers and PSG state | Address | Behavior | | --- | --- | -| `0x04000060..0x04000081` | Idle PSG configuration and documented read masks; writes are ignored while master sound is disabled | +| `0x04000060..0x04000065` | Pulse channel 1 sweep, duty, length, envelope, frequency, and trigger | +| `0x04000066..0x04000081` | Idle channel 2–4 configuration and PSG mixer controls | | `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 | +| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only channel 1 activity in bit 0; channels 2–4 remain inactive | | `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 | @@ -24,10 +25,56 @@ The CPU then accesses wave bank 1. Idle bank selection can expose bank 0 after m 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. -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. +An enabled channel 2–4 trigger returns `MemoryError::UnsupportedIo` with the exact address and value. +This applies even when the configured channel would be inaudible. All PSG writes are ignored while master sound is disabled. +Reserved PSG volume selection 3 is rejected while master sound is enabled, including enable after disabled configuration. +Idle channel 2–4 writes do not imply playback support. + +## Pulse channel 1 + +Channel 1 has four duty patterns: 12.5%, 25%, 50%, and 75%. +Its eight-position waveform advances every `16 * (2048 - frequency)` system clocks. +No general-purpose timer must be enabled for PSG playback. +Frequency writes change the next oscillator reload, not the remaining current interval. +A trigger reloads the oscillator timer, envelope volume, envelope timer, and sweep shadow/timer state. +Retrigger preserves the duty position. Master disable resets it. + +The modulation sequencer receives a 512 Hz clock, once per 32768 system clocks: + +| Sequencer step | Event | +| --- | --- | +| 0, 2, 4, 6 | Length counter clock: 256 Hz | +| 2, 6 | Sweep clock: 128 Hz | +| 7 | Envelope clock: 64 Hz | + +The divider starts at synthetic phase zero and continues while master sound is disabled. +Master enable sets the next sequencer step to zero without resetting that divider. +Channel trigger does not reset the shared sequencer. HALT continues its clocks; STOP freezes them. +Waveform advancement uses arithmetic batches between sequencer events, not a loop for every system clock. + +Length writes load `64 - length_field`. Enabled length expiry clears the channel activity flag. +Enabling length before a sequencer step that does not clock length applies an extra decrement. +A trigger reloads an empty counter to 64, or 63 when that extra-clock condition applies. +Nonempty length counters retain their values on trigger. + +The envelope changes volume by one after each selected number of 64 Hz clocks. +Period zero holds volume. Saturation at zero or fifteen stops envelope changes without clearing channel activity. +Live envelope writes retain current volume and the remaining active countdown. +Reactivating a stopped envelope loads its new countdown. Trigger loads its configured initial volume. +Clearing envelope bits 3–7 disables the channel; setting them again requires a trigger to restart it. +This is the logical gate often called DAC enable in Game Boy references, not a separate physical GBA DAC. + +Sweep uses a shadow frequency independent of later frequency-register writes. +A nonzero shift checks overflow immediately at trigger. +Timed sweep updates check overflow both before and after committing the new frequency. +A result above 2047 disables the channel. Period zero suppresses periodic frequency changes, not the trigger-time check. +Shift zero performs timed overflow checks without committing a changed frequency. +Clearing the subtraction direction after a subtraction calculation disables the channel until a new trigger. + +These rules define a nominal digital model, not exact hardware edge timing. +First-trigger output suppression, trigger sub-divider alignment, envelope trigger-edge delays, and revision-specific envelope write effects are not modeled. +The model samples the selected duty position immediately after trigger and uses a full-period timer reload. +The consulted implementations differ on some initial phases and envelope/sweep edge cases; no GBA hardware audio recording was used as an oracle. ## FIFO and timer behavior @@ -72,8 +119,13 @@ 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. +Each routed Direct Sound sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume. +Pulse channel 1 contributes a centered signed amplitude, `+volume` or `-volume`, while active. +`SOUNDCNT_L` routes it independently left/right and multiplies each side by its volume field plus one. +`SOUNDCNT_H` then applies the PSG ratio: 25%, 50%, or 100%, using an arithmetic right shift. +At full volume, channel 1 therefore spans `-120..120` before mixing with Direct Sound. +This centered mixer follows GBATEK's signed range and the NanoBoyAdvance comparison; exact analog offset is not modeled. +The mixer adds all supported channels and 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. @@ -81,7 +133,7 @@ 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. +Successful steps commit once. Failed steps discard FIFO consumption, held samples, PSG clocks/state, 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. @@ -91,14 +143,21 @@ Original tests cover signed samples, byte order, queue capacity, partial writes, 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. +Pulse tests cover every frequency/duty pair, waveform batching, status, length, envelope, sweep, mixing, and reserved-volume diagnostics. +They also check bus-completion status reads, retrigger timing, clock ownership, and rollback with a running pulse channel. 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. +Next work: PSG channels 2–4, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend. +The local Emerald startup now passes the channel 1 trigger and stops at a channel 2 trigger. See [the local runtime result](../research/emerald-reset.md). ## References +- [GBATEK channel 1](https://mgba-emu.github.io/gbatek/): register fields, duty ratios, and modulation rates. +- [Pan Docs audio details](https://gbdev.io/pandocs/Audio_details.html): duty counters, shadow sweep, length edge rules, and GBA digital-mixer differences. GB-only edge details are not treated as verified GBA measurements. +- [GbdevWiki sound hardware](https://gbdev.gg8.se/wiki/articles/Gameboy_sound_hardware): background sequencer and sweep descriptions. +- [mGBA shared PSG at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gb/audio.c): GBA-style sequencer, length, envelope, sweep, and waveform comparison. +- [NanoBoyAdvance pulse at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/quad_channel.cc), [base channel](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/base_channel.hh), [length](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/length_counter.hh), [envelope](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/envelope.hh), and [sweep](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/sweep.hh): ordinary pulse behavior and documented uncertainty. The adjacent `quad_channel.hh` defines frequency scaling. - [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. diff --git a/docs/hardware/bios.md b/docs/hardware/bios.md index c37582a..c1f6cfc 100644 --- a/docs/hardware/bios.md +++ b/docs/hardware/bios.md @@ -220,7 +220,7 @@ Supported flags can be combined: | 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. [PSG playback](audio.md) and [serial transfers](serial.md) still fail explicitly. +This is not full audio or serial support. [PSG channels 2–4](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. @@ -231,8 +231,9 @@ Zeroed OAM contains regular sprites at the origin, not disabled sprites; softwar After the RAM clears, bit 5 clears SIOCNT and SIODATA8, writes RCNT=0x8000, and clears supported Joybus reset registers. Disconnected general-purpose inputs read high, so RCNT reads back as 0x800f. -Bit 6 writes SOUNDCNT_X=0, SOUNDCNT_H=0, and SOUNDBIAS=0x0200, then clears the accessible wave RAM bank. -Disabled PSG registers remain zero. The other wave bank cannot be enabled in the current subset and remains zero. +Bit 6 disables SOUNDCNT_X, strobes both FIFO resets, clears SOUNDCNT_H, and sets SOUNDBIAS=0x0200. +It then clears accessible wave bank 1. Wave bank 0 is retained and can be selected again after master enable. +Disabled PSG registers remain zero. A running pulse channel stops only when sound reset is selected. Reset does not produce audio, fabricate serial transfers, or bypass normal bus accesses. Bit 7 runs after the selected RAM and sound/serial resets: @@ -510,5 +511,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. -PSG playback, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. +PSG channels 2–4, 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/timers-irq.md b/docs/hardware/timers-irq.md index 41aecf2..a41bfd3 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, timer, and Direct Sound clocks by its nominal cost. +Each successful step advances display, timer, and audio 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. @@ -34,11 +34,12 @@ 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, timer IF bits, and Direct Sound state. +Successful steps commit timers, the shared divider phase, timer IF bits, and audio 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. 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. +The same transaction advances the PSG oscillator and 512 Hz sequencer independently of the general-purpose 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. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index f6f5258..fccef5b 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -10,7 +10,7 @@ No ROM bytes, extracted code, or assets were added to the repository. ## General correction 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 [sound model](../hardware/audio.md) now also supports Direct Sound, bounded FIFO DMA, and nominal pulse channel 1 playback. 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, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default. @@ -30,16 +30,17 @@ direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --w ``` 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: +The Direct Sound increment then reached a PSG channel 1 trigger (`0x04000065`, PC `0x082e03bc`). +After implementing channel 1, both runs pass that trigger and stop at channel 2: ```text -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 +Steps: 321161; instructions: 320649; IRQ entries: 0; DMA units: 512; HALT idle: 0 +Nominal cycles: 1038742 +PC=0x082e03c0 +unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x0400006d ``` -The native run captures three startup frames. Direct Sound behavior is validated by original tests, not by audible game output. +The native run captures three startup frames. Audio-device 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. @@ -52,8 +53,9 @@ 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 PSG channel playback, beginning with pulse channel 1. -Direct Sound now has FIFO clocks, nominal DMA requests, and an instantaneous digital-level inspection interface. +The next observed startup requirement is PSG pulse channel 2, which has no sweep unit. +Channel 1 now has nominal waveform, length, envelope, sweep, and stereo mixing behavior. +Direct Sound has FIFO clocks and nominal DMA requests. Both use the 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. diff --git a/docs/status.md b/docs/status.md index 78dcc7e..0a8c8b6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -79,7 +79,8 @@ Update this file when a feature lands or a limit is removed. - 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. - 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. +- PSG pulse channel 1 supports nominal frequency/duty playback, length, envelope, sweep, status, and stereo mixing. +- Channels 2–4 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. @@ -135,9 +136,10 @@ 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 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. +There is no host audio output or cartridge save support. +The [audio subset](hardware/audio.md) supports Direct Sound, PSG pulse channel 1, idle channel 2–4 state, wave banking, and instantaneous digital mixing. +It does not yet provide a continuous sample stream. Channel 2–4 playback and unverified sound-DMA pairings remain diagnostic. +PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified. 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. @@ -148,7 +150,8 @@ ROM files can run in bounded terminal mode or a window with the original BIOS re 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 master sound enable. -It stops explicitly at a PSG channel 1 trigger at `0x04000065`; no title-screen or gameplay compatibility is established. +It also passes the channel 1 trigger, then stops explicitly at channel 2 (`0x0400006d`). +No title-screen, gameplay, or audible-game 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 fabbba1..aa1e6d2 100644 --- a/docs/usability-roadmap.md +++ b/docs/usability-roadmap.md @@ -39,9 +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 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. +A local Emerald run now passes its initial all-device reset, master sound enable, and PSG channel 1 trigger. +It stops at a channel 2 trigger. The core has tested Direct Sound and pulse channel 1 behavior, but no continuous sample stream or desktop audio output. +See [the runtime result](research/emerald-reset.md). Remaining PSG channels are 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.