feat(audio): implement PSG pulse channel 2

This commit is contained in:
0xrsydn 2026-10-07 02:30:16 +07:00
commit aee3621513
15 changed files with 457 additions and 89 deletions

View file

@ -1,4 +1,4 @@
//! Nominal Direct Sound and PSG pulse channel 1. No host audio output. //! Nominal Direct Sound and PSG pulse channels 1–2. No host audio output.
mod pulse; mod pulse;
use pulse::Pulse; use pulse::Pulse;
@ -78,7 +78,7 @@ impl Fifo {
pub(crate) struct Audio { pub(crate) struct Audio {
enabled: bool, enabled: bool,
psg: [u16; 17], psg: [u16; 17],
pulse: Pulse, pulses: [Pulse; 2],
sequencer_phase: u16, sequencer_phase: u16,
next_step: u8, next_step: u8,
control: u16, control: u16,
@ -99,10 +99,7 @@ impl Audio {
{ {
return Some("reserved PSG volume selection"); return Some("reserved PSG volume selection");
} }
if self.enabled if self.enabled && matches!(address, 0x0400_0075 | 0x0400_007d) && value & 0x80 != 0 {
&& matches!(address, 0x0400_006d | 0x0400_0075 | 0x0400_007d)
&& value & 0x80 != 0
{
Some("PSG channel trigger (synthesis not implemented)") Some("PSG channel trigger (synthesis not implemented)")
} else { } else {
None None
@ -113,9 +110,20 @@ impl Audio {
1 ^ usize::from((self.psg[8] >> 6) & 1) 1 ^ usize::from((self.psg[8] >> 6) & 1)
} }
/// Channel 2 has no sweep register. Its register gap must not alias any
/// pulse state, particularly the shared engine's sweep configuration.
fn pulse_register(address: u32) -> Option<(usize, u32)> {
match address {
SOUND_START..=0x0400_0065 => Some((0, address - SOUND_START)),
0x0400_0068..=0x0400_0069 => Some((1, address - 0x0400_0066)),
0x0400_006c..=0x0400_006d => Some((1, address - 0x0400_0068)),
_ => None,
}
}
pub(crate) fn read8(&self, address: u32) -> Option<u8> { pub(crate) fn read8(&self, address: u32) -> Option<u8> {
if (SOUND_START..=SOUND_START + 5).contains(&address) { if let Some((index, offset)) = Self::pulse_register(address) {
return Some(self.pulse.read(address - SOUND_START)); return Some(self.pulses[index].read(offset));
} }
if (WAVE_RAM..FIFO_A).contains(&address) { if (WAVE_RAM..FIFO_A).contains(&address) {
return Some(self.wave[self.wave_bank()][(address - WAVE_RAM) as usize]); return Some(self.wave[self.wave_bank()][(address - WAVE_RAM) as usize]);
@ -123,9 +131,7 @@ impl Audio {
let value = match address & !1 { let value = match address & !1 {
SOUND_START..=0x0400_0080 => { SOUND_START..=0x0400_0080 => {
let mask = match address & !1 { let mask = match address & !1 {
0x0400_0060 => 0x007f, 0x0400_0074 => 0x4000,
0x0400_0062 | 0x0400_0068 => 0xffc0,
0x0400_0064 | 0x0400_006c | 0x0400_0074 => 0x4000,
0x0400_0070 => 0x00e0, 0x0400_0070 => 0x00e0,
0x0400_0072 => 0xe000, 0x0400_0072 => 0xe000,
0x0400_0078 => 0xff00, 0x0400_0078 => 0xff00,
@ -136,7 +142,11 @@ impl Audio {
self.psg[((address - SOUND_START) / 2) as usize] & mask self.psg[((address - SOUND_START) / 2) as usize] & mask
} }
SOUNDCNT_H => self.control, SOUNDCNT_H => self.control,
SOUNDCNT_X => (u16::from(self.enabled) << 7) | u16::from(self.pulse.active), SOUNDCNT_X => {
(u16::from(self.enabled) << 7)
| u16::from(self.pulses[0].active)
| (u16::from(self.pulses[1].active) << 1)
}
SOUNDBIAS => self.bias, SOUNDBIAS => self.bias,
0x0400_0086 | 0x0400_008a => 0, 0x0400_0086 | 0x0400_008a => 0,
_ => return None, // FIFO is write-only; open-bus readback is not modeled. _ => return None, // FIFO is write-only; open-bus readback is not modeled.
@ -156,9 +166,10 @@ impl Audio {
self.wave[self.wave_bank()][(address - WAVE_RAM) as usize] = value; self.wave[self.wave_bank()][(address - WAVE_RAM) as usize] = value;
continue; continue;
} }
if self.enabled && (SOUND_START..=SOUND_START + 5).contains(&address) { if let Some((index, offset)) = Self::pulse_register(address) {
self.pulse if self.enabled {
.write(address - SOUND_START, value, self.next_step); self.pulses[index].write(offset, value, self.next_step);
}
continue; continue;
} }
let shift = (address & 1) * 8; let shift = (address & 1) * 8;
@ -181,7 +192,7 @@ impl Audio {
SOUNDCNT_X if address & 1 == 0 => { SOUNDCNT_X if address & 1 == 0 => {
if self.enabled && value & 0x80 == 0 { if self.enabled && value & 0x80 == 0 {
self.psg = [0; 17]; self.psg = [0; 17];
self.pulse = Pulse::default(); self.pulses = [Pulse::default(); 2];
for fifo in &mut self.fifo { for fifo in &mut self.fifo {
fifo.reset_queue(); fifo.reset_queue();
} }
@ -207,11 +218,15 @@ impl Audio {
} }
while cycles != 0 { while cycles != 0 {
let count = cycles.min(SEQUENCER_PERIOD - u32::from(self.sequencer_phase)); let count = cycles.min(SEQUENCER_PERIOD - u32::from(self.sequencer_phase));
self.pulse.advance(count); for pulse in &mut self.pulses {
pulse.advance(count);
}
let phase = u32::from(self.sequencer_phase) + count; let phase = u32::from(self.sequencer_phase) + count;
if phase == SEQUENCER_PERIOD { if phase == SEQUENCER_PERIOD {
self.sequencer_phase = 0; self.sequencer_phase = 0;
self.pulse.clock(self.next_step); for pulse in &mut self.pulses {
pulse.clock(self.next_step);
}
self.next_step = (self.next_step + 1) & 7; self.next_step = (self.next_step + 1) & 7;
} else { } else {
self.sequencer_phase = phase as u16; self.sequencer_phase = phase as u16;
@ -256,10 +271,15 @@ impl Audio {
let psg_control = self.psg[16]; let psg_control = self.psg[16];
let ratio = [1, 2, 4, 0][usize::from(self.control & 3)]; let ratio = [1, 2, 4, 0][usize::from(self.control & 3)];
for (side, level) in sides.iter_mut().enumerate() { for (side, level) in sides.iter_mut().enumerate() {
if psg_control & (1 << (8 + side * 4)) != 0 { let mut psg_sample = 0;
let volume = ((psg_control >> (side * 4)) & 7) as i16 + 1; for (index, pulse) in self.pulses.iter().enumerate() {
*level += (self.pulse.sample() * volume * ratio) >> 2; if psg_control & (1 << (8 + side * 4 + index)) != 0 {
psg_sample += pulse.sample();
} }
}
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 = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512; *level = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512;
} }
StereoLevel { StereoLevel {

View file

@ -1,4 +1,5 @@
//! Nominal channel 1 oscillator and modulation units. No host-clock dependency. //! Shared nominal pulse oscillator and modulation units. No host-clock dependency.
//! Channel 2 never receives sweep-register writes, so its sweep remains disabled.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Pulse { pub(super) struct Pulse {

View file

@ -10,7 +10,9 @@ fn sound(memory: &mut Memory, period: u16) {
memory.write16(SOUNDBIAS, 0x200).unwrap(); memory.write16(SOUNDBIAS, 0x200).unwrap();
memory.write16(SOUNDCNT_H, 0x0304).unwrap(); memory.write16(SOUNDCNT_H, 0x0304).unwrap();
memory.write16(0x04000062, 0xf080).unwrap(); memory.write16(0x04000062, 0xf080).unwrap();
memory.write16(0x04000064, 0x87ff).unwrap(); // Running pulse, unrouted; rollback must include it. memory.write16(0x04000064, 0x87ff).unwrap();
memory.write16(0x04000068, 0xf080).unwrap();
memory.write16(0x0400006c, 0x87ff).unwrap(); // Both pulses run unrouted; rollback must include them.
memory memory
.write32( .write32(
TIMER_BASE, TIMER_BASE,
@ -21,22 +23,28 @@ fn sound(memory: &mut Memory, period: u16) {
#[test] #[test]
fn pulse_status_load_observes_length_expiry_at_bus_completion() { fn pulse_status_load_observes_length_expiry_at_bus_completion() {
for (index, duty, frequency) in [(0, 0x04000062, 0x04000064), (1, 0x04000068, 0x0400006c)] {
let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]); let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]);
sound(&mut memory, 1000); sound(&mut memory, 1000);
memory.write16(0x04000062, 0xf03f).unwrap(); memory.write16(duty, 0xf03f).unwrap();
memory.write16(0x04000064, 0xc7ff).unwrap(); memory.write16(frequency, 0xc7ff).unwrap();
memory.advance_cycles(32768 - 7); memory.advance_cycles(32768 - 7);
let mut machine = Machine::new(cpu, memory); let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0x80); let status = 0x83 ^ (1 << index); // The other channel remains active.
assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), 0x80); assert_eq!(machine.cpu().registers()[0], status);
assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), status as u16);
}
} }
#[test] #[test]
fn pulse_trigger_reloads_timer_at_store_completion() { fn pulse_trigger_reloads_timer_at_store_completion() {
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x87ff), (1, 0x04000064)]); for (index, frequency) in [0x04000064, 0x0400006c].into_iter().enumerate() {
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x87ff), (1, frequency)]);
sound(&mut memory, 1000); sound(&mut memory, 1000);
memory.write16(0x04000080, 0x1177).unwrap(); memory
.write16(0x04000080, 0x77 | (0x1100 << index))
.unwrap();
memory.write16(SOUNDCNT_H, 2).unwrap(); memory.write16(SOUNDCNT_H, 2).unwrap();
let mut machine = Machine::new(cpu, memory); let mut machine = Machine::new(cpu, memory);
machine.step().unwrap(); machine.step().unwrap();
@ -45,6 +53,31 @@ fn pulse_trigger_reloads_timer_at_store_completion() {
assert_eq!(machine.memory().audio_level().left, 120); assert_eq!(machine.memory().audio_level().left, 120);
machine.memory_mut().advance_cycles(1); machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().audio_level().left, -120); assert_eq!(machine.memory().audio_level().left, -120);
}
}
#[test]
fn successful_dma_trigger_starts_channel2_after_destination_bus_cycles() {
let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]);
sound(&mut memory, 1000);
memory.write16(0x04000080, 0x2277).unwrap();
memory.write16(SOUNDCNT_H, 2).unwrap();
memory.write8(0x04000069, 0).unwrap();
memory.write8(0x04000069, 0xf0).unwrap(); // DAC gate restored, still needs a trigger.
memory.write32(0x02000000, 0x87ff).unwrap();
memory.write32(DMA_BASE, 0x02000000).unwrap();
memory.write32(DMA_BASE + 4, 0x0400006c).unwrap();
memory.write32(DMA_BASE + 8, 0x84000001).unwrap();
let mut machine = Machine::new(cpu, memory);
assert_eq!(
machine.step().unwrap(),
crate::machine::StepKind::Dma { channel: 0 }
);
assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), 0x83);
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] #[test]
@ -95,7 +128,7 @@ fn failed_cpu_load_rolls_back_timer_audio_and_pending_refill_requests() {
#[test] #[test]
fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges() { fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges() {
for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x0400006c, 0x8000)] { for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x04000074, 0x8000)] {
let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]); let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]);
sound(&mut memory, 1); sound(&mut memory, 1);
memory.write32(FIFO_A, 0x04030201).unwrap(); memory.write32(FIFO_A, 0x04030201).unwrap();
@ -113,6 +146,35 @@ fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges
} }
} }
#[test]
fn arm_block_store_rejects_later_wave_trigger_before_committing_channel2_trigger() {
let (cpu, mut memory) = prepared(
false,
&[0xe8a0001e],
&[
(0, 0x04000068),
(1, 0xf080),
(2, 0x87ff),
(3, 0),
(4, 0x8000),
],
); // STMIA r0!,{r1-r4}: channel2 configuration/trigger, wave configuration/trigger.
memory.write16(SOUNDCNT_X, 0x80).unwrap();
let audio = memory.io.audio;
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
for _ in 0..2 {
assert!(machine
.step()
.unwrap_err()
.to_string()
.contains("PSG channel trigger"));
assert_eq!(machine.memory().io.audio, audio);
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
}
}
#[test] #[test]
fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_write() { fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_write() {
let mut memory = Memory::new(vec![]).unwrap(); let mut memory = Memory::new(vec![]).unwrap();
@ -121,7 +183,9 @@ fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_wri
.write_words(&[ .write_words(&[
(0x02000000, 0x11223344), (0x02000000, 0x11223344),
(SOUNDCNT_X, 0x80), (SOUNDCNT_X, 0x80),
(0x0400006c, 0x8000), (0x04000068, 0xf080),
(0x0400006c, 0x87ff),
(0x04000074, 0x8000),
]) ])
.is_err()); .is_err());
assert_eq!(memory.read32(0x02000000).unwrap(), 0); assert_eq!(memory.read32(0x02000000).unwrap(), 0);

View file

@ -20,7 +20,7 @@ const REGIONS: [(u32, u32, u32); 5] = [
const MARKER: u32 = 0xa55a_c33c; const MARKER: u32 = 0xa55a_c33c;
#[test] #[test]
fn sound_reset_disables_running_pulse_only_when_selected() { fn sound_reset_disables_running_pulses_only_when_selected() {
use gba_core::io::SOUNDCNT_X; use gba_core::io::SOUNDCNT_X;
for thumb in [false, true] { for thumb in [false, true] {
for flags in [0, 0x40] { for flags in [0, 0x40] {
@ -29,10 +29,12 @@ fn sound_reset_disables_running_pulse_only_when_selected() {
bus.write16(SOUNDCNT_X, 0x80).unwrap(); bus.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write16(0x04000062, 0xf080).unwrap(); bus.write16(0x04000062, 0xf080).unwrap();
bus.write16(0x04000064, 0x87ff).unwrap(); bus.write16(0x04000064, 0x87ff).unwrap();
bus.write16(0x04000068, 0xf080).unwrap();
bus.write16(0x0400006c, 0x87fe).unwrap();
complete(&mut machine, return_pc); complete(&mut machine, return_pc);
assert_eq!( assert_eq!(
machine.memory().read16(SOUNDCNT_X).unwrap(), machine.memory().read16(SOUNDCNT_X).unwrap(),
if flags == 0 { 0x81 } else { 0 } if flags == 0 { 0x83 } else { 0 }
); );
} }
} }

View file

@ -167,7 +167,7 @@ fn wave_banks_and_idle_channel_masks_preserve_explicit_limits() {
bus.write16(0x04000070, 0x40).unwrap(); bus.write16(0x04000070, 0x40).unwrap();
assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0); assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0);
bus.write32(WAVE_RAM, 0xaabbccdd).unwrap(); bus.write32(WAVE_RAM, 0xaabbccdd).unwrap();
for address in [0x0400006c, 0x04000074, 0x0400007c] { for address in [0x04000074, 0x0400007c] {
let previous = bus.read16(address).unwrap(); let previous = bus.read16(address).unwrap();
assert!(bus assert!(bus
.write16(address, 0xffff) .write16(address, 0xffff)

View file

@ -104,7 +104,7 @@ fn unsupported_operations_have_specific_retryable_diagnostics() {
#[test] #[test]
fn block_store_rejects_later_activation_before_earlier_control_writes() { fn block_store_rejects_later_activation_before_earlier_control_writes() {
let code: [u32; 7] = [ let code: [u32; 7] = [
0xe3a00301, 0xe2800068, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678, 0xe3a00301, 0xe2800070, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678,
]; ];
let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap(); let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap();
bus.write16(SOUNDCNT_X, 0x80).unwrap(); 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!(error.to_string().contains("PSG channel trigger"));
assert_eq!(machine.cpu(), &before); assert_eq!(machine.cpu(), &before);
assert_eq!(machine.cycles(), cycles); assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.memory().read16(0x04000068).unwrap(), 0); assert_eq!(machine.memory().read16(0x04000070).unwrap(), 0);
assert_eq!(machine.step(), Err(error)); 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.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write32(0x02000000, 0x8000).unwrap(); bus.write32(0x02000000, 0x8000).unwrap();
bus.write32(DMA_BASE, 0x02000000).unwrap(); bus.write32(DMA_BASE, 0x02000000).unwrap();
bus.write32(DMA_BASE + 4, 0x0400006c).unwrap(); bus.write32(DMA_BASE + 4, 0x04000074).unwrap();
bus.write32(DMA_BASE + 8, 0x84000001).unwrap(); bus.write32(DMA_BASE + 8, 0x84000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus); let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let before = machine.cpu().clone(); let before = machine.cpu().clone();

View file

@ -1,4 +1,6 @@
//! Original public-bus tests for PSG channel 1; no game code or audio assets. //! Original public-bus tests for PSG pulse channels; no game code or audio assets.
#[path = "pulse_sound/channel2.rs"]
mod channel2;
use gba_core::{ use gba_core::{
audio::StereoLevel, audio::StereoLevel,
cpu::Cpu, cpu::Cpu,

View file

@ -0,0 +1,263 @@
//! Original channel 2 mapping, isolation, and shared-mixer regressions.
use super::*;
const DUTY2: u32 = 0x04000068;
const FREQUENCY2: u32 = 0x0400006c;
fn channel2() -> Memory {
let mut bus = bus();
bus.write16(MIX, 0x2277).unwrap();
bus
}
fn start2(bus: &mut Memory) {
bus.write16(DUTY2, 0xf080).unwrap();
bus.write16(FREQUENCY2, 0x87ff).unwrap();
}
#[test]
fn channel2_register_widths_masks_and_status_do_not_alias_channel1() {
let mut bus = channel2();
bus.write32(DUTY2, 0xfffff0bf).unwrap(); // High halfword is unused, not frequency.
assert_eq!(bus.read32(DUTY2).unwrap(), 0xf080);
assert_eq!(bus.read16(DUTY).unwrap(), 0);
bus.write8(FREQUENCY2, 255).unwrap();
bus.write8(FREQUENCY2 + 1, 0x87).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
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.write32(SOUNDCNT_X, 0x8f).unwrap(); // Both status bits are read-only.
assert_eq!(bus.read32(SOUNDCNT_X).unwrap(), 0x82);
bus.write32(FREQUENCY2, 0xffffc7ff).unwrap();
assert_eq!(bus.read32(FREQUENCY2).unwrap(), 0x4000);
assert_eq!(bus.read16(FREQUENCY).unwrap(), 0);
assert_eq!(bus.read16(SWEEP).unwrap(), 0);
}
#[test]
fn channel1_sweep_and_unused_register_gaps_cannot_enable_channel2_sweep() {
let mut dirty = channel2();
let mut clean = channel2();
for bus in [&mut dirty, &mut clean] {
start2(bus); // Frequency2047: any positive sweep would overflow.
bus.write16(SWEEP, 0x11).unwrap();
bus.write16(DUTY, 0xf080).unwrap();
bus.write16(FREQUENCY, 0x85dc).unwrap(); // Channel1 frequency1500 overflows on trigger.
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
}
for address in [0x04000066, 0x0400006a, 0x0400006e] {
dirty.write16(address, 0xffff).unwrap();
assert_eq!(dirty.read16(address).unwrap(), 0);
}
for cycles in [15, 1, 31, SEQUENCE * 4, 1000000] {
dirty.advance_cycles(cycles);
clean.advance_cycles(cycles);
assert_eq!(dirty.audio_level(), clean.audio_level());
assert_eq!(dirty.read16(SOUNDCNT_X).unwrap(), 0x82);
}
}
#[test]
fn pulse_channels_keep_independent_frequency_phase_retrigger_and_dac_state() {
let mut bus = bus();
bus.write16(MIX, 0x2177).unwrap(); // Channel1 right, channel2 left.
start(&mut bus);
bus.write16(DUTY2, 0x7080).unwrap();
bus.write16(FREQUENCY2, 0x87fe).unwrap(); // 32 clocks per edge rather than16.
assert_eq!(
bus.audio_level(),
StereoLevel {
left: 56,
right: 120
}
);
bus.advance_cycles(16);
assert_eq!(
bus.audio_level(),
StereoLevel {
left: 56,
right: -120
}
);
bus.write16(FREQUENCY, 0x87ff).unwrap(); // Must not restart channel2's timer.
bus.advance_cycles(16);
assert_eq!(
bus.audio_level(),
StereoLevel {
left: -56,
right: -120
}
);
bus.write8(DUTY + 1, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
assert_eq!(
bus.audio_level(),
StereoLevel {
left: -56,
right: 0
}
);
bus.write8(DUTY2 + 1, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
assert_eq!(bus.audio_level(), stereo(0));
}
#[test]
fn triggers_share_sequencer_phase_but_envelopes_remain_independent() {
let mut bus = bus();
bus.write16(MIX, 0x2177).unwrap();
bus.write16(DUTY, 0xc180).unwrap(); // Volume12, falling every envelope tick.
bus.write16(FREQUENCY, 0x87ff).unwrap();
bus.advance_cycles(SEQUENCE * 6);
bus.write16(DUTY2, 0x3980).unwrap(); // Volume3, rising every envelope tick.
bus.write16(FREQUENCY2, 0x87ff).unwrap();
bus.advance_cycles(SEQUENCE * 2); // Shared step7, not eight new steps after trigger2.
assert_eq!(
bus.audio_level(),
StereoLevel {
left: 32,
right: 88
}
);
bus.advance_cycles(SEQUENCE * 8);
assert_eq!(
bus.audio_level(),
StereoLevel {
left: 40,
right: 80
}
);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x83);
bus.write8(DUTY2 + 1, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
assert_eq!(bus.audio_level(), StereoLevel { left: 0, right: 80 });
}
#[test]
fn separate_length_counters_and_channel2_empty_reload_observe_shared_phase() {
let mut bus = bus();
bus.write16(MIX, 0).unwrap(); // Muting does not pause length.
bus.write16(DUTY, 0xf0bf).unwrap();
bus.write16(FREQUENCY, 0xc7ff).unwrap();
bus.write16(DUTY2, 0xf0be).unwrap();
bus.write16(FREQUENCY2, 0xc7ff).unwrap();
bus.advance_cycles(SEQUENCE);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
bus.advance_cycles(SEQUENCE * 2);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
bus.write16(FREQUENCY2, 0xc7ff).unwrap(); // Next step3: reload length63, not64.
bus.advance_cycles(SEQUENCE * 125);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
bus.advance_cycles(SEQUENCE);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
}
#[test]
fn mixer_sums_pulses_before_rounding_and_clips_only_after_direct_sound() {
let mut bus = bus();
bus.write16(MIX, 0x3300).unwrap(); // Both channels, both sides, master gain1.
bus.write16(DUTY, 0x2080).unwrap(); // +2 at duty position0.
bus.write16(FREQUENCY, 0x8000).unwrap();
bus.write16(DUTY2, 0x1000).unwrap(); // -1 at duty position0.
bus.write16(FREQUENCY2, 0x8000).unwrap();
bus.write16(SOUNDCNT_H, 0).unwrap();
assert_eq!(bus.audio_level(), stereo(0)); // (2 - 1) >>2, not (2 >>2) + (-1 >>2).
bus.write16(MIX, 0x2200).unwrap();
assert_eq!(bus.audio_level(), stereo(-1));
bus.write16(MIX, 0x3377).unwrap();
bus.write16(SOUNDCNT_H, 0x0306).unwrap();
start(&mut bus);
start2(&mut bus);
assert_eq!(bus.audio_level(), stereo(240));
bus.write32(FIFO_A, 0x7f7f7f7f).unwrap();
bus.write32(TIMER_BASE, 0x0080ffff).unwrap();
bus.advance_cycles(1);
assert_eq!(bus.audio_level(), stereo(511));
bus.write16(MIX, 0).unwrap();
assert_eq!(bus.audio_level(), stereo(508));
}
#[test]
fn disabled_writes_and_channel2_dac_gate_cannot_start_playback() {
let mut bus = channel2();
bus.write16(SOUNDCNT_X, 0).unwrap();
bus.write32(DUTY2, u32::MAX).unwrap();
bus.write32(FREQUENCY2, u32::MAX).unwrap();
assert_eq!(bus.read32(DUTY2).unwrap(), 0);
assert_eq!(bus.read32(FREQUENCY2).unwrap(), 0);
bus.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write16(DUTY2, 0x0780).unwrap();
bus.write16(FREQUENCY2, 0x87ff).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
bus.write16(DUTY2, 0x0880).unwrap(); // Rising direction enables the logical DAC gate.
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80); // Still needs trigger.
bus.write16(FREQUENCY2, 0x87ff).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
bus.advance_cycles(SEQUENCE * 64); // Zero initial volume and period: active but silent.
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
start(&mut bus);
start2(&mut bus);
bus.write16(SOUNDCNT_X, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0);
assert_eq!(bus.read16(DUTY).unwrap(), 0);
assert_eq!(bus.read16(DUTY2).unwrap(), 0);
assert_eq!(bus.audio_level(), stereo(0));
}
#[test]
fn channel2_runs_during_halt_and_freezes_during_stop() {
for stop in [false, true] {
let mut bus = channel2();
start2(&mut bus);
bus.write16(FREQUENCY2, 0x87fd).unwrap(); // 48 clocks per edge.
bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
machine.step().unwrap();
assert_eq!(
machine.memory().audio_level(),
stereo(if stop { 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 both_channels_keep_phase_across_large_batches_and_row_capture() {
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(MIX, 0x2177).unwrap();
bus.write16(SWEEP, 0x29).unwrap();
bus.write16(DUTY, 0xa340).unwrap();
bus.write16(FREQUENCY, 0x85ad).unwrap();
bus.write16(DUTY2, 0x29c0).unwrap();
bus.write16(FREQUENCY2, 0x87ab).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()
);
}
let mut bus = channel2();
start2(&mut bus);
bus.advance_cycles(u32::MAX);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x82);
assert_eq!(bus.audio_level(), stereo(120));
}

View file

@ -54,9 +54,10 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing | | `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches | | `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
| `crates/gba-core/src/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, 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.rs` | Shared pulse oscillator, length, envelope, and status; sweep is mapped only for channel 1 |
| `crates/gba-core/src/audio/pulse/tests.rs` | Exhaustive frequency/duty arithmetic and original modulation edge tests | | `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/pulse_sound.rs` | Register masks, stereo mixing, sequencer clocks, idle modes, batching, and explicit limits |
| `crates/gba-core/tests/pulse_sound/channel2.rs` | Channel 2 mapping, register gaps, independent state, shared clocks, and sum-before-rounding mixing |
| `crates/gba-core/tests/direct_sound.rs` | FIFO lanes/reset, signed mixing, timer selection, DMA refill, and HALT/STOP | | `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/memory/audio_step_tests.rs` | Audio access phases, shadow validation, and CPU/DMA failure rollback |
| `crates/gba-core/src/io/inactive.rs` | Disconnected serial initialization with explicit unsupported-activity validation | | `crates/gba-core/src/io/inactive.rs` | Disconnected serial initialization with explicit unsupported-activity validation |

View file

@ -1,7 +1,7 @@
# Audio device subset # Audio device subset
The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) pulse channel 1. The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) pulse channels 1 and 2.
It does not produce desktop audio. PSG channels 2–4 remain unsupported. It does not produce desktop audio. PSG wave channel 3 and noise channel 4 remain unsupported.
`Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream. `Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream.
## Registers and PSG state ## Registers and PSG state
@ -9,14 +9,17 @@ It does not produce desktop audio. PSG channels 2–4 remain unsupported.
| Address | Behavior | | Address | Behavior |
| --- | --- | | --- | --- |
| `0x04000060..0x04000065` | Pulse channel 1 sweep, duty, length, envelope, frequency, and trigger | | `0x04000060..0x04000065` | Pulse channel 1 sweep, duty, length, envelope, frequency, and trigger |
| `0x04000066..0x04000081` | Idle channel 2–4 configuration and PSG mixer controls | | `0x04000068..0x04000069` | Pulse channel 2 duty, length, and envelope |
| `0x0400006c..0x0400006d` | Pulse channel 2 frequency and trigger; no sweep unit |
| `0x04000070..0x04000081` | Idle channel 3–4 configuration and PSG mixer controls |
| `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` | | `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` |
| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only channel 1 activity in bit 0; channels 2–4 remain inactive | | `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only channel 1/2 activity in bits 0/1; channels 3–4 remain inactive |
| `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` | | `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` |
| `0x04000090..0x0400009f` | CPU wave RAM window, opposite the bank selected by `SOUND3CNT_L` bit 6 | | `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 | | `0x040000a0..0x040000a7` | FIFO A/B writes with byte, halfword, and word access boundaries preserved |
Unused mapped register bytes read zero and ignore writes. Unused mapped register bytes read zero and ignore writes.
The halfwords at `0x04000066`, `0x0400006a`, and `0x0400006e` do not alias pulse registers or enable channel 2 sweep.
FIFO reads and the `0x0400008c..0x0400008f` gap remain explicit unmapped-access errors; no FIFO open-bus value is invented. FIFO reads and the `0x0400008c..0x0400008f` gap remain explicit unmapped-access errors; no FIFO open-bus value is invented.
Raw memory starts with zero sound state. RegisterRamReset bit 6 sets the functional bias default to `0x0200`. Raw memory starts with zero sound state. RegisterRamReset bit 6 sets the functional bias default to `0x0200`.
@ -25,19 +28,23 @@ 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. 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. This is a functional reset subset, not a claim of complete firmware ordering or sound-reset equivalence.
An enabled channel 2–4 trigger returns `MemoryError::UnsupportedIo` with the exact address and value. An enabled channel 3–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. 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. 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. Idle channel 3–4 writes do not imply playback support.
## Pulse channel 1 ## Pulse channels 1 and 2
Channel 1 has four duty patterns: 12.5%, 25%, 50%, and 75%. Each pulse channel has four duty patterns: 12.5%, 25%, 50%, and 75%.
Its eight-position waveform advances every `16 * (2048 - frequency)` system clocks. Its eight-position waveform advances every `16 * (2048 - frequency)` system clocks.
The channels keep independent frequency, duty position, length, envelope, logical DAC gate, and activity state.
Only channel 1 has sweep. Channel 2 reuses the pulse engine without any mapped sweep register.
No general-purpose timer must be enabled for PSG playback. No general-purpose timer must be enabled for PSG playback.
Frequency writes change the next oscillator reload, not the remaining current interval. 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. A trigger reloads that channel's oscillator timer, envelope volume, and envelope timer.
Retrigger preserves the duty position. Master disable resets it. Channel 1 also initializes its sweep shadow/timer state.
Retrigger preserves that channel's duty position and does not restart the other channel.
Master disable resets both pulse channels.
The modulation sequencer receives a 512 Hz clock, once per 32768 system clocks: The modulation sequencer receives a 512 Hz clock, once per 32768 system clocks:
@ -49,7 +56,8 @@ The modulation sequencer receives a 512 Hz clock, once per 32768 system clocks:
The divider starts at synthetic phase zero and continues while master sound is disabled. 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. 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. Neither channel trigger resets the shared sequencer. Both receive length/envelope clocks at the same sequencer phase.
HALT continues these clocks; STOP freezes them.
Waveform advancement uses arithmetic batches between sequencer events, not a loop for every system clock. 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. Length writes load `64 - length_field`. Enabled length expiry clears the channel activity flag.
@ -64,7 +72,7 @@ Reactivating a stopped envelope loads its new countdown. Trigger loads its confi
Clearing envelope bits 3–7 disables the channel; setting them again requires a trigger to restart it. 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. 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. Channel 1 sweep uses a shadow frequency independent of later frequency-register writes.
A nonzero shift checks overflow immediately at trigger. A nonzero shift checks overflow immediately at trigger.
Timed sweep updates check overflow both before and after committing the new frequency. 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. A result above 2047 disables the channel. Period zero suppresses periodic frequency changes, not the trigger-time check.
@ -120,10 +128,12 @@ Sub-instruction DMA arbitration and exact refill latency are not modeled.
## Mixing and timing ownership ## Mixing and timing ownership
Each routed Direct Sound sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume. 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. Each active pulse contributes a centered signed amplitude, `+volume` or `-volume`.
`SOUNDCNT_L` routes it independently left/right and multiplies each side by its volume field plus one. `SOUNDCNT_L` routes channel 1 through bits 8/12 and channel 2 through bits 9/13, for right/left respectively.
`SOUNDCNT_H` then applies the PSG ratio: 25%, 50%, or 100%, using an arithmetic right shift. The mixer sums routed PSG amplitudes on each side, then multiplies by that side's volume field plus one.
At full volume, channel 1 therefore spans `-120..120` before mixing with Direct Sound. `SOUNDCNT_H` applies the PSG ratio: 25%, 50%, or 100%, using one arithmetic right shift after summation.
Rounding each pulse separately would produce different low-volume levels and is not used.
At full volume, each pulse 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. 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. 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. `StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero.
@ -144,16 +154,19 @@ They also cover cascaded timers, DMA block/repeat behavior, source progress, com
CPU/DMA failure tests verify transactional rollback and sound writes at bus completion. 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. 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. 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. They also check bus-completion status reads, retrigger timing, clock ownership, and rollback with both pulse channels running.
Channel 2 tests cover register gaps, absence of sweep, independent state, shared sequencer phase, and sum-before-rounding mixing.
Original CPU/DMA tests verify channel 2 activation and rollback when a later block-store value is unsupported.
BIOS reset tests verify that selected sound reset stops both channels, while unselected sound state remains active.
These tests validate the documented nominal model, not hardware audio fidelity. These tests validate the documented nominal model, not hardware audio fidelity.
Next work: PSG channels 2–4, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend. Next work: noise channel 4, 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 channel 1 trigger and stops at a channel 2 trigger. The local Emerald startup now passes both pulse triggers and stops at a noise channel 4 trigger.
See [the local runtime result](../research/emerald-reset.md). See [the local runtime result](../research/emerald-reset.md).
## References ## References
- [GBATEK channel 1](https://mgba-emu.github.io/gbatek/): register fields, duty ratios, and modulation rates. - [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.
- [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. - [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. - [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. - [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.

View file

@ -220,7 +220,7 @@ Supported flags can be combined:
| 7 | Reset supported display, DMA, timer, and interrupt registers as described below | | 7 | Reset supported display, DMA, timer, and interrupt registers as described below |
All low-byte flag combinations, including `r0=0xff`, are accepted within the supported device subset. All low-byte flag combinations, including `r0=0xff`, are accepted within the supported device subset.
This is not full audio or serial support. [PSG channels 2–4](audio.md) and [serial transfers](serial.md) still fail explicitly. This is not full audio or serial support. [PSG channels 3–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. Sound reset retains wave bank 0 and the separate in-flight playback word; exact firmware sound-reset equivalence is not established.
The firmware always clears the low halfword of SIODATA32, even when bit 5 is clear; its upper halfword is preserved. The firmware always clears the low halfword of SIODATA32, even when bit 5 is clear; its upper halfword is preserved.
@ -233,7 +233,7 @@ After the RAM clears, bit 5 clears SIOCNT and SIODATA8, writes RCNT=0x8000, and
Disconnected general-purpose inputs read high, so RCNT reads back as 0x800f. Disconnected general-purpose inputs read high, so RCNT reads back as 0x800f.
Bit 6 disables SOUNDCNT_X, strobes both FIFO resets, clears SOUNDCNT_H, and sets SOUNDBIAS=0x0200. 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. 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. Disabled PSG registers remain zero. Running pulse channels stop only when sound reset is selected.
Reset does not produce audio, fabricate serial transfers, or bypass normal bus accesses. Reset does not produce audio, fabricate serial transfers, or bypass normal bus accesses.
Bit 7 runs after the selected RAM and sound/serial resets: Bit 7 runs after the selected RAM and sound/serial resets:
@ -511,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. The CPU reports `CpuError::UnsupportedInstruction` with `bios::UNSUPPORTED_TRAP`, rather than silently treating a service as a no-op.
Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction. Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction.
Prior boot/service steps remain committed on failure. Prior boot/service steps remain committed on failure.
PSG channels 2–4, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented. PSG channels 3–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. This subset is not sufficient for Pokémon Emerald compatibility.

View file

@ -39,7 +39,7 @@ Failed steps discard staged counters, divider progress, timer/audio writes, FIFO
Display and other device clocks then advance once, without advancing timers again. 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. 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. [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. The same transaction advances both PSG pulse oscillators and their shared 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. Host clock advances and HALT idle batches retain ordinary bulk timer advancement; STOP idle does not advance timers.
The unchanged [prefetch cancellation probe](../research/prefetch-cancellation.md) now matches both published interval totals and actual timer samples. The unchanged [prefetch cancellation probe](../research/prefetch-cancellation.md) now matches both published interval totals and actual timer samples.

View file

@ -10,7 +10,7 @@ No ROM bytes, extracted code, or assets were added to the repository.
## General correction ## General correction
The reset correction introduced bounded sound and [disconnected serial](../hardware/serial.md) initialization models. The reset correction introduced bounded sound and [disconnected serial](../hardware/serial.md) initialization models.
The [sound model](../hardware/audio.md) now also supports Direct Sound, bounded FIFO DMA, and nominal pulse channel 1 playback. The [sound model](../hardware/audio.md) now also supports Direct Sound, bounded FIFO DMA, and both nominal pulse channels.
The BIOS performs reset through original ARM stores against that model. The BIOS performs reset through original ARM stores against that model.
Bit 5 selects general-purpose input pins and clears supported serial reset registers. Bit 5 selects general-purpose input pins and clears supported serial reset registers.
Bit 6 disables sound, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default. Bit 6 disables sound, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default.
@ -31,18 +31,18 @@ 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`). The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`).
The Direct Sound increment then reached a PSG channel 1 trigger (`0x04000065`, PC `0x082e03bc`). 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: Channel 1 support reached the channel 2 trigger (`0x0400006d`, PC `0x082e03c0`).
With both pulse channels implemented, both runs now stop at noise channel 4:
```text ```text
Steps: 321161; instructions: 320649; IRQ entries: 0; DMA units: 512; HALT idle: 0 Steps: 321163; instructions: 320651; IRQ entries: 0; DMA units: 512; HALT idle: 0
Nominal cycles: 1038742 Nominal cycles: 1038746
PC=0x082e03c0 PC=0x082e03c4
unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x0400006d unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x0400007d
``` ```
The native run captures three startup frames. Audio-device behavior is validated by original tests, not by audible game output. The native run captures three startup frames. These do not establish a working title screen or gameplay.
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. The window run does not reach its requested frame limit and correctly exits with failure.
Terminal execution is also diagnostic, not a compatibility pass. Terminal execution is also diagnostic, not a compatibility pass.
Results apply to this local file; no cartridge revision or checksum was inferred from its filename. Results apply to this local file; no cartridge revision or checksum was inferred from its filename.
@ -53,9 +53,10 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
Native ROM-window tests and graphics smoke modes pass on Darwin arm64. Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match. The pinned Pong debug/release reports match.
The next observed startup requirement is PSG pulse channel 2, which has no sweep unit. The next observed startup requirement is noise channel 4. Wave channel 3 is also still missing.
Channel 1 now has nominal waveform, length, envelope, sweep, and stereo mixing behavior. Both pulse channels now have independent nominal waveform, length, envelope, status, and stereo mixing behavior.
Direct Sound has FIFO clocks and nominal DMA requests. Both use the instantaneous digital-level inspection interface. Sweep applies only to channel 1. The two pulses share the sequencer without sharing their channel state.
Direct Sound has FIFO clocks and nominal DMA requests. All supported channels 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. 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. Do not suppress the PSG trigger diagnostic without implementing the requested device behavior.
Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol. Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol.

View file

@ -79,8 +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. - Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices.
- RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request. - RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
- Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks. - Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks.
- PSG pulse channel 1 supports nominal frequency/duty playback, length, envelope, sweep, status, and stereo mixing. - PSG pulse channels 1 and 2 support independent nominal frequency/duty playback, length, envelope, status, and stereo mixing. Channel 1 also supports sweep.
- Channels 2–4 triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback. - Channel 3–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. - ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP.
- Optional ROM-window frame limits and an original input-test ROM generator. - Optional ROM-window frame limits and an original input-test ROM generator.
- Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports. - Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports.
@ -137,8 +137,8 @@ Horizontal window comparators retain four-cycle event history.
Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits. Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits.
Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented. Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented.
There is no host audio output or cartridge save support. 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. The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, idle channel 3–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. It does not yet provide a continuous sample stream. Channel 3–4 playback and unverified sound-DMA pairings remain diagnostic.
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified. 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. The [serial initialization subset](hardware/serial.md) supports idle normal data and disconnected general-purpose pins, not active transfers.
Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT. Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT.
@ -150,7 +150,7 @@ 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. ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices.
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility. Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility.
A local Emerald run now completes its all-device reset and master sound enable. A local Emerald run now completes its all-device reset and master sound enable.
It also passes the channel 1 trigger, then stops explicitly at channel 2 (`0x0400006d`). It also passes both pulse triggers, then stops explicitly at noise channel 4 (`0x0400007d`).
No title-screen, gameplay, or audible-game compatibility is established. 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). Its captured startup frames do not establish title-screen or gameplay compatibility. See [the startup result](research/emerald-reset.md).
See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status. See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status.

View file

@ -39,8 +39,9 @@ The [pinned Pong homebrew](public-pong.md) passes scripted menus, ball movement,
The native Darwin window also starts and presents frames without a diagnostic. The native Darwin window also starts and presents frames without a diagnostic.
This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch. This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch.
Frame-based scenarios and the preparation adapter now make the result repeatable. Frame-based scenarios and the preparation adapter now make the result repeatable.
A local Emerald run now passes its initial all-device reset, master sound enable, and PSG channel 1 trigger. A local Emerald run now passes its initial all-device reset, master sound enable, and both PSG pulse triggers.
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. It stops at a noise channel 4 trigger. The core has tested Direct Sound and both pulse channels.
A continuous sample stream and desktop audio output remain missing.
See [the runtime result](research/emerald-reset.md). Remaining PSG channels are the next demonstrated startup requirement. 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. The separate save requirement remains cartridge SRAM, followed by safe host save persistence.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.