feat(audio): implement PSG noise channel 4

This commit is contained in:
0xrsydn 2026-10-07 02:51:46 +07:00
commit 8ae7b3cf72
14 changed files with 774 additions and 75 deletions

View file

@ -1,7 +1,9 @@
//! Nominal Direct Sound and PSG pulse channels 1–2. No host audio output.
//! Nominal Direct Sound, PSG pulse channels 1–2, and noise channel 4. No host audio output.
mod modulation;
mod noise;
mod pulse;
use noise::Noise;
use pulse::Pulse;
const SEQUENCER_PERIOD: u32 = 32768; // 16,777,216 Hz / 512 Hz.
@ -80,6 +82,7 @@ pub(crate) struct Audio {
enabled: bool,
psg: [u16; 17],
pulses: [Pulse; 2],
noise: Noise,
sequencer_phase: u16,
next_step: u8,
control: u16,
@ -100,7 +103,7 @@ impl Audio {
{
return Some("reserved PSG volume selection");
}
if self.enabled && matches!(address, 0x0400_0075 | 0x0400_007d) && value & 0x80 != 0 {
if self.enabled && address == 0x0400_0075 && value & 0x80 != 0 {
Some("PSG channel trigger (synthesis not implemented)")
} else {
None
@ -123,6 +126,9 @@ impl Audio {
}
pub(crate) fn read8(&self, address: u32) -> Option<u8> {
if (0x0400_0078..=0x0400_007d).contains(&address) {
return Some(self.noise.read(address - 0x0400_0078));
}
if let Some((index, offset)) = Self::pulse_register(address) {
return Some(self.pulses[index].read(offset));
}
@ -135,8 +141,6 @@ impl Audio {
0x0400_0074 => 0x4000,
0x0400_0070 => 0x00e0,
0x0400_0072 => 0xe000,
0x0400_0078 => 0xff00,
0x0400_007c => 0x40ff,
0x0400_0080 => 0xff77,
_ => 0,
};
@ -147,6 +151,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.noise.modulation.active) << 3)
}
SOUNDBIAS => self.bias,
0x0400_0086 | 0x0400_008a => 0,
@ -173,6 +178,13 @@ impl Audio {
}
continue;
}
if (0x0400_0078..=0x0400_007d).contains(&address) {
if self.enabled {
self.noise
.write(address - 0x0400_0078, value, self.next_step);
}
continue;
}
let shift = (address & 1) * 8;
let merge = |old: u16| (old & !(255 << shift)) | (u16::from(value) << shift);
match address & !1 {
@ -194,6 +206,7 @@ impl Audio {
if self.enabled && value & 0x80 == 0 {
self.psg = [0; 17];
self.pulses = [Pulse::default(); 2];
self.noise = Noise::default();
for fifo in &mut self.fifo {
fifo.reset_queue();
}
@ -222,12 +235,14 @@ impl Audio {
for pulse in &mut self.pulses {
pulse.advance(count);
}
self.noise.advance(count);
let phase = u32::from(self.sequencer_phase) + count;
if phase == SEQUENCER_PERIOD {
self.sequencer_phase = 0;
for pulse in &mut self.pulses {
pulse.clock(self.next_step);
}
self.noise.clock(self.next_step);
self.next_step = (self.next_step + 1) & 7;
} else {
self.sequencer_phase = phase as u16;
@ -278,6 +293,9 @@ impl Audio {
psg_sample += pulse.sample();
}
}
if psg_control & (1 << (11 + side * 4)) != 0 {
psg_sample += self.noise.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;

View file

@ -0,0 +1,144 @@
//! Nominal GBA channel 4 polynomial counter. No random numbers or host clocks.
use super::modulation::Modulation;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Noise {
pub(super) modulation: Modulation,
polynomial: u8,
lfsr: u16,
remaining: u32,
high: bool,
}
impl Noise {
pub(super) fn read(&self, offset: u32) -> u8 {
match offset {
1 => self.modulation.envelope,
4 => self.polynomial,
5 => u8::from(self.modulation.length_enabled) << 6,
_ => 0,
}
}
fn short(&self) -> bool {
self.polynomial & 8 != 0
}
fn period(&self) -> u32 {
let ratio = u32::from(self.polynomial & 7);
let base = if ratio == 0 { 32 } else { 64 * ratio };
base << (self.polynomial >> 4)
}
pub(super) fn write(&mut self, offset: u32, value: u8, next_step: u8) {
match offset {
0 => self.modulation.write_length(value),
1 => self.modulation.write_envelope(value),
4 => self.polynomial = value, // Preserve counter and remaining interval on live writes.
5 => {
if self.modulation.write_control(value, next_step) {
self.lfsr = if self.short() { 0x40 } else { 0x4000 };
self.remaining = self.period();
self.high = false;
}
}
_ => {}
}
}
pub(super) fn advance(&mut self, cycles: u32) {
if !self.modulation.active || cycles == 0 {
return;
}
if cycles < self.remaining {
self.remaining -= cycles;
return;
}
let rest = cycles - self.remaining;
let period = self.period();
let edges = 1 + rest / period;
// The output is the carry of the final shift, not the final register's low bit.
let before_last = jump(self.lfsr, edges - 1, self.short());
self.high = before_last & 1 != 0;
self.lfsr = shift(before_last, self.short());
self.remaining = period - rest % period;
}
pub(super) fn clock(&mut self, step: u8) {
self.modulation.clock(step);
}
pub(super) fn sample(&self) -> i16 {
if !self.modulation.active {
return 0;
}
if self.high {
i16::from(self.modulation.volume)
} else {
-i16::from(self.modulation.volume)
}
}
}
const fn shift(state: u16, short: bool) -> u16 {
(state >> 1)
^ if state & 1 != 0 {
if short {
0x60
} else {
0x6000
}
} else {
0
}
}
// Each column maps one input bit. Squaring the linear map doubles its step count.
// This also handles transient upper bits after a live width change; reducing by
// the 127-step short-mode period would incorrectly discard those transient bits.
type Transform = [u16; 15];
const JUMPS: [[Transform; 32]; 2] = [powers(false), powers(true)];
const fn apply(map: &Transform, mut state: u16) -> u16 {
let mut result = 0;
while state != 0 {
result ^= map[state.trailing_zeros() as usize];
state &= state - 1;
}
result
}
const fn powers(short: bool) -> [Transform; 32] {
let mut maps = [[0; 15]; 32];
let mut bit = 0;
while bit < 15 {
maps[0][bit] = shift(1 << bit, short);
bit += 1;
}
let mut power = 1;
while power < 32 {
bit = 0;
while bit < 15 {
maps[power][bit] = apply(&maps[power - 1], maps[power - 1][bit]);
bit += 1;
}
power += 1;
}
maps
}
fn jump(mut state: u16, mut count: u32, short: bool) -> u16 {
let maps = &JUMPS[usize::from(short)];
let mut power = 0;
while count != 0 {
if count & 1 != 0 {
state = apply(&maps[power], state);
}
count >>= 1;
power += 1;
}
state
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,172 @@
//! Original counter references and deterministic clock-batching checks.
use super::*;
fn start(polynomial: u8) -> Noise {
let mut noise = Noise::default();
noise.write(1, 0xf0, 0);
noise.write(4, polynomial, 0);
noise.write(5, 0x80, 0);
noise
}
// Independent bit-array form of the documented Galois counter.
fn reference(state: u16, short: bool) -> (u16, bool) {
let mut bits = [false; 15];
for (index, bit) in bits.iter_mut().enumerate() {
*bit = state & (1 << index) != 0;
}
let carry = bits[0];
bits.copy_within(1..15, 0);
bits[14] = false;
if carry {
let top = if short { 6 } else { 14 };
bits[top] = !bits[top];
bits[top - 1] = !bits[top - 1];
}
let value = bits
.iter()
.enumerate()
.fold(0, |value, (index, &bit)| value | (u16::from(bit) << index));
(value, carry)
}
#[test]
fn both_counters_visit_every_nonzero_state_before_repeating() {
for (short, width) in [(false, 15), (true, 7)] {
let initial = 1 << (width - 1);
let mut state = initial;
let mut seen = vec![false; 1 << width];
let mut positive = 0;
for index in 0..(1 << width) - 1 {
assert_ne!(state, 0);
assert!(!seen[usize::from(state)]);
seen[usize::from(state)] = true;
let (expected, high) = reference(state, short);
assert_eq!(shift(state, short), expected);
if index < width {
assert_eq!(high, index == width - 1);
}
positive += usize::from(high);
state = expected;
}
assert_eq!(state, initial);
assert_eq!(positive, 1 << (width - 1));
}
}
#[test]
fn jump_maps_match_each_state_and_iterated_random_batches_including_transient_upper_bits() {
for short in [false, true] {
for state in 0..=0x7fff {
assert_eq!(jump(state, 1, short), reference(state, short).0);
}
let mut random = 0x4183u32;
for _ in 0..200 {
random = random.wrapping_mul(1664525).wrapping_add(1013904223);
let state = (random & 0x7fff) as u16;
let count = (random >> 16) & 4095;
let mut expected = state;
for _ in 0..count {
expected = reference(expected, short).0;
}
assert_eq!(jump(state, count, short), expected);
assert_eq!(
jump(jump(state, count / 2, short), count - count / 2, short),
expected
);
}
}
}
#[test]
fn all_divider_shift_width_fields_observe_exact_nominal_edges() {
for polynomial in 0..=255u8 {
let divisor = [32, 64, 128, 192, 256, 320, 384, 448][usize::from(polynomial & 7)];
let period = divisor * (1u32 << (polynomial >> 4));
for cycles in [0, period - 1, period, period * 19 + 7] {
let mut noise = start(polynomial);
noise.advance(cycles);
let mut expected = if polynomial & 8 != 0 { 0x40 } else { 0x4000 };
let mut high = false;
for _ in 0..cycles / period {
(expected, high) = reference(expected, polynomial & 8 != 0);
}
assert_eq!(noise.lfsr, expected);
assert_eq!(noise.high, high);
assert_eq!(noise.remaining, period - cycles % period);
assert_eq!(noise.sample(), if high { 15 } else { -15 });
}
}
}
#[test]
fn maximum_clock_batch_matches_independent_cycle_reduction_and_last_carry() {
for (polynomial, width) in [(0, 15), (8, 7)] {
let mut noise = start(polynomial);
noise.advance(u32::MAX);
let edges = u32::MAX / 32;
let mut expected = 1 << (width - 1);
for _ in 0..(edges - 1) % ((1 << width) - 1) {
expected = reference(expected, width == 7).0;
}
let (state, high) = reference(expected, width == 7);
assert_eq!((noise.lfsr, noise.high), (state, high));
assert_eq!(noise.remaining, 1);
}
}
#[test]
fn live_width_and_rate_changes_preserve_counter_and_remaining_interval() {
let mut noise = start(0);
noise.advance(3 * 32 + 15);
assert_eq!(noise.lfsr, 0x800);
noise.write(4, 0x19, 0); // Short mode, new period128. Old interval has17 clocks left.
let mut expected = noise.lfsr;
noise.advance(16);
assert_eq!(noise.lfsr, expected);
noise.advance(1);
expected = reference(expected, true).0;
assert_eq!(noise.lfsr, expected); // Upper bits are not truncated or reseeded.
assert_eq!(noise.remaining, 128);
noise.advance(128 * 1000);
for _ in 0..1000 {
expected = reference(expected, true).0;
}
assert_eq!(noise.lfsr, expected);
noise.write(4, 0, 0);
noise.advance(128 + 32 * 25);
for _ in 0..26 {
expected = reference(expected, false).0;
}
assert_eq!(noise.lfsr, expected);
}
#[test]
fn trigger_reseeds_counter_reloads_timer_and_resets_held_output() {
for (polynomial, width) in [(0, 15), (8, 7)] {
let mut noise = start(polynomial);
noise.advance(width * 32);
assert_eq!(noise.sample(), 15);
noise.write(5, 0x80, 0);
assert_eq!(noise.lfsr, 1 << (width - 1));
assert_eq!(noise.remaining, 32);
assert_eq!(noise.sample(), -15);
noise.write(1, 0, 0);
let before = noise;
noise.advance(u32::MAX);
assert_eq!(noise, before);
assert_eq!(noise.sample(), 0);
}
}
#[test]
fn zero_cycles_and_arbitrary_splits_preserve_full_noise_state() {
let mut bulk = start(8);
let mut split = bulk;
for cycles in [0, 1, 31, 33, 4096, 1000000, u32::MAX] {
bulk.advance(cycles);
split.advance(cycles / 2);
split.advance(cycles - cycles / 2);
assert_eq!(bulk, split);
}
}

View file

@ -12,7 +12,9 @@ fn sound(memory: &mut Memory, period: u16) {
memory.write16(0x04000062, 0xf080).unwrap();
memory.write16(0x04000064, 0x87ff).unwrap();
memory.write16(0x04000068, 0xf080).unwrap();
memory.write16(0x0400006c, 0x87ff).unwrap(); // Both pulses run unrouted; rollback must include them.
memory.write16(0x0400006c, 0x87ff).unwrap();
memory.write16(0x04000078, 0xf000).unwrap();
memory.write16(0x0400007c, 0x8008).unwrap(); // All supported PSG channels run unrouted.
memory
.write32(
TIMER_BASE,
@ -22,16 +24,22 @@ fn sound(memory: &mut Memory, period: u16) {
}
#[test]
fn pulse_status_load_observes_length_expiry_at_bus_completion() {
for (index, duty, frequency) in [(0, 0x04000062, 0x04000064), (1, 0x04000068, 0x0400006c)] {
fn psg_status_load_observes_length_expiry_at_bus_completion() {
for (index, duty, frequency) in [
(0, 0x04000062, 0x04000064),
(1, 0x04000068, 0x0400006c),
(3, 0x04000078, 0x0400007c),
] {
let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]);
sound(&mut memory, 1000);
memory.write16(duty, 0xf03f).unwrap();
memory.write16(frequency, 0xc7ff).unwrap();
memory
.write16(frequency, if index == 3 { 0xc000 } else { 0xc7ff })
.unwrap();
memory.advance_cycles(32768 - 7);
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
let status = 0x83 ^ (1 << index); // The other channel remains active.
let status = 0x8b ^ (1 << index); // The other channels remain active.
assert_eq!(machine.cpu().registers()[0], status);
assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), status as u16);
}
@ -57,27 +65,47 @@ fn pulse_trigger_reloads_timer_at_store_completion() {
}
#[test]
fn successful_dma_trigger_starts_channel2_after_destination_bus_cycles() {
let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]);
fn noise_trigger_reloads_counter_at_store_completion() {
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x8008), (1, 0x0400007c)]);
sound(&mut memory, 1000);
memory.write16(0x04000080, 0x2277).unwrap();
memory.write16(0x04000080, 0x8877).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);
machine.step().unwrap();
assert_eq!(machine.cycles(), 7);
machine.memory_mut().advance_cycles(7 * 32 - 1);
assert_eq!(machine.memory().audio_level().left, -120);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().audio_level().left, 120);
}
#[test]
fn successful_dma_triggers_start_pulse_and_noise_after_destination_bus_cycles() {
for (envelope, destination, routing, control, delay, before, after) in [
(0x04000069, 0x0400006c, 0x2277, 0x87ff, 15, 120, -120),
(0x04000079, 0x0400007c, 0x8877, 0x8008, 223, -120, 120),
] {
let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]);
sound(&mut memory, 1000);
memory.write16(0x04000080, routing).unwrap();
memory.write16(SOUNDCNT_H, 2).unwrap();
memory.write8(envelope, 0).unwrap();
memory.write8(envelope, 0xf0).unwrap(); // DAC gate restored, still needs a trigger.
memory.write32(0x02000000, control).unwrap();
memory.write32(DMA_BASE, 0x02000000).unwrap();
memory.write32(DMA_BASE + 4, destination).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(), 0x8b);
machine.memory_mut().advance_cycles(delay);
assert_eq!(machine.memory().audio_level().left, before);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().audio_level().left, after);
}
}
#[test]
@ -146,6 +174,29 @@ fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges
}
}
#[test]
fn arm_block_store_rejects_later_reserved_ratio_before_committing_noise_trigger() {
let (cpu, mut memory) = prepared(
false,
&[0xe8a0000e],
&[(0, 0x04000078), (1, 0xf000), (2, 0x8008), (3, 0x00030000)],
); // STMIA r0!,{r1-r3}: noise configuration/trigger, then reserved PSG ratio.
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("reserved PSG volume selection"));
assert_eq!(machine.memory().io.audio, audio);
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), 0);
}
}
#[test]
fn arm_block_store_rejects_later_wave_trigger_before_committing_channel2_trigger() {
let (cpu, mut memory) = prepared(

View file

@ -20,7 +20,7 @@ const REGIONS: [(u32, u32, u32); 5] = [
const MARKER: u32 = 0xa55a_c33c;
#[test]
fn sound_reset_disables_running_pulses_only_when_selected() {
fn sound_reset_disables_running_psg_channels_only_when_selected() {
use gba_core::io::SOUNDCNT_X;
for thumb in [false, true] {
for flags in [0, 0x40] {
@ -31,10 +31,12 @@ fn sound_reset_disables_running_pulses_only_when_selected() {
bus.write16(0x04000064, 0x87ff).unwrap();
bus.write16(0x04000068, 0xf080).unwrap();
bus.write16(0x0400006c, 0x87fe).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 { 0x83 } else { 0 }
if flags == 0 { 0x8b } else { 0 }
);
}
}

View file

@ -167,15 +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();
for address in [0x04000074, 0x0400007c] {
let previous = bus.read16(address).unwrap();
assert!(bus
.write16(address, 0xffff)
.unwrap_err()
.to_string()
.contains("PSG channel trigger"));
assert_eq!(bus.read16(address).unwrap(), previous);
}
let previous = bus.read16(0x04000074).unwrap();
assert!(bus
.write16(0x04000074, 0xffff)
.unwrap_err()
.to_string()
.contains("PSG channel trigger"));
assert_eq!(bus.read16(0x04000074).unwrap(), previous);
bus.write16(SOUNDCNT_X, 0).unwrap();
assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0x11223344);
assert_eq!(bus.read16(0x04000062).unwrap(), 0);

View file

@ -0,0 +1,263 @@
//! Original public-bus regressions for noise channel 4. No game audio or assets.
use gba_core::{
audio::StereoLevel,
cpu::Cpu,
io::{FIFO_A, HALTCNT, IF, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE},
machine::Machine,
memory::{Memory, ROM_START},
};
const ENVELOPE: u32 = 0x04000078;
const CONTROL: u32 = 0x0400007c;
const MIX: u32 = 0x04000080;
const SEQUENCE: u32 = 32768;
fn bus() -> Memory {
let mut bus = Memory::new(0xeafffffeu32.to_le_bytes().to_vec()).unwrap();
bus.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write16(SOUNDBIAS, 0x200).unwrap();
bus.write16(MIX, 0x8877).unwrap();
bus.write16(SOUNDCNT_H, 2).unwrap();
bus
}
fn start(bus: &mut Memory, short: bool) {
bus.write16(ENVELOPE, 0xf000).unwrap();
bus.write16(CONTROL, if short { 0x8008 } else { 0x8000 })
.unwrap();
}
fn stereo(value: i16) -> StereoLevel {
StereoLevel {
left: value,
right: value,
}
}
fn reference_level(cycles: u32, short: bool) -> i16 {
let edges = cycles / 32;
if edges == 0 {
return -120;
}
let (mut state, mask, period) = if short {
(0x40u16, 0x60, 127)
} else {
(0x4000, 0x6000, 32767)
};
for _ in 0..(edges - 1) % period {
state = (state >> 1) ^ if state & 1 != 0 { mask } else { 0 };
}
if state & 1 != 0 {
120
} else {
-120
}
}
#[test]
fn register_masks_padding_readonly_status_and_first_carry_in_both_widths() {
for (short, width) in [(false, 15), (true, 7)] {
let mut bus = bus();
bus.write32(ENVELOPE, 0xfffff0ff).unwrap(); // Unused bits/halfword cannot affect control.
assert_eq!(bus.read32(ENVELOPE).unwrap(), 0xf000);
assert_eq!(bus.read16(CONTROL).unwrap(), 0);
bus.write8(CONTROL, if short { 8 } else { 0 }).unwrap();
bus.write8(CONTROL + 1, 0xff).unwrap();
assert_eq!(
bus.read32(CONTROL).unwrap(),
if short { 0x4008 } else { 0x4000 }
);
assert_eq!(bus.read32(SOUNDCNT_X).unwrap(), 0x88);
bus.write16(SOUNDCNT_X, 0x8f).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
assert_eq!(bus.audio_level(), stereo(-120));
bus.advance_cycles(width * 32 - 1);
assert_eq!(bus.audio_level(), stereo(-120));
bus.advance_cycles(1);
assert_eq!(bus.audio_level(), stereo(120));
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
#[test]
fn length_expiry_and_extra_clock_reload_do_not_need_a_general_timer() {
let mut bus = bus();
bus.write16(ENVELOPE, 0xf03e).unwrap();
bus.write16(CONTROL, 0xc000).unwrap();
bus.advance_cycles(SEQUENCE);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
bus.advance_cycles(SEQUENCE * 2);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
assert_eq!(bus.audio_level(), stereo(0));
bus.write16(CONTROL, 0xc000).unwrap(); // Next step3: empty length reloads63.
bus.advance_cycles(SEQUENCE * 125);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
bus.advance_cycles(SEQUENCE);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
}
#[test]
fn envelope_changes_at_shared_step7_and_saturation_does_not_clear_status() {
let mut bus = bus();
bus.advance_cycles(SEQUENCE * 6);
bus.write16(ENVELOPE, 0x3900).unwrap();
bus.write16(CONTROL, 0x8008).unwrap();
bus.advance_cycles(SEQUENCE * 2 - 1);
assert_eq!(bus.audio_level().left.abs(), 24);
bus.advance_cycles(1);
assert_eq!(bus.audio_level().left.abs(), 32);
bus.write16(ENVELOPE, 0xc100).unwrap();
bus.write16(CONTROL, 0x8008).unwrap();
bus.advance_cycles(SEQUENCE * 8 * 20);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
assert_eq!(bus.audio_level(), stereo(0));
}
#[test]
fn master_off_and_dac_gate_prevent_activation_until_reconfigured_and_triggered() {
let mut bus = bus();
start(&mut bus, false);
bus.write8(ENVELOPE + 1, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
assert_eq!(bus.audio_level(), stereo(0));
bus.write8(ENVELOPE + 1, 0xf0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
bus.write16(CONTROL, 0x8000).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
bus.write16(SOUNDCNT_X, 0).unwrap();
bus.write32(ENVELOPE, u32::MAX).unwrap();
bus.write32(CONTROL, u32::MAX).unwrap();
assert_eq!(bus.read32(ENVELOPE).unwrap(), 0);
assert_eq!(bus.read32(CONTROL).unwrap(), 0);
bus.write16(SOUNDCNT_X, 0x80).unwrap();
bus.write16(CONTROL, 0x8000).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
}
#[test]
fn independent_routing_gain_ratios_and_sum_before_rounding_include_noise() {
let mut bus = bus();
start(&mut bus, false);
for (ratio, expected) in [(0, -30), (1, -60), (2, -120)] {
bus.write16(SOUNDCNT_H, ratio).unwrap();
assert_eq!(bus.audio_level(), stereo(expected));
}
bus.write16(MIX, 0x8070).unwrap();
assert_eq!(
bus.audio_level(),
StereoLevel {
left: -120,
right: 0
}
);
bus.write16(MIX, 0x0800).unwrap();
assert_eq!(
bus.audio_level(),
StereoLevel {
left: 0,
right: -15
}
);
bus.write16(MIX, 0x9900).unwrap();
bus.write16(SOUNDCNT_H, 0).unwrap();
bus.write16(0x04000062, 0x2080).unwrap(); // Pulse1 +2.
bus.write16(0x04000064, 0x8000).unwrap();
bus.write16(ENVELOPE, 0x1000).unwrap(); // Noise -1.
bus.write16(CONTROL, 0x8000).unwrap();
assert_eq!(bus.audio_level(), stereo(0)); // Sum1 >>2, not separately rounded0 + -1.
}
#[test]
fn all_supported_sources_mix_before_bias_clipping_and_noise_mute_does_not_stop_it() {
for positive in [false, true] {
let mut bus = bus();
bus.write16(MIX, 0x9977).unwrap(); // Pulse1 and noise, both sides.
bus.write16(SOUNDCNT_H, 0x0306).unwrap(); // Also Direct Sound A.
bus.write16(0x04000062, if positive { 0xf080 } else { 0xf000 })
.unwrap();
bus.write16(0x04000064, 0x8000).unwrap();
start(&mut bus, true);
if positive {
bus.advance_cycles(7 * 32);
}
bus.write32(FIFO_A, if positive { 0x7f7f7f7f } else { 0x80808080 })
.unwrap();
bus.write32(TIMER_BASE, 0x0080ffff).unwrap();
bus.advance_cycles(1);
assert_eq!(bus.audio_level(), stereo(if positive { 511 } else { -512 }));
bus.write16(MIX, 0).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x89);
assert_eq!(bus.audio_level(), stereo(if positive { 508 } else { -512 }));
}
}
#[test]
fn noise_status_and_length_are_independent_of_both_pulses() {
let mut bus = bus();
for (duty, frequency) in [(0x04000062, 0x04000064), (0x04000068, 0x0400006c)] {
bus.write16(duty, 0xf080).unwrap();
bus.write16(frequency, 0x87ff).unwrap();
}
bus.write16(ENVELOPE, 0xf03f).unwrap();
bus.write16(CONTROL, 0xc000).unwrap();
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x8b);
bus.advance_cycles(SEQUENCE);
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x83);
assert_eq!(bus.audio_level(), stereo(0)); // Pulses are not routed here.
}
#[test]
fn halt_keeps_noise_and_modulation_running_but_stop_freezes_them() {
for stop in [false, true] {
let mut bus = bus();
start(&mut bus, true);
bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let before = machine.cpu().clone();
machine.step().unwrap();
assert_eq!(machine.cpu(), &before);
assert_eq!(
machine.memory().audio_level(),
stereo(reference_level(machine.cycles() as u32, true))
);
if stop {
machine.memory_mut().advance_cycles(u32::MAX);
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().audio_level(), stereo(-120));
} else {
assert!(machine.cycles() > 0);
}
}
}
#[test]
fn batched_single_clock_and_row_capture_paths_agree_and_maximum_batches_are_bounded() {
let mut bulk = bus();
let mut rows = bus();
let mut small = bus();
rows.set_scanline_rendering(true);
for bus in [&mut bulk, &mut rows, &mut small] {
bus.write16(ENVELOPE, 0xc100).unwrap();
bus.write16(CONTROL, 0x8008).unwrap();
}
for cycles in [1, 31, 4096, 32768, 262145] {
bulk.advance_cycles(cycles);
rows.advance_cycles(cycles);
for _ in 0..cycles {
small.advance_cycles(1);
}
assert_eq!(bulk.audio_level(), small.audio_level());
assert_eq!(bulk.audio_level(), rows.audio_level());
assert_eq!(
bulk.read16(SOUNDCNT_X).unwrap(),
rows.read16(SOUNDCNT_X).unwrap()
);
}
for short in [false, true] {
let mut bus = bus();
start(&mut bus, short);
bus.advance_cycles(u32::MAX);
assert_eq!(bus.audio_level(), stereo(reference_level(u32::MAX, short)));
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x88);
}
}

View file

@ -56,6 +56,9 @@ The repository is a Cargo workspace with three crates:
| `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` | 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/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 |
| `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/channel2.rs` | Channel 2 mapping, register gaps, independent state, shared clocks, and sum-before-rounding mixing |

View file

@ -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 and 2.
It does not produce desktop audio. PSG wave channel 3 and noise channel 4 remain unsupported.
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.
`Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream.
## Registers and PSG state
@ -11,9 +11,12 @@ It does not produce desktop audio. PSG wave channel 3 and noise channel 4 remain
| `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..0x04000081` | Idle channel 3–4 configuration and PSG mixer controls |
| `0x04000070..0x04000075` | Idle wave channel 3 configuration |
| `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; read-only channel 1/2 activity in bits 0/1; channels 3–4 remain inactive |
| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; channel 1/2/4 activity in read-only bits 0/1/3; channel 3 remains inactive |
| `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` |
| `0x04000090..0x0400009f` | CPU wave RAM window, opposite the bank selected by `SOUND3CNT_L` bit 6 |
| `0x040000a0..0x040000a7` | FIFO A/B writes with byte, halfword, and word access boundaries preserved |
@ -28,10 +31,10 @@ 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 channel 3–4 trigger returns `MemoryError::UnsupportedIo` with the exact address and value.
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.
Reserved PSG volume selection 3 is rejected while master sound is enabled, including enable after disabled configuration.
Idle channel 3–4 writes do not imply playback support.
Idle wave channel 3 writes do not imply playback support.
## Pulse channels 1 and 2
@ -84,6 +87,41 @@ First-trigger output suppression, trigger sub-divider alignment, envelope trigge
The model samples the selected duty position immediately after trigger and uses a full-period timer reload.
The consulted implementations differ on some initial phases and envelope/sweep edge cases; no GBA hardware audio recording was used as an oracle.
## Noise channel 4
Noise uses a deterministic linear-feedback shift register (LFSR), not a host random-number generator.
The nominal counter follows GBATEK's GBA Galois description:
| Width | Trigger seed | Feedback mask | Sequence period |
| --- | --- | --- | --- |
| 15-bit | `0x4000` | `0x6000` | 32767 shifts |
| 7-bit | `0x0040` | `0x0060` | 127 shifts |
At each edge, the old low bit selects the signed output level.
The counter shifts right and, when that bit was one, XORs the feedback mask.
The period between edges is `(ratio == 0 ? 32 : 64 * ratio) << shift` system clocks.
All eight divider fields and sixteen shift fields use this GBA formula, including shifts 14 and 15.
No general-purpose timer or IRQ enable is required.
A trigger reloads the selected seed, full edge interval, envelope, and applicable length state.
The nominal held output starts low and changes at counter edges.
Live divider/shift writes preserve the remaining interval; the new period applies on the next reload.
Live width writes preserve the full counter, including transient upper bits, and select the new feedback mask.
They do not truncate or reseed the counter.
Exact hardware startup alignment and live-width transition behavior remain unverified.
The consulted mGBA implementation uses a different counter representation; this model does not claim matching initial sample phases.
Length, envelope, logical DAC gating, and activity use the same tested modulation implementation as the pulse channels.
All three channels receive the shared sequencer clocks but keep independent state.
Noise status uses SOUNDCNT_X bit 3. Envelope saturation alone does not clear it.
Master disable resets noise configuration and counter state. Muting its routing does not stop it.
The unused halfwords at `0x0400007a` and `0x0400007e` read zero and do not alias noise controls.
Large clock advances combine precomputed powers of the counter transition instead of shifting once per noise sample.
An independent bit-array reference checks these jump-ahead operations, including live-width transients and the final output carry.
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.
## FIFO and timer behavior
Each channel has a seven-word queue, a separate four-byte playback word, and a held signed sample.
@ -128,12 +166,12 @@ Sub-instruction DMA arbitration and exact refill latency are not modeled.
## Mixing and timing ownership
Each routed Direct Sound sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume.
Each active pulse contributes a centered signed amplitude, `+volume` or `-volume`.
`SOUNDCNT_L` routes channel 1 through bits 8/12 and channel 2 through bits 9/13, for right/left respectively.
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.
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 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.
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.
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.
@ -157,15 +195,20 @@ Pulse tests cover every frequency/duty pair, waveform batching, status, length,
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.
Noise tests cover both counter widths, divider/shift fields, complete sequences, jump-ahead arithmetic, retrigger, and live rate/width writes.
They also cover modulation, independent status, stereo mixing, HALT/STOP, and scanline-capture independence.
CPU/DMA tests include noise register writes at bus completion and failed-step rollback with a running counter.
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.
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 both pulse triggers and stops at a noise channel 4 trigger.
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.
See [the local runtime result](../research/emerald-reset.md).
## References
- [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.
- [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.

View file

@ -220,7 +220,7 @@ Supported flags can be combined:
| 7 | Reset supported display, DMA, timer, and interrupt registers as described below |
All low-byte flag combinations, including `r0=0xff`, are accepted within the supported device subset.
This is not full audio or serial support. [PSG channels 3–4](audio.md) and [serial transfers](serial.md) still fail explicitly.
This is not full audio or serial support. [wave channel 3](audio.md) and [serial transfers](serial.md) still fail explicitly.
Sound reset retains wave bank 0 and the separate in-flight playback word; exact firmware sound-reset equivalence is not established.
The firmware always clears the low halfword of SIODATA32, even when bit 5 is clear; its upper halfword is preserved.
@ -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.
Bit 6 disables SOUNDCNT_X, strobes both FIFO resets, clears SOUNDCNT_H, and sets SOUNDBIAS=0x0200.
It then clears accessible wave bank 1. Wave bank 0 is retained and can be selected again after master enable.
Disabled PSG registers remain zero. Running pulse channels stop only when sound reset is selected.
Disabled PSG registers remain zero. Running pulse and noise channels stop only when sound reset is selected.
Reset does not produce audio, fabricate serial transfers, or bypass normal bus accesses.
Bit 7 runs after the selected RAM and sound/serial resets:
@ -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.
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 channels 3–4, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented.
PSG wave channel 3, 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.

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 remaining devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled.
[Direct Sound](audio.md) consumes selected timer overflows in the timer transaction; sound DMA requests commit at step boundaries.
The same transaction advances both PSG pulse oscillators and their shared 512 Hz sequencer independently of the general-purpose timers.
The same transaction advances both PSG pulse oscillators, the noise counter, 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.
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
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 both nominal pulse channels.
The [sound model](../hardware/audio.md) now also supports Direct Sound, bounded FIFO DMA, both pulse channels, and noise channel 4.
The BIOS performs reset through original ARM stores against that model.
Bit 5 selects general-purpose input pins and clears supported serial reset registers.
Bit 6 disables sound, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default.
@ -32,16 +32,17 @@ direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --w
The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`).
The Direct Sound increment then reached a PSG channel 1 trigger (`0x04000065`, PC `0x082e03bc`).
Channel 1 support reached the channel 2 trigger (`0x0400006d`, PC `0x082e03c0`).
With both pulse channels implemented, both runs now stop at noise channel 4:
Both pulse channels then reached the noise trigger (`0x0400007d`, PC `0x082e03c4`).
With noise channel 4 implemented, both runs now stop at general-purpose input/output (GPIO) serial interrupt enable:
```text
Steps: 321163; instructions: 320651; IRQ entries: 0; DMA units: 512; HALT idle: 0
Nominal cycles: 1038746
PC=0x082e03c4
unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x0400007d
Steps: 395420; instructions: 393690; IRQ entries: 2; DMA units: 1728; HALT idle: 0
Nominal cycles: 1328057
PC=0x082e2aac
unsupported GPIO serial interrupt enable: write 0x01 at 0x04000135
```
The native run captures three startup frames. These do not establish a working title screen or gameplay.
The native run captures five 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 window run does not reach its requested frame limit and correctly exits with failure.
Terminal execution is also diagnostic, not a compatibility pass.
@ -53,12 +54,15 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match.
The next observed startup requirement is noise channel 4. Wave channel 3 is also still missing.
Both pulse channels now have independent nominal waveform, length, envelope, status, and stereo mixing behavior.
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.
Do not suppress the PSG trigger diagnostic without implementing the requested device behavior.
The next observed requirement is [serial control-register behavior](../hardware/serial.md), specifically RCNT bit 8.
Check mode gating and interrupt-enable semantics before changing the current rejection.
This diagnostic alone does not establish that the game needs an active link transfer.
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.
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.
Hardware and emulator references are recorded in the linked subsystem documents.

View file

@ -80,7 +80,8 @@ Update this file when a feature lands or a limit is removed.
- RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
- Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks.
- PSG pulse channels 1 and 2 support independent nominal frequency/duty playback, length, envelope, status, and stereo mixing. Channel 1 also supports sweep.
- Channel 3–4 triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback.
- 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.
- 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.
@ -137,8 +138,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.
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.
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 3–4 playback and unverified sound-DMA pairings remain diagnostic.
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.
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
The [serial initialization subset](hardware/serial.md) supports idle normal data and disconnected general-purpose pins, not active transfers.
Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT.
@ -150,7 +151,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.
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility.
A local Emerald run now completes its all-device reset and master sound enable.
It also passes both pulse triggers, then stops explicitly at noise channel 4 (`0x0400007d`).
It also passes the pulse and noise triggers, then stops explicitly at GPIO serial interrupt enable (`0x04000135`).
No title-screen, gameplay, or audible-game compatibility is established.
Its captured startup frames do not establish title-screen or gameplay compatibility. See [the startup result](research/emerald-reset.md).
See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status.

View file

@ -39,10 +39,10 @@ The [pinned Pong homebrew](public-pong.md) passes scripted menus, ball movement,
The native Darwin window also starts and presents frames without a diagnostic.
This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch.
Frame-based scenarios and the preparation adapter now make the result repeatable.
A local Emerald run now passes its initial all-device reset, master sound enable, and both PSG pulse triggers.
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.
A local Emerald run now passes its initial all-device reset, master sound enable, and pulse/noise triggers.
It stops at GPIO serial interrupt control. 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). Serial control-register semantics are the next demonstrated startup requirement.
The separate save requirement remains cartridge SRAM, followed by safe host save persistence.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.