diff --git a/src/native_core/apu/api.rs b/src/native_core/apu/api.rs index bbbb948..fe3bd5f 100644 --- a/src/native_core/apu/api.rs +++ b/src/native_core/apu/api.rs @@ -365,6 +365,6 @@ impl Apu { let dmc = self.dmc_output_level; - ChannelOutputs { pulse1, pulse2, triangle, noise, dmc } + ChannelOutputs { pulse1, pulse2, triangle, noise, dmc, expansion: 0.0 } } } diff --git a/src/native_core/apu/types.rs b/src/native_core/apu/types.rs index 795ca55..ac7ff55 100644 --- a/src/native_core/apu/types.rs +++ b/src/native_core/apu/types.rs @@ -5,6 +5,11 @@ pub struct ChannelOutputs { pub triangle: u8, pub noise: u8, pub dmc: u8, + /// Pre-mixed expansion audio from the cartridge mapper (VRC6, FME-7, + /// Namco163, etc.). Normalized to roughly the same amplitude range as + /// the internal NES APU output. Added linearly to the final sample + /// after the non-linear NES APU mixing stage. + pub expansion: f32, } pub(super) const APU_FRAME_SEQ_4_STEP_CYCLES: u32 = 14_915; diff --git a/src/native_core/bus.rs b/src/native_core/bus.rs index ab36f46..89e6011 100644 --- a/src/native_core/bus.rs +++ b/src/native_core/bus.rs @@ -61,7 +61,9 @@ impl NativeBus { } pub fn apu_channel_outputs(&self) -> crate::native_core::apu::ChannelOutputs { - self.apu.channel_outputs() + let mut outputs = self.apu.channel_outputs(); + outputs.expansion = self.mapper.expansion_audio_sample(); + outputs } pub fn render_frame(&self, out_rgba: &mut [u8], frame_number: u32, buttons: [bool; 8]) { diff --git a/src/native_core/mapper/core.rs b/src/native_core/mapper/core.rs index 69bca09..062a63a 100644 --- a/src/native_core/mapper/core.rs +++ b/src/native_core/mapper/core.rs @@ -26,6 +26,14 @@ pub trait Mapper { fn poll_irq(&mut self) -> bool { false } + /// Returns the current pre-mixed expansion audio sample for mappers that + /// include an on-cartridge sound chip (VRC6, FME-7/Sunsoft 5B, Namco163, + /// etc.). The value is already normalized so that its amplitude is + /// comparable to the internal NES APU output range. Default: 0.0 + /// (no expansion audio). + fn expansion_audio_sample(&self) -> f32 { + 0.0 + } fn save_state(&self, out: &mut Vec); fn load_state(&mut self, data: &[u8]) -> Result<(), String>; } diff --git a/src/native_core/mapper/mappers/fme7.rs b/src/native_core/mapper/mappers/fme7.rs index d5595c5..8ef3e4c 100644 --- a/src/native_core/mapper/mappers/fme7.rs +++ b/src/native_core/mapper/mappers/fme7.rs @@ -16,4 +16,13 @@ pub(crate) struct Fme7 { pub(super) irq_enabled: bool, pub(super) irq_counter_enabled: bool, pub(super) irq_pending: bool, + // Sunsoft 5B (YM2149 / AY-3-8910 compatible) expansion audio. + // Registers R0-R13 hold period, mixer, volume, and envelope config. + // Commands 0xC0-0xCF select audio register (low nibble). + pub(super) ay_regs: [u8; 16], + // Per-channel 12-bit period counter and current square-wave state. + pub(super) ay_timer: [u16; 3], + pub(super) ay_state: [bool; 3], + // Prescaler: the AY chip runs at CPU clock / 16. + pub(super) ay_prescaler: u8, } diff --git a/src/native_core/mapper/mappers/mapper253.rs b/src/native_core/mapper/mappers/mapper253.rs index a3725e4..16a58c8 100644 --- a/src/native_core/mapper/mappers/mapper253.rs +++ b/src/native_core/mapper/mappers/mapper253.rs @@ -103,6 +103,10 @@ impl Fme7 { irq_enabled: false, irq_counter_enabled: false, irq_pending: false, + ay_regs: [0; 16], + ay_timer: [1; 3], + ay_state: [false; 3], + ay_prescaler: 0, } } @@ -137,15 +141,21 @@ impl Mapper for Fme7 { fn cpu_write(&mut self, addr: u16, value: u8) { if (0x8000..=0x9FFF).contains(&addr) { - self.command = value & 0x0F; + self.command = value; return; } if !(0xA000..=0xBFFF).contains(&addr) { return; } - match self.command { - 0x0..=0x7 => self.chr_banks[self.command as usize] = value, + // Commands 0xC0-0xCF: Sunsoft 5B (AY-3-8910) audio registers. + if self.command >= 0xC0 { + self.ay_regs[(self.command & 0x0F) as usize] = value; + return; + } + + match self.command & 0x0F { + 0x0..=0x7 => self.chr_banks[(self.command & 0x0F) as usize] = value, 0x8 => { self.low_bank = value & 0x3F; self.low_is_ram = (value & 0x40) != 0; @@ -238,19 +248,57 @@ impl Mapper for Fme7 { } fn clock_cpu(&mut self, cycles: u8) { - if !self.irq_counter_enabled { - return; - } - for _ in 0..cycles { - if self.irq_counter == 0 { - self.irq_counter = 0xFFFF; - if self.irq_enabled { - self.irq_pending = true; + if self.irq_counter_enabled { + for _ in 0..cycles { + if self.irq_counter == 0 { + self.irq_counter = 0xFFFF; + if self.irq_enabled { + self.irq_pending = true; + } + } else { + self.irq_counter = self.irq_counter.wrapping_sub(1); } - } else { - self.irq_counter = self.irq_counter.wrapping_sub(1); } } + + // Sunsoft 5B AY-3-8910 timer: chip runs at CPU clock / 16. + // Each time the prescaler wraps, tick all three tone channels. + for _ in 0..cycles { + self.ay_prescaler = self.ay_prescaler.wrapping_add(1); + if self.ay_prescaler < 16 { + continue; + } + self.ay_prescaler = 0; + for ch in 0..3usize { + let period = { + let lo = self.ay_regs[ch * 2] as u16; + let hi = (self.ay_regs[ch * 2 + 1] & 0x0F) as u16; + let p = (hi << 8) | lo; + if p == 0 { 1 } else { p } + }; + if self.ay_timer[ch] == 0 { + self.ay_timer[ch] = period; + self.ay_state[ch] = !self.ay_state[ch]; + } else { + self.ay_timer[ch] -= 1; + } + } + } + } + + fn expansion_audio_sample(&self) -> f32 { + // Mixer register R7: bits 2:0 are tone-disable flags (0 = enabled). + let mixer = self.ay_regs[7]; + let mut sample = 0.0f32; + for ch in 0..3usize { + let tone_enabled = (mixer >> ch) & 1 == 0; + if tone_enabled && self.ay_state[ch] { + let volume = (self.ay_regs[8 + ch] & 0x0F) as f32; + // Scale similarly to a NES pulse channel. + sample += volume * 0.00752; + } + } + sample } fn poll_irq(&mut self) -> bool { @@ -271,12 +319,14 @@ impl Mapper for Fme7 { out.push(u8::from(self.irq_counter_enabled)); out.push(u8::from(self.irq_pending)); out.push(encode_mirroring(self.mirroring)); + out.extend_from_slice(&self.ay_regs); write_state_bytes(out, &self.low_ram); write_chr_state(out, &self.chr_data); } fn load_state(&mut self, data: &[u8]) -> Result<(), String> { - if data.len() < 21 { + // 21 original + 16 ay_regs bytes + if data.len() < 21 + 16 { return Err("mapper state is truncated".to_string()); } let mut cursor = 0usize; @@ -302,6 +352,8 @@ impl Mapper for Fme7 { cursor += 1; self.mirroring = decode_mirroring(data[cursor]); cursor += 1; + self.ay_regs.copy_from_slice(&data[cursor..cursor + 16]); + cursor += 16; let low_ram_payload = read_state_bytes(data, &mut cursor)?; if low_ram_payload.len() != self.low_ram.len() { return Err("mapper state does not match loaded ROM".to_string()); diff --git a/src/native_core/mapper/mappers/namco163_19.rs b/src/native_core/mapper/mappers/namco163_19.rs index 271b779..7ce9b4f 100644 --- a/src/native_core/mapper/mappers/namco163_19.rs +++ b/src/native_core/mapper/mappers/namco163_19.rs @@ -12,6 +12,12 @@ pub(crate) struct Namco163_19 { irq_counter: u16, irq_enabled: bool, irq_pending: bool, + // Namco163 wavetable audio. Each active channel has a 24-bit phase + // accumulator. Channels 7..7-N+1 are active (N from audio_ram[0x7F]). + // Phase increments by the 18-bit frequency value every 15 CPU cycles + // per active channel (chip cycles sequentially through all channels). + namco_phase: [u32; 8], + namco_cycle: u16, } impl Namco163_19 { @@ -28,6 +34,8 @@ impl Namco163_19 { irq_counter: 0, irq_enabled: false, irq_pending: false, + namco_phase: [0; 8], + namco_cycle: 0, } } @@ -144,14 +152,64 @@ impl Mapper for Namco163_19 { } fn clock_cpu(&mut self, cycles: u8) { - if !self.irq_enabled { - return; + if self.irq_enabled { + let sum = self.irq_counter as u32 + cycles as u32; + if sum > 0x7FFF { + self.irq_pending = true; + } + self.irq_counter = (sum as u16) & 0x7FFF; } - let sum = self.irq_counter as u32 + cycles as u32; - if sum > 0x7FFF { - self.irq_pending = true; + + // Namco163 audio: the chip cycles through all active channels, clocking + // one channel every 15 CPU cycles. When all channels have been clocked + // once, each channel's phase has advanced by its 18-bit frequency value. + let num_active = ((self.audio_ram[0x7F] >> 4) & 0x07) as u16 + 1; + let period = 15 * num_active; + for _ in 0..cycles { + self.namco_cycle += 1; + if self.namco_cycle >= period { + self.namco_cycle = 0; + for j in 0..num_active as usize { + // Channel j registers start at audio_ram[0x40 + j*8]. + let base = 0x40 + j * 8; + let freq = (self.audio_ram[base] as u32) + | ((self.audio_ram[base + 2] as u32) << 8) + | (((self.audio_ram[base + 4] & 0x03) as u32) << 16); + self.namco_phase[j] = + (self.namco_phase[j] + freq) & 0x00FF_FFFF; + } + } } - self.irq_counter = (sum as u16) & 0x7FFF; + } + + fn expansion_audio_sample(&self) -> f32 { + let num_active = ((self.audio_ram[0x7F] >> 4) & 0x07) as usize + 1; + let mut output = 0.0f32; + for j in 0..num_active { + let base = 0x40 + j * 8; + // Wave length is stored in the upper 6 bits of the byte at base+4, + // encoded as (256 - wave_nibbles): value 0 → 256 nibbles. + let len_raw = (self.audio_ram[base + 4] >> 2) as u16; + let wave_len = if len_raw == 0 { 256u16 } else { 256 - len_raw * 4 }; + let wave_len = wave_len.max(1); + let wave_addr = self.audio_ram[base + 6] as u16; + let volume = (self.audio_ram[base + 7] & 0x0F) as f32; + + // Current position in the waveform (nibble index). + let nibble_pos = ((self.namco_phase[j] >> 16) as u16 % wave_len + wave_addr) + & 0xFF; + let byte = self.audio_ram[(nibble_pos / 2) as usize]; + let nibble = if nibble_pos & 1 == 0 { + byte & 0x0F + } else { + (byte >> 4) & 0x0F + } as f32; + + // Centre at 8 (DC = 0), scale by volume, normalize. + output += (nibble - 8.0) * volume / (15.0 * num_active as f32); + } + // Scale to NES amplitude range. + output * 0.02 } fn poll_irq(&mut self) -> bool { diff --git a/src/native_core/mapper/mappers/vrc/vrc6_24.rs b/src/native_core/mapper/mappers/vrc/vrc6_24.rs index 24fcd5c..7c73ed1 100644 --- a/src/native_core/mapper/mappers/vrc/vrc6_24.rs +++ b/src/native_core/mapper/mappers/vrc/vrc6_24.rs @@ -18,6 +18,27 @@ pub(crate) struct Vrc6_24 { irq_mode_cpu: bool, irq_pending: bool, irq_prescaler: i16, + // VRC6 expansion audio — 2 pulse channels + 1 sawtooth channel. + // Pulse channel n: 12-bit period timer, 4-bit volume, 3-bit duty (0-7), + // mode flag (ignore duty → always output), gate (enabled) flag. + // Timer decrements each CPU cycle; at 0 reload and advance duty_step (0-15). + // Output: if mode OR duty_step <= duty → volume, else 0. + vrc6_pulse_period: [u16; 2], + vrc6_pulse_counter: [u16; 2], + vrc6_pulse_duty_step: [u8; 2], + vrc6_pulse_duty: [u8; 2], + vrc6_pulse_volume: [u8; 2], + vrc6_pulse_mode: [bool; 2], + vrc6_pulse_enabled: [bool; 2], + // Sawtooth channel: 12-bit period timer, 6-bit accumulator rate. + // Step counter 0-6; on steps 1/3/5 accumulator += rate; on step 6 reset. + // Output: accumulator >> 3. + vrc6_saw_period: u16, + vrc6_saw_counter: u16, + vrc6_saw_step: u8, + vrc6_saw_accumulator: u8, + vrc6_saw_rate: u8, + vrc6_saw_enabled: bool, } impl Vrc6_24 { @@ -44,6 +65,19 @@ impl Vrc6_24 { irq_mode_cpu: false, irq_pending: false, irq_prescaler: 341, + vrc6_pulse_period: [0; 2], + vrc6_pulse_counter: [0; 2], + vrc6_pulse_duty_step: [0; 2], + vrc6_pulse_duty: [0; 2], + vrc6_pulse_volume: [0; 2], + vrc6_pulse_mode: [false; 2], + vrc6_pulse_enabled: [false; 2], + vrc6_saw_period: 0, + vrc6_saw_counter: 0, + vrc6_saw_step: 0, + vrc6_saw_accumulator: 0, + vrc6_saw_rate: 0, + vrc6_saw_enabled: false, } } @@ -118,7 +152,47 @@ impl Mapper for Vrc6_24 { } match self.decode_register(addr) { 0x8000..=0x8003 => self.prg_bank_16k = value & 0x0F, + // VRC6 pulse 1 registers ($9000-$9002) + 0x9000 => { + self.vrc6_pulse_mode[0] = (value & 0x80) != 0; + self.vrc6_pulse_duty[0] = (value >> 4) & 0x07; + self.vrc6_pulse_volume[0] = value & 0x0F; + } + 0x9001 => { + self.vrc6_pulse_period[0] = + (self.vrc6_pulse_period[0] & 0x0F00) | value as u16; + } + 0x9002 => { + self.vrc6_pulse_enabled[0] = (value & 0x80) != 0; + self.vrc6_pulse_period[0] = + (self.vrc6_pulse_period[0] & 0x00FF) | (((value & 0x0F) as u16) << 8); + } 0x9003 => self.control = value, + // VRC6 pulse 2 registers ($A000-$A002) + 0xA000 => { + self.vrc6_pulse_mode[1] = (value & 0x80) != 0; + self.vrc6_pulse_duty[1] = (value >> 4) & 0x07; + self.vrc6_pulse_volume[1] = value & 0x0F; + } + 0xA001 => { + self.vrc6_pulse_period[1] = + (self.vrc6_pulse_period[1] & 0x0F00) | value as u16; + } + 0xA002 => { + self.vrc6_pulse_enabled[1] = (value & 0x80) != 0; + self.vrc6_pulse_period[1] = + (self.vrc6_pulse_period[1] & 0x00FF) | (((value & 0x0F) as u16) << 8); + } + // VRC6 sawtooth registers ($B000-$B002) + 0xB000 => self.vrc6_saw_rate = value & 0x3F, + 0xB001 => { + self.vrc6_saw_period = (self.vrc6_saw_period & 0x0F00) | value as u16; + } + 0xB002 => { + self.vrc6_saw_enabled = (value & 0x80) != 0; + self.vrc6_saw_period = + (self.vrc6_saw_period & 0x00FF) | (((value & 0x0F) as u16) << 8); + } 0xC000..=0xC003 => self.prg_bank_8k = value & 0x1F, 0xD000 => self.chr_banks_1k[0] = value, 0xD001 => self.chr_banks_1k[1] = value, @@ -193,6 +267,65 @@ impl Mapper for Vrc6_24 { fn clock_cpu(&mut self, cycles: u8) { vrc_irq_clock(cycles, self.irq_state()); + + for _ in 0..cycles { + // Pulse channels + for i in 0..2usize { + if !self.vrc6_pulse_enabled[i] { + continue; + } + if self.vrc6_pulse_counter[i] == 0 { + self.vrc6_pulse_counter[i] = self.vrc6_pulse_period[i].max(1); + self.vrc6_pulse_duty_step[i] = (self.vrc6_pulse_duty_step[i] + 1) & 0x0F; + } else { + self.vrc6_pulse_counter[i] -= 1; + } + } + // Sawtooth channel + if self.vrc6_saw_enabled { + if self.vrc6_saw_counter == 0 { + self.vrc6_saw_counter = self.vrc6_saw_period.max(1); + self.vrc6_saw_step += 1; + match self.vrc6_saw_step { + 1 | 3 | 5 => { + self.vrc6_saw_accumulator = + self.vrc6_saw_accumulator.wrapping_add(self.vrc6_saw_rate); + } + 6 => { + self.vrc6_saw_accumulator = 0; + self.vrc6_saw_step = 0; + } + _ => {} + } + } else { + self.vrc6_saw_counter -= 1; + } + } + } + } + + fn expansion_audio_sample(&self) -> f32 { + // Pulse 1 & 2: 4-bit output (0-15), scaled like NES pulse channels. + let mut sample = 0.0f32; + for i in 0..2usize { + if self.vrc6_pulse_enabled[i] { + let raw = if self.vrc6_pulse_mode[i] + || self.vrc6_pulse_duty_step[i] <= self.vrc6_pulse_duty[i] + { + self.vrc6_pulse_volume[i] as f32 + } else { + 0.0 + }; + // Scale to match NES pulse level (0.00752 * 15 ≈ 0.113 max per channel). + sample += raw * 0.00752; + } + } + // Sawtooth: accumulator >> 3 gives a 0-23 range; scale comparably. + if self.vrc6_saw_enabled { + let raw = (self.vrc6_saw_accumulator >> 3) as f32; + sample += raw * 0.00752; + } + sample } fn poll_irq(&mut self) -> bool { @@ -214,12 +347,30 @@ impl Mapper for Vrc6_24 { out.push(u8::from(self.irq_mode_cpu)); out.push(u8::from(self.irq_pending)); out.extend_from_slice(&self.irq_prescaler.to_le_bytes()); + // VRC6 expansion audio state (24 bytes) + for i in 0..2 { + out.extend_from_slice(&self.vrc6_pulse_period[i].to_le_bytes()); + out.extend_from_slice(&self.vrc6_pulse_counter[i].to_le_bytes()); + out.push(self.vrc6_pulse_duty_step[i]); + out.push(self.vrc6_pulse_duty[i]); + out.push(self.vrc6_pulse_volume[i]); + out.push( + u8::from(self.vrc6_pulse_mode[i]) | (u8::from(self.vrc6_pulse_enabled[i]) << 1), + ); + } + out.extend_from_slice(&self.vrc6_saw_period.to_le_bytes()); + out.extend_from_slice(&self.vrc6_saw_counter.to_le_bytes()); + out.push(self.vrc6_saw_step); + out.push(self.vrc6_saw_accumulator); + out.push(self.vrc6_saw_rate); + out.push(u8::from(self.vrc6_saw_enabled)); write_state_bytes(out, &self.prg_ram); write_chr_state(out, &self.chr_data); } fn load_state(&mut self, data: &[u8]) -> Result<(), String> { - if data.len() < 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 { + // 20 fixed + 24 VRC6 audio bytes + if data.len() < 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 + 24 { return Err("mapper state is truncated".to_string()); } let mut cursor = 0usize; @@ -247,6 +398,37 @@ impl Mapper for Vrc6_24 { cursor += 1; self.irq_prescaler = i16::from_le_bytes([data[cursor], data[cursor + 1]]); cursor += 2; + // VRC6 expansion audio state + for i in 0..2 { + self.vrc6_pulse_period[i] = + u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.vrc6_pulse_counter[i] = + u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.vrc6_pulse_duty_step[i] = data[cursor]; + cursor += 1; + self.vrc6_pulse_duty[i] = data[cursor]; + cursor += 1; + self.vrc6_pulse_volume[i] = data[cursor]; + cursor += 1; + let flags = data[cursor]; + cursor += 1; + self.vrc6_pulse_mode[i] = (flags & 0x01) != 0; + self.vrc6_pulse_enabled[i] = (flags & 0x02) != 0; + } + self.vrc6_saw_period = u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.vrc6_saw_counter = u16::from_le_bytes([data[cursor], data[cursor + 1]]); + cursor += 2; + self.vrc6_saw_step = data[cursor]; + cursor += 1; + self.vrc6_saw_accumulator = data[cursor]; + cursor += 1; + self.vrc6_saw_rate = data[cursor]; + cursor += 1; + self.vrc6_saw_enabled = data[cursor] != 0; + cursor += 1; let prg_ram = read_state_bytes(data, &mut cursor)?; if prg_ram.len() != self.prg_ram.len() { diff --git a/src/runtime/audio.rs b/src/runtime/audio.rs index 777cf2b..855dd0c 100644 --- a/src/runtime/audio.rs +++ b/src/runtime/audio.rs @@ -56,11 +56,27 @@ impl AudioMixer { let samples = self.sample_accumulator.floor() as usize; self.sample_accumulator -= samples as f64; - let pulse_out = 0.00752 * (f32::from(channels.pulse1) + f32::from(channels.pulse2)); - let tnd_out = 0.00851 * f32::from(channels.triangle) - + 0.00494 * f32::from(channels.noise) - + 0.00335 * f32::from(channels.dmc); - let sample = pulse_out + tnd_out; + // NES non-linear APU mixing (Blargg's reference formulas). + // Pulse channels use a shared lookup: + // pulse_out = 95.88 / (8128 / (p1 + p2) + 100) + // TND channels use a separate lookup: + // tnd_out = 159.79 / (1 / (tri/8227 + noise/12241 + dmc/22638) + 100) + // Both formulas produce 0.0 when all contributing channels are silent. + let p_sum = f32::from(channels.pulse1) + f32::from(channels.pulse2); + let pulse_out = if p_sum == 0.0 { + 0.0 + } else { + 95.88 / (8128.0 / p_sum + 100.0) + }; + let tnd_sum = f32::from(channels.triangle) / 8227.0 + + f32::from(channels.noise) / 12241.0 + + f32::from(channels.dmc) / 22638.0; + let tnd_out = if tnd_sum == 0.0 { + 0.0 + } else { + 159.79 / (1.0 / tnd_sum + 100.0) + }; + let sample = pulse_out + tnd_out + channels.expansion; if samples == 0 { return; @@ -118,6 +134,7 @@ mod tests { triangle: 15, noise: 15, dmc: 127, + expansion: 0.0, }; let mut out = Vec::new(); mixer.push_cycles(50, channels, &mut out); @@ -143,6 +160,7 @@ mod tests { triangle: 15, noise: 15, dmc: 127, + expansion: 0.0, }, &mut out, );