diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs index 6d1d049..df10efa 100644 --- a/crates/desktop/tests/rom_cli.rs +++ b/crates/desktop/tests/rom_cli.rs @@ -77,6 +77,39 @@ fn raw_original_program_runs_with_a_bounded_report_without_changing_the_file() { assert_eq!(fs::read(path).unwrap(), original); } +fn wave_setup() -> Vec { + // Original ARM halfword stores and a status load; no external audio or assets. + let mut code = Vec::new(); + for (address, value) in [ + (0x04000084, 0x80), + (0x04000090, 0xf012), // CPU bank 1 before selecting playback bank 1. + (0x04000070, 0xc0), + (0x04000072, 0x2000), + (0x04000074, 0x87ff), + ] { + code.extend([0xe59f0000, 0xea000000, address]); + code.extend([0xe59f1000, 0xea000000, value]); + code.push(0xe1c010b0); // STRH r1,[r0] + } + code.extend([0xe59f0000, 0xea000000, 0x04000084, 0xe1d020b0, 0xeafffffe]); + code +} + +#[test] +fn original_wave_program_can_activate_channel3_without_host_audio() { + let fixture = Fixture::new(); + let path = fixture.rom(&wave_setup()); + let original = fs::read(&path).unwrap(); + let output = run(&path, "1000"); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + stdout(&output).contains("r2=0x00000084"), + "{}", + stdout(&output) + ); + assert_eq!(fs::read(path).unwrap(), original); +} + fn joybus_setup() -> Vec { // Original ARM: select Joybus, enable IRQ, supply a reply, inspect local status/data. vec![ @@ -458,6 +491,11 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() ); assert!(stdout(&output).contains("Captured ROM frames: 3")); assert!(stdout(&output).contains("Result: window frame limit reached")); + let wave_path = fixture.rom(&wave_setup()); + let wave_output = window(&wave_path, false, None); + assert!(wave_output.status.success(), "{}", stderr(&wave_output)); + assert!(stdout(&wave_output).contains("Captured ROM frames: 3")); + assert!(stdout(&wave_output).contains("r2=0x00000084")); let joybus_path = fixture.rom(&joybus_setup()); let joybus_output = window(&joybus_path, false, None); assert!(joybus_output.status.success(), "{}", stderr(&joybus_output)); diff --git a/crates/gba-core/src/audio.rs b/crates/gba-core/src/audio.rs index 52bf2dc..98fca2d 100644 --- a/crates/gba-core/src/audio.rs +++ b/crates/gba-core/src/audio.rs @@ -1,10 +1,12 @@ -//! Nominal Direct Sound, PSG pulse channels 1–2, and noise channel 4. No host audio output. +//! Nominal Direct Sound and PSG channels, including single-bank wave playback. No host output. mod modulation; mod noise; mod pulse; +mod wave; use noise::Noise; use pulse::Pulse; +use wave::Wave; const SEQUENCER_PERIOD: u32 = 32768; // 16,777,216 Hz / 512 Hz. @@ -83,6 +85,7 @@ pub(crate) struct Audio { psg: [u16; 17], pulses: [Pulse; 2], noise: Noise, + wave_channel: Wave, sequencer_phase: u16, next_step: u8, control: u16, @@ -103,15 +106,15 @@ impl Audio { { return Some("reserved PSG volume selection"); } - if self.enabled && address == 0x0400_0075 && value & 0x80 != 0 { - Some("PSG channel trigger (synthesis not implemented)") + if self.enabled && (0x0400_0070..=0x0400_0075).contains(&address) { + self.wave_channel.unsupported(address - 0x0400_0070, value) } else { None } } fn wave_bank(&self) -> usize { - 1 ^ usize::from((self.psg[8] >> 6) & 1) + 1 ^ self.wave_channel.bank() } /// Channel 2 has no sweep register. Its register gap must not alias any @@ -126,6 +129,9 @@ impl Audio { } pub(crate) fn read8(&self, address: u32) -> Option { + if (0x0400_0070..=0x0400_0075).contains(&address) { + return Some(self.wave_channel.read(address - 0x0400_0070)); + } if (0x0400_0078..=0x0400_007d).contains(&address) { return Some(self.noise.read(address - 0x0400_0078)); } @@ -138,9 +144,6 @@ impl Audio { let value = match address & !1 { SOUND_START..=0x0400_0080 => { let mask = match address & !1 { - 0x0400_0074 => 0x4000, - 0x0400_0070 => 0x00e0, - 0x0400_0072 => 0xe000, 0x0400_0080 => 0xff77, _ => 0, }; @@ -151,6 +154,7 @@ impl Audio { (u16::from(self.enabled) << 7) | u16::from(self.pulses[0].modulation.active) | (u16::from(self.pulses[1].modulation.active) << 1) + | (u16::from(self.wave_channel.active) << 2) | (u16::from(self.noise.modulation.active) << 3) } SOUNDBIAS => self.bias, @@ -178,6 +182,13 @@ impl Audio { } continue; } + if (0x0400_0070..=0x0400_0075).contains(&address) { + if self.enabled { + self.wave_channel + .write(address - 0x0400_0070, value, self.next_step); + } + continue; + } if (0x0400_0078..=0x0400_007d).contains(&address) { if self.enabled { self.noise @@ -207,6 +218,7 @@ impl Audio { self.psg = [0; 17]; self.pulses = [Pulse::default(); 2]; self.noise = Noise::default(); + self.wave_channel = Wave::default(); for fifo in &mut self.fifo { fifo.reset_queue(); } @@ -236,6 +248,7 @@ impl Audio { pulse.advance(count); } self.noise.advance(count); + self.wave_channel.advance(count, &mut self.wave); let phase = u32::from(self.sequencer_phase) + count; if phase == SEQUENCER_PERIOD { self.sequencer_phase = 0; @@ -243,6 +256,7 @@ impl Audio { pulse.clock(self.next_step); } self.noise.clock(self.next_step); + self.wave_channel.clock(self.next_step); self.next_step = (self.next_step + 1) & 7; } else { self.sequencer_phase = phase as u16; @@ -290,15 +304,18 @@ impl Audio { let mut psg_sample = 0; for (index, pulse) in self.pulses.iter().enumerate() { if psg_control & (1 << (8 + side * 4 + index)) != 0 { - psg_sample += pulse.sample(); + psg_sample += pulse.sample() * 4; } } if psg_control & (1 << (11 + side * 4)) != 0 { - psg_sample += self.noise.sample(); + psg_sample += self.noise.sample() * 4; + } + if psg_control & (1 << (10 + side * 4)) != 0 { + psg_sample += self.wave_channel.sample_quarters(); } let volume = ((psg_control >> (side * 4)) & 7) as i16 + 1; // Sum PSG channels before scaling, so fractional bits are discarded once. - *level += (psg_sample * volume * ratio) >> 2; + *level += (psg_sample * volume * ratio) >> 4; *level = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512; } StereoLevel { diff --git a/crates/gba-core/src/audio/wave.rs b/crates/gba-core/src/audio/wave.rs new file mode 100644 index 0000000..3a28d97 --- /dev/null +++ b/crates/gba-core/src/audio/wave.rs @@ -0,0 +1,133 @@ +//! Nominal single-bank wave playback. RAM rotates in high-nibble-first order. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Wave { + control: u8, + volume: u8, + frequency: u16, + length: u16, + length_enabled: bool, + pub(super) active: bool, + remaining: u32, + sample: Option, +} + +impl Wave { + pub(super) fn bank(&self) -> usize { + usize::from((self.control >> 6) & 1) + } + + pub(super) fn unsupported(&self, offset: u32, value: u8) -> Option<&'static str> { + if (offset == 5 && value & 0x80 != 0 && self.control & 0xa0 == 0xa0) + || (offset == 0 && self.active && value & 0xa0 == 0xa0) + { + return Some("64-sample wave playback"); + } + if offset == 0 && self.active && value & 0x80 != 0 && (value ^ self.control) & 0x40 != 0 { + return Some("wave bank change during playback"); + } + None + } + + pub(super) fn read(&self, offset: u32) -> u8 { + match offset { + 0 => self.control, + 3 => self.volume, + 5 => u8::from(self.length_enabled) << 6, + _ => 0, + } + } + + fn period(&self) -> u32 { + 8 * (2048 - u32::from(self.frequency)) + } + + pub(super) fn write(&mut self, offset: u32, value: u8, next_step: u8) { + match offset { + 0 => { + self.control = value & 0xe0; + if value & 0x80 == 0 { + self.active = false; + } + } + 2 => self.length = 256 - u16::from(value), + 3 => self.volume = value & 0xe0, + 4 => self.frequency = (self.frequency & 0x700) | u16::from(value), + 5 => { + self.frequency = (self.frequency & 255) | (u16::from(value & 7) << 8); + let extra_clock = next_step & 1 != 0; + if !self.length_enabled && value & 0x40 != 0 && extra_clock { + self.clock_length(); + } + self.length_enabled = value & 0x40 != 0; + if value & 0x80 != 0 { + self.active = self.control & 0x80 != 0; + if self.length == 0 { + self.length = 256 - u16::from(self.length_enabled && extra_clock); + } + self.remaining = self.period(); + // Nominal startup policy: silence until the first full sample period. + // Retrigger cannot restore RAM that playback has already rotated. + self.sample = None; + } + } + _ => {} + } + } + + 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(); + } + } + + pub(super) fn advance(&mut self, cycles: u32, banks: &mut [[u8; 16]; 2]) { + if !self.active || cycles == 0 { + return; + } + if cycles < self.remaining { + self.remaining -= cycles; + return; + } + let rest = cycles - self.remaining; + let edges = 1 + rest / self.period(); + self.remaining = self.period() - rest % self.period(); + let data = &mut banks[self.bank()]; + let old = *data; + let digit = |index: usize| { + let byte = old[(index % 32) / 2]; + (byte >> if index & 1 == 0 { 4 } else { 0 }) & 15 + }; + self.sample = Some(digit(((edges - 1) % 32) as usize)); + let rotation = (edges % 32) as usize; + for (index, byte) in data.iter_mut().enumerate() { + *byte = (digit(index * 2 + rotation) << 4) | digit(index * 2 + rotation + 1); + } + } + + /// Quarter-units of the pulse/noise amplitude. Keep fractional wave volume + /// until after routing, master volume, and PSG ratio have been combined. + pub(super) fn sample_quarters(&self) -> i16 { + if !self.active { + return 0; + } + let Some(sample) = self.sample else { return 0 }; + let gain = if self.volume & 0x80 != 0 { + 3 + } else { + [0, 4, 2, 1][usize::from(self.volume >> 5)] + }; + (i16::from(sample) - 8) * 2 * gain + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/gba-core/src/audio/wave/tests.rs b/crates/gba-core/src/audio/wave/tests.rs new file mode 100644 index 0000000..8ebc586 --- /dev/null +++ b/crates/gba-core/src/audio/wave/tests.rs @@ -0,0 +1,132 @@ +use super::Wave; + +fn start(bank: u8, frequency: u16) -> Wave { + let mut wave = Wave::default(); + wave.write(0, 0x80 | (bank << 6), 0); + wave.write(3, 0x20, 0); + wave.write(4, frequency as u8, 0); + wave.write(5, 0x80 | (frequency >> 8) as u8, 0); + wave +} + +#[test] +fn every_frequency_and_bank_follow_high_nibble_first_rotation() { + for frequency in 0..2048 { + for bank in 0..2 { + let mut wave = start(bank, frequency); + let mut ram = [[0xab; 16]; 2]; + let mut digits: Vec = (0..32).map(|i| (i * 7 % 16) as u8).collect(); + for (index, byte) in ram[bank as usize].iter_mut().enumerate() { + *byte = digits[index * 2] * 16 + digits[index * 2 + 1]; + } + let period = (2048 - u32::from(frequency)) * 8; + assert_eq!(wave.sample_quarters(), 0); + for _ in 0..65 { + let before = ram; + wave.advance(period - 1, &mut ram); + assert_eq!(ram, before); + let output = digits.remove(0); + digits.push(output); + wave.advance(1, &mut ram); + assert_eq!(wave.sample_quarters(), (i16::from(output) - 8) * 8); + for (index, byte) in ram[bank as usize].iter().enumerate() { + assert_eq!(*byte, digits[index * 2] * 16 + digits[index * 2 + 1]); + } + assert_eq!(ram[1 - bank as usize], [0xab; 16]); + } + } + } +} + +#[test] +fn batch_advance_matches_single_clocks_and_full_rotations_keep_the_last_sample() { + for frequency in [0, 1024, 2047] { + let mut fast = start(0, frequency); + let mut slow = fast; + let mut a = [[0x12; 16], [0xab; 16]]; + let mut b = a; + fast.advance(100003, &mut a); + for _ in 0..100003 { + slow.advance(1, &mut b); + } + assert_eq!((fast, a), (slow, b)); + } + let mut wave = start(0, 2047); + let mut ram = [[0x12; 16]; 2]; + wave.advance(256, &mut ram); + assert_eq!(wave.sample, Some(2)); + assert_eq!(ram, [[0x12; 16]; 2]); + wave.advance(u32::MAX, &mut ram); + assert_eq!(wave.sample, Some(1)); // floor(MAX/8) is odd. + assert_eq!(wave.remaining, 1); + assert_eq!(ram[0], [0x21; 16]); +} + +#[test] +fn volume_mute_and_force_gain_preserve_fractional_units_and_activity() { + let mut wave = start(0, 2047); + for digit in 0..16 { + let mut ram = [[digit * 17; 16]; 2]; + wave.advance(8, &mut ram); + for control in 0..8 { + wave.write(3, control << 5, 0); + let gain = if control >= 4 { + 3 + } else { + [0, 4, 2, 1][control as usize] + }; + assert_eq!(wave.sample_quarters(), (i16::from(digit) - 8) * 2 * gain); + assert!(wave.active); + } + } +} + +#[test] +fn trigger_frequency_length_and_gate_have_independent_effects() { + let mut wave = start(0, 2047); + let mut ram = [[0x12; 16]; 2]; + wave.advance(3, &mut ram); + wave.write(4, 0, 0); // Change next reload, not the remaining five cycles. + assert_eq!(wave.remaining, 5); + wave.advance(5, &mut ram); + assert_eq!(wave.sample, Some(1)); + assert_eq!(wave.remaining, 2048); + wave.write(5, 0x87, 0); // Retrigger: fresh period, rotated RAM is not restored. + assert_eq!(wave.sample, None); + wave.advance(2048, &mut ram); + assert_eq!(wave.sample, Some(2)); + wave.write(0, 0, 0); + assert!(!wave.active); + let before = ram; + wave.advance(1000000, &mut ram); + assert_eq!(ram, before); + wave.write(0, 0x80, 0); + assert!(!wave.active); // Gate enable alone is not a trigger. + wave.write(2, 255, 0); + wave.write(5, 0xc0, 0); + assert_eq!(wave.length, 1); + wave.clock(1); + assert!(wave.active); + wave.clock(2); + assert!(!wave.active); + wave.write(5, 0xc0, 1); + assert_eq!(wave.length, 255); + assert!(wave.active); +} + +#[test] +fn length_enable_extra_clock_and_muted_trigger_do_not_reset_ram() { + let mut wave = start(0, 2047); + wave.write(2, 255, 0); + wave.write(5, 0x47, 1); + assert_eq!(wave.length, 0); + assert!(!wave.active); + wave.write(0, 0, 0); + wave.write(5, 0xc7, 1); + assert_eq!(wave.length, 255); + assert!(!wave.active); + for _ in 0..255 { + wave.clock(0); + } + assert_eq!(wave.length, 0); +} diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index f747161..90d98da 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -26,6 +26,8 @@ mod serial_step_tests; mod timer_step_tests; #[cfg(test)] mod timing_event_tests; +#[cfg(test)] +mod wave_step_tests; pub(crate) use fetch::InstructionFetch; use iwram_bus::IwramBus; diff --git a/crates/gba-core/src/memory/audio_step_tests.rs b/crates/gba-core/src/memory/audio_step_tests.rs index fb63209..90d338a 100644 --- a/crates/gba-core/src/memory/audio_step_tests.rs +++ b/crates/gba-core/src/memory/audio_step_tests.rs @@ -14,7 +14,7 @@ fn sound(memory: &mut Memory, period: u16) { memory.write16(0x04000068, 0xf080).unwrap(); memory.write16(0x0400006c, 0x87ff).unwrap(); memory.write16(0x04000078, 0xf000).unwrap(); - memory.write16(0x0400007c, 0x8008).unwrap(); // All supported PSG channels run unrouted. + memory.write16(0x0400007c, 0x8008).unwrap(); // Pulse and noise channels run unrouted. memory .write32( TIMER_BASE, @@ -160,6 +160,7 @@ fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]); sound(&mut memory, 1); memory.write32(FIFO_A, 0x04030201).unwrap(); + memory.write16(0x04000070, 0xa0).unwrap(); memory.write32(0x02000000, word).unwrap(); memory.write32(DMA_BASE, source).unwrap(); memory.write32(DMA_BASE + 4, destination).unwrap(); @@ -206,7 +207,7 @@ fn arm_block_store_rejects_later_wave_trigger_before_committing_channel2_trigger (0, 0x04000068), (1, 0xf080), (2, 0x87ff), - (3, 0), + (3, 0xa0), (4, 0x8000), ], ); // STMIA r0!,{r1-r4}: channel2 configuration/trigger, wave configuration/trigger. @@ -219,7 +220,7 @@ fn arm_block_store_rejects_later_wave_trigger_before_committing_channel2_trigger .step() .unwrap_err() .to_string() - .contains("PSG channel trigger")); + .contains("64-sample wave playback")); assert_eq!(machine.memory().io.audio, audio); assert_eq!(machine.cpu(), &cpu); assert_eq!(machine.cycles(), 0); @@ -236,6 +237,7 @@ fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_wri (SOUNDCNT_X, 0x80), (0x04000068, 0xf080), (0x0400006c, 0x87ff), + (0x04000070, 0xa0), (0x04000074, 0x8000), ]) .is_err()); diff --git a/crates/gba-core/src/memory/serial_step_tests.rs b/crates/gba-core/src/memory/serial_step_tests.rs index 66bf844..915cbcc 100644 --- a/crates/gba-core/src/memory/serial_step_tests.rs +++ b/crates/gba-core/src/memory/serial_step_tests.rs @@ -137,6 +137,7 @@ fn dma_start_excludes_source_cycles_and_failed_destination_discards_serial_progr if fail { start(&mut memory, 60); memory.write16(SOUNDCNT_X, 0x80).unwrap(); + memory.write16(0x04000070, 0xa0).unwrap(); // Unsupported two-bank wave playback. } memory .write32(0x02000000, if fail { 0x8000 } else { 0x4083 }) diff --git a/crates/gba-core/src/memory/wave_step_tests.rs b/crates/gba-core/src/memory/wave_step_tests.rs new file mode 100644 index 0000000..b780862 --- /dev/null +++ b/crates/gba-core/src/memory/wave_step_tests.rs @@ -0,0 +1,112 @@ +//! Original wave clocks, RAM rotation, and access-phase rollback probes. +use super::{timing_event_tests::prepared, *}; +use crate::{ + io::{SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, WAVE_RAM}, + machine::{Machine, StepKind}, +}; + +fn setup(bus: &mut Memory) { + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(SOUNDBIAS, 0x200).unwrap(); + bus.write16(SOUNDCNT_H, 2).unwrap(); + bus.write16(0x04000080, 0x4477).unwrap(); + bus.write16(0x04000070, 0x40).unwrap(); + bus.write32(WAVE_RAM, 0x123456f0).unwrap(); // Bank 0, high nibble first. + bus.write16(0x04000070, 0x80).unwrap(); + bus.write16(0x04000072, 0x2000).unwrap(); +} + +#[test] +fn arm_thumb_and_dma_triggers_start_after_the_store_bus_phase() { + for kind in 0..3 { + let (cpu, mut memory) = prepared( + kind == 1, + &[if kind == 1 { 0x6008 } else { 0xe5810000 }], + &[(0, 0x87ff), (1, 0x04000074)], + ); + setup(&mut memory); + if kind == 2 { + memory.write32(0x02000000, 0x87ff).unwrap(); + memory.write32(DMA_BASE, 0x02000000).unwrap(); + memory.write32(DMA_BASE + 4, 0x04000074).unwrap(); + memory.write32(DMA_BASE + 8, 0x84000001).unwrap(); + } + let mut machine = Machine::new(cpu, memory); + assert_eq!( + machine.step().unwrap(), + if kind == 2 { + StepKind::Dma { channel: 0 } + } else { + StepKind::Instruction + } + ); + assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), 0x84); + machine.memory_mut().advance_cycles(7); + assert_eq!(machine.memory().audio_level().left, 0); + machine.memory_mut().advance_cycles(1); + assert_eq!(machine.memory().audio_level().left, 112); + } +} + +#[test] +fn length_expiry_at_load_bus_completion_is_visible_in_status() { + let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]); + setup(&mut memory); + memory.write16(0x04000072, 0x20ff).unwrap(); + memory.write16(0x04000074, 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().audio_level().left, 0); +} + +#[test] +fn failed_block_access_rolls_back_rotated_ram_samples_configuration_and_clocks() { + for store in [false, true] { + let (cpu, mut memory) = if store { + prepared( + false, + &[0xe8a0003e], + &[ + (0, 0x04000070), + (1, 0x20000080), + (2, 0x87ff), + (3, 0), + (4, 0), + (5, 0x00030000), + ], + ) + } else { + prepared(false, &[0xe8910005], &[(1, SOUNDBIAS)]) + }; + setup(&mut memory); + memory.write16(0x04000074, 0x87ff).unwrap(); + memory.advance_cycles(7); + 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.cpu(), &cpu); + assert_eq!(machine.cycles(), 7); + } + } +} + +#[test] +fn staged_bank_switch_reads_the_current_rotated_ram_not_committed_storage() { + let (_, mut memory) = prepared(false, &[0xe1a00000], &[]); + setup(&mut memory); + memory.write16(0x04000074, 0x87ff).unwrap(); + memory.advance_cycles(8); + let audio = memory.io.audio; + memory.begin_timer_step(); + memory.write16(0x04000070, 0x40).unwrap(); // Stop and expose played bank. + assert_eq!(memory.read32(WAVE_RAM).unwrap(), 0x20416305); + memory.write32(WAVE_RAM, 0xaabbccdd).unwrap(); + assert_eq!(memory.read32(WAVE_RAM).unwrap(), 0xaabbccdd); + memory.discard_timer_step(); + assert_eq!(memory.io.audio, audio); +} diff --git a/crates/gba-core/tests/bios/ram_reset.rs b/crates/gba-core/tests/bios/ram_reset.rs index 4fc5bb8..6157ffb 100644 --- a/crates/gba-core/tests/bios/ram_reset.rs +++ b/crates/gba-core/tests/bios/ram_reset.rs @@ -31,12 +31,15 @@ fn sound_reset_disables_running_psg_channels_only_when_selected() { bus.write16(0x04000064, 0x87ff).unwrap(); bus.write16(0x04000068, 0xf080).unwrap(); bus.write16(0x0400006c, 0x87fe).unwrap(); + bus.write16(0x04000070, 0x80).unwrap(); + bus.write16(0x04000072, 0x2000).unwrap(); + bus.write16(0x04000074, 0x87fd).unwrap(); bus.write16(0x04000078, 0xf000).unwrap(); bus.write16(0x0400007c, 0x8000).unwrap(); complete(&mut machine, return_pc); assert_eq!( machine.memory().read16(SOUNDCNT_X).unwrap(), - if flags == 0 { 0x8b } else { 0 } + if flags == 0 { 0x8f } else { 0 } ); } } diff --git a/crates/gba-core/tests/direct_sound.rs b/crates/gba-core/tests/direct_sound.rs index 4434ac1..2fbb21b 100644 --- a/crates/gba-core/tests/direct_sound.rs +++ b/crates/gba-core/tests/direct_sound.rs @@ -167,12 +167,13 @@ fn wave_banks_and_idle_channel_masks_preserve_explicit_limits() { bus.write16(0x04000070, 0x40).unwrap(); assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0); bus.write32(WAVE_RAM, 0xaabbccdd).unwrap(); + bus.write16(0x04000070, 0xe0).unwrap(); // Unimplemented two-bank playback. let previous = bus.read16(0x04000074).unwrap(); assert!(bus .write16(0x04000074, 0xffff) .unwrap_err() .to_string() - .contains("PSG channel trigger")); + .contains("64-sample wave playback")); assert_eq!(bus.read16(0x04000074).unwrap(), previous); bus.write16(SOUNDCNT_X, 0).unwrap(); assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0x11223344); diff --git a/crates/gba-core/tests/inactive_devices.rs b/crates/gba-core/tests/inactive_devices.rs index ad0641a..d6c9f12 100644 --- a/crates/gba-core/tests/inactive_devices.rs +++ b/crates/gba-core/tests/inactive_devices.rs @@ -97,7 +97,7 @@ fn unsupported_operations_have_specific_retryable_diagnostics() { #[test] fn block_store_rejects_later_activation_before_earlier_control_writes() { let code: [u32; 7] = [ - 0xe3a00301, 0xe2800070, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678, + 0xe3a00301, 0xe2800070, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x123400a0, ]; let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); bus.write16(SOUNDCNT_X, 0x80).unwrap(); @@ -108,7 +108,7 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() { let before = machine.cpu().clone(); let cycles = machine.cycles(); let error = machine.step().unwrap_err(); - assert!(error.to_string().contains("PSG channel trigger")); + assert!(error.to_string().contains("64-sample wave playback")); assert_eq!(machine.cpu(), &before); assert_eq!(machine.cycles(), cycles); assert_eq!(machine.memory().read16(0x04000070).unwrap(), 0); @@ -119,6 +119,7 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() { fn dma_activation_error_preserves_channel_progress_and_clocks() { let mut bus = memory(); bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(0x04000070, 0xa0).unwrap(); bus.write32(0x02000000, 0x8000).unwrap(); bus.write32(DMA_BASE, 0x02000000).unwrap(); bus.write32(DMA_BASE + 4, 0x04000074).unwrap(); @@ -126,7 +127,7 @@ fn dma_activation_error_preserves_channel_progress_and_clocks() { let mut machine = Machine::new(Cpu::new(ROM_START), bus); let before = machine.cpu().clone(); let error = machine.step().unwrap_err(); - assert!(error.to_string().contains("PSG channel trigger")); + assert!(error.to_string().contains("64-sample wave playback")); assert_eq!(machine.cycles(), 0); assert_eq!(machine.cpu(), &before); assert_eq!(machine.step(), Err(error)); diff --git a/crates/gba-core/tests/wave_sound.rs b/crates/gba-core/tests/wave_sound.rs new file mode 100644 index 0000000..9157058 --- /dev/null +++ b/crates/gba-core/tests/wave_sound.rs @@ -0,0 +1,208 @@ +//! Original channel 3 tests. Digital levels are not host audio or hardware recordings. +use gba_core::{ + audio::StereoLevel, + io::{HALTCNT, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, WAVE_RAM}, + memory::Memory, +}; +const CONTROL: u32 = 0x04000070; +const LENGTH: u32 = 0x04000072; +const FREQUENCY: u32 = 0x04000074; +const ROUTE: u32 = 0x04000080; + +fn bus() -> Memory { + let mut bus = Memory::new(vec![]).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(SOUNDBIAS, 0x200).unwrap(); + bus.write16(SOUNDCNT_H, 2).unwrap(); + bus.write16(ROUTE, 0x4477).unwrap(); + bus +} +fn start(bus: &mut Memory, bank: u16, data: u8) { + bus.write16(CONTROL, (bank ^ 1) << 6).unwrap(); + for offset in 0..16 { + bus.write8(WAVE_RAM + offset, data).unwrap(); + } + bus.write16(CONTROL, 0x80 | (bank << 6)).unwrap(); + bus.write16(LENGTH, 0x2000).unwrap(); + bus.write16(FREQUENCY, 0x87ff).unwrap(); +} +fn stereo(level: i16) -> StereoLevel { + StereoLevel { + left: level, + right: level, + } +} + +#[test] +fn register_masks_gate_status_and_unused_halfword_do_not_alias_other_channels() { + let mut bus = bus(); + bus.write16(CONTROL, 0xffff).unwrap(); + bus.write16(LENGTH, 0xffff).unwrap(); + bus.write16(FREQUENCY, 0x7fff).unwrap(); // No trigger, 64-sample configuration is idle. + bus.write16(FREQUENCY + 2, 0xffff).unwrap(); + assert_eq!(bus.read16(CONTROL).unwrap(), 0xe0); + assert_eq!(bus.read16(LENGTH).unwrap(), 0xe000); + assert_eq!(bus.read16(FREQUENCY).unwrap(), 0x4000); + assert_eq!(bus.read16(FREQUENCY + 2).unwrap(), 0); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + bus.write16(CONTROL, 0).unwrap(); + bus.write16(FREQUENCY, 0x87ff).unwrap(); // A trigger with the gate off cannot play. + bus.advance_cycles(1000000); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + assert_eq!(bus.audio_level(), stereo(0)); + start(&mut bus, 0, 0xf0); + bus.write16(SOUNDCNT_X, 0xff).unwrap(); // Status is read-only. + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x84); + assert_eq!(bus.audio_level(), stereo(0)); + bus.advance_cycles(8); + assert_eq!(bus.audio_level(), stereo(112)); + bus.advance_cycles(8); + assert_eq!(bus.audio_level(), stereo(-128)); + bus.write16(CONTROL, 0).unwrap(); + bus.write16(CONTROL, 0x80).unwrap(); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); +} + +#[test] +fn cpu_bank_access_and_readback_follow_rotated_storage_without_touching_the_other_bank() { + for bank in [0, 1] { + let mut bus = bus(); + start(&mut bus, bank, 0x12); + bus.write32(WAVE_RAM, 0xabcdef90).unwrap(); // Other bank remains writable during playback. + bus.advance_cycles(8); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0xabcdef90); + bus.write16(CONTROL, (bank ^ 1) << 6).unwrap(); // Stop and expose the played bank. + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0x21212121); + bus.write16(SOUNDCNT_X, 0).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + bus.write16(CONTROL, (bank ^ 1) << 6).unwrap(); + assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0x21212121); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + } +} + +#[test] +fn wave_gain_routes_and_psg_ratio_use_one_final_signed_rounding() { + let mut bus = bus(); + start(&mut bus, 1, 0x99); // Centered amplitude +2 before volume. + bus.advance_cycles(8); + for ratio in 0..3 { + bus.write16(SOUNDCNT_H, ratio).unwrap(); + for setting in 0..8 { + bus.write8(LENGTH + 1, setting << 5).unwrap(); + let gain = if setting >= 4 { + 3 + } else { + [0, 4, 2, 1][setting as usize] + }; + for volume in 0..8 { + bus.write16(ROUTE, 0x4400 | volume | (volume << 4)).unwrap(); + let expected = (2 * gain * (volume as i16 + 1) * (1 << ratio)) >> 4; + assert_eq!(bus.audio_level(), stereo(expected)); + } + } + } + bus.write16(ROUTE, 0x0400).unwrap(); + assert_eq!(bus.audio_level(), StereoLevel { left: 0, right: 1 }); // +1.5 rounds once. + bus.write16(ROUTE, 0).unwrap(); + assert_eq!(bus.audio_level(), stereo(0)); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x84); +} + +#[test] +fn wave_fraction_combines_with_pulse_before_rounding_and_direct_sound_bias_clipping() { + let mut bus = bus(); + start(&mut bus, 0, 0x77); // -2 amplitude. + bus.advance_cycles(8); + bus.write16(LENGTH, 0x8000).unwrap(); // -1.5 at forced 75%. + bus.write16(0x04000062, 0x1080).unwrap(); // Pulse amplitude +1 at duty position zero. + bus.write16(0x04000064, 0x8000).unwrap(); + bus.write16(ROUTE, 0x5500).unwrap(); + assert_eq!(bus.audio_level(), stereo(-1)); // (-1.5 + 1), not individually rounded by a different rule. + bus.write16(SOUNDBIAS, 0).unwrap(); + assert_eq!(bus.audio_level(), stereo(-512)); + bus.write16(SOUNDBIAS, 0x3fe).unwrap(); + bus.write16(0x04000062, 0xf080).unwrap(); + bus.write16(0x04000064, 0x8000).unwrap(); + bus.write16(ROUTE, 0x5577).unwrap(); + assert_eq!(bus.audio_level(), stereo(511)); + // Fractional wave and pulse contributions must not be rounded separately. + bus.write16(SOUNDBIAS, 0x200).unwrap(); + start(&mut bus, 0, 0x99); + bus.advance_cycles(8); + bus.write16(LENGTH, 0x8000).unwrap(); // +1.5. + bus.write16(0x04000062, 0x1080).unwrap(); + bus.write16(0x04000064, 0x8000).unwrap(); // +1. + bus.write16(ROUTE, 0x5500).unwrap(); + bus.write16(SOUNDCNT_H, 1).unwrap(); // 50%: (+1.5 + 1)/2, rounded once. + assert_eq!(bus.audio_level(), stereo(1)); +} + +#[test] +fn length_shared_clock_halt_stop_and_master_disable_have_distinct_effects() { + let mut bus = bus(); + start(&mut bus, 0, 0xf0); + bus.write16(LENGTH, 0x20ff).unwrap(); // One length tick. + bus.write16(FREQUENCY, 0xc7ff).unwrap(); + bus.write8(HALTCNT, 0).unwrap(); + bus.advance_cycles(32767); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x84); + bus.advance_cycles(1); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); + assert_eq!(bus.audio_level(), stereo(0)); + start(&mut bus, 0, 0xf0); + bus.advance_cycles(8); + bus.write8(HALTCNT, 0x80).unwrap(); + let before = bus.audio_level(); + bus.advance_cycles(1000000); + assert_eq!(bus.audio_level(), before); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x84); + bus.write16(SOUNDCNT_X, 0).unwrap(); + bus.write32(CONTROL, u32::MAX).unwrap(); // Disabled writes ignored. + bus.write16(FREQUENCY, 0xffff).unwrap(); + bus.write16(SOUNDCNT_X, 0x80).unwrap(); + assert_eq!(bus.read32(CONTROL).unwrap(), 0); + assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); +} + +#[test] +fn unsupported_live_bank_changes_and_two_bank_playback_are_retryable_and_atomic() { + let mut bus = bus(); + start(&mut bus, 0, 0x12); + bus.advance_cycles(8); + let before = bus.audio_level(); + for value in [0xc0, 0xa0, 0xe0] { + let error = bus.write32(CONTROL, 0xe0000000 | value).unwrap_err(); + assert_eq!(bus.write32(CONTROL, 0xe0000000 | value), Err(error)); + assert_eq!(bus.read16(CONTROL).unwrap(), 0x80); + assert_eq!(bus.read16(LENGTH).unwrap(), 0x2000); + assert_eq!(bus.audio_level(), before); + } + bus.write16(CONTROL, 0).unwrap(); + bus.write16(CONTROL, 0xa0).unwrap(); + let error = bus.write16(FREQUENCY, 0xffff).unwrap_err(); + assert!(error.to_string().contains("64-sample wave playback")); + assert_eq!(bus.write16(FREQUENCY, 0xffff), Err(error)); + assert_eq!(bus.read16(FREQUENCY).unwrap(), 0); +} + +#[test] +fn batched_clocks_match_small_chunks_with_and_without_scanline_capture() { + let mut a = bus(); + let mut b = bus(); + for bus in [&mut a, &mut b] { + start(bus, 1, 0x12); + } + b.set_scanline_rendering(true); + a.advance_cycles(100003); + for _ in 0..100003 { + b.advance_cycles(1); + } + assert_eq!(a.audio_level(), b.audio_level()); + for bus in [&mut a, &mut b] { + bus.write16(CONTROL, 0).unwrap(); + } + for offset in 0..16 { + assert_eq!(a.read8(WAVE_RAM + offset), b.read8(WAVE_RAM + offset)); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index abe38d6..f5fe6ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,9 +64,13 @@ 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 state, PSG sequencer, idle channel/wave banks, and instantaneous stereo mixing | +| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, wave banks, and instantaneous stereo mixing | | `crates/gba-core/src/audio/pulse.rs` | Shared pulse oscillator; sweep is mapped only for channel 1 | | `crates/gba-core/src/audio/modulation.rs` | Shared PSG length, envelope, logical DAC gate, and activity state | +| `crates/gba-core/src/audio/wave.rs` | Single-bank wave samples, rotating RAM, length/gate state, and fractional gain | +| `crates/gba-core/src/audio/wave/tests.rs` | Every sample rate/bank, nibble-order reference, full rotations, batches, volumes, and length edges | +| `crates/gba-core/tests/wave_sound.rs` | Wave masks, bank access, stereo gain, status, HALT/STOP, reset, and explicit unsupported modes | +| `crates/gba-core/src/memory/wave_step_tests.rs` | Wave trigger/status bus phases, rotated RAM transactions, ARM/Thumb/DMA, and rollback | | `crates/gba-core/src/audio/noise.rs` | Noise channel 4 divider, 7/15-bit counter, and bounded jump-ahead advancement | | `crates/gba-core/src/audio/noise/tests.rs` | Counter periods, independent bit-array reference, divider fields, and clock batching | | `crates/gba-core/tests/noise_sound.rs` | Noise registers, modulation, stereo mixing, independent status, HALT/STOP, and capture independence | diff --git a/docs/hardware/audio.md b/docs/hardware/audio.md index 146e38e..a52edfb 100644 --- a/docs/hardware/audio.md +++ b/docs/hardware/audio.md @@ -1,7 +1,7 @@ # Audio device subset -The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) pulse channels 1–2 and noise channel 4. -It does not produce desktop audio. PSG wave channel 3 remains unsupported. +The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) channels 1–4. +Wave channel 3 supports single-bank playback only. The core does not produce desktop audio. `Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream. ## Registers and PSG state @@ -11,12 +11,12 @@ It does not produce desktop audio. PSG wave channel 3 remains unsupported. | `0x04000060..0x04000065` | Pulse channel 1 sweep, duty, length, envelope, frequency, and trigger | | `0x04000068..0x04000069` | Pulse channel 2 duty, length, and envelope | | `0x0400006c..0x0400006d` | Pulse channel 2 frequency and trigger; no sweep unit | -| `0x04000070..0x04000075` | Idle wave channel 3 configuration | +| `0x04000070..0x04000075` | Wave channel 3 gate, bank, length, volume, frequency, and trigger | | `0x04000078..0x04000079` | Noise channel 4 length and envelope | | `0x0400007c..0x0400007d` | Noise divider, counter width, shift, length enable, and trigger | | `0x04000080..0x04000081` | PSG stereo routing and volume | | `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` | -| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; channel 1/2/4 activity in read-only bits 0/1/3; channel 3 remains inactive | +| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; channel 1–4 activity in read-only bits 0–3 | | `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 | @@ -31,10 +31,9 @@ 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 wave channel 3 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. +Unsupported two-bank wave playback and active wave-bank changes return `MemoryError::UnsupportedIo` with the exact address and value. +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 wave channel 3 writes do not imply playback support. ## Pulse channels 1 and 2 @@ -122,6 +121,56 @@ An independent bit-array reference checks these jump-ahead operations, including Both complete sequence periods and maximum clock batches have deterministic regressions. This is device-state advancement, not a sampled audio stream or hardware recording comparison. +## Wave channel 3: single-bank playback + +The selected bank supplies 32 four-bit samples. The CPU reads and writes the opposite bank, including during playback. +SOUND3CNT_L retains gate, bank, and dimension bits with mask `0xe0`. +The volume register retains bits 13–15. Frequency, length-load, and trigger fields are write-only. +The unused halfword at `0x04000076` reads zero and ignores writes. + +Each nominal sample period is `8 * (2048 - frequency)` system clocks. +Playback consumes each byte's high nibble before its low nibble, then proceeds to the next byte. +The selected bank rotates by one nibble per sample; it is not immutable storage addressed by a hidden pointer. +Stopping playback and exposing that bank therefore shows rotated data. +Complete 32-sample rotations restore the bank layout but still update the held output sample. +Clock batches compute the final rotation and sample directly, without looping once per sample. + +A trigger reloads a full sample period and clears the held output to nominal silence until the first edge. +It cannot restore data already rotated in wave RAM. Retrigger uses the bank's current layout. +Frequency writes affect the next reload, not the remaining sample interval. +Clearing the gate stops activity. Setting the gate again requires a trigger to resume. +A trigger with the gate clear is accepted but cannot activate playback, including with an idle two-bank configuration. +Mute volume and disabled routes do not stop playback or clear activity. +Master disable resets channel configuration/state but preserves both wave RAM banks in their current layouts. + +The length counter loads `256 - length_field` and uses the shared 256 Hz length clocks. +It follows the same nominal extra-clock/reload rule as other PSG channels, with maximum length 256 rather than 64. +A nonempty counter survives retrigger. Enabled expiry clears SOUNDCNT_X bit 2. +HALT continues sample and length clocks; STOP freezes them. No timer or IRQ enable is required. + +The centered full-volume amplitude is `2 * (sample - 8)`, from -16 through +14. +Volume selects mute, 100%, 50%, or 25%. Force-volume overrides all selections with 75%. +The mixer retains quarter-units until after combining PSG sources, stereo volume, and the overall PSG ratio. +This avoids separately rounding fractional wave amplitudes. It follows the signed NanoBoyAdvance mixer comparison, not an analog recording. + +### Evidence limits + +Only dimension zero playback is implemented. Idle dimension-one configuration is writable, but triggering it with the gate enabled is diagnostic. +Selecting dimension one or changing the selected bank during active playback also remains diagnostic; clear the gate first. +These failures retain register lanes, RAM rotation, activity, and clocks through ordinary audio transactions. + +GBATEK describes rotating wave RAM and selected-bank-first two-bank playback. +The pinned mGBA implementation rotates storage, while NanoBoyAdvance uses a sample pointer and changes bank at wrap. +Their two-bank starts, observable bank state, and retrigger behavior differ. +This increment does not choose an unverified two-bank model merely to accept more register writes. +The single-bank order/rate follows GBATEK; trigger phase, held-sample startup, live frequency/volume edges, and length quirks remain nominal. +There is no Game Boy wave-corruption model or physical GBA waveform validation. + +Original tests cover every frequency and both single-bank selections, independent nibble rotation, complete rotations, maximum batches, and volume settings. +Integration checks cover masks, gating, status, stereo mixing/clipping, length, HALT/STOP, master reset, and BIOS reset. +ARM/Thumb/DMA tests verify trigger bus phases, status at length expiry, rotated RAM readback, and failed-step/block-store isolation. +Original terminal and native-window programs activate channel 3 without external game assets or host audio. + ## FIFO and timer behavior Each channel has a seven-word queue, a separate four-byte playback word, and a held signed sample. @@ -167,11 +216,12 @@ Sub-instruction DMA arbitration and exact refill latency are not modeled. Each routed Direct Sound sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume. Each active pulse or noise channel contributes a centered signed amplitude, `+volume` or `-volume`. -`SOUNDCNT_L` routes channel 1 through bits 8/12, channel 2 through bits 9/13, and noise through bits 11/15, for right/left respectively. +`SOUNDCNT_L` routes channel 1 through bits 8/12, channel 2 through bits 9/13, wave through bits 10/14, and noise through bits 11/15. +Each pair selects right/left respectively. The mixer sums routed PSG amplitudes on each side, then multiplies by that side's volume field plus one. `SOUNDCNT_H` applies the PSG ratio: 25%, 50%, or 100%, using one arithmetic right shift after summation. Rounding each PSG channel separately would produce different low-volume levels and is not used. -At full volume, each supported PSG channel spans `-120..120` before mixing with Direct Sound. +At full stereo volume, each pulse/noise channel spans `-120..120`; wave spans `-128..112`, 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. @@ -201,12 +251,16 @@ CPU/DMA tests include noise register writes at bus completion and failed-step ro BIOS reset tests verify that selected sound reset stops all supported PSG channels, while unselected sound state remains active. These tests validate the documented nominal model, not hardware audio fidelity. -Remaining audio work: wave channel 3, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend. -The local Emerald startup now passes the pulse and noise triggers, then stops at GPIO serial interrupt control. +Remaining audio work: two-bank wave playback, verified live bank changes, a continuous output stream, PWM/mixer sampling, and a Darwin host backend. +A local Emerald input probe exposed a wave trigger with the gate disabled. This is now accepted without activating playback. +The same scheduled-input probe passes that point and completes its extended frame budget; audible output and gameplay remain unverified. See [the local runtime result](../research/emerald-reset.md). ## References +- [GBATEK wave channel 3](https://problemkaputt.de/gbatek-gba-sound-channel-3-wave-output.htm): bank selection, nibble order, rotating RAM, sample rate, length, and force volume. +- [NanoBoyAdvance wave at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.cc) and [header](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/wave_channel.hh): signed gain and rate comparison; pointer-based RAM and two-bank/retrigger differences are not copied. + - [GBATEK noise channel 4](https://mgba-emu.github.io/gbatek/): length/envelope registers, divider formula, Galois counter seeds, masks, and sequence periods. - [NanoBoyAdvance noise at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/noise_channel.cc) and [noise header](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/noise_channel.hh): GBA divider scaling, counter feedback, trigger, and register behavior comparison. - [GBATEK channels 1 and 2](https://mgba-emu.github.io/gbatek/): register fields, duty ratios, modulation rates, channel 2's register gap, and absence of sweep. diff --git a/docs/hardware/bios.md b/docs/hardware/bios.md index f66343b..81880f2 100644 --- a/docs/hardware/bios.md +++ b/docs/hardware/bios.md @@ -253,7 +253,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. [wave channel 3](audio.md) and [serial transfers](serial.md) still fail explicitly. +This is not full audio or serial support. [Two-bank wave playback](audio.md) and [connected serial transfers](serial.md) remain unsupported. 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. @@ -544,6 +544,6 @@ 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 wave channel 3, host audio output, connected serial links, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. +Two-bank wave playback, host audio output, connected serial links, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. Disconnected normal serial transfers use the [bounded serial model](serial.md); cancel active transfers before BIOS serial reset. This subset is not sufficient for Pokémon Emerald compatibility. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index ed47f71..9c39c5c 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -114,9 +114,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. -Next, inspect visible startup/menu progress and exercise button presses and releases in a bounded run. -Identify any waiting loop or new diagnostic before selecting another compatibility correction. -Do not treat frame-limit success as a title-screen or gameplay pass. +A subsequent [scheduled-input probe](emerald-wave.md) exposed a wave-channel trigger with its gate disabled. +The general single-bank wave model now passes that point and completes an extended Start/A input schedule. +Visible menu semantics and gameplay still require verification; frame-limit success alone cannot establish either. Flash programming, erase, busy behavior, and safe persistence remain required before claiming save compatibility. RTC persistence and cartridge IRQ behavior remain separate missing features. Ordinary ROM writes remain read-only. Without `--rtc`, the earlier GPIO diagnostic is still expected. @@ -125,7 +125,7 @@ This result does not establish gameplay, linked transfers, or cartridge/serial G Noise now has deterministic counter clocks and shares tested length/envelope logic with the independent pulse channels. Sweep applies only to channel 1. Direct Sound has FIFO clocks and nominal DMA requests. All supported channels use the instantaneous digital-level inspection interface. -Wave channel 3, a continuous sample stream, and desktop output are still missing. +Wave channel 3 now supports single-bank playback. Two-bank playback, a continuous sample stream, and desktop output are still missing. Do not treat digital-level tests as audible-game validation or suppress remaining diagnostics without implementing their behavior. Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol. diff --git a/docs/research/emerald-wave.md b/docs/research/emerald-wave.md new file mode 100644 index 0000000..dce6acb --- /dev/null +++ b/docs/research/emerald-wave.md @@ -0,0 +1,97 @@ +# Local Emerald input probe: wave trigger + +## Claim and method + +The earlier 600-frame native run established execution without a diagnostic, not gameplay. +This probe checks whether scheduled input changes captured output and exposes another unsupported operation. +The game remains a local user-supplied file. No ROM bytes, assets, screenshots, or extracted instructions are committed or submitted to Jev. + +A temporary host driver uses public core APIs: + +1. Boot the local ROM with `bios::boot` and select `CartridgeHardware::Rtc` and `SaveDevice::Flash128`. +2. Set deterministic RTC components to `[24, 1, 1, 1, 12, 0, 0]`. Do not advance this clock during the probe. +3. Enable scanline rendering and call `Machine::run_until_vblank(400000)` once per requested frame. +4. Apply each input snapshot after the named VBlank, before the next machine step. +5. Require `present_frame` to return a complete image. Stop on any execution/render diagnostic. +6. Compare captured pixels locally against a fresh no-input run with identical hardware/time configuration. + +The initial budget is 900 VBlanks. Each frame has its own machine-step bound; this is not the CLI's total step budget. +A follow-up extends the limit to 1,320 VBlanks. The temporary driver rejects requests above 1,500 VBlanks. +These are local diagnostic runs, not committed ROM-suite assertions or a commercial compatibility test. +The current manifest versions cannot select this cartridge hardware, so `--test-suite` cannot reproduce this setup directly. + +| VBlank | Buttons held after the event | +| --- | --- | +| 0 | None | +| 600 | Start | +| 610 | None | +| 720 | Start | +| 730 | None | +| 840 | Start | +| 850 | None | +| 960 | A, extended run only | +| 970 | None | +| 1080 | A, extended run only | +| 1090 | None | +| 1200 | A, extended run only | +| 1210 | None | + +## Observed failure + +Before wave support, the first Start press changed captured output. +Execution then stopped before frame 638: + +```text +PC=0x082e104c +successful steps through frame 637: 62597533 +unsupported PSG channel trigger (synthesis not implemented): write 0x86 at 0x04000075 +SOUND3CNT_L readback: 0 +SOUND3CNT_H readback: 0 +``` + +The wave gate was disabled. A trigger in this state must not activate channel 3. +Rejecting every trigger was an overly strict feature limit, not evidence that the game needed audible wave output at that moment. +The correction implements general [single-bank wave behavior](../hardware/audio.md#wave-channel-3-single-bank-playback), including the inactive trigger case. +It does not patch this game's instruction or skip its write. + +## Result after the correction + +The unchanged initial schedule reaches 900 frames and 89,199,123 successful machine steps without a diagnostic. +The extended schedule reaches 1,320 frames and 128,588,887 successful machine steps, ending at PC `0x080008ca`. +The no-input control also reaches 900 frames without a diagnostic. + +Local pixel comparisons against that control show: + +| Captured frame | Different pixels out of 38,400 | +| --- | --- | +| 600, before input | 0 | +| 620 | 38,400 | +| 660 | 38,232 | +| 900 | 38,400 | + +This establishes input-dependent execution and different captured output under fixed device configuration. +It does not establish readable menus, correct scene rendering, release behavior inside the game, or gameplay. +Local optical character recognition did not identify reliable menu text and supplied no semantic acceptance criterion. +No visual classifier or Jev call was used. No title-screen claim follows from color counts or pixel differences. +The file's cartridge revision was not inferred from its filename. + +Original regressions separately test active wave synthesis, nibble rotation, lengths, gains, gating, bus phases, and rollback. +Workspace debug/release tests, public ARM/Thumb/memory/BIOS suites, pinned Pong scenarios, and native Darwin checks pass. +The pinned Pong debug/release reports match. Native ROM-window tests include an original wave-activation program. +A separate 600-frame Emerald native run still reaches its frame limit without input or a diagnostic. + +## Remaining requirements + +- Verify visible menu text and gameplay through direct window testing or explicit independently justified assertions. +- Implement continuous sample production and a Darwin output backend before claiming audible sound. +- Research two-bank wave playback and live bank changes separately. Both remain diagnostic during active playback. +- Implement Flash programming/erase and safe persistence before claiming saving. + +For a manual native check, use: + +```sh +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window +``` + +Enter maps to Start. Z maps to A. Escape exits. This command does not supply the deterministic probe schedule or fixed RTC. +Hardware sources and implementation comparisons are recorded in [audio references](../hardware/audio.md#references). diff --git a/docs/status.md b/docs/status.md index 9195aec..586b59b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -89,7 +89,8 @@ Update this file when a feature lands or a limit is removed. - Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks. - PSG pulse channels 1 and 2 support independent nominal frequency/duty playback, length, envelope, status, and stereo mixing. Channel 1 also supports sweep. - Noise channel 4 supports nominal 7/15-bit generation, divider clocks, shared length/envelope behavior, status, and stereo mixing. -- Wave channel 3 triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback. +- Wave channel 3 supports single-bank nibble playback, rotating RAM, volume/force-volume, length, activity status, and digital mixing. +- Two-bank wave playback, active bank changes, and unsupported serial operations remain diagnostic 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. @@ -151,8 +152,8 @@ Flash programming, erase, busy polling, wider accesses, unverified aliases, and The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes. RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified. The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic. -The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, noise channel 4, idle wave channel 3 state, and instantaneous digital mixing. -It does not yet provide a continuous sample stream. Wave channel 3 playback and unverified sound-DMA pairings remain diagnostic. +The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, single-bank wave channel 3, noise channel 4, and instantaneous digital mixing. +It does not yet provide a continuous sample stream. Two-bank wave playback, active wave-bank changes, and unverified sound-DMA pairings remain diagnostic. PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified. The [serial subset](hardware/serial.md) supports disconnected normal 8/32-bit transfers, external-clock waiting, idle multiplayer configuration, and general-purpose pins. Internal clocks shift high input at nominal rates, clear busy, and request completion IRQs. External requests retain busy without clock edges. @@ -177,7 +178,10 @@ It now also completes internally clocked normal transfers and selects idle multi With explicit `--rtc` selection, it also passes Game Pak GPIO configuration and RTC calendar reads. With `--save-type flash128`, it also passes Flash identification and reaches a later startup check. It now passes disconnected Joybus setup and completes a bounded 600-frame native run without a diagnostic error. -This run uses no scripted input or pixel assertions; menu progress and gameplay remain unverified. +That run used no scripted input or pixel assertions. +A later [fixed-time input probe](research/emerald-wave.md) found a wave trigger with its gate disabled. +Wave support now passes that point and reaches 1,320 frames with scheduled Start/A presses and releases. +Output differs from a no-input control, but readable menus and gameplay remain unverified. Flash writing and persistence remain unimplemented. Without `--rtc`, cartridge GPIO writes still return read-only diagnostics. No title-screen, gameplay, or audible-game compatibility is established. diff --git a/docs/testing.md b/docs/testing.md index d69a531..8059c5a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -79,7 +79,11 @@ ARM/Thumb probes verify RAM byte reads, ROM command writes, all save wait settin Wide accesses, DMA, instruction fetches, and CPU reads outside work RAM retain explicit diagnostics. An original desktop ROM installs its Flash byte reader in IWRAM; terminal/native tests check explicit selection and unchanged files. They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior. -The optional [local Emerald startup check](research/emerald-reset.md) now reaches sound activation but still fails deliberately there. +Wave tests cover every frequency and bank, independent nibble rotation, full rotations, bounded maximum batches, and fractional gains. +They also check inactive triggers, gates, length, status, master/BIOS reset, HALT/STOP, live-write limits, and CPU/DMA rollback. +Original terminal/native programs activate wave playback without external assets or host audio. +The optional [local Emerald input probe](research/emerald-wave.md) now completes its scheduled Start/A frame budget. +Frame completion and different pixels are not assertions about readable menus, correct graphics, or gameplay. [Jev evidence review](jev-debugging.md) is optional and never changes deterministic test verdicts. Its Python tests mock the provider and verify schemas, response validation, credential handling, non-overwriting logs, and failure behavior. Live classifier smoke probes are separate from offline tests and must not become mandatory CI checks. diff --git a/docs/usability-roadmap.md b/docs/usability-roadmap.md index 844ccab..de204ad 100644 --- a/docs/usability-roadmap.md +++ b/docs/usability-roadmap.md @@ -44,9 +44,12 @@ It also passes normal serial transfers and idle multiplayer configuration. With `--rtc --save-type flash128`, it passes Game Pak GPIO setup, RTC reads, and Flash identification. It also passes disconnected Joybus configuration and completes a bounded 600-frame native run without a diagnostic error. That run does not verify screen contents, button responses, or gameplay. -The core has tested Direct Sound, both pulse channels, and noise channel 4. -Wave channel 3, a continuous sample stream, and desktop audio output remain missing. -See [the runtime result](research/emerald-reset.md). Next, verify visible menu progress and button responses rather than treating frame counts as playability. +A [fixed-time input probe](research/emerald-wave.md) then exposed an unsupported wave trigger with its gate disabled. +Single-bank wave support now passes that point and completes 1,320 frames with scheduled Start/A input. +Captured output differs from a no-input control; readable menus and gameplay remain unverified. +The core has tested Direct Sound, both pulse channels, single-bank wave channel 3, and noise channel 4. +Two-bank wave playback, a continuous sample stream, and desktop audio output remain missing. +See [the runtime result](research/emerald-reset.md). Next, verify visible menu progress rather than treating frame counts as playability. The separate save requirement includes Flash programming/erase and safe host persistence. Identification/read support is not successful saving. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.