Skip to main content

rustynes_apu/
pulse.rs

1//! Pulse channel (1 of 2). 4-step duty sequencer + envelope + sweep + length.
2//!
3//! Per `docs/apu-2a03.md` §Behavior and NESdev wiki "APU Pulse" page.
4//!
5//! Two pulse channels share the same architecture but differ in sweep
6//! negation: pulse 1 uses one's-complement (`!t`), pulse 2 uses two's-
7//! complement (`-t`).  This produces an audible difference at low periods.
8
9use crate::envelope::Envelope;
10use crate::length::LengthCounter;
11
12/// Duty waveforms for the pulse channels.  Each entry is the 8-step output
13/// pattern for one of the four duty values.  Index = duty selection
14/// (`$4000` bits 6-7).  Step index runs 0..8 with 0 being the "current"
15/// position; the LSB is the output bit at the current step.
16const DUTY_TABLE: [[u8; 8]; 4] = [
17    [0, 1, 0, 0, 0, 0, 0, 0], // 12.5%
18    [0, 1, 1, 0, 0, 0, 0, 0], // 25.0%
19    [0, 1, 1, 1, 1, 0, 0, 0], // 50.0%
20    [1, 0, 0, 1, 1, 1, 1, 1], // 25.0% negated
21];
22
23/// Pulse channel state.
24#[derive(Debug, Clone, Copy)]
25pub struct Pulse {
26    /// Duty selection (0..=3).
27    pub(crate) duty: u8,
28    /// Step index into the duty table (0..=7). Decremented on each timer underflow.
29    pub(crate) step: u8,
30    /// 11-bit timer reload (from `$4002`/`$4003` low+high writes).
31    pub(crate) timer_period: u16,
32    /// Internal countdown timer.
33    pub(crate) timer: u16,
34    /// Envelope generator.
35    pub envelope: Envelope,
36    /// Length counter.
37    pub length: LengthCounter,
38    /// Sweep enabled (`$4001` bit 7).
39    pub(crate) sweep_enabled: bool,
40    /// Sweep divider period (3 bits, +1 -> 1..=8).
41    pub(crate) sweep_period: u8,
42    /// Sweep negate flag.
43    pub(crate) sweep_negate: bool,
44    /// Sweep shift count (3 bits).
45    pub(crate) sweep_shift: u8,
46    /// Sweep reload flag — set by `$4001` write; consumed at next half-frame.
47    pub(crate) sweep_reload: bool,
48    /// Internal sweep divider.
49    pub(crate) sweep_divider: u8,
50    /// Pulse 1 vs pulse 2 (controls one's-complement vs two's-complement).
51    pub(crate) is_pulse1: bool,
52}
53
54impl Pulse {
55    /// Construct a new pulse channel. `is_pulse1=true` for the pulse-1 sweep
56    /// negation flavor (one's complement).
57    #[must_use]
58    pub const fn new(is_pulse1: bool) -> Self {
59        Self {
60            duty: 0,
61            step: 0,
62            timer_period: 0,
63            timer: 0,
64            envelope: Envelope {
65                start: false,
66                loop_flag: false,
67                constant: false,
68                volume_or_period: 0,
69                divider: 0,
70                decay: 0,
71            },
72            length: LengthCounter {
73                count: 0,
74                halt: false,
75                new_halt: false,
76                enabled: false,
77                reload_val: 0,
78                previous_count: 0,
79            },
80            sweep_enabled: false,
81            sweep_period: 0,
82            sweep_negate: false,
83            sweep_shift: 0,
84            sweep_reload: false,
85            sweep_divider: 0,
86            is_pulse1,
87        }
88    }
89
90    /// `$4000` / `$4004` write: duty + length-halt + envelope.
91    pub fn write_ctrl(&mut self, value: u8) {
92        self.duty = (value >> 6) & 0x03;
93        let halt = (value & 0x20) != 0;
94        // Length-halt is deferred (applied after the same-cycle half-frame
95        // clock, per `LengthCounter::reload`); the envelope loop flag is not.
96        self.length.set_halt(halt);
97        self.envelope.loop_flag = halt;
98        self.envelope.constant = (value & 0x10) != 0;
99        self.envelope.volume_or_period = value & 0x0F;
100    }
101
102    /// `$4001` / `$4005` write: sweep config.
103    pub fn write_sweep(&mut self, value: u8) {
104        self.sweep_enabled = (value & 0x80) != 0;
105        self.sweep_period = (value >> 4) & 0x07;
106        self.sweep_negate = (value & 0x08) != 0;
107        self.sweep_shift = value & 0x07;
108        self.sweep_reload = true;
109    }
110
111    /// `$4002` / `$4006` write: timer low.
112    pub fn write_timer_lo(&mut self, value: u8) {
113        self.timer_period = (self.timer_period & 0xFF00) | u16::from(value);
114    }
115
116    /// `$4003` / `$4007` write: length load + timer high.
117    pub fn write_timer_hi(&mut self, value: u8) {
118        self.timer_period = (self.timer_period & 0x00FF) | (u16::from(value & 0x07) << 8);
119        self.length.load(value);
120        self.step = 0;
121        self.envelope.start = true;
122    }
123
124    /// One APU clock (half CPU clock).
125    pub fn clock_timer(&mut self) {
126        if self.timer == 0 {
127            self.timer = self.timer_period;
128            // 8-step duty sequencer, decremented (NESdev wiki).
129            self.step = (self.step + 1) & 0x07;
130        } else {
131            self.timer -= 1;
132        }
133    }
134
135    /// Half-frame clock: sweep + length.
136    pub fn clock_half_frame(&mut self) {
137        // Sweep first (because length might mute the channel).
138        let target = self.sweep_target();
139        if self.sweep_divider == 0 && self.sweep_enabled && self.sweep_shift > 0 && !self.muted() {
140            // Apply sweep: write the new period.
141            self.timer_period = target;
142        }
143        if self.sweep_divider == 0 || self.sweep_reload {
144            self.sweep_divider = self.sweep_period;
145            self.sweep_reload = false;
146        } else {
147            self.sweep_divider -= 1;
148        }
149        self.length.clock();
150    }
151
152    /// Quarter-frame clock: envelope.
153    pub fn clock_quarter_frame(&mut self) {
154        self.envelope.clock();
155    }
156
157    /// Compute the sweep target period (one's vs two's complement per channel).
158    ///
159    /// A negated sum below zero CLAMPS to zero (NESdev "APU Sweep": "the
160    /// target period is the sum of the current period and the change amount,
161    /// clamped to zero if this sum is negative"). The only way to get there on
162    /// an unmuted channel is pulse 1 with shift 0, where the ones'-complement
163    /// change amount is `-period - 1` and the sum is `-1`. That is the `$08`
164    /// write games use to fully disable the sweep; wrapping it to `$FFFF`
165    /// instead made the `> $7FF` rule mute pulse 1 for as long as the write
166    /// stood. Saturating arithmetic is the clamp, and for every non-negative
167    /// sum it yields the same value the wrapping form did.
168    fn sweep_target(&self) -> u16 {
169        let shifted = self.timer_period >> self.sweep_shift;
170        if self.sweep_negate {
171            if self.is_pulse1 {
172                self.timer_period.saturating_sub(shifted).saturating_sub(1)
173            } else {
174                self.timer_period.saturating_sub(shifted)
175            }
176        } else {
177            self.timer_period.wrapping_add(shifted)
178        }
179    }
180
181    /// Sweep mute: timer < 8 OR target > $7FF.
182    #[must_use]
183    pub fn muted(&self) -> bool {
184        self.timer_period < 8 || self.sweep_target() > 0x7FF
185    }
186
187    /// Per-cycle output volume (0..=15).
188    #[must_use]
189    pub fn output(&self) -> u8 {
190        if self.length.count == 0
191            || self.muted()
192            || DUTY_TABLE[self.duty as usize][self.step as usize] == 0
193        {
194            0
195        } else {
196            self.envelope.output()
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn write_ctrl_sets_duty_and_envelope_period() {
207        let mut p = Pulse::new(true);
208        p.write_ctrl(0b1011_0101); // duty=2, halt=1, const=1, period=5
209        assert_eq!(p.duty, 2);
210        // Halt is deferred: latched in `new_halt`, promoted to `halt` by
211        // `LengthCounter::reload` (after the half-frame clock).
212        assert!(p.length.new_halt);
213        p.length.reload();
214        assert!(p.length.halt);
215        assert!(p.envelope.loop_flag);
216        assert!(p.envelope.constant);
217        assert_eq!(p.envelope.volume_or_period, 5);
218    }
219
220    #[test]
221    fn timer_underflow_advances_duty_step() {
222        let mut p = Pulse::new(true);
223        p.timer_period = 1;
224        p.timer = 0;
225        p.clock_timer();
226        assert_eq!(p.step, 1);
227    }
228
229    #[test]
230    fn pulse1_sweep_negation_is_ones_complement() {
231        let mut p = Pulse::new(true);
232        p.timer_period = 0x100;
233        p.sweep_negate = true;
234        p.sweep_shift = 1;
235        // shifted = 0x80; 0x100 - 0x80 - 1 = 0x7F.
236        assert_eq!(p.sweep_target(), 0x7F);
237    }
238
239    #[test]
240    fn pulse2_sweep_negation_is_twos_complement() {
241        let mut p = Pulse::new(false);
242        p.timer_period = 0x100;
243        p.sweep_negate = true;
244        p.sweep_shift = 1;
245        // 0x100 - 0x80 = 0x80.
246        assert_eq!(p.sweep_target(), 0x80);
247    }
248
249    /// The `$4001 = $08` idiom (negate on, shift 0) must NOT mute pulse 1.
250    ///
251    /// NESdev "APU Sweep": the target period is the current period plus the
252    /// change amount, "clamped to zero if this sum is negative", and writing
253    /// `$08` is the documented way to *fully disable* the sweep unit precisely
254    /// because it keeps the target from exceeding `$7FF`. With shift 0 the
255    /// ones'-complement change amount is `-period - 1`, so the sum is `-1`,
256    /// which clamps to 0. Unclamped 16-bit arithmetic instead wraps it to
257    /// `$FFFF`, which is `> $7FF` and mutes the channel. Core audit ledger T-01.
258    #[test]
259    fn pulse1_negate_shift0_clamps_to_zero_and_does_not_mute() {
260        let mut p = Pulse::new(true);
261        p.write_sweep(0x08); // negate on, shift 0, sweep disabled
262        p.timer_period = 0x100;
263        assert_eq!(p.sweep_target(), 0, "negative target clamps to zero");
264        assert!(!p.muted(), "$4001=$08 must leave pulse 1 audible");
265    }
266
267    /// Pulse 2 (two's complement) reaches exactly 0 on the same idiom; it was
268    /// never affected, and must stay that way.
269    #[test]
270    fn pulse2_negate_shift0_targets_zero_and_does_not_mute() {
271        let mut p = Pulse::new(false);
272        p.write_sweep(0x08);
273        p.timer_period = 0x100;
274        assert_eq!(p.sweep_target(), 0);
275        assert!(!p.muted());
276    }
277
278    /// The clamp changes nothing when the sum is non-negative, which for
279    /// pulse 1 is every shift >= 1 at every period >= 8 (the only periods that
280    /// are not already muted by the `period < 8` rule).
281    #[test]
282    fn pulse1_negate_clamp_is_inert_for_nonzero_shift() {
283        let mut p = Pulse::new(true);
284        for shift in 1..=7u8 {
285            for period in 8..=0x7FFu16 {
286                p.write_sweep(0x08 | shift);
287                p.timer_period = period;
288                let expect = period - (period >> shift) - 1;
289                assert_eq!(p.sweep_target(), expect, "shift {shift} period {period:#x}");
290            }
291        }
292    }
293
294    #[test]
295    fn sweep_mutes_when_period_too_low() {
296        let mut p = Pulse::new(true);
297        p.timer_period = 7;
298        assert!(p.muted());
299        p.timer_period = 8;
300        assert!(!p.muted());
301    }
302
303    #[test]
304    fn sweep_mutes_when_target_above_7ff() {
305        let mut p = Pulse::new(true);
306        p.timer_period = 0x780;
307        p.sweep_negate = false;
308        p.sweep_shift = 1; // target = 0x780 + 0x3C0 = 0xB40 > $7FF
309        assert!(p.muted());
310    }
311
312    #[test]
313    fn output_zero_when_length_zero() {
314        let mut p = Pulse::new(true);
315        p.length.count = 0;
316        p.envelope.constant = true;
317        p.envelope.volume_or_period = 15;
318        assert_eq!(p.output(), 0);
319    }
320
321    #[test]
322    fn timer_hi_write_resets_duty_phase_not_divider() {
323        // NESdev "APU Pulse": writing $4003/$4007 resets the duty sequencer
324        // phase to step 0 but does NOT reset the timer divider.
325        let mut p = Pulse::new(true);
326        p.step = 5;
327        p.timer = 42;
328        p.write_timer_hi(0x03);
329        assert_eq!(p.step, 0, "duty sequencer phase must reset to 0");
330        assert_eq!(p.timer, 42, "timer divider must be preserved");
331        assert!(p.envelope.start, "envelope restart flag must be set");
332    }
333
334    #[test]
335    fn length_load_only_when_enabled() {
336        let mut p = Pulse::new(true);
337        p.length.enabled = false;
338        p.write_timer_hi(0x08);
339        // The load is deferred; resolve it (no half-frame clock in between, so
340        // `reload` applies it in-cycle). A disabled channel still ignores it.
341        p.length.reload();
342        assert_eq!(p.length.count, 0);
343        p.length.enabled = true;
344        p.write_timer_hi(0x08);
345        p.length.reload();
346        assert_ne!(p.length.count, 0);
347    }
348}