feat(audio): implement PSG pulse channel 1
This commit is contained in:
parent
5888c4d292
commit
9a646651da
18 changed files with 850 additions and 55 deletions
|
|
@ -1,4 +1,9 @@
|
|||
//! Nominal Direct Sound device state. No host audio or PSG synthesis.
|
||||
//! Nominal Direct Sound and PSG pulse channel 1. No host audio output.
|
||||
|
||||
mod pulse;
|
||||
use pulse::Pulse;
|
||||
|
||||
const SEQUENCER_PERIOD: u32 = 32768; // 16,777,216 Hz / 512 Hz.
|
||||
|
||||
pub const SOUND_START: u32 = 0x0400_0060;
|
||||
pub const SOUNDCNT_H: u32 = 0x0400_0082;
|
||||
|
|
@ -73,6 +78,9 @@ impl Fifo {
|
|||
pub(crate) struct Audio {
|
||||
enabled: bool,
|
||||
psg: [u16; 17],
|
||||
pulse: Pulse,
|
||||
sequencer_phase: u16,
|
||||
next_step: u8,
|
||||
control: u16,
|
||||
bias: u16,
|
||||
wave: [[u8; 16]; 2],
|
||||
|
|
@ -86,11 +94,13 @@ impl Audio {
|
|||
}
|
||||
|
||||
pub(crate) fn unsupported(&self, address: u32, value: u8) -> Option<&'static str> {
|
||||
if (self.enabled && address == SOUNDCNT_H && value & 3 == 3)
|
||||
|| (address == SOUNDCNT_X && value & 0x80 != 0 && self.control & 3 == 3)
|
||||
{
|
||||
return Some("reserved PSG volume selection");
|
||||
}
|
||||
if self.enabled
|
||||
&& matches!(
|
||||
address,
|
||||
0x0400_0065 | 0x0400_006d | 0x0400_0075 | 0x0400_007d
|
||||
)
|
||||
&& matches!(address, 0x0400_006d | 0x0400_0075 | 0x0400_007d)
|
||||
&& value & 0x80 != 0
|
||||
{
|
||||
Some("PSG channel trigger (synthesis not implemented)")
|
||||
|
|
@ -104,6 +114,9 @@ impl Audio {
|
|||
}
|
||||
|
||||
pub(crate) fn read8(&self, address: u32) -> Option<u8> {
|
||||
if (SOUND_START..=SOUND_START + 5).contains(&address) {
|
||||
return Some(self.pulse.read(address - SOUND_START));
|
||||
}
|
||||
if (WAVE_RAM..FIFO_A).contains(&address) {
|
||||
return Some(self.wave[self.wave_bank()][(address - WAVE_RAM) as usize]);
|
||||
}
|
||||
|
|
@ -123,7 +136,7 @@ impl Audio {
|
|||
self.psg[((address - SOUND_START) / 2) as usize] & mask
|
||||
}
|
||||
SOUNDCNT_H => self.control,
|
||||
SOUNDCNT_X => u16::from(self.enabled) << 7,
|
||||
SOUNDCNT_X => (u16::from(self.enabled) << 7) | u16::from(self.pulse.active),
|
||||
SOUNDBIAS => self.bias,
|
||||
0x0400_0086 | 0x0400_008a => 0,
|
||||
_ => return None, // FIFO is write-only; open-bus readback is not modeled.
|
||||
|
|
@ -143,6 +156,11 @@ impl Audio {
|
|||
self.wave[self.wave_bank()][(address - WAVE_RAM) as usize] = value;
|
||||
continue;
|
||||
}
|
||||
if self.enabled && (SOUND_START..=SOUND_START + 5).contains(&address) {
|
||||
self.pulse
|
||||
.write(address - SOUND_START, value, self.next_step);
|
||||
continue;
|
||||
}
|
||||
let shift = (address & 1) * 8;
|
||||
let merge = |old: u16| (old & !(255 << shift)) | (u16::from(value) << shift);
|
||||
match address & !1 {
|
||||
|
|
@ -163,10 +181,14 @@ impl Audio {
|
|||
SOUNDCNT_X if address & 1 == 0 => {
|
||||
if self.enabled && value & 0x80 == 0 {
|
||||
self.psg = [0; 17];
|
||||
self.pulse = Pulse::default();
|
||||
for fifo in &mut self.fifo {
|
||||
fifo.reset_queue();
|
||||
}
|
||||
}
|
||||
if !self.enabled && value & 0x80 != 0 {
|
||||
self.next_step = 0;
|
||||
}
|
||||
self.enabled = value & 0x80 != 0;
|
||||
}
|
||||
SOUNDBIAS => self.bias = merge(self.bias) & 0xc3fe,
|
||||
|
|
@ -175,6 +197,29 @@ impl Audio {
|
|||
}
|
||||
}
|
||||
|
||||
/// Free-running 512 Hz divider; master enable resets the sequencer step,
|
||||
/// not the divider phase. STOP is gated by the memory bus before this call.
|
||||
pub(crate) fn advance(&mut self, mut cycles: u32) {
|
||||
if !self.enabled {
|
||||
self.sequencer_phase = ((u64::from(self.sequencer_phase) + u64::from(cycles))
|
||||
% u64::from(SEQUENCER_PERIOD)) as u16;
|
||||
return;
|
||||
}
|
||||
while cycles != 0 {
|
||||
let count = cycles.min(SEQUENCER_PERIOD - u32::from(self.sequencer_phase));
|
||||
self.pulse.advance(count);
|
||||
let phase = u32::from(self.sequencer_phase) + count;
|
||||
if phase == SEQUENCER_PERIOD {
|
||||
self.sequencer_phase = 0;
|
||||
self.pulse.clock(self.next_step);
|
||||
self.next_step = (self.next_step + 1) & 7;
|
||||
} else {
|
||||
self.sequencer_phase = phase as u16;
|
||||
}
|
||||
cycles -= count;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn timer_overflows(&mut self, timer: usize, count: u64) {
|
||||
if !self.enabled || timer > 1 {
|
||||
return;
|
||||
|
|
@ -208,7 +253,13 @@ impl Audio {
|
|||
}
|
||||
}
|
||||
}
|
||||
for level in &mut sides {
|
||||
let psg_control = self.psg[16];
|
||||
let ratio = [1, 2, 4, 0][usize::from(self.control & 3)];
|
||||
for (side, level) in sides.iter_mut().enumerate() {
|
||||
if psg_control & (1 << (8 + side * 4)) != 0 {
|
||||
let volume = ((psg_control >> (side * 4)) & 7) as i16 + 1;
|
||||
*level += (self.pulse.sample() * volume * ratio) >> 2;
|
||||
}
|
||||
*level = (*level + (self.bias & 0x3ff) as i16).clamp(0, 1023) - 512;
|
||||
}
|
||||
StereoLevel {
|
||||
|
|
|
|||
207
crates/gba-core/src/audio/pulse.rs
Normal file
207
crates/gba-core/src/audio/pulse.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//! Nominal channel 1 oscillator and modulation units. No host-clock dependency.
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Pulse {
|
||||
sweep: u8,
|
||||
duty: u8,
|
||||
envelope: u8,
|
||||
frequency: u16,
|
||||
length_enabled: bool,
|
||||
length: u8,
|
||||
pub(super) active: bool,
|
||||
phase: u8,
|
||||
remaining: u32,
|
||||
volume: u8,
|
||||
envelope_ticks: u8,
|
||||
envelope_running: bool,
|
||||
sweep_shadow: u16,
|
||||
sweep_ticks: u8,
|
||||
sweep_running: bool,
|
||||
subtracted: bool,
|
||||
}
|
||||
|
||||
impl Pulse {
|
||||
pub(super) fn read(&self, offset: u32) -> u8 {
|
||||
match offset {
|
||||
0 => self.sweep,
|
||||
2 => self.duty << 6,
|
||||
3 => self.envelope,
|
||||
5 => u8::from(self.length_enabled) << 6,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn period(&self) -> u32 {
|
||||
16 * (2048 - u32::from(self.frequency))
|
||||
}
|
||||
|
||||
fn dac_enabled(&self) -> bool {
|
||||
self.envelope & 0xf8 != 0
|
||||
}
|
||||
|
||||
fn envelope_can_run(&self) -> bool {
|
||||
self.envelope & 7 != 0
|
||||
&& if self.envelope & 8 != 0 {
|
||||
self.volume < 15
|
||||
} else {
|
||||
self.volume > 0
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep_period(&self) -> u8 {
|
||||
let period = (self.sweep >> 4) & 7;
|
||||
if period == 0 {
|
||||
8
|
||||
} else {
|
||||
period
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_sweep(&mut self) -> u16 {
|
||||
let delta = self.sweep_shadow >> (self.sweep & 7);
|
||||
if self.sweep & 8 != 0 {
|
||||
self.subtracted = true;
|
||||
self.sweep_shadow - delta
|
||||
} else {
|
||||
self.sweep_shadow + delta
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write(&mut self, offset: u32, value: u8, next_step: u8) {
|
||||
match offset {
|
||||
0 => {
|
||||
if self.subtracted && self.sweep & 8 != 0 && value & 8 == 0 {
|
||||
self.active = false;
|
||||
}
|
||||
self.sweep = value & 0x7f;
|
||||
}
|
||||
2 => {
|
||||
self.duty = value >> 6;
|
||||
self.length = 64 - (value & 63);
|
||||
}
|
||||
3 => {
|
||||
let was_running = self.envelope_running;
|
||||
self.envelope = value;
|
||||
if !self.dac_enabled() {
|
||||
self.active = false;
|
||||
}
|
||||
self.envelope_running = self.envelope_can_run();
|
||||
if !was_running && self.envelope_running {
|
||||
self.envelope_ticks = value & 7;
|
||||
}
|
||||
}
|
||||
4 => self.frequency = (self.frequency & 0x700) | u16::from(value),
|
||||
5 => {
|
||||
self.frequency = (self.frequency & 255) | (u16::from(value & 7) << 8);
|
||||
let extra_length_clock = next_step & 1 != 0;
|
||||
if !self.length_enabled && value & 0x40 != 0 && extra_length_clock {
|
||||
self.clock_length();
|
||||
}
|
||||
self.length_enabled = value & 0x40 != 0;
|
||||
if value & 0x80 != 0 {
|
||||
self.active = self.dac_enabled();
|
||||
if self.length == 0 {
|
||||
self.length = 64;
|
||||
if self.length_enabled && extra_length_clock {
|
||||
self.length -= 1;
|
||||
}
|
||||
}
|
||||
// Retrigger reloads the oscillator timer but preserves duty position.
|
||||
// Sub-divider alignment and hardware startup latency are not modeled.
|
||||
self.remaining = self.period();
|
||||
self.volume = self.envelope >> 4;
|
||||
self.envelope_ticks = if self.envelope & 7 == 0 {
|
||||
8
|
||||
} else {
|
||||
self.envelope & 7
|
||||
};
|
||||
self.envelope_running = self.envelope_can_run();
|
||||
self.sweep_shadow = self.frequency;
|
||||
self.sweep_ticks = self.sweep_period();
|
||||
self.sweep_running = self.sweep & 0x77 != 0;
|
||||
self.subtracted = false;
|
||||
if self.sweep & 7 != 0 && self.calculate_sweep() > 2047 {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch oscillator edges until the next modulation event or bus access.
|
||||
pub(super) fn advance(&mut self, cycles: u32) {
|
||||
if !self.active || cycles == 0 {
|
||||
return;
|
||||
}
|
||||
if cycles < self.remaining {
|
||||
self.remaining -= cycles;
|
||||
return;
|
||||
}
|
||||
let rest = cycles - self.remaining;
|
||||
let period = self.period();
|
||||
self.phase = ((u32::from(self.phase) + 1 + rest / period) & 7) as u8;
|
||||
self.remaining = period - rest % period;
|
||||
}
|
||||
|
||||
fn clock_length(&mut self) {
|
||||
if self.length != 0 {
|
||||
self.length -= 1;
|
||||
if self.length == 0 {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clock(&mut self, step: u8) {
|
||||
if step & 1 == 0 && self.length_enabled {
|
||||
self.clock_length();
|
||||
}
|
||||
if step & 3 == 2 && self.sweep_running {
|
||||
self.sweep_ticks -= 1;
|
||||
if self.sweep_ticks == 0 {
|
||||
self.sweep_ticks = self.sweep_period();
|
||||
if self.sweep & 0x70 != 0 {
|
||||
let next = self.calculate_sweep();
|
||||
if next > 2047 {
|
||||
self.active = false;
|
||||
} else if self.sweep & 7 != 0 {
|
||||
self.frequency = next;
|
||||
self.sweep_shadow = next;
|
||||
if self.calculate_sweep() > 2047 {
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if step == 7 && self.active && self.envelope_running {
|
||||
self.envelope_ticks -= 1;
|
||||
if self.envelope_ticks == 0 {
|
||||
if self.envelope & 8 != 0 {
|
||||
self.volume += 1;
|
||||
} else {
|
||||
self.volume -= 1;
|
||||
}
|
||||
self.envelope_ticks = self.envelope & 7;
|
||||
self.envelope_running = self.envelope_can_run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed pulse amplitude before PSG master volume and stereo routing.
|
||||
pub(super) fn sample(&self) -> i16 {
|
||||
if !self.active {
|
||||
return 0;
|
||||
}
|
||||
let high = [0x80u8, 0x81, 0xe1, 0x7e][usize::from(self.duty)] & (1 << self.phase) != 0;
|
||||
if high {
|
||||
i16::from(self.volume)
|
||||
} else {
|
||||
-i16::from(self.volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
205
crates/gba-core/src/audio/pulse/tests.rs
Normal file
205
crates/gba-core/src/audio/pulse/tests.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! Original arithmetic and state-transition regressions, not imported hardware tests.
|
||||
use super::*;
|
||||
|
||||
fn start(frequency: u16, duty: u8, envelope: u8, sweep: u8) -> Pulse {
|
||||
let mut pulse = Pulse::default();
|
||||
pulse.write(0, sweep, 0);
|
||||
pulse.write(2, duty << 6, 0);
|
||||
pulse.write(3, envelope, 0);
|
||||
pulse.write(4, frequency as u8, 0);
|
||||
pulse.write(5, 0x80 | (frequency >> 8) as u8, 0);
|
||||
pulse
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_frequencies_and_duties_match_independent_edge_arithmetic() {
|
||||
let high_phases: [&[u8]; 4] = [&[7], &[0, 7], &[0, 5, 6, 7], &[1, 2, 3, 4, 5, 6]];
|
||||
for frequency in 0..2048 {
|
||||
let period = 16 * (2048 - u32::from(frequency));
|
||||
for duty in 0..4 {
|
||||
for cycles in [0, period - 1, period, period * 8, u32::MAX] {
|
||||
let mut pulse = start(frequency, duty, 0xf0, 0);
|
||||
pulse.advance(cycles);
|
||||
let phase = ((cycles / period) % 8) as u8;
|
||||
assert_eq!(pulse.phase, phase);
|
||||
assert_eq!(pulse.remaining, period - cycles % period);
|
||||
assert_eq!(
|
||||
pulse.sample(),
|
||||
if high_phases[duty as usize].contains(&phase) {
|
||||
15
|
||||
} else {
|
||||
-15
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_writes_preserve_current_interval_and_trigger_preserves_duty_phase() {
|
||||
let mut pulse = start(2046, 2, 0xf0, 0); // 32 clocks per edge.
|
||||
pulse.advance(31);
|
||||
pulse.write(4, 255, 0); // Next period is 16 clocks; current interval has one left.
|
||||
pulse.advance(1);
|
||||
assert_eq!(pulse.phase, 1);
|
||||
assert_eq!(pulse.remaining, 16);
|
||||
pulse.advance(7);
|
||||
pulse.write(5, 0x87, 0);
|
||||
assert_eq!(pulse.phase, 1);
|
||||
assert_eq!(pulse.remaining, 16);
|
||||
pulse.advance(15);
|
||||
assert_eq!(pulse.phase, 1);
|
||||
pulse.advance(1);
|
||||
assert_eq!(pulse.phase, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_load_enable_extra_clock_expiry_and_zero_reload() {
|
||||
let mut pulse = start(1000, 2, 0xf0, 0);
|
||||
pulse.write(2, 63, 0); // One remaining length tick.
|
||||
pulse.clock(0); // Disabled length does not expire.
|
||||
assert!(pulse.active);
|
||||
pulse.write(5, 0x43, 1); // Enable before a step that does not clock length.
|
||||
assert_eq!(pulse.length, 0);
|
||||
assert!(!pulse.active);
|
||||
pulse.write(5, 0xc3, 1); // Trigger with empty length reloads 63 in this phase.
|
||||
assert_eq!(pulse.length, 63);
|
||||
assert!(pulse.active);
|
||||
for _ in 0..62 {
|
||||
pulse.clock(0);
|
||||
}
|
||||
assert!(pulse.active);
|
||||
pulse.clock(0);
|
||||
assert!(!pulse.active);
|
||||
pulse.clock(0);
|
||||
assert_eq!(pulse.length, 0);
|
||||
pulse.write(5, 0xc3, 0);
|
||||
assert_eq!(pulse.length, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_length_enable_and_trigger_reload_after_extra_expiry() {
|
||||
let mut pulse = start(1000, 0, 0xf0, 0);
|
||||
pulse.write(2, 63, 0);
|
||||
pulse.write(5, 0xc3, 1);
|
||||
assert_eq!(pulse.length, 63);
|
||||
assert!(pulse.active);
|
||||
pulse.write(2, 62, 0);
|
||||
pulse.write(5, 0xc3, 1); // Already enabled: do not add another extra decrement.
|
||||
assert_eq!(pulse.length, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_step_at_selected_period_saturate_and_do_not_clear_status() {
|
||||
for envelope in [0x39, 0xc1] {
|
||||
let mut pulse = start(0, 1, envelope, 0);
|
||||
for tick in 1..=20 {
|
||||
pulse.clock(7);
|
||||
let expected = if envelope & 8 != 0 {
|
||||
(3 + tick).min(15)
|
||||
} else {
|
||||
12u8.saturating_sub(tick)
|
||||
};
|
||||
assert_eq!(pulse.volume, expected);
|
||||
assert!(pulse.active);
|
||||
}
|
||||
}
|
||||
let mut pulse = start(0, 1, 0x53, 0);
|
||||
pulse.clock(7);
|
||||
pulse.clock(7);
|
||||
assert_eq!(pulse.volume, 5);
|
||||
pulse.clock(7);
|
||||
assert_eq!(pulse.volume, 4);
|
||||
pulse.write(3, 0xa0, 0); // Period zero holds current volume until retrigger.
|
||||
for _ in 0..16 {
|
||||
pulse.clock(7);
|
||||
}
|
||||
assert_eq!(pulse.volume, 4);
|
||||
pulse.write(5, 0x80, 0);
|
||||
assert_eq!(pulse.volume, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_envelope_configuration_retains_volume_and_dac_off_requires_retrigger() {
|
||||
let mut pulse = start(0, 1, 0x52, 0);
|
||||
pulse.clock(7);
|
||||
pulse.write(3, 0xa9, 0); // Change direction/period, not current volume or remaining tick.
|
||||
assert_eq!(pulse.volume, 5);
|
||||
pulse.clock(7);
|
||||
assert_eq!(pulse.volume, 6);
|
||||
pulse.write(3, 0, 0);
|
||||
assert!(!pulse.active);
|
||||
assert_eq!(pulse.sample(), 0);
|
||||
pulse.write(3, 0x08, 0); // Rising envelope with initial zero has DAC enabled.
|
||||
assert!(!pulse.active);
|
||||
pulse.write(5, 0x80, 0);
|
||||
assert!(pulse.active);
|
||||
assert_eq!(pulse.volume, 0);
|
||||
pulse.write(3, 7, 0); // Period alone cannot enable DAC.
|
||||
pulse.write(5, 0x80, 0);
|
||||
assert!(!pulse.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_checks_trigger_overflow_and_second_calculation_after_update() {
|
||||
assert!(!start(1500, 1, 0xf0, 0x11).active);
|
||||
assert!(!start(1500, 1, 0xf0, 1).active); // Time zero still checks at trigger.
|
||||
let mut pulse = start(1000, 1, 0xf0, 0x11);
|
||||
assert!(pulse.active);
|
||||
pulse.clock(2);
|
||||
assert_eq!(pulse.frequency, 1500);
|
||||
assert!(!pulse.active); // Next calculated 2250 disables before another sweep tick.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_uses_shadow_frequency_and_preserves_live_write_until_next_update() {
|
||||
let mut pulse = start(400, 1, 0xf0, 0x21);
|
||||
pulse.write(4, 200, 0);
|
||||
assert_eq!(pulse.frequency, 456);
|
||||
pulse.clock(2);
|
||||
assert_eq!(pulse.frequency, 456);
|
||||
pulse.clock(6);
|
||||
assert_eq!(pulse.frequency, 600); // 400 + 400/2, not based on 456.
|
||||
assert_eq!(pulse.read(4), 0); // Frequency is write-only.
|
||||
assert_eq!(pulse.read(5), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_zero_period_shift_zero_and_subtraction_direction_rules() {
|
||||
let mut pulse = start(500, 1, 0xf0, 1);
|
||||
for _ in 0..32 {
|
||||
pulse.clock(2);
|
||||
}
|
||||
assert_eq!(pulse.frequency, 500); // Time zero suppresses periodic changes.
|
||||
let mut pulse = start(1200, 1, 0xf0, 0x10);
|
||||
assert!(pulse.active); // Shift zero skips trigger-time overflow check.
|
||||
pulse.clock(2);
|
||||
assert!(!pulse.active); // Timed overflow check still applies.
|
||||
let mut pulse = start(1000, 1, 0xf0, 0x19);
|
||||
pulse.clock(2);
|
||||
assert_eq!(pulse.frequency, 500);
|
||||
pulse.write(0, 0x11, 0);
|
||||
assert!(!pulse.active); // Clearing negate after subtraction disables the channel.
|
||||
let mut pulse = start(1000, 1, 0xf0, 0x18);
|
||||
pulse.write(0, 0x10, 0); // No subtraction calculation has happened.
|
||||
assert!(pulse.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_clock_advances_match_one_batch() {
|
||||
let mut random = 0x5a17u32;
|
||||
let mut bulk = start(1783, 3, 0xa2, 0x29);
|
||||
let mut split = bulk;
|
||||
for step in 0..1000u32 {
|
||||
random = random.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let cycles = random & 0xffff;
|
||||
bulk.advance(cycles);
|
||||
for part in [cycles / 3, cycles / 3, cycles - (cycles / 3) * 2] {
|
||||
split.advance(part);
|
||||
}
|
||||
bulk.clock((step & 7) as u8);
|
||||
split.clock((step & 7) as u8);
|
||||
assert_eq!(bulk, split);
|
||||
}
|
||||
}
|
||||
|
|
@ -438,6 +438,7 @@ impl Io {
|
|||
audio: &mut Audio,
|
||||
cycles: u32,
|
||||
) -> u16 {
|
||||
audio.advance(cycles);
|
||||
let mut pending = 0;
|
||||
let mut overflows = 0;
|
||||
for (index, timer) in timers.iter_mut().enumerate() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Staged timer and Direct Sound clocks for one instruction, IRQ entry, or DMA unit.
|
||||
//! Staged timer and audio clocks for one instruction, IRQ entry, or DMA unit.
|
||||
//! Other devices remain on the instruction-boundary scheduler.
|
||||
use super::{Io, Timer, IF, TIMER_BASE};
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ impl Memory {
|
|||
Ok(memory)
|
||||
}
|
||||
|
||||
/// Current committed Direct Sound mixer level. This is not a host sample stream.
|
||||
/// Current committed audio mixer level. This is not a host sample stream.
|
||||
pub fn audio_level(&self) -> StereoLevel {
|
||||
self.io.audio.level()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ fn sound(memory: &mut Memory, period: u16) {
|
|||
memory.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
memory.write16(SOUNDBIAS, 0x200).unwrap();
|
||||
memory.write16(SOUNDCNT_H, 0x0304).unwrap();
|
||||
memory.write16(0x04000062, 0xf080).unwrap();
|
||||
memory.write16(0x04000064, 0x87ff).unwrap(); // Running pulse, unrouted; rollback must include it.
|
||||
memory
|
||||
.write32(
|
||||
TIMER_BASE,
|
||||
|
|
@ -17,6 +19,34 @@ fn sound(memory: &mut Memory, period: u16) {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_status_load_observes_length_expiry_at_bus_completion() {
|
||||
let (cpu, mut memory) = prepared(false, &[0xe5910000], &[(1, SOUNDCNT_X)]);
|
||||
sound(&mut memory, 1000);
|
||||
memory.write16(0x04000062, 0xf03f).unwrap();
|
||||
memory.write16(0x04000064, 0xc7ff).unwrap();
|
||||
memory.advance_cycles(32768 - 7);
|
||||
let mut machine = Machine::new(cpu, memory);
|
||||
machine.step().unwrap();
|
||||
assert_eq!(machine.cpu().registers()[0], 0x80);
|
||||
assert_eq!(machine.memory().read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_trigger_reloads_timer_at_store_completion() {
|
||||
let (cpu, mut memory) = prepared(false, &[0xe5810000], &[(0, 0x87ff), (1, 0x04000064)]);
|
||||
sound(&mut memory, 1000);
|
||||
memory.write16(0x04000080, 0x1177).unwrap();
|
||||
memory.write16(SOUNDCNT_H, 2).unwrap();
|
||||
let mut machine = Machine::new(cpu, memory);
|
||||
machine.step().unwrap();
|
||||
assert_eq!(machine.cycles(), 7);
|
||||
machine.memory_mut().advance_cycles(15);
|
||||
assert_eq!(machine.memory().audio_level().left, 120);
|
||||
machine.memory_mut().advance_cycles(1);
|
||||
assert_eq!(machine.memory().audio_level().left, -120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_store_becomes_visible_after_its_bus_completion_not_at_instruction_start() {
|
||||
let (cpu, mut memory) = prepared(
|
||||
|
|
@ -65,7 +95,7 @@ fn failed_cpu_load_rolls_back_timer_audio_and_pending_refill_requests() {
|
|||
|
||||
#[test]
|
||||
fn failed_dma_source_and_destination_restore_audio_after_speculative_timer_edges() {
|
||||
for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x04000064, 0x8000)] {
|
||||
for (source, destination, word) in [(FIFO_A, 0x02001000, 0), (0x02000000, 0x0400006c, 0x8000)] {
|
||||
let (cpu, mut memory) = prepared(false, &[0xe1a00000], &[]);
|
||||
sound(&mut memory, 1);
|
||||
memory.write32(FIFO_A, 0x04030201).unwrap();
|
||||
|
|
@ -91,7 +121,7 @@ fn batch_validation_simulates_sound_enable_and_disable_before_committing_any_wri
|
|||
.write_words(&[
|
||||
(0x02000000, 0x11223344),
|
||||
(SOUNDCNT_X, 0x80),
|
||||
(0x04000064, 0x8000),
|
||||
(0x0400006c, 0x8000),
|
||||
])
|
||||
.is_err());
|
||||
assert_eq!(memory.read32(0x02000000).unwrap(), 0);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,25 @@ const REGIONS: [(u32, u32, u32); 5] = [
|
|||
];
|
||||
const MARKER: u32 = 0xa55a_c33c;
|
||||
|
||||
#[test]
|
||||
fn sound_reset_disables_running_pulse_only_when_selected() {
|
||||
use gba_core::io::SOUNDCNT_X;
|
||||
for thumb in [false, true] {
|
||||
for flags in [0, 0x40] {
|
||||
let (mut machine, return_pc) = prepare(flags, thumb, 0x1f);
|
||||
let bus = machine.memory_mut();
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
bus.write16(0x04000062, 0xf080).unwrap();
|
||||
bus.write16(0x04000064, 0x87ff).unwrap();
|
||||
complete(&mut machine, return_pc);
|
||||
assert_eq!(
|
||||
machine.memory().read16(SOUNDCNT_X).unwrap(),
|
||||
if flags == 0 { 0x81 } else { 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_reset_clears_disabled_fifo_queues_and_only_accessible_wave_bank() {
|
||||
use gba_core::io::{FIFO_A, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE, WAVE_RAM};
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ fn full_queue_overflow_clears_queued_words() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn wave_banks_and_idle_psg_masks_work_without_claiming_psg_playback() {
|
||||
fn wave_banks_and_idle_channel_masks_preserve_explicit_limits() {
|
||||
let mut bus = bus();
|
||||
bus.write16(0x04000062, 0xffff).unwrap();
|
||||
assert_eq!(bus.read16(0x04000062).unwrap(), 0xffc0);
|
||||
|
|
@ -167,7 +167,7 @@ fn wave_banks_and_idle_psg_masks_work_without_claiming_psg_playback() {
|
|||
bus.write16(0x04000070, 0x40).unwrap();
|
||||
assert_eq!(bus.read32(WAVE_RAM).unwrap(), 0);
|
||||
bus.write32(WAVE_RAM, 0xaabbccdd).unwrap();
|
||||
for address in [0x04000064, 0x0400006c, 0x04000074, 0x0400007c] {
|
||||
for address in [0x0400006c, 0x04000074, 0x0400007c] {
|
||||
let previous = bus.read16(address).unwrap();
|
||||
assert!(bus
|
||||
.write16(address, 0xffff)
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn unsupported_operations_have_specific_retryable_diagnostics() {
|
|||
#[test]
|
||||
fn block_store_rejects_later_activation_before_earlier_control_writes() {
|
||||
let code: [u32; 7] = [
|
||||
0xe3a00301, 0xe2800060, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678,
|
||||
0xe3a00301, 0xe2800068, 0xe59f1008, 0xe3a02902, 0xe8a00006, 0xeafffffe, 0x12345678,
|
||||
];
|
||||
let mut bus = Memory::new(code.into_iter().flat_map(u32::to_le_bytes).collect()).unwrap();
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
|
|
@ -118,7 +118,7 @@ fn block_store_rejects_later_activation_before_earlier_control_writes() {
|
|||
assert!(error.to_string().contains("PSG channel trigger"));
|
||||
assert_eq!(machine.cpu(), &before);
|
||||
assert_eq!(machine.cycles(), cycles);
|
||||
assert_eq!(machine.memory().read16(0x04000060).unwrap(), 0);
|
||||
assert_eq!(machine.memory().read16(0x04000068).unwrap(), 0);
|
||||
assert_eq!(machine.step(), Err(error));
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ fn dma_activation_error_preserves_channel_progress_and_clocks() {
|
|||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
bus.write32(0x02000000, 0x8000).unwrap();
|
||||
bus.write32(DMA_BASE, 0x02000000).unwrap();
|
||||
bus.write32(DMA_BASE + 4, 0x04000064).unwrap();
|
||||
bus.write32(DMA_BASE + 4, 0x0400006c).unwrap();
|
||||
bus.write32(DMA_BASE + 8, 0x84000001).unwrap();
|
||||
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
|
||||
let before = machine.cpu().clone();
|
||||
|
|
|
|||
213
crates/gba-core/tests/pulse_sound.rs
Normal file
213
crates/gba-core/tests/pulse_sound.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! Original public-bus tests for PSG channel 1; no game code or audio assets.
|
||||
use gba_core::{
|
||||
audio::StereoLevel,
|
||||
cpu::Cpu,
|
||||
io::{FIFO_A, HALTCNT, IF, SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, TIMER_BASE},
|
||||
machine::Machine,
|
||||
memory::{Memory, ROM_START},
|
||||
};
|
||||
|
||||
const SWEEP: u32 = 0x04000060;
|
||||
const DUTY: u32 = 0x04000062;
|
||||
const FREQUENCY: u32 = 0x04000064;
|
||||
const MIX: u32 = 0x04000080;
|
||||
const SEQUENCE: u32 = 32768;
|
||||
|
||||
fn bus() -> Memory {
|
||||
let mut bus = Memory::new(0xeafffffeu32.to_le_bytes().to_vec()).unwrap();
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
bus.write16(SOUNDBIAS, 0x200).unwrap();
|
||||
bus.write16(MIX, 0x1177).unwrap(); // Channel 1, both sides, maximum volume.
|
||||
bus.write16(SOUNDCNT_H, 2).unwrap(); // PSG 100%.
|
||||
bus
|
||||
}
|
||||
|
||||
fn start(bus: &mut Memory) {
|
||||
bus.write16(DUTY, 0xf080).unwrap(); // 50% duty, volume15, no envelope, length64.
|
||||
bus.write16(FREQUENCY, 0x87ff).unwrap(); // Frequency2047, trigger; length disabled.
|
||||
}
|
||||
|
||||
fn stereo(value: i16) -> StereoLevel {
|
||||
StereoLevel {
|
||||
left: value,
|
||||
right: value,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_widths_masks_status_and_wave_edges_without_general_timers() {
|
||||
let mut bus = bus();
|
||||
bus.write32(SWEEP, 0xf0bf0080).unwrap(); // Sweep bit7 ignored; duty50%, length1.
|
||||
assert_eq!(bus.read32(SWEEP).unwrap(), 0xf0800000);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
bus.write8(FREQUENCY, 255).unwrap();
|
||||
bus.write8(FREQUENCY + 1, 0x87).unwrap();
|
||||
assert_eq!(bus.read32(FREQUENCY).unwrap(), 0);
|
||||
assert_eq!(bus.read32(SOUNDCNT_X).unwrap(), 0x81);
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap(); // Status cannot be cleared by a write.
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
assert_eq!(bus.audio_level(), stereo(120));
|
||||
bus.advance_cycles(15);
|
||||
assert_eq!(bus.audio_level(), stereo(120));
|
||||
bus.advance_cycles(1);
|
||||
assert_eq!(bus.audio_level(), stereo(-120));
|
||||
bus.advance_cycles(64);
|
||||
assert_eq!(bus.audio_level(), stereo(120));
|
||||
assert_eq!(bus.read16(IF).unwrap(), 0); // PSG clocks do not generate IRQs.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psg_stereo_volume_ratios_and_direct_sound_share_bias_and_clipping() {
|
||||
let mut bus = bus();
|
||||
start(&mut bus);
|
||||
for (ratio, expected) in [(0, 30), (1, 60), (2, 120)] {
|
||||
bus.write16(SOUNDCNT_H, ratio).unwrap();
|
||||
assert_eq!(bus.audio_level(), stereo(expected));
|
||||
}
|
||||
bus.write16(MIX, 0x1070).unwrap();
|
||||
assert_eq!(
|
||||
bus.audio_level(),
|
||||
StereoLevel {
|
||||
left: 120,
|
||||
right: 0
|
||||
}
|
||||
);
|
||||
bus.write16(MIX, 0x0100).unwrap(); // Volume field zero means one, not mute.
|
||||
assert_eq!(bus.audio_level(), StereoLevel { left: 0, right: 15 });
|
||||
bus.write16(MIX, 0x1177).unwrap();
|
||||
bus.write16(SOUNDCNT_H, 0x0306).unwrap();
|
||||
bus.write32(FIFO_A, 0x7f7f7f7f).unwrap();
|
||||
bus.write32(TIMER_BASE, 0x0080ffff).unwrap();
|
||||
bus.advance_cycles(1);
|
||||
assert_eq!(bus.audio_level(), stereo(511)); // 508 + 120 clips after mixing.
|
||||
bus.write8(DUTY + 1, 0).unwrap();
|
||||
assert_eq!(bus.audio_level(), stereo(508)); // DAC off removes only pulse contribution.
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_clocks_at_256_hz_and_status_expires_independently_of_routing() {
|
||||
let mut bus = bus();
|
||||
bus.write16(MIX, 0).unwrap();
|
||||
bus.write16(DUTY, 0xf0be).unwrap(); // Two length ticks.
|
||||
bus.write16(FREQUENCY, 0xc7ff).unwrap();
|
||||
bus.advance_cycles(SEQUENCE - 1);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
bus.advance_cycles(1); // Step0, first length tick.
|
||||
bus.advance_cycles(SEQUENCE * 2 - 1);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
bus.advance_cycles(1); // Step2, second length tick.
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
assert_eq!(bus.read16(FREQUENCY).unwrap(), 0x4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_64_hz_and_sweep_128_hz_use_distinct_sequencer_steps() {
|
||||
let mut bus = bus();
|
||||
bus.write16(DUTY, 0x3980).unwrap(); // Volume3, increase every envelope tick.
|
||||
bus.write16(FREQUENCY, 0x87ff).unwrap();
|
||||
bus.advance_cycles(SEQUENCE * 8 - 1);
|
||||
assert_eq!(bus.audio_level().left.abs(), 24);
|
||||
bus.advance_cycles(1); // Step7.
|
||||
assert_eq!(bus.audio_level().left.abs(), 32);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
|
||||
let mut bus = self::bus();
|
||||
bus.write16(SWEEP, 0x11).unwrap();
|
||||
bus.write16(DUTY, 0xf080).unwrap();
|
||||
bus.write16(FREQUENCY, 0x83e8).unwrap(); // 1000 ->1500; second check overflows2250.
|
||||
bus.advance_cycles(SEQUENCE * 3 - 1);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
bus.advance_cycles(1); // Step2, first sweep clock.
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
assert_eq!(bus.audio_level(), stereo(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequencer_divider_runs_while_master_off_and_enable_resets_only_step() {
|
||||
let mut bus = Memory::new(vec![]).unwrap();
|
||||
bus.advance_cycles(SEQUENCE - 1);
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
bus.write16(DUTY, 0xf03f).unwrap();
|
||||
bus.write16(FREQUENCY, 0xc000).unwrap();
|
||||
bus.advance_cycles(1); // Not one whole new interval after enable.
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
bus.write16(SOUNDCNT_X, 0).unwrap();
|
||||
assert_eq!(bus.read16(DUTY).unwrap(), 0);
|
||||
bus.write16(DUTY, 0xffff).unwrap();
|
||||
bus.write16(FREQUENCY, 0xffff).unwrap();
|
||||
bus.write16(SOUNDCNT_X, 0x80).unwrap();
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x80);
|
||||
assert_eq!(bus.read16(DUTY).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_psg_ratio_errors_are_atomic_including_enable_after_disabled_configuration() {
|
||||
let mut bus = bus();
|
||||
start(&mut bus);
|
||||
let before = bus.audio_level();
|
||||
let error = bus.write32(MIX, 0x00030000).unwrap_err();
|
||||
assert!(error.to_string().contains("reserved PSG volume selection"));
|
||||
assert_eq!(bus.read16(MIX).unwrap(), 0x1177);
|
||||
assert_eq!(bus.read16(SOUNDCNT_H).unwrap(), 2);
|
||||
assert_eq!(bus.audio_level(), before);
|
||||
bus.write16(SOUNDCNT_X, 0).unwrap();
|
||||
bus.write16(SOUNDCNT_H, 3).unwrap(); // Disabled sound still retains these bits.
|
||||
assert!(bus.write16(SOUNDCNT_X, 0x80).is_err());
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halt_keeps_pulse_running_and_stop_freezes_it() {
|
||||
for stop in [false, true] {
|
||||
let mut bus = bus();
|
||||
start(&mut bus);
|
||||
bus.write16(FREQUENCY, 0x87fd).unwrap(); // 48 clocks per duty step.
|
||||
bus.write8(HALTCNT, if stop { 0x80 } else { 0 }).unwrap();
|
||||
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
|
||||
let cpu = machine.cpu().clone();
|
||||
machine.step().unwrap();
|
||||
assert_eq!(machine.cpu(), &cpu);
|
||||
assert_eq!(
|
||||
machine.memory().audio_level(),
|
||||
stereo(if stop { 120 } else { -120 })
|
||||
);
|
||||
if stop {
|
||||
machine.memory_mut().advance_cycles(u32::MAX);
|
||||
assert_eq!(machine.cycles(), 0);
|
||||
assert_eq!(machine.memory().audio_level(), stereo(120));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_batching_and_row_capture_do_not_change_psg_result() {
|
||||
let mut bulk = bus();
|
||||
let mut rows = bus();
|
||||
let mut split = bus();
|
||||
rows.set_scanline_rendering(true);
|
||||
for bus in [&mut bulk, &mut rows, &mut split] {
|
||||
bus.write16(SWEEP, 0x29).unwrap();
|
||||
bus.write16(DUTY, 0xa340).unwrap();
|
||||
bus.write16(FREQUENCY, 0x85ad).unwrap();
|
||||
}
|
||||
for cycles in [1, 15, 32751, 262145, 77777] {
|
||||
bulk.advance_cycles(cycles);
|
||||
rows.advance_cycles(cycles);
|
||||
for _ in 0..cycles {
|
||||
split.advance_cycles(1);
|
||||
}
|
||||
assert_eq!(bulk.audio_level(), split.audio_level());
|
||||
assert_eq!(bulk.audio_level(), rows.audio_level());
|
||||
assert_eq!(
|
||||
bulk.read16(SOUNDCNT_X).unwrap(),
|
||||
split.read16(SOUNDCNT_X).unwrap()
|
||||
);
|
||||
}
|
||||
// Maximum batch must remain bounded and preserve an indefinite channel.
|
||||
let mut bus = bus();
|
||||
start(&mut bus);
|
||||
bus.advance_cycles(u32::MAX);
|
||||
assert_eq!(bus.read16(SOUNDCNT_X).unwrap(), 0x81);
|
||||
assert_eq!(bus.audio_level(), stereo(120)); // floor(MAX/16) modulo8 = 7.
|
||||
}
|
||||
|
|
@ -53,7 +53,10 @@ The repository is a Cargo workspace with three crates:
|
|||
| `crates/gba-core/src/cpu/fetch_history_tests.rs` | Shared sample consumption, no execution-entry rereads, and failed-step retry |
|
||||
| `crates/gba-core/src/cpu/fetch_boundary_tests.rs` | Actual fetch-region snapshots, split refills, IWRAM lane requirements, ROM-window boundaries, and nominal timing |
|
||||
| `crates/gba-core/src/io.rs` | I/O registers, timers, HALT/STOP wake-up, next-event bounds, and IRQ latches |
|
||||
| `crates/gba-core/src/audio.rs` | Direct Sound FIFO/playback state, idle PSG/wave banks, and instantaneous stereo mixing |
|
||||
| `crates/gba-core/src/audio.rs` | Direct Sound state, PSG sequencer, idle channel/wave banks, and instantaneous stereo mixing |
|
||||
| `crates/gba-core/src/audio/pulse.rs` | Pulse channel 1 oscillator, length, envelope, sweep, and status |
|
||||
| `crates/gba-core/src/audio/pulse/tests.rs` | Exhaustive frequency/duty arithmetic and original modulation edge tests |
|
||||
| `crates/gba-core/tests/pulse_sound.rs` | Register masks, stereo mixing, sequencer clocks, idle modes, batching, and explicit limits |
|
||||
| `crates/gba-core/tests/direct_sound.rs` | FIFO lanes/reset, signed mixing, timer selection, DMA refill, and HALT/STOP |
|
||||
| `crates/gba-core/src/memory/audio_step_tests.rs` | Audio access phases, shadow validation, and CPU/DMA failure rollback |
|
||||
| `crates/gba-core/src/io/inactive.rs` | Disconnected serial initialization with explicit unsupported-activity validation |
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
# Direct Sound subset
|
||||
# Audio device subset
|
||||
|
||||
The core models timer-driven Direct Sound A/B playback and a bounded sound-DMA configuration.
|
||||
It does not produce desktop audio. Programmable sound generator (PSG) synthesis remains unsupported.
|
||||
The core models Direct Sound A/B, bounded sound DMA, and programmable sound generator (PSG) pulse channel 1.
|
||||
It does not produce desktop audio. PSG channels 2–4 remain unsupported.
|
||||
`Memory::audio_level()` exposes the current digital stereo level for deterministic inspection, not a continuous sample stream.
|
||||
|
||||
## Registers and inactive PSG state
|
||||
## Registers and PSG state
|
||||
|
||||
| Address | Behavior |
|
||||
| --- | --- |
|
||||
| `0x04000060..0x04000081` | Idle PSG configuration and documented read masks; writes are ignored while master sound is disabled |
|
||||
| `0x04000060..0x04000065` | Pulse channel 1 sweep, duty, length, envelope, frequency, and trigger |
|
||||
| `0x04000066..0x04000081` | Idle channel 2–4 configuration and PSG mixer controls |
|
||||
| `0x04000082` (`SOUNDCNT_H`) | Mixing, routing, timer selection, and FIFO reset strobes; readback mask `0x770f` |
|
||||
| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only PSG activity flags remain zero |
|
||||
| `0x04000084` (`SOUNDCNT_X`) | Master enable in bit 7; read-only channel 1 activity in bit 0; channels 2–4 remain inactive |
|
||||
| `0x04000088` (`SOUNDBIAS`) | Bias/resolution configuration with mask `0xc3fe` |
|
||||
| `0x04000090..0x0400009f` | CPU wave RAM window, opposite the bank selected by `SOUND3CNT_L` bit 6 |
|
||||
| `0x040000a0..0x040000a7` | FIFO A/B writes with byte, halfword, and word access boundaries preserved |
|
||||
|
|
@ -24,10 +25,56 @@ The CPU then accesses wave bank 1. Idle bank selection can expose bank 0 after m
|
|||
The original BIOS reset clears bank 1 and both FIFO queues. It retains bank 0 and any separate in-flight playback word.
|
||||
This is a functional reset subset, not a claim of complete firmware ordering or sound-reset equivalence.
|
||||
|
||||
An enabled PSG channel trigger returns `MemoryError::UnsupportedIo` with the exact address and value.
|
||||
This applies even when the configured channel would be inaudible. Disabled PSG trigger writes are ignored.
|
||||
Length, envelope, sweep, frequency, and wave-bank playback clocks are not implemented.
|
||||
Do not interpret successful idle configuration writes as PSG synthesis.
|
||||
An enabled channel 2–4 trigger returns `MemoryError::UnsupportedIo` with the exact address and value.
|
||||
This applies even when the configured channel would be inaudible. All PSG writes are ignored while master sound is disabled.
|
||||
Reserved PSG volume selection 3 is rejected while master sound is enabled, including enable after disabled configuration.
|
||||
Idle channel 2–4 writes do not imply playback support.
|
||||
|
||||
## Pulse channel 1
|
||||
|
||||
Channel 1 has four duty patterns: 12.5%, 25%, 50%, and 75%.
|
||||
Its eight-position waveform advances every `16 * (2048 - frequency)` system clocks.
|
||||
No general-purpose timer must be enabled for PSG playback.
|
||||
Frequency writes change the next oscillator reload, not the remaining current interval.
|
||||
A trigger reloads the oscillator timer, envelope volume, envelope timer, and sweep shadow/timer state.
|
||||
Retrigger preserves the duty position. Master disable resets it.
|
||||
|
||||
The modulation sequencer receives a 512 Hz clock, once per 32768 system clocks:
|
||||
|
||||
| Sequencer step | Event |
|
||||
| --- | --- |
|
||||
| 0, 2, 4, 6 | Length counter clock: 256 Hz |
|
||||
| 2, 6 | Sweep clock: 128 Hz |
|
||||
| 7 | Envelope clock: 64 Hz |
|
||||
|
||||
The divider starts at synthetic phase zero and continues while master sound is disabled.
|
||||
Master enable sets the next sequencer step to zero without resetting that divider.
|
||||
Channel trigger does not reset the shared sequencer. HALT continues its clocks; STOP freezes them.
|
||||
Waveform advancement uses arithmetic batches between sequencer events, not a loop for every system clock.
|
||||
|
||||
Length writes load `64 - length_field`. Enabled length expiry clears the channel activity flag.
|
||||
Enabling length before a sequencer step that does not clock length applies an extra decrement.
|
||||
A trigger reloads an empty counter to 64, or 63 when that extra-clock condition applies.
|
||||
Nonempty length counters retain their values on trigger.
|
||||
|
||||
The envelope changes volume by one after each selected number of 64 Hz clocks.
|
||||
Period zero holds volume. Saturation at zero or fifteen stops envelope changes without clearing channel activity.
|
||||
Live envelope writes retain current volume and the remaining active countdown.
|
||||
Reactivating a stopped envelope loads its new countdown. Trigger loads its configured initial volume.
|
||||
Clearing envelope bits 3–7 disables the channel; setting them again requires a trigger to restart it.
|
||||
This is the logical gate often called DAC enable in Game Boy references, not a separate physical GBA DAC.
|
||||
|
||||
Sweep uses a shadow frequency independent of later frequency-register writes.
|
||||
A nonzero shift checks overflow immediately at trigger.
|
||||
Timed sweep updates check overflow both before and after committing the new frequency.
|
||||
A result above 2047 disables the channel. Period zero suppresses periodic frequency changes, not the trigger-time check.
|
||||
Shift zero performs timed overflow checks without committing a changed frequency.
|
||||
Clearing the subtraction direction after a subtraction calculation disables the channel until a new trigger.
|
||||
|
||||
These rules define a nominal digital model, not exact hardware edge timing.
|
||||
First-trigger output suppression, trigger sub-divider alignment, envelope trigger-edge delays, and revision-specific envelope write effects are not modeled.
|
||||
The model samples the selected duty position immediately after trigger and uses a full-period timer reload.
|
||||
The consulted implementations differ on some initial phases and envelope/sweep edge cases; no GBA hardware audio recording was used as an oracle.
|
||||
|
||||
## FIFO and timer behavior
|
||||
|
||||
|
|
@ -72,8 +119,13 @@ Sub-instruction DMA arbitration and exact refill latency are not modeled.
|
|||
|
||||
## Mixing and timing ownership
|
||||
|
||||
Each routed sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume.
|
||||
The mixer adds the ten-bit bias, clips each side to `0..1023`, and subtracts 512.
|
||||
Each routed Direct Sound sample contributes `sample * 2` at 50% volume or `sample * 4` at 100% volume.
|
||||
Pulse channel 1 contributes a centered signed amplitude, `+volume` or `-volume`, while active.
|
||||
`SOUNDCNT_L` routes it independently left/right and multiplies each side by its volume field plus one.
|
||||
`SOUNDCNT_H` then applies the PSG ratio: 25%, 50%, or 100%, using an arithmetic right shift.
|
||||
At full volume, channel 1 therefore spans `-120..120` before mixing with Direct Sound.
|
||||
This centered mixer follows GBATEK's signed range and the NanoBoyAdvance comparison; exact analog offset is not modeled.
|
||||
The mixer adds all supported channels and the ten-bit bias, clips each side to `0..1023`, and subtracts 512.
|
||||
`StereoLevel` therefore contains signed levels in `-512..511`. Master disable returns zero.
|
||||
Bias resolution bits retain their values but do not yet drive pulse-width modulation (PWM) sampling or quantization.
|
||||
There is no resampling, analog filtering, sample queue, or host output backend.
|
||||
|
|
@ -81,7 +133,7 @@ Polling once per video frame will not reconstruct audio.
|
|||
|
||||
Timer and sound state share the staged CPU/DMA clock.
|
||||
Reads and writes observe their nominal bus-completion phase, after earlier timer overflows.
|
||||
Successful steps commit once. Failed steps discard FIFO consumption, held samples, register writes, and refill requests.
|
||||
Successful steps commit once. Failed steps discard FIFO consumption, held samples, PSG clocks/state, register writes, and refill requests.
|
||||
Block-store validation simulates sound enable/disable in a temporary state before committing any RAM or I/O writes.
|
||||
Other devices retain their existing scheduling limits.
|
||||
|
||||
|
|
@ -91,14 +143,21 @@ Original tests cover signed samples, byte order, queue capacity, partial writes,
|
|||
They also cover cascaded timers, DMA block/repeat behavior, source progress, completion IRQs, HALT/STOP, and clock batching.
|
||||
CPU/DMA failure tests verify transactional rollback and sound writes at bus completion.
|
||||
BIOS tests cover disabled FIFO reset and the supported wave-bank policy.
|
||||
Pulse tests cover every frequency/duty pair, waveform batching, status, length, envelope, sweep, mixing, and reserved-volume diagnostics.
|
||||
They also check bus-completion status reads, retrigger timing, clock ownership, and rollback with a running pulse channel.
|
||||
|
||||
These tests validate the documented nominal model, not hardware audio fidelity.
|
||||
Next work: PSG clocks and synthesis, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend.
|
||||
The local Emerald startup now passes master enable and stops at a PSG channel 1 trigger.
|
||||
Next work: PSG channels 2–4, a timestamped or fixed-rate output stream, PWM/mixer sampling, and a Darwin host backend.
|
||||
The local Emerald startup now passes the channel 1 trigger and stops at a channel 2 trigger.
|
||||
See [the local runtime result](../research/emerald-reset.md).
|
||||
|
||||
## References
|
||||
|
||||
- [GBATEK channel 1](https://mgba-emu.github.io/gbatek/): register fields, duty ratios, and modulation rates.
|
||||
- [Pan Docs audio details](https://gbdev.io/pandocs/Audio_details.html): duty counters, shadow sweep, length edge rules, and GBA digital-mixer differences. GB-only edge details are not treated as verified GBA measurements.
|
||||
- [GbdevWiki sound hardware](https://gbdev.gg8.se/wiki/articles/Gameboy_sound_hardware): background sequencer and sweep descriptions.
|
||||
- [mGBA shared PSG at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gb/audio.c): GBA-style sequencer, length, envelope, sweep, and waveform comparison.
|
||||
- [NanoBoyAdvance pulse at 55b5cf0a](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/quad_channel.cc), [base channel](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/base_channel.hh), [length](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/length_counter.hh), [envelope](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/envelope.hh), and [sweep](https://github.com/nba-emu/NanoBoyAdvance/blob/55b5cf0ae3d929582ac5bfd486558173502b8354/src/nba/src/hw/apu/channel/sweep.hh): ordinary pulse behavior and documented uncertainty. The adjacent `quad_channel.hh` defines frequency scaling.
|
||||
- [GBATEK Direct Sound](https://problemkaputt.de/gbatek-gba-sound-channel-a-and-b-dma-sound.htm): registers, timer selection, signed samples, and DMA blocks.
|
||||
- [GBATEK sound control](https://problemkaputt.de/gbatek-gba-sound-control-registers.htm): master enable, read masks, reset strobes, routing, and bias.
|
||||
- [GBATEK mirror](https://mgba-emu.github.io/gbatek/): wave-bank access and register layout.
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ Supported flags can be combined:
|
|||
| 7 | Reset supported display, DMA, timer, and interrupt registers as described below |
|
||||
|
||||
All low-byte flag combinations, including `r0=0xff`, are accepted within the supported device subset.
|
||||
This is not full audio or serial support. [PSG playback](audio.md) and [serial transfers](serial.md) still fail explicitly.
|
||||
This is not full audio or serial support. [PSG channels 2–4](audio.md) and [serial transfers](serial.md) still fail explicitly.
|
||||
Sound reset retains wave bank 0 and the separate in-flight playback word; exact firmware sound-reset equivalence is not established.
|
||||
The firmware always clears the low halfword of SIODATA32, even when bit 5 is clear; its upper halfword is preserved.
|
||||
|
||||
|
|
@ -231,8 +231,9 @@ Zeroed OAM contains regular sprites at the origin, not disabled sprites; softwar
|
|||
|
||||
After the RAM clears, bit 5 clears SIOCNT and SIODATA8, writes RCNT=0x8000, and clears supported Joybus reset registers.
|
||||
Disconnected general-purpose inputs read high, so RCNT reads back as 0x800f.
|
||||
Bit 6 writes SOUNDCNT_X=0, SOUNDCNT_H=0, and SOUNDBIAS=0x0200, then clears the accessible wave RAM bank.
|
||||
Disabled PSG registers remain zero. The other wave bank cannot be enabled in the current subset and remains zero.
|
||||
Bit 6 disables SOUNDCNT_X, strobes both FIFO resets, clears SOUNDCNT_H, and sets SOUNDBIAS=0x0200.
|
||||
It then clears accessible wave bank 1. Wave bank 0 is retained and can be selected again after master enable.
|
||||
Disabled PSG registers remain zero. A running pulse channel stops only when sound reset is selected.
|
||||
Reset does not produce audio, fabricate serial transfers, or bypass normal bus accesses.
|
||||
|
||||
Bit 7 runs after the selected RAM and sound/serial resets:
|
||||
|
|
@ -510,5 +511,5 @@ Unsupported SWIs, unsupported exception vectors, and null/misaligned IRQ callbac
|
|||
The CPU reports `CpuError::UnsupportedInstruction` with `bios::UNSUPPORTED_TRAP`, rather than silently treating a service as a no-op.
|
||||
Invalid arithmetic/decompression arguments use the same CPU error type with the distinct `bios::INVALID_ARGUMENT_TRAP` instruction.
|
||||
Prior boot/service steps remain committed on failure.
|
||||
PSG playback, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented.
|
||||
PSG channels 2–4, host audio output, serial transfers, HardReset, and serial/Game Pak STOP wake-up remain unimplemented.
|
||||
This subset is not sufficient for Pokémon Emerald compatibility.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
Otherwise it services one ready DMA data unit, keeping the CPU paused.
|
||||
If HALT is waiting and no DMA is ready, it advances device clocks to the next event without executing CPU code.
|
||||
Otherwise, it samples the IRQ line, including the CPU's interrupt mask, then enters IRQ mode or executes one instruction.
|
||||
Each successful step advances display, timer, and Direct Sound clocks by its nominal cost.
|
||||
Each successful step advances display, timer, and audio clocks by its nominal cost.
|
||||
`Machine::last_timing()` reports separate code, data, internal, and idle cycle totals for the last successful step.
|
||||
`StepTiming::idle_cycles` counts device-clock cycles during HALT; it does not count CPU work.
|
||||
IRQ entry is a separate step; the vector instruction runs on the following step.
|
||||
|
|
@ -34,11 +34,12 @@ The transaction also owns timer IF bits. Reads see overflows through the current
|
|||
A later internal cycle can raise an acknowledged timer request again. Non-timer IF bits retain their existing behavior.
|
||||
IRQ delivery still waits until the next machine step. A committed timer request can wake HALT, but not STOP.
|
||||
|
||||
Successful steps commit timers, the shared divider phase, timer IF bits, and Direct Sound state.
|
||||
Successful steps commit timers, the shared divider phase, timer IF bits, and audio state.
|
||||
Failed steps discard staged counters, divider progress, timer/audio writes, FIFO consumption, and requests.
|
||||
Display and other device clocks then advance once, without advancing timers again.
|
||||
Display and remaining devices still use the bulk scheduler. Mid-instruction display events, general bus arbitration, and IRQ synchronization delays remain unmodeled.
|
||||
[Direct Sound](audio.md) consumes selected timer overflows in the timer transaction; sound DMA requests commit at step boundaries.
|
||||
The same transaction advances the PSG oscillator and 512 Hz sequencer independently of the general-purpose timers.
|
||||
Host clock advances and HALT idle batches retain ordinary bulk timer advancement; STOP idle does not advance timers.
|
||||
|
||||
The unchanged [prefetch cancellation probe](../research/prefetch-cancellation.md) now matches both published interval totals and actual timer samples.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ No ROM bytes, extracted code, or assets were added to the repository.
|
|||
## General correction
|
||||
|
||||
The reset correction introduced bounded sound and [disconnected serial](../hardware/serial.md) initialization models.
|
||||
The [sound model](../hardware/audio.md) now also supports timer-driven Direct Sound and bounded FIFO DMA.
|
||||
The [sound model](../hardware/audio.md) now also supports Direct Sound, bounded FIFO DMA, and nominal pulse channel 1 playback.
|
||||
The BIOS performs reset through original ARM stores against that model.
|
||||
Bit 5 selects general-purpose input pins and clears supported serial reset registers.
|
||||
Bit 6 disables sound, resets FIFO queues, clears mixing configuration and accessible wave RAM, and sets the functional bias default.
|
||||
|
|
@ -30,16 +30,17 @@ direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --w
|
|||
```
|
||||
|
||||
The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`).
|
||||
After the Direct Sound increment, both runs pass that write and stop at an enabled PSG channel 1 trigger:
|
||||
The Direct Sound increment then reached a PSG channel 1 trigger (`0x04000065`, PC `0x082e03bc`).
|
||||
After implementing channel 1, both runs pass that trigger and stop at channel 2:
|
||||
|
||||
```text
|
||||
Steps: 321159; instructions: 320647; IRQ entries: 0; DMA units: 512; HALT idle: 0
|
||||
Nominal cycles: 1038738
|
||||
PC=0x082e03bc
|
||||
unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x04000065
|
||||
Steps: 321161; instructions: 320649; IRQ entries: 0; DMA units: 512; HALT idle: 0
|
||||
Nominal cycles: 1038742
|
||||
PC=0x082e03c0
|
||||
unsupported PSG channel trigger (synthesis not implemented): write 0x80 at 0x0400006d
|
||||
```
|
||||
|
||||
The native run captures three startup frames. Direct Sound behavior is validated by original tests, not by audible game output.
|
||||
The native run captures three startup frames. Audio-device behavior is validated by original tests, not by audible game output.
|
||||
|
||||
The native run captures startup frames before this diagnostic. These are not evidence of a working title screen or gameplay.
|
||||
The window run does not reach its requested frame limit and correctly exits with failure.
|
||||
|
|
@ -52,8 +53,9 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
|
|||
Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
|
||||
The pinned Pong debug/release reports match.
|
||||
|
||||
The next observed startup requirement is PSG channel playback, beginning with pulse channel 1.
|
||||
Direct Sound now has FIFO clocks, nominal DMA requests, and an instantaneous digital-level inspection interface.
|
||||
The next observed startup requirement is PSG pulse channel 2, which has no sweep unit.
|
||||
Channel 1 now has nominal waveform, length, envelope, sweep, and stereo mixing behavior.
|
||||
Direct Sound has FIFO clocks and nominal DMA requests. Both use the instantaneous digital-level inspection interface.
|
||||
A continuous sample stream and desktop output are still missing. Do not treat digital-level tests as audible-game validation.
|
||||
Do not suppress the PSG trigger diagnostic without implementing the requested device behavior.
|
||||
Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol.
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ Update this file when a feature lands or a limit is removed.
|
|||
- Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices.
|
||||
- RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
|
||||
- Direct Sound A/B support timer-driven FIFO playback, stereo digital levels, and DMA1/A or DMA2/B refill blocks.
|
||||
- PSG triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback.
|
||||
- PSG pulse channel 1 supports nominal frequency/duty playback, length, envelope, sweep, status, and stereo mixing.
|
||||
- Channels 2–4 triggers and unsupported serial operations produce explicit address/value diagnostics with whole-access and block-store rollback.
|
||||
- ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP.
|
||||
- Optional ROM-window frame limits and an original input-test ROM generator.
|
||||
- Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports.
|
||||
|
|
@ -135,9 +136,10 @@ Internal affine origins, vertical window flags, and vertical mosaic counters tra
|
|||
Horizontal window comparators retain four-cycle event history.
|
||||
Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits.
|
||||
Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented.
|
||||
There is no PSG synthesis, host audio output, or cartridge save support.
|
||||
The [Direct Sound subset](hardware/audio.md) supports FIFO/timer/DMA behavior, idle PSG state, wave banking, and instantaneous digital mixing.
|
||||
It does not yet provide a continuous sample stream. PSG playback and unverified sound-DMA pairings remain diagnostic.
|
||||
There is no host audio output or cartridge save support.
|
||||
The [audio subset](hardware/audio.md) supports Direct Sound, PSG pulse channel 1, idle channel 2–4 state, wave banking, and instantaneous digital mixing.
|
||||
It does not yet provide a continuous sample stream. Channel 2–4 playback and unverified sound-DMA pairings remain diagnostic.
|
||||
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
|
||||
The [serial initialization subset](hardware/serial.md) supports idle normal data and disconnected general-purpose pins, not active transfers.
|
||||
Mapped I/O covers DMA, timers, interrupts, WAITCNT, display control/status, background control/scroll/affine registers, window/effect registers, KEYINPUT/KEYCNT, POSTFLG, and HALTCNT.
|
||||
Keypad IRQs use a documented polling model; hardware retrigger edges and asynchronous input timing remain unverified.
|
||||
|
|
@ -148,7 +150,8 @@ ROM files can run in bounded terminal mode or a window with the original BIOS re
|
|||
ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices.
|
||||
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility.
|
||||
A local Emerald run now completes its all-device reset and master sound enable.
|
||||
It stops explicitly at a PSG channel 1 trigger at `0x04000065`; no title-screen or gameplay compatibility is established.
|
||||
It also passes the channel 1 trigger, then stops explicitly at channel 2 (`0x0400006d`).
|
||||
No title-screen, gameplay, or audible-game compatibility is established.
|
||||
Its captured startup frames do not establish title-screen or gameplay compatibility. See [the startup result](research/emerald-reset.md).
|
||||
See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status.
|
||||
The [ROM suite runner](rom-tests.md) has original regressions and a [pinned public ARM baseline](public-arm-tests.md).
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ The [pinned Pong homebrew](public-pong.md) passes scripted menus, ball movement,
|
|||
The native Darwin window also starts and presents frames without a diagnostic.
|
||||
This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch.
|
||||
Frame-based scenarios and the preparation adapter now make the result repeatable.
|
||||
A local Emerald run now passes its initial all-device reset and master sound enable, then stops at a PSG channel 1 trigger.
|
||||
The core has a tested Direct Sound subset, but no continuous sample stream or desktop audio output.
|
||||
See [the runtime result](research/emerald-reset.md). PSG playback is the next demonstrated startup requirement.
|
||||
A local Emerald run now passes its initial all-device reset, master sound enable, and PSG channel 1 trigger.
|
||||
It stops at a channel 2 trigger. The core has tested Direct Sound and pulse channel 1 behavior, but no continuous sample stream or desktop audio output.
|
||||
See [the runtime result](research/emerald-reset.md). Remaining PSG channels are the next demonstrated startup requirement.
|
||||
The separate save requirement remains cartridge SRAM, followed by safe host save persistence.
|
||||
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue