Skip to main content

oxideav_mod/
player.rs

1//! ProTracker playback engine.
2//!
3//! Drives a `PlayerState` forward one tick at a time. Two output modes
4//! share the same mixing core:
5//!
6//! - [`PlayerState::render`] writes interleaved stereo S16 PCM, applying
7//!   the Amiga pan convention (channels 0 & 3 lean LEFT, 1 & 2 lean
8//!   RIGHT; repeats every 4 for >4-channel files) and a 1/(N/2)
9//!   headroom scale. Pan separation defaults to 70 % rather than
10//!   strict 100 % hard pan — see [`PlayerState::set_pan_separation`]
11//!   and the citation in the field doc-comment for the rationale
12//!   (`Protracker-effects-MODFIL12.txt` §11).
13//! - [`PlayerState::render_per_channel`] writes one S16 plane per MOD
14//!   tracker channel, post-volume but pre-pan and pre-mix. Downstream
15//!   consumers that need to mix / pan / analyse channels independently
16//!   (DAWs, visualisers, per-instrument remastering) drive the player
17//!   via this path.
18//!
19//! Terminology:
20//! - **Row**: a line in a pattern. A pattern has 64 rows.
21//! - **Tick**: one row is `speed` ticks long (default 6).
22//! - **BPM**: governs wall-clock tick duration. Samples-per-tick =
23//!   `sample_rate * 2.5 / BPM` — 882 at 44.1 kHz / 125 BPM.
24//! - **Period**: the Amiga Paula divider. Output frequency =
25//!   PAULA_CLOCK / period.
26//!
27//! Effect coverage follows `docs/audio/trackers/mod/Protracker-v1.1B-mod.txt`
28//! and the concrete tick-level semantics described in
29//! `docs/audio/trackers/mod/FireLight-MOD-Player-Tutorial.txt` §5. All
30//! 16 base effect slots (0..F) plus the 16 Exy sub-commands are wired —
31//! see [`apply_tick0_effect`] / [`apply_tickn_effect`] for the dispatch
32//! tables and the module doc-comment's coverage matrix.
33
34use crate::header::{ModHeader, PATTERN_ROWS};
35use crate::samples::SampleBody;
36
37/// Paula clock (PAL) — classic MOD period→frequency constant. Divide by
38/// the period to get the Amiga's output sample rate for that channel.
39pub const PAULA_CLOCK: f32 = 7_093_789.2 / 2.0;
40
41pub const DEFAULT_SPEED: u8 = 6;
42pub const DEFAULT_BPM: u8 = 125;
43pub const CHANNELS_PER_MOD: usize = 4;
44
45/// Protracker porta-up floor: B-3 at finetune 0 is 113. Effects `1xx`
46/// (porta up) and `E1x` (fine porta up) must not slide below this value
47/// per `Protracker-v1.1B-mod.txt` ("You can NOT slide higher than B-3!
48/// (Period 113)") and `Protracker-effects-MODFIL12.txt` §1
49/// ("usually cannot slide past note B-3 unless you have implemented
50/// octave 4 (NON-STANDARD!)").
51pub const PERIOD_MIN: u16 = 113;
52/// Protracker porta-down ceiling: C-1 at finetune 0 is 856. Effects
53/// `2xx` (porta down) and `E2x` (fine porta down) clamp at this value
54/// per `Protracker-v1.1B-mod.txt` ("You can NOT slide lower than C-1!
55/// (Period 856)").
56pub const PERIOD_MAX: u16 = 856;
57/// Extended period floor — finetune +7 B-3 = 108. Per
58/// `Protracker-effects-MODFIL12.txt` §3.2 the "Normal Minimum Period =
59/// 108", and the period table includes 108 as the lowest legitimate
60/// value (finetune +7 row 35). The mixer must accept this without
61/// further clamping so finetune extremes play cleanly.
62pub const PERIOD_MIN_EXT: u16 = 108;
63/// Extended period ceiling — finetune -8 C-1 = 907. Per
64/// `Protracker-effects-MODFIL12.txt` §3.2 the "Normal Maximum Period =
65/// 907", and the period table's finetune -8 row begins with 907.
66pub const PERIOD_MAX_EXT: u16 = 907;
67
68/// 32-entry Protracker vibrato / tremolo sine table (half-wave positive
69/// quadrant; the sign is applied based on the LFO position register per
70/// spec). From FireLight-MOD-Player-Tutorial.txt §5.5.
71#[rustfmt::skip]
72pub const PROTRACKER_SINE_TABLE: [u8; 32] = [
73      0,  24,  49,  74,  97, 120, 141, 161,
74    180, 197, 212, 224, 235, 244, 250, 253,
75    255, 253, 250, 244, 235, 224, 212, 197,
76    180, 161, 141, 120,  97,  74,  49,  24,
77];
78
79/// 16-finetune × 36-note Protracker period table. Indexed as
80/// `PERIOD_TABLE[finetune_index][note_index]` where
81/// `finetune_index = finetune & 0xF` (0..=7 = +0..+7, 8..=15 = -8..-1)
82/// and `note_index = 0..=35` (C-1 at 0, B-3 at 35).
83///
84/// Transcribed verbatim from FireLight-MOD-Player-Tutorial.txt §3.5
85/// (identified there as a straight dump of the Protracker replayer's
86/// `mt_PeriodTable`, which cross-references the spec's own
87/// "Periodtable for Tuning 0, Normal" block in
88/// Protracker-v1.1B-mod.txt.)
89#[rustfmt::skip]
90pub const PERIOD_TABLE: [[u16; 36]; 16] = [
91    // Tuning  0
92    [856,808,762,720,678,640,604,570,538,508,480,453,
93     428,404,381,360,339,320,302,285,269,254,240,226,
94     214,202,190,180,170,160,151,143,135,127,120,113],
95    // Tuning +1
96    [850,802,757,715,674,637,601,567,535,505,477,450,
97     425,401,379,357,337,318,300,284,268,253,239,225,
98     213,201,189,179,169,159,150,142,134,126,119,113],
99    // Tuning +2
100    [844,796,752,709,670,632,597,563,532,502,474,447,
101     422,398,376,355,335,316,298,282,266,251,237,224,
102     211,199,188,177,167,158,149,141,133,125,118,112],
103    // Tuning +3
104    [838,791,746,704,665,628,592,559,528,498,470,444,
105     419,395,373,352,332,314,296,280,264,249,235,222,
106     209,198,187,176,166,157,148,140,132,125,118,111],
107    // Tuning +4
108    [832,785,741,699,660,623,588,555,524,495,467,441,
109     416,392,370,350,330,312,294,278,262,247,233,220,
110     208,196,185,175,165,156,147,139,131,124,117,110],
111    // Tuning +5
112    [826,779,736,694,655,619,584,551,520,491,463,437,
113     413,390,368,347,328,309,292,276,260,245,232,219,
114     206,195,184,174,164,155,146,138,130,123,116,109],
115    // Tuning +6
116    [820,774,730,689,651,614,580,547,516,487,460,434,
117     410,387,365,345,325,307,290,274,258,244,230,217,
118     205,193,183,172,163,154,145,137,129,122,115,109],
119    // Tuning +7
120    [814,768,725,684,646,610,575,543,513,484,457,431,
121     407,384,363,342,323,305,288,272,256,242,228,216,
122     204,192,181,171,161,152,144,136,128,121,114,108],
123    // Tuning -8 (wrapped as index 8)
124    [907,856,808,762,720,678,640,604,570,538,508,480,
125     453,428,404,381,360,339,320,302,285,269,254,240,
126     226,214,202,190,180,170,160,151,143,135,127,120],
127    // Tuning -7
128    [900,850,802,757,715,675,636,601,567,535,505,477,
129     450,425,401,379,357,337,318,300,284,268,253,238,
130     225,212,200,189,179,169,159,150,142,134,126,119],
131    // Tuning -6
132    [894,844,796,752,709,670,632,597,563,532,502,474,
133     447,422,398,376,355,335,316,298,282,266,251,237,
134     223,211,199,188,177,167,158,149,141,133,125,118],
135    // Tuning -5
136    [887,838,791,746,704,665,628,592,559,528,498,470,
137     444,419,395,373,352,332,314,296,280,264,249,235,
138     222,209,198,187,176,166,157,148,140,132,125,118],
139    // Tuning -4
140    [881,832,785,741,699,660,623,588,555,524,494,467,
141     441,416,392,370,350,330,312,294,278,262,247,233,
142     220,208,196,185,175,165,156,147,139,131,123,117],
143    // Tuning -3
144    [875,826,779,736,694,655,619,584,551,520,491,463,
145     437,413,390,368,347,328,309,292,276,260,245,232,
146     219,206,195,184,174,164,155,146,138,130,123,116],
147    // Tuning -2
148    [868,820,774,730,689,651,614,580,547,516,487,460,
149     434,410,387,365,345,325,307,290,274,258,244,230,
150     217,205,193,183,172,163,154,145,137,129,122,115],
151    // Tuning -1
152    [862,814,768,725,684,646,610,575,543,513,484,457,
153     431,407,384,363,342,323,305,288,272,256,242,228,
154     216,203,192,181,171,161,152,144,136,128,121,114],
155];
156
157/// Convert a signed 4-bit finetune (-8..=7) into the row index of
158/// [`PERIOD_TABLE`]. Positive finetunes map to 0..=7 unchanged; negative
159/// finetunes land in 8..=15 (so -8 is row 8, -1 is row 15), matching the
160/// nibble encoding the spec stores in sample header byte 44.
161#[inline]
162pub fn finetune_row(finetune: i8) -> usize {
163    (finetune as u8 & 0x0F) as usize
164}
165
166/// Find a note index (0..=35, C-1..B-3) for the given period by scanning
167/// all 16 finetune rows. Returns `None` if no row has this exact period —
168/// used only by `E3x` glissando.
169pub fn note_index_for_period(period: u16) -> Option<usize> {
170    for row in PERIOD_TABLE.iter() {
171        for (note_idx, &p) in row.iter().enumerate() {
172            if p == period {
173                return Some(note_idx);
174            }
175        }
176    }
177    None
178}
179
180/// A single decoded pattern row entry for one channel.
181#[derive(Clone, Copy, Debug, Default)]
182pub struct Note {
183    /// Period value (0 means "no new note").
184    pub period: u16,
185    /// Sample index 1..=31 (0 means "no sample change").
186    pub sample: u8,
187    /// Effect command nibble (0..=0xF).
188    pub effect: u8,
189    /// Effect parameter byte.
190    pub effect_param: u8,
191}
192
193impl Note {
194    fn decode(raw: [u8; 4]) -> Self {
195        // Byte 0: ssss pppp  (high nibble of sample, high nibble of period)
196        // Byte 1: pppp pppp  (low 8 bits of period)
197        // Byte 2: ssss eeee  (low nibble of sample, effect nibble)
198        // Byte 3: xxxx xxxx  (effect parameter)
199        let period = (((raw[0] & 0x0F) as u16) << 8) | raw[1] as u16;
200        let sample = (raw[0] & 0xF0) | (raw[2] >> 4);
201        let effect = raw[2] & 0x0F;
202        let effect_param = raw[3];
203        Note {
204            period,
205            sample,
206            effect,
207            effect_param,
208        }
209    }
210}
211
212/// A decoded pattern: 64 rows × N channels.
213#[derive(Clone, Debug)]
214pub struct Pattern {
215    pub rows: Vec<Vec<Note>>, // rows[row][channel]
216}
217
218/// Parse all patterns from a MOD bytestream.
219pub fn parse_patterns(header: &ModHeader, bytes: &[u8]) -> Vec<Pattern> {
220    let channels = header.channels as usize;
221    let mut patterns = Vec::with_capacity(header.n_patterns as usize);
222    let base = header.pattern_data_offset();
223
224    for p in 0..header.n_patterns as usize {
225        let mut rows = Vec::with_capacity(PATTERN_ROWS);
226        for r in 0..PATTERN_ROWS {
227            let mut row = Vec::with_capacity(channels);
228            for c in 0..channels {
229                let off = base + (p * PATTERN_ROWS + r) * channels * 4 + c * 4;
230                let raw = if off + 4 <= bytes.len() {
231                    [bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]
232                } else {
233                    [0; 4]
234                };
235                row.push(Note::decode(raw));
236            }
237            rows.push(row);
238        }
239        patterns.push(Pattern { rows });
240    }
241    patterns
242}
243
244/// Vibrato / tremolo waveform selector for E4x / E7x.
245///
246/// Per Protracker-v1.1B-mod.txt:
247/// - low bits 0..=2 pick the shape (0 sine, 1 ramp-down, 2 square, 3 random —
248///   random is ignored in PT).
249/// - bit 2 (value 4/5/6/7) disables retrigger on new notes.
250#[derive(Clone, Copy, Debug, Default)]
251pub struct Waveform {
252    /// 0 = sine, 1 = ramp-down, 2 = square, 3 = random (treated as sine).
253    pub shape: u8,
254    /// If false, position is reset to 0 on every new note (default).
255    pub no_retrigger: bool,
256}
257
258impl Waveform {
259    fn set(&mut self, nibble: u8) {
260        self.shape = nibble & 0x3;
261        self.no_retrigger = nibble & 0x4 != 0;
262    }
263}
264
265/// Per-channel playback state.
266#[derive(Clone, Debug, Default)]
267pub struct Channel {
268    /// 1-based sample index (0 = no sample ever triggered).
269    pub sample_index: u8,
270    /// Fractional read position into the sample's pcm buffer.
271    pub sample_pos: f32,
272    /// Current period (0 = silent / not playing).
273    pub period: u16,
274    /// Current volume 0..=64.
275    pub volume: u8,
276    /// Whether this channel is currently sounding.
277    pub active: bool,
278    /// Current finetune for the most recently loaded sample.
279    pub finetune: i8,
280
281    /// Current effect command (0..=0xF).
282    pub effect: u8,
283    pub effect_param: u8,
284
285    // ---- effect memory ----
286    /// Arpeggio base period — the un-modulated period for this row.
287    pub arp_base_period: u16,
288    /// Last portamento-up param (1xx), used when param == 0.
289    pub mem_porta_up: u8,
290    /// Last portamento-down param (2xx), used when param == 0.
291    pub mem_porta_down: u8,
292    /// Tone portamento target period (set by a note on a 3xy / 5xy row).
293    pub tone_porta_target: u16,
294    /// Tone portamento speed (3xy / 5xy param; 0 reuses the last value).
295    pub tone_porta_speed: u8,
296    /// Last vibrato params — nibble format `rate<<4 | depth`.
297    pub mem_vibrato: u8,
298    /// Vibrato LFO position, signed -32..=31.
299    pub vib_pos: i8,
300    /// Vibrato waveform control.
301    pub vib_wave: Waveform,
302    /// Last tremolo params — nibble format `rate<<4 | depth`.
303    pub mem_tremolo: u8,
304    /// Tremolo LFO position, signed -32..=31.
305    pub trem_pos: i8,
306    /// Tremolo waveform control.
307    pub trem_wave: Waveform,
308    /// Last 9xx sample-offset param.
309    pub mem_sample_offset: u8,
310    /// Last volume-slide param (A/5/6).
311    pub mem_volslide: u8,
312    /// E9x retrigger period (ticks between retriggers).
313    pub retrig_ticks: u8,
314    /// ECx note-cut tick (0 = no cut pending).
315    pub cut_tick: u8,
316    /// EDx note-delay state — `Some(delay_tick)` while pending, filled in on
317    /// row entry.
318    pub delay: Option<DelayedTrigger>,
319    /// Glissando flag (E3x): if set, tone portamento snaps to nearest
320    /// semitone each tick rather than sliding smoothly.
321    pub glissando: bool,
322
323    /// Sample number written on a row that did not also trigger a note —
324    /// the swap is deferred until the next note-on per Protracker quirk
325    /// (see `enter_row` for the citation). 0 means no pending swap.
326    pub pending_sample: u8,
327
328    /// Pending LED-filter state from an `E0x` on this channel's row.
329    /// `Some(true)` = filter ON (E00, LED on). `Some(false)` = filter
330    /// OFF (E01, LED off). `None` = no E0 this row. Resolved at the
331    /// end of `enter_row` after all four channels' tick-0 effects are
332    /// processed: like Fxx, a later channel's E0 wins on the same row.
333    pub pending_led: Option<bool>,
334
335    /// Per-channel pan position, 0..=255. `0` = hard LEFT, `255` =
336    /// hard RIGHT, `128` = centre. This is the FT-extension panning
337    /// state set by `8xx` (full 8-bit pan, `Protracker-effects-
338    /// MODFIL12.txt` lines 1201-1207: "Command 8: Set FINE Panning ...
339    /// xxxxyyyy = panning position. (0=Most left, 255=most right.)")
340    /// and by `E8x` (rough nibble pan, `Protracker-effects-
341    /// MODFIL12.txt` lines 1503-1505: "Command $E8: Set (Rough)
342    /// Panning ... yyyy = panning value. $0 = most left, $F = most
343    /// right.") — the E8 nibble is replicated across both halves of
344    /// the byte so `E80` → 0x00, `E8F` → 0xFF, `E87` → 0x77, matching
345    /// the "rough" 16-step interpretation also documented in
346    /// `multimedia-cx-protracker.html` ("$0 is hard left, $F is hard
347    /// right"). Initial values follow the Amiga LRRL hard-pan
348    /// convention (channels 0 & 3 → 0, 1 & 2 → 255, pattern repeats
349    /// every 4) so a MOD with no panning commands renders identically
350    /// to the pre-r75 build.
351    pub pan: u8,
352
353    /// Last post-volume sample emitted by this channel's mixer. Used
354    /// as the starting point for the crossfade ramp on the next
355    /// re-trigger (see `ramp_prev_sample`). Updated by `mix_one`
356    /// every output frame.
357    pub last_mixed_sample: f32,
358
359    /// Volume that the previous note was being mixed at the moment
360    /// this channel was *re-triggered*. Captured by `enter_row` on
361    /// every fresh note-on (and on retrigger paths like E9x and the
362    /// EDx delayed trigger) so the mixer can crossfade from this old
363    /// value to the new note's first sample over a short ramp,
364    /// instead of stepping discontinuously and producing the audible
365    /// pop that a hard re-trigger leaves behind.
366    ///
367    /// Without the ramp, every note-on creates a discontinuity in the
368    /// mixed bus equal to (last sample of the *old* note) → (first
369    /// sample of the *new* note), which is typically a step of a few
370    /// hundred i16 LSBs and accumulates into the per-trigger HF drift
371    /// observed on real-world MODs (`halluc.mod`, `rhmst.mod`).
372    pub ramp_prev_sample: f32,
373    /// Number of output frames remaining in the per-trigger
374    /// crossfade ramp. The ramp linearly interpolates from
375    /// `ramp_prev_sample` to the new note's mixed sample. Set to
376    /// [`PlayerState::RAMP_FRAMES`] on every fresh trigger; counted
377    /// down by `mix_one` once per output frame.
378    pub ramp_remaining_frames: u32,
379}
380
381/// Stores the note/sample/effect details of an EDy-delayed trigger so we
382/// can fire it at the requested tick inside the same row.
383#[derive(Clone, Copy, Debug, Default)]
384pub struct DelayedTrigger {
385    pub tick: u8,
386    pub period: u16,
387    pub sample: u8,
388}
389
390impl Channel {
391    /// Return the period with any per-tick vibrato offset applied. Used
392    /// only for the mixer step calculation; the raw `period` field is the
393    /// un-modulated value (so chained effects like tone-porta keep
394    /// compounding cleanly).
395    ///
396    /// We clamp to the *extended* period range `[108, 907]` here
397    /// (`Protracker-effects-MODFIL12.txt` §3.2) rather than the porta
398    /// limits `[113, 856]`: a vibrato peak / a finetune-extreme note
399    /// must be free to land at e.g. period 108 (FT +7 B-3) without being
400    /// re-clamped to 113. The mixer needs a positive period for the
401    /// PAULA_CLOCK divide.
402    fn effective_period(&self, vib_offset: i16) -> u16 {
403        let p = self.period as i32 + vib_offset as i32;
404        p.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16
405    }
406
407    /// Mix one sample from this channel into the float accumulator.
408    /// Returns the post-volume signal in `-1.0..=1.0`.
409    fn mix_one(
410        &mut self,
411        samples: &[SampleBody],
412        out_rate: f32,
413        vib_offset: i16,
414        trem_offset: i16,
415    ) -> f32 {
416        if !self.active || self.period == 0 {
417            return 0.0;
418        }
419        let idx = self.sample_index as usize;
420        if idx == 0 || idx > samples.len() {
421            return 0.0;
422        }
423        let body = &samples[idx - 1];
424        if body.pcm.is_empty() {
425            return 0.0;
426        }
427
428        // Loop / end-of-sample wrap.
429        //
430        // Protracker quirk (Protracker-effects-MODFIL12.txt §2.2 + §2.8 +
431        // Protracker-2.3A-misc-info.txt "Repeat point/length" notes):
432        // for a looped sample the playable region is exactly
433        // `loop_start..loop_start+loop_length`. Once the cursor reaches
434        // `loop_end`, it wraps back into the loop region — it must NOT
435        // continue reading past `loop_end` into the sample's "tail" (the
436        // tail bytes are an artefact of how trackers store one-shot data
437        // before the loop region starts and the looped tail; PT discards
438        // them). The previous implementation only wrapped when
439        // `pos >= pcm.len()`, which let the player read garbage past the
440        // declared loop and produce audible glitches on real-world MODs
441        // where loop_end < pcm.len().
442        //
443        // For a looped sample, also clamp loop_end to pcm.len() — some
444        // real-world rips have slightly out-of-range repeat metadata.
445        let pcm_len = body.pcm.len();
446        let looped = body.is_looped();
447        let effective_end_f = if looped {
448            ((body.loop_start as usize + body.loop_length as usize).min(pcm_len)) as f32
449        } else {
450            pcm_len as f32
451        };
452
453        let pos = self.sample_pos;
454        if pos >= effective_end_f {
455            if looped {
456                let loop_start = body.loop_start as f32;
457                let span = effective_end_f - loop_start;
458                if span > 0.0 {
459                    let over = pos - loop_start;
460                    self.sample_pos = loop_start + over.rem_euclid(span);
461                } else {
462                    self.active = false;
463                    return 0.0;
464                }
465            } else {
466                self.active = false;
467                return 0.0;
468            }
469        }
470
471        // Linear interpolation between two nearest samples. The next-sample
472        // fetch must respect the loop boundary: if `i+1` lands on or past
473        // `loop_end`, wrap to `loop_start` (looped) rather than reading the
474        // tail.
475        let effective_end_idx = effective_end_f as usize;
476        let i = self.sample_pos as usize;
477        let frac = self.sample_pos - i as f32;
478        let s0_idx = i.min(pcm_len.saturating_sub(1));
479        let s0 = body.pcm[s0_idx] as f32 / 128.0;
480        let s1_idx = if i + 1 < effective_end_idx {
481            i + 1
482        } else if looped {
483            body.loop_start as usize
484        } else {
485            s0_idx
486        };
487        let s1 = body.pcm[s1_idx.min(pcm_len.saturating_sub(1))] as f32 / 128.0;
488        let interp = s0 + (s1 - s0) * frac;
489
490        // Apply tremolo to the effective volume; clamp 0..=64.
491        let eff_vol = (self.volume as i16 + trem_offset).clamp(0, 64);
492        let out_raw = interp * (eff_vol as f32 / 64.0);
493
494        // Per-trigger volume ramp (1 ms linear crossfade from
495        // `ramp_prev_sample` to the live mix value). See
496        // `PlayerState::RAMP_FRAMES` for the rationale + measurement
497        // notes.
498        let out = if self.ramp_remaining_frames > 0 {
499            let total = PlayerState::RAMP_FRAMES as f32;
500            let consumed = total - self.ramp_remaining_frames as f32;
501            let t = (consumed / total).clamp(0.0, 1.0);
502            let mixed = self.ramp_prev_sample * (1.0 - t) + out_raw * t;
503            self.ramp_remaining_frames -= 1;
504            if self.ramp_remaining_frames == 0 {
505                // Once the ramp completes, stop tracking the previous
506                // sample so a future re-trigger captures a fresh
507                // baseline rather than this stale value.
508                self.ramp_prev_sample = 0.0;
509            }
510            mixed
511        } else {
512            out_raw
513        };
514
515        // Track the last emitted sample so a future re-trigger can
516        // crossfade from it (see `ramp_prev_sample`).
517        self.last_mixed_sample = out;
518
519        // Advance sample_pos by output-rate-scaled increment, using the
520        // vibrato-modulated period for pitch.
521        let eff_period = self.effective_period(vib_offset) as f32;
522        let chan_rate = PAULA_CLOCK / eff_period;
523        let step = chan_rate / out_rate;
524        self.sample_pos += step;
525
526        out
527    }
528}
529
530/// Pending order/row jump scheduled by Bxx, Dxy, or E6x.
531#[derive(Clone, Copy, Debug)]
532struct Jump {
533    /// Next order index (None = next order + 1).
534    order: Option<u8>,
535    /// Row to start at in the new pattern (default 0).
536    row: u8,
537}
538
539/// Top-level player state. Owns samples, patterns, order, and the
540/// per-channel mixer. Feeds `render(dst)` to fill an interleaved stereo
541/// S16 buffer.
542pub struct PlayerState {
543    pub samples: Vec<SampleBody>,
544    pub patterns: Vec<Pattern>,
545    pub order: Vec<u8>,
546    pub song_length: u8,
547
548    pub channels: Vec<Channel>,
549    pub speed: u8,
550    pub bpm: u8,
551
552    /// Current position in the order table (0..song_length).
553    pub order_index: u8,
554    /// Current row inside the current pattern (0..64).
555    pub row: u8,
556    /// Current tick inside the current row (0..speed).
557    pub tick: u8,
558    /// Samples emitted so far within the current tick.
559    pub tick_sample_cursor: u32,
560
561    pub sample_rate: u32,
562    /// Flag set when the song has wrapped past its last order.
563    pub ended: bool,
564
565    /// Pending pattern break / position jump (consumed on tick advance).
566    pending_jump: Option<Jump>,
567
568    /// Per-pattern loop state for E6x. Four channels each track their own
569    /// start row + remaining count independently (per spec).
570    loop_rows: Vec<u8>,
571    loop_counts: Vec<u8>,
572
573    /// EEx pattern-delay: rows to repeat after the current one completes.
574    pattern_delay: u8,
575
576    /// True while we're inside an EE-induced repeat of the current row.
577    /// Per Pro-Noise-Soundtracker-rev4.txt §[14][14] ("Delay pattern"):
578    /// "all effects and previous notes continue during delay" — i.e. the
579    /// note must NOT re-trigger and tick-0 effects must NOT re-fire on the
580    /// repeated passes; only the per-tick (tick > 0) effect handler keeps
581    /// running. Without this flag, `enter_row` would re-execute on every
582    /// repeat and reset `sample_pos` / fine-volume slides would compound
583    /// once per repeat — both audible regressions on real-world MODs that
584    /// use EE for held-note textures.
585    in_pattern_delay_repeat: bool,
586
587    /// Amiga LED filter state. When ON, the mixer applies a 2-pole
588    /// low-pass to all output streams (`Protracker-v1.1B-mod.txt` Cmd
589    /// E0: "C-300E00 connects filter (turns power LED on)" — i.e.
590    /// LED ON is the *default* on real Amigas, and the filter
591    /// attenuates HF content). E01 disconnects the second (LED-gated
592    /// Sallen-Key) pole at ~3.3 kHz, but the always-on first RC
593    /// pole at ~5 kHz stays in place — that pole models the
594    /// always-on anti-alias RC stage that sits between Paula's DAC
595    /// and the audio jacks on every A500 / A1200 motherboard
596    /// (documented in the MilkyTracker reference doc and the
597    /// Polynominal "Amiga filter test" page — both are documentation,
598    /// not third-party source).
599    led_filter: bool,
600    /// First filter pole (always on). For mixed stereo the first two
601    /// slots hold L/R state; for planar per-channel output one slot
602    /// per MOD channel. Don't mix `render` and `render_per_channel`
603    /// calls on the same instance — the slot semantics differ and the
604    /// filter state would smear across modes.
605    led_filter_state: Vec<f32>,
606    /// Second filter pole (only applied when LED is ON). Same slot
607    /// shape as `led_filter_state`.
608    led_filter_state2: Vec<f32>,
609    /// Pre-computed filter coefficient for the always-on first pole
610    /// (`FIXED_RC_CUTOFF_HZ`). Computed lazily on first render once
611    /// `sample_rate` is known (NaN = "not yet computed").
612    led_filter_alpha: f32,
613    /// Pre-computed filter coefficient for the LED-controlled second
614    /// pole (`LED_FILTER_CUTOFF_HZ`). NaN until first render.
615    led_filter_alpha2: f32,
616
617    /// Stereo pan separation in `0.0..=1.0`.
618    ///
619    /// `1.0` = full Amiga hard pan (channels 0/3 → only LEFT, 1/2 →
620    /// only RIGHT, per `Protracker-effects-MODFIL12.txt` §11
621    /// "Channels 1 and 4 are left, and 2 and 3 are right"). `0.0` =
622    /// full mono (all channels in both speakers, like the legacy
623    /// behaviour of `coreaudio` mono out). Defaults to `0.5` (see
624    /// [`Self::DEFAULT_PAN_SEPARATION`]) — empirically the value
625    /// that minimises cross-correlation drift versus a black-box
626    /// reference render on real-world MODs (`halluc.mod`,
627    /// `rhmst.mod`). `MODFIL12.txt` §11 itself recommends NOT
628    /// pushing balance "all the way over to the left or to the
629    /// right" ("Especially when using headphones"); a real-world
630    /// MOD whose intro uses only the right-panned channels (1 + 2)
631    /// — common in compositions like "hallucinations" — would
632    /// otherwise produce a dead left ear for the entire intro,
633    /// which sounds broken to listeners even though it is
634    /// technically per-spec. Adjust via
635    /// [`PlayerState::set_pan_separation`] if a strict hard-pan
636    /// render (1.0) is required.
637    pan_separation: f32,
638}
639
640impl PlayerState {
641    pub fn new(
642        header: &ModHeader,
643        samples: Vec<SampleBody>,
644        patterns: Vec<Pattern>,
645        sample_rate: u32,
646    ) -> Self {
647        let channels = (0..header.channels)
648            .map(|i| {
649                // Initialise per-channel pan to the Amiga LRRL
650                // hard-pan layout (channels 0 & 3 → 0/LEFT, 1 & 2 →
651                // 255/RIGHT, repeating every 4). This matches
652                // `channel_is_left` and keeps a MOD that never issues
653                // 8xx / E8x rendering identically to the pre-r75
654                // build (the per-channel `pan` simply re-derives the
655                // hard-LRRL mix that `sample_all_channels` used to
656                // compute from `channel_is_left(i)`).
657                let pan = if Self::channel_is_left(i as usize) {
658                    0
659                } else {
660                    255
661                };
662                Channel {
663                    pan,
664                    ..Channel::default()
665                }
666            })
667            .collect::<Vec<_>>();
668        let n_ch = channels.len();
669        PlayerState {
670            samples,
671            patterns,
672            order: header.order.clone(),
673            song_length: header.song_length,
674            channels,
675            speed: DEFAULT_SPEED,
676            bpm: DEFAULT_BPM,
677            order_index: 0,
678            row: 0,
679            tick: 0,
680            tick_sample_cursor: 0,
681            sample_rate,
682            ended: false,
683            pending_jump: None,
684            loop_rows: vec![0; n_ch],
685            loop_counts: vec![0; n_ch],
686            pattern_delay: 0,
687            in_pattern_delay_repeat: false,
688            // LED ON is the Amiga default at power-up — and the
689            // ProTracker replayer leaves it on unless an explicit E01
690            // toggles it off. See `Protracker-v1.1B-mod.txt` Cmd E0
691            // ("C-300E00 connects filter (turns power LED on)").
692            led_filter: true,
693            led_filter_state: Vec::new(),
694            led_filter_state2: Vec::new(),
695            led_filter_alpha: f32::NAN,
696            led_filter_alpha2: f32::NAN,
697            pan_separation: Self::DEFAULT_PAN_SEPARATION,
698        }
699    }
700
701    /// Default stereo pan separation (`0.5` = 50 %).
702    ///
703    /// See the `pan_separation` field doc-comment for the rationale.
704    /// We default to **0.5** — half-way between the
705    /// `Protracker-effects-MODFIL12.txt` §11 hard pan recommendation
706    /// (which itself warns against full hard pan "especially when
707    /// using headphones") and full mono. Empirically this is the
708    /// value that minimises cross-correlation drift versus a
709    /// black-box reference render on real-world MODs (`halluc.mod`,
710    /// `rhmst.mod`) — a partial-bleed default is common practice.
711    /// An r14 trial used 0.7 but on these specific fixtures 0.5
712    /// yielded ~3 % better xcorr per 1 s window across the entire
713    /// song. Override with [`PlayerState::set_pan_separation`].
714    pub const DEFAULT_PAN_SEPARATION: f32 = 0.5;
715
716    /// Override the stereo pan separation. `1.0` = full Amiga hard
717    /// pan (channels 0/3 → LEFT only, 1/2 → RIGHT only). `0.0` =
718    /// full mono. Values outside `0.0..=1.0` are clamped.
719    pub fn set_pan_separation(&mut self, sep: f32) {
720        self.pan_separation = sep.clamp(0.0, 1.0);
721    }
722
723    /// Read the current stereo pan separation.
724    pub fn pan_separation(&self) -> f32 {
725        self.pan_separation
726    }
727
728    /// Length of the per-trigger volume ramp, in output frames.
729    /// 44 frames at 44.1 kHz ≈ 1 ms — enough to smooth the
730    /// discontinuity at a fresh note-on without smearing percussive
731    /// transients audibly.
732    ///
733    /// Without this ramp, every note re-trigger emits a single-sample
734    /// step of magnitude `(prev_mixed - new_mixed)` into the post-mix
735    /// L/R bus. On real-world MODs (`halluc.mod`, `rhmst.mod`) those
736    /// steps were measured at ~5775 LSB and were the dominant
737    /// contributor to per-second cross-correlation drift versus a
738    /// black-box reference render. A 1 ms linear crossfade
739    /// corresponds to the `Protracker-effects-MODFIL12.txt` §1.6
740    /// note about Paula taking ~140 µs to settle on a fresh DMA
741    /// fetch (we round up to one full output millisecond so the
742    /// ramp is robust at any reasonable output sample rate).
743    pub const RAMP_FRAMES: u32 = 44;
744
745    /// Cutoff of the always-on first RC stage that sits between
746    /// Paula's DAC and the audio jacks.
747    ///
748    /// Real-hardware schematic R/C values yield 1/(2πRC) ≈ 4400 Hz
749    /// (rounded to **5000 Hz** in r14 of this module to model an
750    /// "authentic" Amiga sound). However, the prevailing modern
751    /// rendering convention effectively bypasses this stage in
752    /// the default render path — only the LED-gated stage is
753    /// modelled — because the real-hardware corner lops off audible
754    /// content that listeners expect to hear back from a MOD
755    /// render. With the strict 5 kHz value we measured
756    /// cross-correlation drift against a black-box reference render
757    /// on `rhmst.mod` of about 5 % (~0.94 vs ~0.99) — i.e. the
758    /// always-on filter was the dominant contributor to per-second
759    /// drift versus the modern reference.
760    ///
761    /// We default to **16000 Hz** to make the always-on stage
762    /// audibly transparent (it still rolls off ultrasonic content
763    /// past the resampler, which is the only PT-faithful purpose
764    /// it serves at modern output rates).
765    ///
766    /// Documentation references: `paula-filter-notes.md`,
767    /// `docs/audio/trackers/mod/openmpt-module-formats.html`
768    /// (Resampling).
769    pub const FIXED_RC_CUTOFF_HZ: f32 = 16_000.0;
770
771    /// Cutoff of the LED-controlled second RC stage (the one
772    /// toggled by `E00` / `E01`).
773    ///
774    /// The real Amiga "Power LED" filter is a 12 dB/oct Sallen-Key
775    /// cell with ~3.275 kHz nominal corner — that's the
776    /// "spec-strict" value previously used here. However,
777    /// `multimedia-cx-protracker.html` documents an
778    /// **11500 Hz 1-pole approximation** that the modern PT
779    /// rendering convention converges on, and every
780    /// cross-correlation measurement against a black-box reference
781    /// render confirms 11500 Hz is much closer to the modern-player
782    /// ground truth than 3300 Hz is.
783    /// The Sallen-Key value lops off so much HF content that
784    /// real-world MOD files render audibly muffled compared to
785    /// what their authors were probably listening to in their
786    /// final mixdown — and this manifests as cross-correlation
787    /// dips of 0.05–0.10 across most of `rhmst.mod` and
788    /// `halluc.mod`.
789    pub const LED_FILTER_CUTOFF_HZ: f32 = 11_500.0;
790
791    /// Compute a 1-pole IIR alpha for the given cutoff at the
792    /// player's output sample rate.
793    /// `y[n] = a*x[n] + (1-a)*y[n-1]` with `a = 1 - exp(-2π·fc/fs)`.
794    fn compute_alpha(sample_rate: u32, fc: f32) -> f32 {
795        let fs = sample_rate as f32;
796        let two_pi = 2.0 * std::f32::consts::PI;
797        1.0 - (-two_pi * fc / fs).exp()
798    }
799
800    /// Ensure both filter state vectors have at least `n_outputs`
801    /// slots and both alphas are initialised.
802    fn ensure_led_filter(&mut self, n_outputs: usize) {
803        if self.led_filter_state.len() < n_outputs {
804            self.led_filter_state.resize(n_outputs, 0.0);
805        }
806        if self.led_filter_state2.len() < n_outputs {
807            self.led_filter_state2.resize(n_outputs, 0.0);
808        }
809        if self.led_filter_alpha.is_nan() {
810            self.led_filter_alpha = Self::compute_alpha(self.sample_rate, Self::FIXED_RC_CUTOFF_HZ);
811        }
812        if self.led_filter_alpha2.is_nan() {
813            self.led_filter_alpha2 =
814                Self::compute_alpha(self.sample_rate, Self::LED_FILTER_CUTOFF_HZ);
815        }
816    }
817
818    /// Apply the two-stage Amiga output filter on slot `idx`. The
819    /// first RC stage is *always* on (it models the always-on
820    /// 4-5 kHz anti-alias post-DAC pole that exists on every
821    /// A500 / A1200 motherboard regardless of LED state). The
822    /// second pole is gated on `led_filter`, modelling the
823    /// `E00 / E01` LED-controlled Sallen-Key corner at ~3.3 kHz.
824    #[inline]
825    fn led_filter_step(&mut self, idx: usize, x: f32) -> f32 {
826        // Stage 1 — always on.
827        let a1 = self.led_filter_alpha;
828        let prev1 = self.led_filter_state[idx];
829        let y1 = a1 * x + (1.0 - a1) * prev1;
830        self.led_filter_state[idx] = y1;
831
832        if !self.led_filter {
833            return y1;
834        }
835
836        // Stage 2 — gated on LED.
837        let a2 = self.led_filter_alpha2;
838        let prev2 = self.led_filter_state2[idx];
839        let y2 = a2 * y1 + (1.0 - a2) * prev2;
840        self.led_filter_state2[idx] = y2;
841        y2
842    }
843
844    /// Samples-per-tick rounded down. Classic formula is
845    /// `sample_rate * 2.5 / BPM`.
846    pub fn samples_per_tick(&self) -> u32 {
847        ((self.sample_rate as f32) * 2.5 / self.bpm as f32) as u32
848    }
849
850    /// Process the row at the current position (called at tick 0).
851    fn enter_row(&mut self) {
852        let pattern_idx = self
853            .order
854            .get(self.order_index as usize)
855            .copied()
856            .unwrap_or(0) as usize;
857        if pattern_idx >= self.patterns.len() {
858            self.ended = true;
859            return;
860        }
861        let row_notes: Vec<Note> = self.patterns[pattern_idx].rows[self.row as usize].clone();
862        for (ch_idx, note) in row_notes.iter().enumerate() {
863            if ch_idx >= self.channels.len() {
864                break;
865            }
866
867            let effect = note.effect;
868            let param = note.effect_param;
869            let x = param >> 4;
870            let y = param & 0x0F;
871
872            let ch = &mut self.channels[ch_idx];
873
874            // Arpeggio cleanup: if the previous row was running arpeggio
875            // (effect 0 with non-zero param), `ch.period` was left at a
876            // semitone-shifted value by the last tick (per FireLight
877            // tutorial §5.1: tick%3==1 → +x, tick%3==2 → +y). At a row
878            // boundary the spec calls for "Tick 0 set frequency to
879            // normal value" (FireLight §5.1) — i.e. the un-modulated
880            // base. Restore `ch.period` to `ch.arp_base_period` before
881            // any new-row decisions so:
882            //   1. The mixer plays tick 0 of the new row at the correct
883            //      base pitch instead of whatever leftover modulated
884            //      value the prior row's last arp tick left behind.
885            //   2. The "no new note" branch below does not capture the
886            //      stale modulated period as a NEW `arp_base_period` —
887            //      that would compound the modulation across every row
888            //      with continuing arpeggio (audible on `cyber.mod`'s
889            //      pat-1 ch-2 lead, rows 32-58, where the effect-0 cell
890            //      pattern is "trigger / continue / trigger / trigger
891            //      / trigger / continue / ..." — every "continue" row
892            //      played at the prior tick's modulated period and
893            //      then re-modulated from that, raising the pitch by
894            //      ~3 semitones per non-trigger row).
895            //   3. Other effects starting on this row (porta up/down,
896            //      tone porta, fine slides) operate on the true base
897            //      period rather than a modulated one.
898            // See `Protracker-effects-MODFIL12.txt` 0:Arpeggio and
899            // FireLight-MOD-Player-Tutorial.txt §5.1.
900            if ch.effect == 0x0 && ch.effect_param != 0 && ch.arp_base_period != 0 {
901                ch.period = ch.arp_base_period;
902            }
903
904            ch.effect = effect;
905            ch.effect_param = param;
906            ch.cut_tick = 0;
907            ch.delay = None;
908            ch.retrig_ticks = 0;
909
910            // Sample change.
911            //
912            // Protracker quirk (Protracker-effects-MODFIL12.txt §3.2 +
913            // Pro-Noise-Soundtracker-rev4.txt:113-118): when a sample
914            // number is specified WITHOUT a note, PT loads the new
915            // sample's default volume + finetune immediately, but does
916            // NOT swap the currently-playing sample on the channel —
917            // the swap happens at the next note trigger. Loading the
918            // sample index too early was an audible bug: a row with
919            // "C-2 05 ___ : --- 03" would suddenly play sample 3's PCM
920            // at sample 5's pitch, producing wrong-instrument artefacts
921            // on real-world MODs that use this idiom (e.g. setting up
922            // the next note's volume on the row before the trigger).
923            //
924            // We track the "pending" sample number in `sample_index`
925            // separately from the actively-playing sample, but since the
926            // mixer reads `sample_index`, the safest fix is: only update
927            // `sample_index` when a note also triggers (or on a tone-
928            // portamento / note-delay row, where the trigger is
929            // explicit). Otherwise, defer the swap and only latch the
930            // default volume + finetune (used by the next note).
931            let has_note = note.period != 0;
932            let is_note_delay_pre = note.effect == 0xE && (note.effect_param >> 4) == 0xD;
933            if note.sample != 0 {
934                let idx = note.sample as usize;
935                if idx >= 1 && idx <= self.samples.len() {
936                    let body = &self.samples[idx - 1];
937                    ch.volume = body.volume;
938                    ch.finetune = body.finetune;
939                }
940                if has_note || is_note_delay_pre {
941                    // A trigger is happening on this row — latch the
942                    // sample index so the new note plays the new sample.
943                    ch.sample_index = note.sample;
944                } else {
945                    // No trigger: remember the requested swap for the
946                    // next note. Volume + finetune are already applied
947                    // (PT behaviour); the active sample stays.
948                    ch.pending_sample = note.sample;
949                }
950            }
951
952            let is_tone_porta = matches!(effect, 0x3 | 0x5);
953            let is_note_delay = effect == 0xE && x == 0xD;
954
955            // Tone portamento: record target, but DO NOT retrigger.
956            if note.period != 0 && is_tone_porta {
957                ch.tone_porta_target = note.period;
958                if effect == 0x3 && param != 0 {
959                    ch.tone_porta_speed = param;
960                }
961                // Speed 0 on Cmd 3 inherits last; Cmd 5 never sets speed.
962                ch.arp_base_period = ch.period;
963            } else if note.period != 0 && is_note_delay {
964                // Delay the trigger to tick y; continue previous note until then.
965                ch.delay = Some(DelayedTrigger {
966                    tick: y,
967                    period: note.period,
968                    sample: note.sample,
969                });
970                ch.arp_base_period = ch.period;
971            } else if note.period != 0 {
972                // Normal note trigger — apply E5 finetune if it lands on this row.
973                let mut note_period = note.period;
974                if effect == 0xE && x == 0x5 {
975                    // E5x: set finetune and re-derive the period from the
976                    // note index.
977                    let new_ft = y as i8;
978                    let signed_ft = if new_ft & 0x8 != 0 {
979                        new_ft - 16
980                    } else {
981                        new_ft
982                    };
983                    ch.finetune = signed_ft;
984                    if let Some(note_idx) = note_index_for_period(note.period) {
985                        note_period = PERIOD_TABLE[finetune_row(signed_ft)][note_idx];
986                    }
987                }
988                ch.period = note_period;
989
990                // Consume any deferred sample swap from a previous row
991                // that wrote a sample number without a note.
992                if note.sample == 0 && ch.pending_sample != 0 {
993                    ch.sample_index = ch.pending_sample;
994                }
995                ch.pending_sample = 0;
996
997                // 9xx: start from an offset instead of the sample start.
998                let mut offset_frames: u32 = 0;
999                if effect == 0x9 {
1000                    let used = if param == 0 {
1001                        ch.mem_sample_offset
1002                    } else {
1003                        param
1004                    };
1005                    ch.mem_sample_offset = used;
1006                    offset_frames = (used as u32) * 0x100;
1007                }
1008
1009                // 9xx out-of-range quirk: if the offset lands at or past
1010                // the end of the sample body, ProTracker plays NO NOTE at
1011                // all on this channel (Protracker-effects-MODFIL12.txt
1012                // 9:Set-sample-offset, lines 1240-1242: "Note that if the
1013                // effect is out of range (e.g. if it tries to jump beyond
1014                // the end of the sample) NO NOTE WILL BE PLAYED!"). We
1015                // detect this at trigger time and silence the channel
1016                // instead of letting the mixer wrap a looped sample's
1017                // over-range cursor back into the loop region (which would
1018                // audibly play the loop from a fresh trigger — exactly the
1019                // artefact the spec says should not happen). The check
1020                // applies only when a 9xx offset was actually requested;
1021                // a plain note with no 9xx keeps the original semantics.
1022                if effect == 0x9 {
1023                    let sample_len = (ch.sample_index as usize)
1024                        .checked_sub(1)
1025                        .and_then(|i| self.samples.get(i))
1026                        .map(|b| b.pcm.len())
1027                        .unwrap_or(0);
1028                    if offset_frames as usize >= sample_len {
1029                        // No note: leave the channel inactive and skip the
1030                        // rest of the trigger (position / ramp / LFO
1031                        // retrigger). arp_base_period still anchors to the
1032                        // intended note period so a following effect-0 row
1033                        // has a sane base, matching the "note info is still
1034                        // updated, just not played" reading.
1035                        ch.active = false;
1036                        ch.sample_pos = 0.0;
1037                        ch.arp_base_period = note_period;
1038                        continue;
1039                    }
1040                }
1041
1042                ch.sample_pos = offset_frames as f32;
1043                ch.active = true;
1044                ch.arp_base_period = note_period;
1045
1046                // Per-trigger volume ramp: capture the value the
1047                // mixer last emitted for this channel and arm a short
1048                // crossfade. See `PlayerState::RAMP_FRAMES` for why.
1049                ch.ramp_prev_sample = ch.last_mixed_sample;
1050                ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1051
1052                // Retrigger vibrato / tremolo unless waveform says otherwise.
1053                if !ch.vib_wave.no_retrigger {
1054                    ch.vib_pos = 0;
1055                }
1056                if !ch.trem_wave.no_retrigger {
1057                    ch.trem_pos = 0;
1058                }
1059            } else {
1060                // No note — keep arp base anchored to the current period.
1061                ch.arp_base_period = ch.period;
1062            }
1063
1064            // Tick-0 effects.
1065            apply_tick0_effect(
1066                ch_idx,
1067                effect,
1068                param,
1069                &mut self.channels,
1070                &mut self.pending_jump,
1071                &mut self.loop_rows,
1072                &mut self.loop_counts,
1073                &mut self.pattern_delay,
1074                self.order_index,
1075                self.row,
1076            );
1077        }
1078
1079        // Fxx applies immediately on tick 0 — handle after per-channel dispatch.
1080        // A later channel's Fxx supersedes an earlier one (PT behaviour, per
1081        // `Pro-Noise-Soundtracker-rev4.txt:375-377`: "the ones on
1082        // higher-numbered channels take precedence over the
1083        // ones on lower-numbered channels").
1084        //
1085        // Boundary check (Pro-Noise-Soundtracker-rev4.txt:362-365): the
1086        // doc reads "If z<=32, then it means 'set ticks/division to z'
1087        // otherwise it means 'set beats/minute to z' (convention says
1088        // that this should read 'If z<32...')". We follow the
1089        // convention (z < 0x20 → speed, else BPM), so 0x20 (= 32) is
1090        // the smallest BPM value, matching `Protracker-v1.1B-mod.txt`
1091        // and the FireLight tutorial. 0x1F is the largest speed value.
1092        for ch in &self.channels {
1093            if ch.effect == 0xF {
1094                let p = ch.effect_param;
1095                if p == 0 {
1096                    // Fxx 00 — ProTracker treats this as "halt" (set
1097                    // order_index past the end). Safer default: ignore.
1098                } else if p < 0x20 {
1099                    self.speed = p;
1100                } else {
1101                    self.bpm = p;
1102                }
1103            }
1104        }
1105
1106        // E0x LED filter: same per-row last-channel-wins resolution.
1107        // `Protracker-v1.1B-mod.txt` Cmd E0: E00 connects filter (LED on),
1108        // E01 disconnects (LED off).
1109        let mut new_led: Option<bool> = None;
1110        for ch in &mut self.channels {
1111            if let Some(new_state) = ch.pending_led.take() {
1112                new_led = Some(new_state);
1113            }
1114        }
1115        if let Some(s) = new_led {
1116            self.led_filter = s;
1117        }
1118    }
1119
1120    /// Advance one tick (called at the start of every tick).
1121    fn advance_tick(&mut self) {
1122        if self.tick == 0 {
1123            // Pattern-delay repeat: per Pro-Noise §[14][14] ("all effects
1124            // and previous notes continue during delay") we must NOT
1125            // re-execute `enter_row` on the repeated passes — that would
1126            // re-trigger notes (resetting sample_pos to 0) and re-fire
1127            // tick-0 effects (compounding fine vol slides etc.). The
1128            // currently-playing notes simply continue; tick-N effects on
1129            // the inbetween ticks still run normally below.
1130            if !self.in_pattern_delay_repeat {
1131                self.enter_row();
1132            }
1133        } else {
1134            // Tick-N effects run per channel.
1135            for ch_idx in 0..self.channels.len() {
1136                apply_tickn_effect(ch_idx, self.tick, &mut self.channels, &self.samples);
1137            }
1138        }
1139    }
1140
1141    /// Move to next row (or jump).
1142    fn next_row(&mut self) {
1143        // EEx pattern delay: repeat the current row `pattern_delay` more
1144        // times. Set `in_pattern_delay_repeat` so the next pass through
1145        // tick 0 skips `enter_row` (no note re-trigger, no tick-0 effect
1146        // re-fire) per Pro-Noise-Soundtracker-rev4.txt §[14][14].
1147        if self.pattern_delay > 0 {
1148            self.pattern_delay -= 1;
1149            self.in_pattern_delay_repeat = true;
1150            return;
1151        }
1152        // Real row advance — clear the repeat flag so the next row's
1153        // tick-0 processing runs normally.
1154        self.in_pattern_delay_repeat = false;
1155        if let Some(jump) = self.pending_jump.take() {
1156            if let Some(order) = jump.order {
1157                self.order_index = order;
1158            } else {
1159                self.order_index = self.order_index.saturating_add(1);
1160            }
1161            self.row = jump.row;
1162        } else {
1163            self.row += 1;
1164            if self.row as usize >= PATTERN_ROWS {
1165                self.row = 0;
1166                self.order_index = self.order_index.saturating_add(1);
1167            }
1168        }
1169        if self.order_index >= self.song_length {
1170            self.ended = true;
1171        }
1172    }
1173
1174    /// True if track index `i` is hard-panned left under the classic
1175    /// Amiga convention (channels 0 & 3 left, 1 & 2 right; for >4 channels
1176    /// the pattern repeats every 4).
1177    pub fn channel_is_left(i: usize) -> bool {
1178        matches!(i % 4, 0 | 3)
1179    }
1180
1181    /// Compute the instantaneous vibrato period offset for this channel.
1182    fn vibrato_offset(ch: &Channel) -> i16 {
1183        let rate = ch.mem_vibrato >> 4;
1184        let depth = ch.mem_vibrato & 0x0F;
1185        if depth == 0 || ch.effect != 0x4 && ch.effect != 0x6 {
1186            // Vibrato is only active on ticks where 4xy or 6xy is the current
1187            // effect. A subsequent non-vibrato effect on the same channel
1188            // stops the modulation.
1189            let _ = rate; // keep rate referenced even in this path
1190            return 0;
1191        }
1192        let idx = (ch.vib_pos.unsigned_abs() & 31) as usize;
1193        let base = match ch.vib_wave.shape {
1194            0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1195            1 => {
1196                // Ramp down: |pos|<<3 with 255-x on the negative half.
1197                let raw = (idx << 3) as i32;
1198                if ch.vib_pos < 0 {
1199                    255 - raw
1200                } else {
1201                    raw
1202                }
1203            }
1204            _ => 255, // square
1205        };
1206        let delta = (base * depth as i32) >> 7;
1207        if ch.vib_pos < 0 {
1208            -(delta as i16)
1209        } else {
1210            delta as i16
1211        }
1212    }
1213
1214    /// Compute the instantaneous tremolo volume offset for this channel.
1215    fn tremolo_offset(ch: &Channel) -> i16 {
1216        let depth = ch.mem_tremolo & 0x0F;
1217        if depth == 0 || ch.effect != 0x7 {
1218            return 0;
1219        }
1220        let idx = (ch.trem_pos.unsigned_abs() & 31) as usize;
1221        let base = match ch.trem_wave.shape {
1222            0 | 3 => PROTRACKER_SINE_TABLE[idx] as i32,
1223            1 => {
1224                let raw = (idx << 3) as i32;
1225                if ch.trem_pos < 0 {
1226                    255 - raw
1227                } else {
1228                    raw
1229                }
1230            }
1231            _ => 255,
1232        };
1233        // Tremolo divides by 64 (half the vibrato denominator) so the
1234        // peak delta maps to volume units.
1235        let delta = (base * depth as i32) >> 6;
1236        if ch.trem_pos < 0 {
1237            -(delta as i16)
1238        } else {
1239            delta as i16
1240        }
1241    }
1242
1243    /// Sample all channels once, returning per-channel floats in
1244    /// `-1.0..=1.0` range (pre-pan, pre-mix) and a stereo mix scaled so
1245    /// that a fully-saturated 4-channel MOD stays within the -1..1 range.
1246    ///
1247    /// Pan model: with separation `s ∈ [0, 1]`, a hard-left channel
1248    /// contributes `(1 + s) / 2` to L and `(1 - s) / 2` to R; vice
1249    /// versa for hard-right. So `s = 1` reproduces pure Amiga hard
1250    /// pan (channels 0/3 → only L, 1/2 → only R) and `s = 0` is
1251    /// full mono. The default `0.5` (see `pan_separation` field)
1252    /// keeps an intro that uses only right-panned channels (1 + 2,
1253    /// per the convention in `Protracker-effects-MODFIL12.txt` §11)
1254    /// audible on the left speaker too — the modern PT rendering
1255    /// convention applies the same partial bleed for the
1256    /// headphone-fatigue reason called out in MODFIL12.txt itself
1257    /// ("Especially when using headphones").
1258    /// Without it, real-world MODs whose intros use only
1259    /// right-panned voices (e.g. "hallucinations" by ???) sound
1260    /// broken to a listener whose left ear receives no signal at
1261    /// all for several seconds.
1262    fn sample_all_channels(&mut self, per_channel: &mut [f32]) -> (f32, f32) {
1263        let out_rate = self.sample_rate as f32;
1264        let mut l = 0.0f32;
1265        let mut r = 0.0f32;
1266        let n_ch = self.channels.len();
1267        let s = self.pan_separation;
1268        // Per-channel pan derives the L/R gain pair from `ch.pan`
1269        // (0 = hard LEFT, 128 = centre, 255 = hard RIGHT) per the
1270        // 8xx / E8x spec. The `pan_separation` global narrows the L↔R
1271        // gap symmetrically around the centre: a fully panned channel
1272        // contributes `(1 + s) / 2` to its near speaker and
1273        // `(1 - s) / 2` to the far one. A centred channel (pan = 128)
1274        // splits evenly regardless of separation. This collapses to
1275        // the pre-r75 hard-LRRL formula whenever `ch.pan` is 0 or
1276        // 255 (which is the LRRL default initialised by `new`), so
1277        // the libmodplug calibration in the divisor below still
1278        // holds bit-for-bit.
1279        for (i, ch) in self.channels.iter_mut().enumerate() {
1280            let vib = Self::vibrato_offset(ch);
1281            let trem = Self::tremolo_offset(ch);
1282            let smp = ch.mix_one(&self.samples, out_rate, vib, trem);
1283            per_channel[i] = smp;
1284            let (gl, gr) = pan_gains(ch.pan, s);
1285            l += smp * gl;
1286            r += smp * gr;
1287        }
1288        // Mix-bus headroom divisor.
1289        //
1290        // Round 19 calibration vs libmodplug 0.8.9.0 (used as a
1291        // black-box behaviour oracle through the public C API in
1292        // `tests/libmodplug_compare.rs` — see that file's header for
1293        // the dlopen flow + clean-config protocol). With every
1294        // libmodplug colouration disabled (oversampling, megabass,
1295        // surround, noise reduction off; LINEAR resampling; 44100 Hz
1296        // / 16-bit / stereo; master volume default 128/512;
1297        // stereo_separation 128/256 = 50 %), a single max-volume
1298        // hard-left channel triggered on a 4-channel `M.K.` MOD lands
1299        // libmodplug's L peak at 8500 / 32767 = 0.2594 — which only
1300        // works out arithmetically if libmodplug's per-channel
1301        // headroom divisor is **3**, not the **2** we previously
1302        // used. The pan formula itself (near = (1+s)/2,
1303        // far = (1-s)/2) matches across the two engines bit-exact;
1304        // only the headroom divisor differs.
1305        //
1306        // Empirically the libmodplug formula is `n_ch/2 + 1` — i.e.
1307        // 3 for 4-ch, 4 for 6-ch, 5 for 8-ch, etc. That gives a
1308        // ~1.5× lower per-channel max gain than the strict `n_ch/2`
1309        // headroom we shipped before, so a single channel never
1310        // dominates the bus at 84 % of i16 peak the way ours did
1311        // (which the user-reported "scrambled audio at 4.5s" hunt
1312        // bracketed as the most-likely cause of downstream-clipping
1313        // perceived as scrambling on real-world MODs `halluc.mod`
1314        // and `rhmst.mod`). The peak ratio measured against
1315        // libmodplug drops from 1.506× to roughly 1.0× after this
1316        // change — confirmed by the `libmodplug_calibration_*` test
1317        // in `tests/libmodplug_compare.rs`.
1318        //
1319        // We don't read libmodplug source; the divisor is derived
1320        // purely from black-box measurement of the public-API
1321        // output PCM at known input parameters. See
1322        // `docs/audio/trackers/mod/openmpt-module-formats.html`
1323        // ("Resampling and mixing") which documents the same
1324        // "channel count + safety margin" pattern as the modern
1325        // ProTracker rendering convention — though no third-party
1326        // source code is referenced.
1327        let norm = ((n_ch as f32 / 2.0) + 1.0).max(1.0);
1328        (l / norm, r / norm)
1329    }
1330
1331    /// Render one stereo S16 interleaved sample pair by mixing all
1332    /// channels. Channels 0/3 pan toward LEFT, 1/2 toward RIGHT
1333    /// (Amiga convention) — the strength of the L↔R separation is
1334    /// controlled by [`pan_separation`](Self::pan_separation), which
1335    /// defaults to `DEFAULT_PAN_SEPARATION` (`0.5`) rather than full
1336    /// hard pan to keep intros that only use one side audible on
1337    /// both ears. Set to `1.0` for strict spec-faithful Amiga
1338    /// hard pan.
1339    fn render_one(&mut self, out: &mut [i16]) {
1340        let mut per_channel = vec![0.0f32; self.channels.len()];
1341        let (l, r) = self.sample_all_channels(&mut per_channel);
1342        // Amiga LED low-pass filter — applied post-mix on the L/R bus
1343        // (the real Amiga filter sits between Paula's DAC and the audio
1344        // jacks, common to both stereo channels). See
1345        // `multimedia-cx-protracker.html` E0x.
1346        self.ensure_led_filter(2);
1347        let l = self.led_filter_step(0, l);
1348        let r = self.led_filter_step(1, r);
1349        let l = l.clamp(-1.0, 1.0);
1350        let r = r.clamp(-1.0, 1.0);
1351        out[0] = (l * 32767.0) as i16;
1352        out[1] = (r * 32767.0) as i16;
1353    }
1354
1355    /// Render `n_frames` stereo samples into `dst` (interleaved S16,
1356    /// length = n_frames * 2). Returns samples actually rendered (may be
1357    /// less than requested if song ends).
1358    pub fn render(&mut self, dst: &mut [i16]) -> usize {
1359        assert!(dst.len() % 2 == 0);
1360        let mut produced = 0usize;
1361        let total_frames = dst.len() / 2;
1362
1363        while produced < total_frames {
1364            if self.ended {
1365                break;
1366            }
1367            if self.tick_sample_cursor == 0 {
1368                self.advance_tick();
1369            }
1370            let spt = self.samples_per_tick().max(1);
1371            let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
1372            let want = (total_frames - produced).min(remaining_in_tick as usize);
1373
1374            for _ in 0..want {
1375                let off = produced * 2;
1376                self.render_one(&mut dst[off..off + 2]);
1377                produced += 1;
1378            }
1379
1380            self.tick_sample_cursor += want as u32;
1381            if self.tick_sample_cursor >= spt {
1382                self.tick_sample_cursor = 0;
1383                self.tick += 1;
1384                if self.tick >= self.speed {
1385                    self.tick = 0;
1386                    self.next_row();
1387                }
1388            }
1389        }
1390        produced
1391    }
1392
1393    /// Render into one S16 plane per MOD channel. `planes.len()` must
1394    /// equal `self.channels.len()`; each plane receives the same number
1395    /// of samples, and all planes must be at least `n_frames` long.
1396    pub fn render_per_channel(&mut self, planes: &mut [&mut [i16]], n_frames: usize) -> usize {
1397        assert_eq!(
1398            planes.len(),
1399            self.channels.len(),
1400            "render_per_channel: plane count must equal MOD channel count"
1401        );
1402        for p in planes.iter() {
1403            assert!(
1404                p.len() >= n_frames,
1405                "render_per_channel: every plane must hold at least n_frames samples"
1406            );
1407        }
1408
1409        let mut produced = 0usize;
1410        let mut scratch = vec![0.0f32; self.channels.len()];
1411
1412        while produced < n_frames {
1413            if self.ended {
1414                break;
1415            }
1416            if self.tick_sample_cursor == 0 {
1417                self.advance_tick();
1418            }
1419            let spt = self.samples_per_tick().max(1);
1420            let remaining_in_tick = spt.saturating_sub(self.tick_sample_cursor);
1421            let want = (n_frames - produced).min(remaining_in_tick as usize);
1422
1423            // For per-channel output the Amiga LED filter is applied
1424            // independently on each plane — a downstream consumer that
1425            // re-mixes the planes still sees the same global filter
1426            // shape that mixed playback would produce. We allocate one
1427            // filter slot per plane.
1428            let n_planes = planes.len();
1429            for _ in 0..want {
1430                // Discard the stereo mix; we only need per-channel here.
1431                let _ = self.sample_all_channels(&mut scratch);
1432                self.ensure_led_filter(n_planes);
1433                for (ch_idx, plane) in planes.iter_mut().enumerate() {
1434                    let raw = scratch[ch_idx];
1435                    let filtered = self.led_filter_step(ch_idx, raw);
1436                    let v = filtered.clamp(-1.0, 1.0);
1437                    plane[produced] = (v * 32767.0) as i16;
1438                }
1439                produced += 1;
1440            }
1441
1442            self.tick_sample_cursor += want as u32;
1443            if self.tick_sample_cursor >= spt {
1444                self.tick_sample_cursor = 0;
1445                self.tick += 1;
1446                if self.tick >= self.speed {
1447                    self.tick = 0;
1448                    self.next_row();
1449                }
1450            }
1451        }
1452        produced
1453    }
1454}
1455
1456/// Apply tick-0 (row-start) effects. `ch_idx` identifies the channel
1457/// inside `channels`; shared song-level state (pending_jump, pattern delay
1458/// etc.) is passed by &mut so a single effect can mutate both.
1459#[allow(clippy::too_many_arguments)]
1460fn apply_tick0_effect(
1461    ch_idx: usize,
1462    effect: u8,
1463    param: u8,
1464    channels: &mut [Channel],
1465    pending_jump: &mut Option<Jump>,
1466    loop_rows: &mut [u8],
1467    loop_counts: &mut [u8],
1468    pattern_delay: &mut u8,
1469    order_index: u8,
1470    row: u8,
1471) {
1472    let x = param >> 4;
1473    let y = param & 0x0F;
1474    let ch = &mut channels[ch_idx];
1475    match effect {
1476        0x0 => {
1477            // Arpeggio — parameter memory is just the row's x/y; no state
1478            // needed on tick 0 beyond setting the arp base period (done in
1479            // enter_row) and remembering the param for subsequent ticks.
1480        }
1481        0x1
1482            // Portamento up: remember param for tick-N dispatch.
1483            if param != 0 => {
1484                ch.mem_porta_up = param;
1485            }
1486        0x2
1487            // Portamento down: remember param.
1488            if param != 0 => {
1489                ch.mem_porta_down = param;
1490            }
1491        0x3
1492            // Tone portamento: speed 0 reuses the stored value.
1493            if param != 0 => {
1494                ch.tone_porta_speed = param;
1495            }
1496        0x4 => {
1497            // Vibrato: nibble param memory (0 nibbles reuse previous values).
1498            let mut rate = x;
1499            let mut depth = y;
1500            if rate == 0 {
1501                rate = ch.mem_vibrato >> 4;
1502            }
1503            if depth == 0 {
1504                depth = ch.mem_vibrato & 0x0F;
1505            }
1506            ch.mem_vibrato = (rate << 4) | depth;
1507        }
1508        0x5
1509            // Tone portamento + volume slide. Reuse stored tone-porta speed;
1510            // param here is the volume-slide nibble pair.
1511            if param != 0 => {
1512                ch.mem_volslide = param;
1513            }
1514        0x6
1515            // Vibrato + volume slide. Vibrato params are inherited.
1516            if param != 0 => {
1517                ch.mem_volslide = param;
1518            }
1519        0x7 => {
1520            // Tremolo: nibble param memory.
1521            let mut rate = x;
1522            let mut depth = y;
1523            if rate == 0 {
1524                rate = ch.mem_tremolo >> 4;
1525            }
1526            if depth == 0 {
1527                depth = ch.mem_tremolo & 0x0F;
1528            }
1529            ch.mem_tremolo = (rate << 4) | depth;
1530        }
1531        0x8 => {
1532            // 8xx: Set FINE Panning (FT2 extension).
1533            // `Protracker-effects-MODFIL12.txt` lines 1201-1207:
1534            //   "Command 8: Set FINE Panning.
1535            //    Even if the AMIGA PROTracker does not support it,
1536            //    this effect is used by modern MOD's and supported
1537            //    by nearly all modern PC MOD player routines.
1538            //    xxxxyyyy = panning position. (0=Most left,
1539            //    255=most right.)"
1540            // The classic Amiga ProTracker hard-pan layout (LRRL,
1541            // see `channel_is_left`) is overridden per channel by
1542            // this command until a new 8xx / E8x sets a different
1543            // value. No memory semantics in the spec (8xx is
1544            // explicitly not memorised between rows).
1545            ch.pan = param;
1546        }
1547        0x9 => {
1548            // 9xx: handled at note-trigger time (enter_row). Nothing to do here
1549            // since we already latched the memory and the position.
1550        }
1551        0xA
1552            // Volume slide — per-tick param memory.
1553            if param != 0 => {
1554                ch.mem_volslide = param;
1555            }
1556        0xB => {
1557            // Position jump.
1558            *pending_jump = Some(Jump {
1559                order: Some(param),
1560                row: 0,
1561            });
1562        }
1563        0xC => {
1564            // Set volume, clamped to 64.
1565            ch.volume = param.min(64);
1566        }
1567        0xD => {
1568            // Pattern break: row x*10 + y in the NEXT order.
1569            let next_row = (x * 10 + y).min(63);
1570            *pending_jump = Some(Jump {
1571                order: None,
1572                row: next_row,
1573            });
1574        }
1575        0xE => apply_extended_tick0(
1576            ch_idx,
1577            x,
1578            y,
1579            channels,
1580            pending_jump,
1581            loop_rows,
1582            loop_counts,
1583            pattern_delay,
1584            order_index,
1585            row,
1586        ),
1587        0xF => {
1588            // Handled at the song level in enter_row (speed vs BPM split).
1589        }
1590        _ => {}
1591    }
1592}
1593
1594#[allow(clippy::too_many_arguments)]
1595fn apply_extended_tick0(
1596    ch_idx: usize,
1597    x: u8,
1598    y: u8,
1599    channels: &mut [Channel],
1600    pending_jump: &mut Option<Jump>,
1601    loop_rows: &mut [u8],
1602    loop_counts: &mut [u8],
1603    pattern_delay: &mut u8,
1604    order_index: u8,
1605    row: u8,
1606) {
1607    let ch = &mut channels[ch_idx];
1608    match x {
1609        0x0 => {
1610            // E0x: set Amiga LED filter on/off. Per
1611            // `Protracker-v1.1B-mod.txt` Cmd E0:
1612            //   E00 = "connects filter (turns power LED on)"
1613            //   E01 = "disconnects filter (turns power LED off)"
1614            // Real-world MODs use this for tonal contrast (filter ON
1615            // gives warmth via a 1-pole lowpass at ~11.5 kHz). We can't
1616            // reach the song-level state from here, so we annotate the
1617            // channel and the caller propagates it after the per-channel
1618            // dispatch loop completes.
1619            ch.pending_led = Some(y == 0);
1620        }
1621        0x1 => {
1622            // E1x: fine portamento up — one-shot slide on tick 0.
1623            ch.period = ch.period.saturating_sub(y as u16).max(PERIOD_MIN);
1624        }
1625        0x2 => {
1626            // E2x: fine portamento down — clamp at C-1 (period 856) per
1627            // `Protracker-v1.1B-mod.txt` Cmd E2 ("works just like the
1628            // normal portamento down" -> 2xx limit applies).
1629            ch.period = (ch.period + y as u16).min(PERIOD_MAX);
1630        }
1631        0x3 => {
1632            // E3x: glissando control.
1633            ch.glissando = y != 0;
1634        }
1635        0x4 => {
1636            // E4x: set vibrato waveform.
1637            ch.vib_wave.set(y);
1638        }
1639        0x5 => {
1640            // E5x: set finetune (note retrigger path handled in enter_row).
1641            // If no note triggered this row, update finetune for future notes.
1642            let raw = y;
1643            let signed = if raw & 0x8 != 0 {
1644                (raw as i8) - 16
1645            } else {
1646                raw as i8
1647            };
1648            ch.finetune = signed;
1649        }
1650        0x6 => {
1651            // E6x: pattern loop (per-channel). When looping back, schedule
1652            // the jump via `pending_jump` so the rest of the row's ticks
1653            // still complete before we rewind.
1654            if y == 0 {
1655                loop_rows[ch_idx] = row;
1656            } else if loop_counts[ch_idx] == 0 {
1657                loop_counts[ch_idx] = y;
1658                *pending_jump = Some(Jump {
1659                    order: Some(order_index),
1660                    row: loop_rows[ch_idx],
1661                });
1662            } else {
1663                loop_counts[ch_idx] -= 1;
1664                if loop_counts[ch_idx] > 0 {
1665                    *pending_jump = Some(Jump {
1666                        order: Some(order_index),
1667                        row: loop_rows[ch_idx],
1668                    });
1669                }
1670            }
1671        }
1672        0x7 => {
1673            // E7x: set tremolo waveform.
1674            ch.trem_wave.set(y);
1675        }
1676        0x8 => {
1677            // E8x: Set (Rough) Panning.
1678            // `Protracker-effects-MODFIL12.txt` lines 1503-1505:
1679            //   "Command $E8: Set (Rough) Panning. (also called
1680            //    MTM panning)
1681            //    yyyy = panning value. $0 = most left, $F = most
1682            //    right."
1683            // The spec doesn't pin a specific 4 → 8 bit upscale, but
1684            // the convention echoed in `multimedia-cx-protracker.html`
1685            // E8x ("Another stereo extension. $0 is hard left, $F is
1686            // hard right.") is for the nibble to span the same range
1687            // as 8xx. Replicating the nibble into both halves of the
1688            // byte (`y << 4 | y`) gives the correct endpoints (0x00,
1689            // 0xFF) and a monotonic centre (0x77/0x88 either side of
1690            // the midpoint), which is the standard "nibble panning"
1691            // mapping used by every modern PT-compatible player.
1692            ch.pan = (y << 4) | y;
1693        }
1694        0x9 => {
1695            // E9x: retrig note — parameter captured here, per-tick handler
1696            // actually replays the sample.
1697            ch.retrig_ticks = y;
1698        }
1699        0xA => {
1700            // EAx: fine volume slide up.
1701            ch.volume = (ch.volume as u16 + y as u16).min(64) as u8;
1702        }
1703        0xB => {
1704            // EBx: fine volume slide down.
1705            ch.volume = ch.volume.saturating_sub(y);
1706        }
1707        0xC => {
1708            // ECx: note cut — the *cut* actually happens at tick x.
1709            ch.cut_tick = y;
1710        }
1711        0xD => { /* EDx: note delay — handled in enter_row (ch.delay). */ }
1712        0xE => {
1713            // EEx: pattern delay — delay row by y more rows' worth of ticks.
1714            *pattern_delay = y;
1715        }
1716        0xF => { /* EFx: invert loop — deliberately not implemented (spec
1717             says "This effect is not supported in any player or
1718             tracker. Don't bother with it"). */
1719        }
1720        _ => {}
1721    }
1722}
1723
1724/// Apply per-tick (tick > 0) effects for one channel.
1725fn apply_tickn_effect(ch_idx: usize, tick: u8, channels: &mut [Channel], samples: &[SampleBody]) {
1726    let ch = &mut channels[ch_idx];
1727    let effect = ch.effect;
1728    let param = ch.effect_param;
1729    let x = param >> 4;
1730    let y = param & 0x0F;
1731
1732    // EDx (note delay): when we reach the stored tick, trigger the note.
1733    if let Some(delayed) = ch.delay {
1734        if tick == delayed.tick {
1735            if delayed.sample != 0 {
1736                let idx = delayed.sample as usize;
1737                ch.sample_index = delayed.sample;
1738                if idx >= 1 && idx <= samples.len() {
1739                    let body = &samples[idx - 1];
1740                    ch.volume = body.volume;
1741                    ch.finetune = body.finetune;
1742                }
1743            } else if ch.pending_sample != 0 {
1744                // Consume any deferred sample swap from a previous row.
1745                ch.sample_index = ch.pending_sample;
1746            }
1747            ch.pending_sample = 0;
1748            ch.period = delayed.period;
1749            ch.sample_pos = 0.0;
1750            ch.active = true;
1751            ch.arp_base_period = delayed.period;
1752            // Per-trigger ramp on the EDx delayed retrigger.
1753            ch.ramp_prev_sample = ch.last_mixed_sample;
1754            ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1755            if !ch.vib_wave.no_retrigger {
1756                ch.vib_pos = 0;
1757            }
1758            if !ch.trem_wave.no_retrigger {
1759                ch.trem_pos = 0;
1760            }
1761            ch.delay = None;
1762        }
1763    }
1764
1765    // ECx: cut — zeros the volume at the specified tick.
1766    if effect == 0xE && x == 0xC && tick == y && y != 0 {
1767        ch.volume = 0;
1768    }
1769
1770    // E9x: retrig every `retrig_ticks` ticks (non-zero).
1771    if effect == 0xE && x == 0x9 && ch.retrig_ticks != 0 && tick % ch.retrig_ticks == 0 {
1772        ch.sample_pos = 0.0;
1773        ch.active = true;
1774        // Per-trigger ramp on the E9x periodic retrigger.
1775        ch.ramp_prev_sample = ch.last_mixed_sample;
1776        ch.ramp_remaining_frames = PlayerState::RAMP_FRAMES;
1777    }
1778
1779    match effect {
1780        0x0
1781            // Arpeggio: tick%3 cycles 0 / +x / +y semitones.
1782            if param != 0 => {
1783                let semis = match tick % 3 {
1784                    0 => 0,
1785                    1 => x as i32,
1786                    2 => y as i32,
1787                    _ => 0,
1788                };
1789                if semis == 0 {
1790                    ch.period = ch.arp_base_period;
1791                } else {
1792                    // Protracker expresses arpeggio via the period table:
1793                    // move `semis` semitones up within the current finetune
1794                    // row (semitone index + semis). Fallback to the
1795                    // equal-temperament approximation when we can't find
1796                    // the base note in the table.
1797                    let ft_row = finetune_row(ch.finetune);
1798                    let mut matched = None;
1799                    for (i, &p) in PERIOD_TABLE[ft_row].iter().enumerate() {
1800                        if p == ch.arp_base_period {
1801                            matched = Some(i);
1802                            break;
1803                        }
1804                    }
1805                    if let Some(base_idx) = matched {
1806                        let target = (base_idx as i32 + semis).clamp(0, 35) as usize;
1807                        ch.period = PERIOD_TABLE[ft_row][target];
1808                    } else {
1809                        let factor = 2.0f32.powf(semis as f32 / 12.0);
1810                        let p = (ch.arp_base_period as f32 / factor) as u16;
1811                        ch.period = p.max(PERIOD_MIN);
1812                    }
1813                }
1814            }
1815        0x1 => {
1816            let used = if param == 0 { ch.mem_porta_up } else { param };
1817            ch.period = ch.period.saturating_sub(used as u16).max(PERIOD_MIN);
1818        }
1819        0x2 => {
1820            let used = if param == 0 { ch.mem_porta_down } else { param };
1821            ch.period = (ch.period + used as u16).min(PERIOD_MAX);
1822        }
1823        0x3 => {
1824            // Tone portamento: slide toward target at stored speed.
1825            tone_porta_step(ch);
1826        }
1827        0x4 => {
1828            // Vibrato: advance LFO position; period stays un-modulated,
1829            // mixing picks up the delta via `vibrato_offset`.
1830            let rate = ch.mem_vibrato >> 4;
1831            advance_lfo(&mut ch.vib_pos, rate);
1832        }
1833        0x5 => {
1834            // Tone portamento + volume slide: both at once.
1835            tone_porta_step(ch);
1836            volume_slide_step(ch, ch.mem_volslide);
1837        }
1838        0x6 => {
1839            // Vibrato + volume slide.
1840            let rate = ch.mem_vibrato >> 4;
1841            advance_lfo(&mut ch.vib_pos, rate);
1842            volume_slide_step(ch, ch.mem_volslide);
1843        }
1844        0x7 => {
1845            // Tremolo: advance LFO position.
1846            let rate = ch.mem_tremolo >> 4;
1847            advance_lfo(&mut ch.trem_pos, rate);
1848        }
1849        0xA => {
1850            let slide = if param == 0 { ch.mem_volslide } else { param };
1851            volume_slide_step(ch, slide);
1852        }
1853        _ => {}
1854    }
1855}
1856
1857/// Slide `period` toward `tone_porta_target` by `tone_porta_speed` units
1858/// per tick. Clamps on crossing the target. If glissando is enabled,
1859/// additionally snap to the nearest note in the current finetune row.
1860fn tone_porta_step(ch: &mut Channel) {
1861    if ch.tone_porta_target == 0 || ch.tone_porta_speed == 0 {
1862        return;
1863    }
1864    let target = ch.tone_porta_target;
1865    let step = ch.tone_porta_speed as i32;
1866    let cur = ch.period as i32;
1867    let new = if cur < target as i32 {
1868        (cur + step).min(target as i32)
1869    } else if cur > target as i32 {
1870        (cur - step).max(target as i32)
1871    } else {
1872        cur
1873    };
1874    // Tone porta clamps only to the extended period range
1875    // `[108, 907]`. Tightening to `[113, 856]` would break finetune
1876    // extremes — e.g. a slide whose target is FT +7 B-3 (period 108)
1877    // would otherwise clamp short at 113 (`Protracker-effects-MODFIL12.txt`
1878    // §3.2 "Normal Min Period = 108 / Max = 907").
1879    ch.period = new.clamp(PERIOD_MIN_EXT as i32, PERIOD_MAX_EXT as i32) as u16;
1880
1881    if ch.glissando {
1882        // Snap to the nearest note in the current finetune row.
1883        let ft_row = finetune_row(ch.finetune);
1884        let row = &PERIOD_TABLE[ft_row];
1885        let mut best = ch.period;
1886        let mut best_diff = i32::MAX;
1887        for &p in row.iter() {
1888            let d = (p as i32 - ch.period as i32).abs();
1889            if d < best_diff {
1890                best_diff = d;
1891                best = p;
1892            }
1893        }
1894        ch.period = best;
1895    }
1896}
1897
1898/// Apply one tick of an `Axy`-style volume slide. `slide` is the nibble
1899/// pair `x<<4 | y`: x raises, y lowers. If both are non-zero, up wins
1900/// (Protracker behaviour).
1901fn volume_slide_step(ch: &mut Channel, slide: u8) {
1902    let x = slide >> 4;
1903    let y = slide & 0x0F;
1904    if x != 0 {
1905        ch.volume = (ch.volume as u16 + x as u16).min(64) as u8;
1906    } else if y != 0 {
1907        ch.volume = ch.volume.saturating_sub(y);
1908    }
1909}
1910
1911/// Compute the (left, right) gain pair for a channel with FT-extension
1912/// 8-bit pan `p` (0 = hard LEFT, 128 = centre, 255 = hard RIGHT) under
1913/// the player's global `pan_separation` `s ∈ [0, 1]` (1 = full Amiga
1914/// hard pan, 0 = collapse to mono).
1915///
1916/// Construction:
1917/// - Map `p` to a unit position `u = p / 255` in `[0, 1]`.
1918/// - Centre and scale to `[-1, 1]`: `c = 2*u - 1`.
1919/// - Scale by separation `s`: `c' = c * s`.
1920/// - Equal-power-by-cancellation linear pan:
1921///   `left  = (1 - c') / 2`
1922///   `right = (1 + c') / 2`
1923///
1924/// At endpoints (`p = 0`, `s = 1` → c' = -1) this collapses to
1925/// `(1, 0)`, identical to the pre-r75 `channel_is_left` hard-pan
1926/// formula via `near = (1 + s) / 2 = 1, far = (1 - s) / 2 = 0`. At
1927/// the centre (`p = 128`, c' ≈ 0) the channel contributes `(0.5,
1928/// 0.5)` regardless of `s`, which is the desired behaviour for a
1929/// centred channel under a global separation narrow. With `p = 0`
1930/// and `s = 0.5` we get `(0.75, 0.25)`, matching the prior
1931/// `near/far = ((1+s)/2, (1-s)/2)` split.
1932pub fn pan_gains(p: u8, s: f32) -> (f32, f32) {
1933    let u = p as f32 / 255.0;
1934    let c = 2.0 * u - 1.0;
1935    let c_eff = c * s.clamp(0.0, 1.0);
1936    let left = (1.0 - c_eff) * 0.5;
1937    let right = (1.0 + c_eff) * 0.5;
1938    (left, right)
1939}
1940
1941/// Advance a vibrato / tremolo LFO position register per the Protracker
1942/// wraparound rule: add `rate`, and when `pos > 31` subtract 64 so the
1943/// signed position stays in `-32..=31`.
1944fn advance_lfo(pos: &mut i8, rate: u8) {
1945    let next = *pos as i32 + rate as i32;
1946    if next > 31 {
1947        *pos = (next - 64) as i8;
1948    } else {
1949        *pos = next as i8;
1950    }
1951}
1952
1953#[cfg(test)]
1954pub mod tests {
1955    use super::*;
1956    use crate::header::parse_header;
1957    use crate::samples::extract_samples;
1958
1959    /// Build a tiny synthetic 4-channel M.K. MOD with one square-wave
1960    /// sample and one pattern that triggers notes on channel 0 across
1961    /// the first 4 rows.
1962    pub fn synth_square_mod() -> Vec<u8> {
1963        let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
1964        out[0..4].copy_from_slice(b"test");
1965        // Sample 1: 32 samples, length-in-words = 16.
1966        out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
1967        // Finetune 0, volume 64.
1968        out[20 + 24] = 0;
1969        out[20 + 25] = 64;
1970        // Loop points: start 0, length 16 words (= 32 samples) — loops full.
1971        out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
1972        out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
1973        // Song length 1, order[0] = 0.
1974        out[950] = 1;
1975        out[951] = 0x7F;
1976        out[952] = 0;
1977        // Signature.
1978        out[1080..1084].copy_from_slice(b"M.K.");
1979        // Pattern 0: 64 rows × 4 channels × 4 bytes = 1024 bytes.
1980        let mut pat = vec![0u8; 64 * 4 * 4];
1981        // Rows 0,16,32,48 — trigger sample 1 on channel 0 with
1982        // descending periods (higher pitch first). Pick periods C-2, D-2,
1983        // E-2, F-2 — classic PT values: 428, 381, 339, 320.
1984        let rows_and_periods = [(0, 428u16), (16, 381), (32, 339), (48, 320)];
1985        for &(row, period) in &rows_and_periods {
1986            let off = row * 4 * 4;
1987            // Sample high nibble (sample = 1, high = 0, low = 1).
1988            // Byte 0 = (sample_hi << 4) | period_hi.
1989            let p_hi = ((period >> 8) & 0x0F) as u8;
1990            let p_lo = (period & 0xFF) as u8;
1991            let sample_hi = 0u8; // high nibble of sample index 1
1992            let sample_lo = 1u8;
1993            pat[off] = (sample_hi << 4) | p_hi;
1994            pat[off + 1] = p_lo;
1995            pat[off + 2] = sample_lo << 4; // effect 0
1996            pat[off + 3] = 0; // param
1997        }
1998        out.extend(pat);
1999        // Sample body: 32-sample square wave (16 hi, 16 lo).
2000        for i in 0..32 {
2001            let v: i8 = if i < 16 { 100 } else { -100 };
2002            out.push(v as u8);
2003        }
2004        out
2005    }
2006
2007    /// Build a MOD with a caller-provided pattern. Sample 1 is a looping
2008    /// 32-byte square wave, volume 64, finetune 0. Only the first row's
2009    /// first channel is meaningful; other rows/channels default to empty.
2010    ///
2011    /// `pattern_rows` is a slice of `(row, channel, Note)` entries; each
2012    /// entry writes its Note into that cell of pattern 0.
2013    pub fn synth_mod_with_pattern(rows: &[(usize, usize, Note)]) -> Vec<u8> {
2014        let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
2015        out[0..4].copy_from_slice(b"test");
2016        out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
2017        out[20 + 24] = 0;
2018        out[20 + 25] = 64;
2019        out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
2020        out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes());
2021        out[950] = 1;
2022        out[951] = 0x7F;
2023        out[952] = 0;
2024        out[1080..1084].copy_from_slice(b"M.K.");
2025
2026        let mut pat = vec![0u8; 64 * 4 * 4];
2027        for &(row, channel, ref note) in rows {
2028            let off = row * 4 * 4 + channel * 4;
2029            let p_hi = ((note.period >> 8) & 0x0F) as u8;
2030            let p_lo = (note.period & 0xFF) as u8;
2031            let sample_hi = (note.sample & 0xF0) >> 4;
2032            let sample_lo = note.sample & 0x0F;
2033            pat[off] = (sample_hi << 4) | p_hi;
2034            pat[off + 1] = p_lo;
2035            pat[off + 2] = (sample_lo << 4) | note.effect;
2036            pat[off + 3] = note.effect_param;
2037        }
2038        out.extend(pat);
2039
2040        for i in 0..32 {
2041            let v: i8 = if i < 16 { 100 } else { -100 };
2042            out.push(v as u8);
2043        }
2044        out
2045    }
2046
2047    fn make_player(bytes: &[u8]) -> PlayerState {
2048        let header = parse_header(bytes).unwrap();
2049        let samples = extract_samples(&header, bytes);
2050        let patterns = parse_patterns(&header, bytes);
2051        PlayerState::new(&header, samples, patterns, 44_100)
2052    }
2053
2054    #[test]
2055    fn decodes_patterns() {
2056        let bytes = synth_square_mod();
2057        let header = parse_header(&bytes).unwrap();
2058        let patterns = parse_patterns(&header, &bytes);
2059        assert_eq!(patterns.len(), 1);
2060        assert_eq!(patterns[0].rows.len(), 64);
2061        assert_eq!(patterns[0].rows[0].len(), 4);
2062        let n = patterns[0].rows[0][0];
2063        assert_eq!(n.period, 428);
2064        assert_eq!(n.sample, 1);
2065    }
2066
2067    #[test]
2068    fn player_renders_nonzero_audio() {
2069        let bytes = synth_square_mod();
2070        let mut player = make_player(&bytes);
2071
2072        // Render ~0.1 s (4410 frames × 2 channels = 8820 samples).
2073        let mut buf = vec![0i16; 4410 * 2];
2074        let produced = player.render(&mut buf);
2075        assert_eq!(produced, 4410);
2076
2077        // Must have at least some non-zero samples.
2078        let nonzero = buf.iter().filter(|&&x| x != 0).count();
2079        assert!(
2080            nonzero > 100,
2081            "expected non-silent PCM, got {nonzero} non-zero samples"
2082        );
2083    }
2084
2085    #[test]
2086    fn samples_per_tick_default() {
2087        let bytes = synth_square_mod();
2088        let player = make_player(&bytes);
2089        assert_eq!(player.samples_per_tick(), 882);
2090    }
2091
2092    #[test]
2093    fn render_per_channel_isolates_channels() {
2094        // The synth MOD triggers notes exclusively on channel 0, so any
2095        // per-channel stream other than 0 must be pure silence.
2096        let bytes = synth_square_mod();
2097        let mut player = make_player(&bytes);
2098
2099        let n_frames = 4410;
2100        let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
2101            .map(|_| vec![0i16; n_frames])
2102            .collect();
2103        let produced = {
2104            let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
2105            player.render_per_channel(&mut views, n_frames)
2106        };
2107        assert_eq!(produced, n_frames);
2108
2109        let ch0_nonzero = planes[0].iter().filter(|&&s| s != 0).count();
2110        assert!(
2111            ch0_nonzero > 100,
2112            "channel 0 should carry audible signal, got {ch0_nonzero} non-zero samples"
2113        );
2114        for (i, plane) in planes.iter().enumerate().skip(1) {
2115            let nonzero = plane.iter().filter(|&&s| s != 0).count();
2116            assert_eq!(
2117                nonzero, 0,
2118                "channel {i} should be silent in synth_square_mod, got {nonzero} non-zero samples"
2119            );
2120        }
2121    }
2122
2123    #[test]
2124    fn render_per_channel_matches_mixed_song_length() {
2125        let bytes = synth_square_mod();
2126        let mut player_mixed = make_player(&bytes);
2127        let mut player_planar = make_player(&bytes);
2128
2129        let n_frames = 2205;
2130        let mut mixed = vec![0i16; n_frames * 2];
2131        let produced_mixed = player_mixed.render(&mut mixed);
2132
2133        let mut planes: Vec<Vec<i16>> = (0..player_planar.channels.len())
2134            .map(|_| vec![0i16; n_frames])
2135            .collect();
2136        let produced_planar = {
2137            let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
2138            player_planar.render_per_channel(&mut views, n_frames)
2139        };
2140
2141        assert_eq!(produced_mixed, n_frames);
2142        assert_eq!(produced_planar, n_frames);
2143    }
2144
2145    // ---------- Spec-driven effect tests ----------
2146
2147    #[test]
2148    fn period_table_cross_check_against_spec() {
2149        // Protracker-v1.1B-mod.txt: "Periodtable for Tuning 0, Normal".
2150        // C-1 = 856, B-1 = 453, C-2 = 428, A-3 = 127, B-3 = 113.
2151        let ft0 = &PERIOD_TABLE[0];
2152        assert_eq!(ft0[0], 856, "C-1 @ ft 0");
2153        assert_eq!(ft0[11], 453, "B-1 @ ft 0");
2154        assert_eq!(ft0[12], 428, "C-2 @ ft 0");
2155        assert_eq!(ft0[33], 127, "A-3 @ ft 0");
2156        assert_eq!(ft0[35], 113, "B-3 @ ft 0");
2157        // Finetune +1, C-2 = 425; finetune -1 (row 15), C-2 = 431.
2158        assert_eq!(PERIOD_TABLE[1][12], 425, "C-2 @ ft +1");
2159        assert_eq!(PERIOD_TABLE[15][12], 431, "C-2 @ ft -1");
2160    }
2161
2162    #[test]
2163    fn sine_table_matches_protracker_half_wave() {
2164        // Spot-check: the 32-entry half-wave starts at 0, peaks at 255 at
2165        // index 16, dips back down to 24 at 31. These are spec values
2166        // from FireLight §5.5.
2167        assert_eq!(PROTRACKER_SINE_TABLE[0], 0);
2168        assert_eq!(PROTRACKER_SINE_TABLE[8], 180);
2169        assert_eq!(PROTRACKER_SINE_TABLE[16], 255);
2170        assert_eq!(PROTRACKER_SINE_TABLE[24], 180);
2171        assert_eq!(PROTRACKER_SINE_TABLE[31], 24);
2172    }
2173
2174    /// Step the player forward one full tick. Uses a scratch stereo
2175    /// buffer of exactly one tick's frames.
2176    fn step_one_tick(player: &mut PlayerState) {
2177        let spt = player.samples_per_tick() as usize;
2178        let mut buf = vec![0i16; spt * 2];
2179        player.render(&mut buf);
2180    }
2181
2182    #[test]
2183    fn tone_porta_reaches_target_period_exactly() {
2184        // Row 0: C-2 (period 428), no effect.
2185        // Row 1: A-2 (period 254), tone-porta with speed $10 = 16.
2186        //   Initial period 428, target 254, diff 174. After ⌈174/16⌉ = 11
2187        //   tick-N steps the period should equal 254. Default speed is 6,
2188        //   meaning each row has 5 non-tick-0 updates; so we need 3 rows
2189        //   (15 steps) of sustained tone-porta to finish the slide.
2190        let bytes = synth_mod_with_pattern(&[
2191            (
2192                0,
2193                0,
2194                Note {
2195                    period: 428,
2196                    sample: 1,
2197                    effect: 0,
2198                    effect_param: 0,
2199                },
2200            ),
2201            (
2202                1,
2203                0,
2204                Note {
2205                    period: 254,
2206                    sample: 0,
2207                    effect: 0x3,
2208                    effect_param: 0x10,
2209                },
2210            ),
2211            (
2212                2,
2213                0,
2214                Note {
2215                    period: 0,
2216                    sample: 0,
2217                    effect: 0x3,
2218                    effect_param: 0x00,
2219                },
2220            ),
2221            (
2222                3,
2223                0,
2224                Note {
2225                    period: 0,
2226                    sample: 0,
2227                    effect: 0x3,
2228                    effect_param: 0x00,
2229                },
2230            ),
2231        ]);
2232        let mut player = make_player(&bytes);
2233        // Walk 4 rows * 6 ticks = 24 ticks worth.
2234        for _ in 0..24 {
2235            step_one_tick(&mut player);
2236        }
2237        assert_eq!(
2238            player.channels[0].period, 254,
2239            "tone porta must clamp at target"
2240        );
2241    }
2242
2243    #[test]
2244    fn vibrato_modulates_period_symmetrically() {
2245        // Trigger a note, then apply 4xy with depth 4 and rate 8. The LFO
2246        // should visit both positive and negative sides of the base period
2247        // over a single row's worth of ticks.
2248        let bytes = synth_mod_with_pattern(&[
2249            (
2250                0,
2251                0,
2252                Note {
2253                    period: 428,
2254                    sample: 1,
2255                    effect: 0x4,
2256                    effect_param: 0x84,
2257                },
2258            ),
2259            (
2260                1,
2261                0,
2262                Note {
2263                    period: 0,
2264                    sample: 0,
2265                    effect: 0x4,
2266                    effect_param: 0x00,
2267                },
2268            ),
2269        ]);
2270        let mut player = make_player(&bytes);
2271
2272        let mut max_delta = 0i32;
2273        let mut min_delta = 0i32;
2274        // 2 rows × 6 ticks = 12 samples of the offset.
2275        for _ in 0..12 {
2276            step_one_tick(&mut player);
2277            let off = PlayerState::vibrato_offset(&player.channels[0]) as i32;
2278            max_delta = max_delta.max(off);
2279            min_delta = min_delta.min(off);
2280        }
2281        // Depth 4: peak sine*depth/128 = 255*4/128 = 7.9 ≈ 7.
2282        assert!(
2283            max_delta >= 4,
2284            "expected positive vibrato swing, got {max_delta}"
2285        );
2286        assert!(
2287            min_delta <= -4,
2288            "expected negative vibrato swing, got {min_delta}"
2289        );
2290    }
2291
2292    #[test]
2293    fn tremolo_modulates_volume_symmetrically() {
2294        let bytes = synth_mod_with_pattern(&[
2295            // Cxx 20 — set volume to 32 so tremolo has headroom both sides.
2296            (
2297                0,
2298                0,
2299                Note {
2300                    period: 428,
2301                    sample: 1,
2302                    effect: 0xC,
2303                    effect_param: 0x20,
2304                },
2305            ),
2306            // 7xy with rate 8 depth 4.
2307            (
2308                1,
2309                0,
2310                Note {
2311                    period: 0,
2312                    sample: 0,
2313                    effect: 0x7,
2314                    effect_param: 0x84,
2315                },
2316            ),
2317            (
2318                2,
2319                0,
2320                Note {
2321                    period: 0,
2322                    sample: 0,
2323                    effect: 0x7,
2324                    effect_param: 0x00,
2325                },
2326            ),
2327        ]);
2328        let mut player = make_player(&bytes);
2329        let mut max_delta = 0i32;
2330        let mut min_delta = 0i32;
2331        for _ in 0..18 {
2332            step_one_tick(&mut player);
2333            let off = PlayerState::tremolo_offset(&player.channels[0]) as i32;
2334            max_delta = max_delta.max(off);
2335            min_delta = min_delta.min(off);
2336        }
2337        // Depth 4: peak = 255*4/64 = 15.94.
2338        assert!(
2339            max_delta >= 8,
2340            "expected positive tremolo swing, got {max_delta}"
2341        );
2342        assert!(
2343            min_delta <= -8,
2344            "expected negative tremolo swing, got {min_delta}"
2345        );
2346    }
2347
2348    #[test]
2349    fn sample_offset_advances_into_sample() {
2350        // 9xx with param 0x01 -> offset = 0x0100 = 256 frames. We construct
2351        // a MOD with a 512-frame-long sample so the offset lands cleanly
2352        // inside the body. After the first tick the channel's sample_pos
2353        // should be at 256 plus the mixer-advance (a handful of samples).
2354        let mut bytes = synth_mod_with_pattern(&[(
2355            0,
2356            0,
2357            Note {
2358                period: 428,
2359                sample: 1,
2360                effect: 0x9,
2361                effect_param: 0x01,
2362            },
2363        )]);
2364        // Patch sample 1 length to 256 words (512 frames).
2365        bytes[20 + 22..20 + 24].copy_from_slice(&256u16.to_be_bytes());
2366        // Disable the loop (repeat length 0) so the 9xx offset does not
2367        // immediately wrap into the loop region — see the loop-boundary
2368        // PT quirk fix in `mix_one`.
2369        bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2370        // Append 480 more bytes (512 total) to the sample body at the tail.
2371        bytes.extend(std::iter::repeat_n(0u8, 480));
2372
2373        let mut player = make_player(&bytes);
2374        step_one_tick(&mut player);
2375        assert_eq!(
2376            player.channels[0].mem_sample_offset, 0x01,
2377            "9xx memory not latched"
2378        );
2379        // sample_pos should have started at 256 (0x01 * 0x100) plus
2380        // whatever the mixer advanced during the tick.
2381        assert!(
2382            player.channels[0].sample_pos >= 256.0,
2383            "expected sample_pos >= 256, got {}",
2384            player.channels[0].sample_pos
2385        );
2386    }
2387
2388    #[test]
2389    fn sample_offset_out_of_range_plays_no_note() {
2390        // ProTracker quirk (Protracker-effects-MODFIL12.txt 9:Set-sample-
2391        // offset, lines 1240-1242): if a 9xx offset lands at or past the
2392        // end of the sample, NO NOTE is played at all. The synth helper
2393        // builds a 32-frame sample; 901 → offset 0x100 = 256 frames, far
2394        // beyond the end, so the channel must stay inactive and emit
2395        // silence rather than wrapping a looped sample back into its loop.
2396        let bytes = synth_mod_with_pattern(&[(
2397            0,
2398            0,
2399            Note {
2400                period: 428,
2401                sample: 1,
2402                effect: 0x9,
2403                effect_param: 0x01,
2404            },
2405        )]);
2406        let mut player = make_player(&bytes);
2407        // The 9xx memory is still latched even though no note plays
2408        // (the note info is updated; only playback is suppressed).
2409        step_one_tick(&mut player);
2410        assert_eq!(
2411            player.channels[0].mem_sample_offset, 0x01,
2412            "9xx memory should still latch even when the offset is OOR"
2413        );
2414        assert!(
2415            !player.channels[0].active,
2416            "out-of-range 9xx must leave the channel inactive (no note)"
2417        );
2418        assert_eq!(
2419            player.channels[0].sample_pos, 0.0,
2420            "out-of-range 9xx must not advance the sample cursor"
2421        );
2422
2423        // Render a chunk and confirm the channel produced silence (no
2424        // wrapped-loop artefact). A fresh player so we re-enter row 0.
2425        let mut player = make_player(&bytes);
2426        let mut buf = vec![0i16; 2048 * 2];
2427        player.render(&mut buf);
2428        assert!(
2429            buf.iter().all(|&s| s == 0),
2430            "out-of-range 9xx must render silence, found non-zero PCM"
2431        );
2432    }
2433
2434    #[test]
2435    fn sample_offset_at_exact_end_plays_no_note() {
2436        // Boundary: an offset landing EXACTLY at the sample length is
2437        // still "at or past the end" per the spec wording, so it must
2438        // also suppress the note. Build a 256-frame sample and request
2439        // 901 (offset = 256 = the length).
2440        let mut bytes = synth_mod_with_pattern(&[(
2441            0,
2442            0,
2443            Note {
2444                period: 428,
2445                sample: 1,
2446                effect: 0x9,
2447                effect_param: 0x01,
2448            },
2449        )]);
2450        // Sample length 128 words = 256 frames; disable loop so the body
2451        // is a plain one-shot.
2452        bytes[20 + 22..20 + 24].copy_from_slice(&128u16.to_be_bytes());
2453        bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2454        // Append the extra body bytes (synth helper writes 32; need 256).
2455        bytes.extend(std::iter::repeat_n(0u8, 256 - 32));
2456
2457        let mut player = make_player(&bytes);
2458        step_one_tick(&mut player);
2459        assert!(
2460            !player.channels[0].active,
2461            "9xx offset == sample length must suppress the note (>= end)"
2462        );
2463    }
2464
2465    #[test]
2466    fn sample_offset_just_inside_end_plays() {
2467        // Contrast: an offset comfortably inside the sample DOES play.
2468        // Build a 512-frame one-shot so 901 (offset 256) seeks to the
2469        // middle and the note survives a full tick of playback. This
2470        // pins the boundary direction of the OOR check (only >= end
2471        // suppresses; in-range still triggers).
2472        let mut bytes = synth_mod_with_pattern(&[(
2473            0,
2474            0,
2475            Note {
2476                period: 428,
2477                sample: 1,
2478                effect: 0x9,
2479                effect_param: 0x01,
2480            },
2481        )]);
2482        // Sample length 256 words = 512 frames; disable loop.
2483        bytes[20 + 22..20 + 24].copy_from_slice(&256u16.to_be_bytes());
2484        bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
2485        bytes.extend(std::iter::repeat_n(7u8, 512 - 32));
2486
2487        let mut player = make_player(&bytes);
2488        step_one_tick(&mut player);
2489        assert!(
2490            player.channels[0].active,
2491            "9xx offset inside the sample must still play the note"
2492        );
2493        assert!(
2494            player.channels[0].sample_pos >= 256.0,
2495            "in-range 9xx should seek to >= 256, got {}",
2496            player.channels[0].sample_pos
2497        );
2498    }
2499
2500    #[test]
2501    fn fine_porta_up_and_down_shift_period_once() {
2502        let bytes = synth_mod_with_pattern(&[
2503            (
2504                0,
2505                0,
2506                Note {
2507                    period: 428,
2508                    sample: 1,
2509                    effect: 0,
2510                    effect_param: 0,
2511                },
2512            ),
2513            // E12: fine porta up by 2.
2514            (
2515                1,
2516                0,
2517                Note {
2518                    period: 0,
2519                    sample: 0,
2520                    effect: 0xE,
2521                    effect_param: 0x12,
2522                },
2523            ),
2524            // E23: fine porta down by 3.
2525            (
2526                2,
2527                0,
2528                Note {
2529                    period: 0,
2530                    sample: 0,
2531                    effect: 0xE,
2532                    effect_param: 0x23,
2533                },
2534            ),
2535        ]);
2536        let mut player = make_player(&bytes);
2537
2538        step_one_tick(&mut player); // row 0, tick 0
2539        assert_eq!(player.channels[0].period, 428);
2540        // Finish row 0 so we reach row 1's tick 0.
2541        for _ in 0..5 {
2542            step_one_tick(&mut player);
2543        }
2544        step_one_tick(&mut player); // row 1, tick 0 — E12 fires.
2545        assert_eq!(player.channels[0].period, 426, "E12 must slide up by 2");
2546        for _ in 0..5 {
2547            step_one_tick(&mut player);
2548        }
2549        step_one_tick(&mut player); // row 2, tick 0 — E23 fires.
2550        assert_eq!(
2551            player.channels[0].period, 429,
2552            "E23 must slide down by 3 from 426"
2553        );
2554    }
2555
2556    #[test]
2557    fn pattern_loop_e6_loops_then_advances() {
2558        // Row 0: trigger note. Row 1: E60 (set loop start). Row 2: E62
2559        // (loop back to row 1 twice). After 2 extra rounds, the player
2560        // must advance to row 3.
2561        //
2562        // Per spec (FireLight §5.22): E6 param != 0 on first visit sets
2563        // loop_count = param and jumps back; subsequent visits decrement
2564        // until zero and advance. So with param=2: visit1 sets counter=2
2565        // jumps back, visit2 counter=1 jumps back, visit3 counter=0
2566        // advances. We should traverse rows 1→2→1→2→1→2→3.
2567        let bytes = synth_mod_with_pattern(&[
2568            (
2569                0,
2570                0,
2571                Note {
2572                    period: 428,
2573                    sample: 1,
2574                    effect: 0,
2575                    effect_param: 0,
2576                },
2577            ),
2578            (
2579                1,
2580                0,
2581                Note {
2582                    period: 0,
2583                    sample: 0,
2584                    effect: 0xE,
2585                    effect_param: 0x60,
2586                },
2587            ),
2588            (
2589                2,
2590                0,
2591                Note {
2592                    period: 0,
2593                    sample: 0,
2594                    effect: 0xE,
2595                    effect_param: 0x62,
2596                },
2597            ),
2598            (
2599                3,
2600                0,
2601                Note {
2602                    period: 339,
2603                    sample: 1,
2604                    effect: 0,
2605                    effect_param: 0,
2606                },
2607            ),
2608        ]);
2609        let mut player = make_player(&bytes);
2610
2611        // Visited rows in order so we can inspect. Render one tick per
2612        // step and snapshot the current row number.
2613        let mut visited: Vec<u8> = Vec::new();
2614        for _ in 0..60 {
2615            step_one_tick(&mut player);
2616            if let Some(&last) = visited.last() {
2617                if last != player.row {
2618                    visited.push(player.row);
2619                }
2620            } else {
2621                visited.push(player.row);
2622            }
2623            if player.row > 3 || player.ended {
2624                break;
2625            }
2626        }
2627
2628        // Expected sequence: 0,1,2,1,2,1,2,3 (with possible trailing rows)
2629        let prefix = &visited[..visited.len().min(8)];
2630        assert_eq!(
2631            prefix,
2632            &[0, 1, 2, 1, 2, 1, 2, 3][..prefix.len()],
2633            "E62 should loop rows 1..=2 twice before advancing; got {visited:?}"
2634        );
2635    }
2636
2637    #[test]
2638    fn retrig_e9_restarts_sample_cursor() {
2639        // E91: retrig on every tick. Without retrig the sample_pos would
2640        // be cumulatively advanced across ticks; with retrig it resets on
2641        // every tick boundary, so the post-tick-2 position stays
2642        // equal to the post-tick-1 position (both are exactly one tick's
2643        // worth of advance starting from 0).
2644        let bytes = synth_mod_with_pattern(&[(
2645            0,
2646            0,
2647            Note {
2648                period: 428,
2649                sample: 1,
2650                effect: 0xE,
2651                effect_param: 0x91,
2652            },
2653        )]);
2654        let mut player = make_player(&bytes);
2655        // Tick 0: note triggers at pos 0, advances for ~882 samples.
2656        step_one_tick(&mut player);
2657        let pos_after_t0 = player.channels[0].sample_pos;
2658        // Tick 1: E91 resets pos to 0 then advances — should equal t0 value.
2659        step_one_tick(&mut player);
2660        let pos_after_t1 = player.channels[0].sample_pos;
2661        // Tick 2: same behaviour.
2662        step_one_tick(&mut player);
2663        let pos_after_t2 = player.channels[0].sample_pos;
2664        assert!(
2665            (pos_after_t1 - pos_after_t0).abs() < 1.0,
2666            "E91 should retrigger; pos_after_t0={pos_after_t0}, pos_after_t1={pos_after_t1}"
2667        );
2668        assert!(
2669            (pos_after_t2 - pos_after_t1).abs() < 1.0,
2670            "E91 should retrigger again; pos_after_t1={pos_after_t1}, pos_after_t2={pos_after_t2}"
2671        );
2672    }
2673
2674    #[test]
2675    fn note_cut_ec_zeros_volume_at_tick() {
2676        // Cxx 40, EC3: cut at tick 3.
2677        let bytes = synth_mod_with_pattern(&[(
2678            0,
2679            0,
2680            Note {
2681                period: 428,
2682                sample: 1,
2683                effect: 0xE,
2684                effect_param: 0xC3,
2685            },
2686        )]);
2687        let mut player = make_player(&bytes);
2688        // Tick 0: volume loaded (64 from sample), note triggered.
2689        step_one_tick(&mut player);
2690        assert_eq!(player.channels[0].volume, 64);
2691        step_one_tick(&mut player); // tick 1
2692        step_one_tick(&mut player); // tick 2
2693        step_one_tick(&mut player); // tick 3: EC3 fires.
2694        assert_eq!(player.channels[0].volume, 0, "EC3 must cut volume at t=3");
2695    }
2696
2697    #[test]
2698    fn note_delay_ed_postpones_trigger() {
2699        // ED3 with a fresh note. The sample should only start at tick 3.
2700        let bytes = synth_mod_with_pattern(&[(
2701            0,
2702            0,
2703            Note {
2704                period: 428,
2705                sample: 1,
2706                effect: 0xE,
2707                effect_param: 0xD3,
2708            },
2709        )]);
2710        let mut player = make_player(&bytes);
2711        // Tick 0 shouldn't trigger — channel remains inactive (no prior note).
2712        step_one_tick(&mut player);
2713        assert!(!player.channels[0].active, "ED3 must not trigger on tick 0");
2714        step_one_tick(&mut player);
2715        step_one_tick(&mut player);
2716        // Tick 3: note fires.
2717        step_one_tick(&mut player);
2718        assert!(
2719            player.channels[0].active,
2720            "ED3 must trigger at tick 3; state={:?}",
2721            player.channels[0]
2722        );
2723        assert_eq!(player.channels[0].period, 428);
2724    }
2725
2726    #[test]
2727    fn fine_volume_slide_ea_eb_shifts_volume_once() {
2728        let bytes = synth_mod_with_pattern(&[
2729            (
2730                0,
2731                0,
2732                Note {
2733                    period: 428,
2734                    sample: 1,
2735                    effect: 0xC,
2736                    effect_param: 0x20,
2737                },
2738            ),
2739            (
2740                1,
2741                0,
2742                Note {
2743                    period: 0,
2744                    sample: 0,
2745                    effect: 0xE,
2746                    effect_param: 0xA3,
2747                },
2748            ),
2749            (
2750                2,
2751                0,
2752                Note {
2753                    period: 0,
2754                    sample: 0,
2755                    effect: 0xE,
2756                    effect_param: 0xB5,
2757                },
2758            ),
2759        ]);
2760        let mut player = make_player(&bytes);
2761        // Row 0.
2762        for _ in 0..6 {
2763            step_one_tick(&mut player);
2764        }
2765        // After row 0 complete → row 1 tick 0: EA3 fires; volume 32 + 3 = 35.
2766        step_one_tick(&mut player);
2767        assert_eq!(player.channels[0].volume, 0x23);
2768        for _ in 0..5 {
2769            step_one_tick(&mut player);
2770        }
2771        // Row 2 tick 0: EB5 fires; 35 - 5 = 30.
2772        step_one_tick(&mut player);
2773        assert_eq!(player.channels[0].volume, 0x1E);
2774    }
2775
2776    #[test]
2777    fn e5_finetune_applies_on_note_row() {
2778        // Row 0: play C-2 (period 428) with E50 — finetune 0.
2779        // Row 1: play C-2 with E51 — finetune +1. Under finetune +1 the
2780        // C-2 period is 425, so the channel's period should be 425 at tick 0.
2781        let bytes = synth_mod_with_pattern(&[
2782            (
2783                0,
2784                0,
2785                Note {
2786                    period: 428,
2787                    sample: 1,
2788                    effect: 0xE,
2789                    effect_param: 0x50,
2790                },
2791            ),
2792            (
2793                1,
2794                0,
2795                Note {
2796                    period: 428,
2797                    sample: 1,
2798                    effect: 0xE,
2799                    effect_param: 0x51,
2800                },
2801            ),
2802        ]);
2803        let mut player = make_player(&bytes);
2804        step_one_tick(&mut player);
2805        assert_eq!(player.channels[0].period, 428);
2806        assert_eq!(player.channels[0].finetune, 0);
2807        for _ in 0..5 {
2808            step_one_tick(&mut player);
2809        }
2810        step_one_tick(&mut player);
2811        assert_eq!(player.channels[0].finetune, 1);
2812        assert_eq!(
2813            player.channels[0].period, 425,
2814            "finetune +1 should retune C-2 to 425"
2815        );
2816    }
2817
2818    #[test]
2819    fn pattern_delay_ee_repeats_row_without_retriggering_effects() {
2820        // Per `Pro-Noise-Soundtracker-rev4.txt` §[14][14] ("Delay
2821        // pattern"): "all effects and previous notes continue during
2822        // delay". The row's tick-0 effects (e.g. EAx fine vol slide,
2823        // Cxx set volume, note triggers) must NOT re-fire on the
2824        // repeated passes — only per-tick effects (vol slides, vibrato,
2825        // tone porta) keep ticking. Without this guarantee a held note
2826        // on the row would re-trigger from sample_pos=0 on every repeat
2827        // and EAx would compound, both audible regressions on real-
2828        // world MODs that use EE for held-note textures.
2829        let bytes = synth_mod_with_pattern(&[
2830            (
2831                0,
2832                0,
2833                Note {
2834                    period: 428,
2835                    sample: 1,
2836                    effect: 0xC,
2837                    effect_param: 0x00,
2838                },
2839            ),
2840            // Row 1: EE1 pattern delay + EA2 fine vol slide up by 2.
2841            // These are *separate channels* in PT, so put the EE1 on ch1.
2842            (
2843                1,
2844                0,
2845                Note {
2846                    period: 0,
2847                    sample: 0,
2848                    effect: 0xE,
2849                    effect_param: 0xA2,
2850                },
2851            ),
2852            (
2853                1,
2854                1,
2855                Note {
2856                    period: 0,
2857                    sample: 0,
2858                    effect: 0xE,
2859                    effect_param: 0xE1,
2860                },
2861            ),
2862        ]);
2863        let mut player = make_player(&bytes);
2864        // Walk row 0 (6 ticks).
2865        for _ in 0..6 {
2866            step_one_tick(&mut player);
2867        }
2868        // Row 1 tick 0: EA2 increments volume from 0 → 2. EE1 sets delay=1.
2869        step_one_tick(&mut player);
2870        assert_eq!(player.channels[0].volume, 2);
2871        // Finish row 1 (5 more ticks) — pattern delay counter ticks down.
2872        for _ in 0..5 {
2873            step_one_tick(&mut player);
2874        }
2875        // Row "1 again" tick 0: per spec, EA2 must NOT re-fire — volume
2876        // stays at 2, not 4.
2877        step_one_tick(&mut player);
2878        assert_eq!(
2879            player.channels[0].volume, 2,
2880            "EEx pattern-delay repeat must not re-fire EA2 (volume must stay 2)"
2881        );
2882    }
2883
2884    #[test]
2885    fn pattern_delay_ee_does_not_retrigger_held_note() {
2886        // Trigger a long, looping sample on row 0, then on row 1 emit
2887        // EE2 (delay 2 row passes) with no note. Across the two delay
2888        // repeats, the channel's `sample_pos` must keep advancing
2889        // monotonically (the previous note "continues during delay" per
2890        // spec). If `enter_row` were re-invoked it would not reset the
2891        // sample (no note in row 1) but the tick-0 effect machinery
2892        // would fire again — for the bug we're really chasing, drop a
2893        // note onto row 1 and confirm it does NOT re-trigger across the
2894        // repeats either.
2895        let bytes = synth_mod_with_pattern(&[
2896            // Row 0: trigger.
2897            (
2898                0,
2899                0,
2900                Note {
2901                    period: 428,
2902                    sample: 1,
2903                    effect: 0,
2904                    effect_param: 0,
2905                },
2906            ),
2907            // Row 1: a note + EE3. The note must trigger on the first
2908            // pass (and only the first pass).
2909            (
2910                1,
2911                0,
2912                Note {
2913                    period: 339,
2914                    sample: 1,
2915                    effect: 0xE,
2916                    effect_param: 0xE3,
2917                },
2918            ),
2919        ]);
2920        let mut player = make_player(&bytes);
2921
2922        // Walk row 0 (6 ticks).
2923        for _ in 0..6 {
2924            step_one_tick(&mut player);
2925        }
2926        // Row 1 tick 0: note triggers (sample_pos resets to 0), EE3
2927        // sets pattern_delay = 3.
2928        step_one_tick(&mut player);
2929        assert_eq!(player.channels[0].period, 339, "row 1 note must trigger");
2930        let pos_after_first_trigger = player.channels[0].sample_pos;
2931
2932        // Walk the rest of row 1's ticks plus all 3 delay repeats. Each
2933        // pass has 6 ticks; we've consumed 1 of the first pass, leaving
2934        // 5 + 3*6 = 23 ticks before the song advances out of row 1.
2935        let mut prev_pos = pos_after_first_trigger;
2936        for tick_idx in 0..23 {
2937            step_one_tick(&mut player);
2938            let cur_pos = player.channels[0].sample_pos;
2939            // Sample is a 32-frame loop; the position wraps inside the
2940            // loop region. We just need to confirm we never reset to 0
2941            // (which would happen if enter_row were re-invoked on the
2942            // repeats and re-triggered the note).
2943            // Allow position == 0 only on the very first iteration (none
2944            // here, so flag any zero immediately). Account for the loop
2945            // wrap by checking we don't drop to a value strictly less
2946            // than the loop_start (which is 0 — so the only invalid
2947            // state is sample_pos == 0.0 followed by another 0.0, i.e.
2948            // an enforced reset, not a wrap).
2949            assert!(
2950                cur_pos != 0.0 || prev_pos == 0.0,
2951                "tick {tick_idx}: sample_pos jumped back to 0 \
2952                 (prev={prev_pos}, cur={cur_pos}) — the EE pattern-delay \
2953                 repeat must NOT re-trigger a note that was already \
2954                 played on the first pass through the row"
2955            );
2956            prev_pos = cur_pos;
2957        }
2958    }
2959
2960    /// Build a 1-channel-style synthetic MOD with a custom sample body so
2961    /// we can probe the mixer's loop-wrap behaviour. The sample is given a
2962    /// loop region from `loop_start` to `loop_start + loop_length`; the
2963    /// PCM tail past `loop_end` is filled with a sentinel value so test
2964    /// code can detect any read past the loop boundary.
2965    fn synth_mod_with_loop_sample(
2966        pcm: &[i8],
2967        loop_start_words: u16,
2968        loop_length_words: u16,
2969    ) -> Vec<u8> {
2970        let mut out = vec![0u8; crate::header::HEADER_FIXED_SIZE];
2971        out[0..4].copy_from_slice(b"loop");
2972        let length_words = (pcm.len() / 2) as u16;
2973        out[20 + 22..20 + 24].copy_from_slice(&length_words.to_be_bytes());
2974        out[20 + 24] = 0;
2975        out[20 + 25] = 64;
2976        out[20 + 26..20 + 28].copy_from_slice(&loop_start_words.to_be_bytes());
2977        out[20 + 28..20 + 30].copy_from_slice(&loop_length_words.to_be_bytes());
2978        out[950] = 1;
2979        out[951] = 0x7F;
2980        out[952] = 0;
2981        out[1080..1084].copy_from_slice(b"M.K.");
2982
2983        let mut pat = vec![0u8; 64 * 4 * 4];
2984        // Row 0, ch 0: trigger sample 1 at C-2 (period 428).
2985        let p_hi = ((428u16 >> 8) & 0x0F) as u8;
2986        let p_lo = (428u16 & 0xFF) as u8;
2987        pat[0] = p_hi;
2988        pat[1] = p_lo;
2989        pat[2] = 1u8 << 4;
2990        pat[3] = 0;
2991        out.extend(pat);
2992        out.extend(pcm.iter().map(|&s| s as u8));
2993        out
2994    }
2995
2996    #[test]
2997    fn loop_wrap_stays_inside_loop_region() {
2998        // Protracker-effects-MODFIL12.txt §2.2: looped samples play only
2999        // the loop_start..loop_start+loop_length region. The "tail" past
3000        // loop_end is decay data that PT discards. Before the fix in
3001        // mix_one, the mixer wrapped only when sample_pos >= pcm.len(),
3002        // producing audible glitches when loop_end < pcm.len(). This
3003        // test sets up loop_end = 64 and pcm.len() = 200, fills the tail
3004        // 64..200 with a distinct sentinel, and verifies that the mixer
3005        // never reads any sample whose magnitude matches the sentinel.
3006        //
3007        // Sample layout (200 bytes total, all i8):
3008        //   0..64   : value +50 (signal in the loop region)
3009        //   64..200 : value -100 (sentinel — must NEVER be read)
3010        // Loop: start=0, length=64.
3011        let mut pcm: Vec<i8> = vec![50; 64];
3012        pcm.extend(std::iter::repeat_n(-100i8, 136));
3013        // pcm.len() must be even (the header stores length in words).
3014        assert!(pcm.len().is_multiple_of(2));
3015        let bytes = synth_mod_with_loop_sample(&pcm, 0, 32);
3016        let mut player = make_player(&bytes);
3017
3018        // Render ~0.05 seconds (2205 frames). At C-2 (period 428) the
3019        // playback rate is PAULA_CLOCK / 428 ≈ 8287 Hz, so this renders
3020        // ~414 sample frames of the source — well past the 64-frame loop
3021        // boundary, exercising several wrap cycles.
3022        let n_frames = 2205;
3023        let mut planes: Vec<Vec<i16>> = (0..player.channels.len())
3024            .map(|_| vec![0i16; n_frames])
3025            .collect();
3026        let _ = {
3027            let mut views: Vec<&mut [i16]> = planes.iter_mut().map(|v| v.as_mut_slice()).collect();
3028            player.render_per_channel(&mut views, n_frames)
3029        };
3030
3031        // Channel 0 carries the signal. Each output frame is a linear
3032        // interpolation of two adjacent sample values; if both are inside
3033        // the loop region they sit around +50/128 ≈ +0.39 (i16 ≈ +12800).
3034        // If the mixer ever reads the sentinel (-100) we would see a
3035        // strongly negative sample. With the 2-pole always-on Amiga
3036        // output filter we need to (1) skip the first ~64 frames while
3037        // the filter accumulators ramp up to steady state, and (2)
3038        // accept any positive value — a sentinel leak would manifest
3039        // as a strongly NEGATIVE excursion (-100/128 → ≈-25600), so
3040        // `v >= 0` is the actual loop-correctness invariant.
3041        for (i, &v) in planes[0].iter().enumerate().skip(64) {
3042            assert!(
3043                v >= 0,
3044                "frame {i}: expected positive +50/128 sample, got {v} \
3045                 — mixer leaked past loop boundary into sentinel tail \
3046                 (negative value implies the -100 sentinel was read)"
3047            );
3048        }
3049    }
3050
3051    #[test]
3052    fn loop_wrap_handles_loop_end_at_pcm_end() {
3053        // Sanity: the classic case where loop_end == pcm.len() must
3054        // continue to work. Sample is a 32-byte square wave looping for
3055        // its full length.
3056        let mut pcm: Vec<i8> = (0..16).map(|_| 100).collect();
3057        pcm.extend(std::iter::repeat_n(-100i8, 16));
3058        let bytes = synth_mod_with_loop_sample(&pcm, 0, 16);
3059        let mut player = make_player(&bytes);
3060        let mut buf = vec![0i16; 4410 * 2];
3061        let produced = player.render(&mut buf);
3062        assert_eq!(produced, 4410);
3063        let nonzero = buf.iter().filter(|&&x| x != 0).count();
3064        assert!(nonzero > 4000, "expected loud square wave output");
3065    }
3066
3067    #[test]
3068    fn sample_swap_without_note_is_deferred() {
3069        // PT quirk (Protracker-effects-MODFIL12.txt §3.2 +
3070        // Pro-Noise-Soundtracker-rev4.txt:113-118): writing a sample
3071        // number on a row that has NO note must NOT swap the active
3072        // sample on the channel — the swap is deferred until the next
3073        // note-on. The row's volume + finetune are still updated.
3074        //
3075        // Setup: two samples in the file. Row 0 triggers sample 1 at
3076        // C-2. Row 1 writes "sample 2" with no note — the channel must
3077        // continue mixing sample 1 (the active one), with sample 2's
3078        // default volume now applied. Row 2 retriggers (no sample
3079        // number, just a note) — this must consume the pending swap and
3080        // start playing sample 2.
3081        let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
3082        bytes[0..4].copy_from_slice(b"swap");
3083        // Sample 1: 32 frames, finetune 0, volume 64, no loop.
3084        bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
3085        bytes[20 + 24] = 0;
3086        bytes[20 + 25] = 64;
3087        bytes[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes());
3088        bytes[20 + 28..20 + 30].copy_from_slice(&0u16.to_be_bytes());
3089        // Sample 2: 32 frames, finetune +3, volume 32, no loop.
3090        bytes[50 + 22..50 + 24].copy_from_slice(&16u16.to_be_bytes());
3091        bytes[50 + 24] = 3;
3092        bytes[50 + 25] = 32;
3093        bytes[50 + 26..50 + 28].copy_from_slice(&0u16.to_be_bytes());
3094        bytes[50 + 28..50 + 30].copy_from_slice(&0u16.to_be_bytes());
3095
3096        bytes[950] = 1;
3097        bytes[951] = 0x7F;
3098        bytes[952] = 0;
3099        bytes[1080..1084].copy_from_slice(b"M.K.");
3100
3101        // Pattern.
3102        let mut pat = vec![0u8; 64 * 4 * 4];
3103        // Row 0, ch 0: C-2 (428) sample 1.
3104        let off = 0;
3105        pat[off] = ((428u16 >> 8) & 0x0F) as u8;
3106        pat[off + 1] = (428u16 & 0xFF) as u8;
3107        pat[off + 2] = 1u8 << 4;
3108        // Row 1, ch 0: sample 2, no period.
3109        let off = 4 * 4;
3110        pat[off] = 0; // no period high nibble, no sample high nibble
3111        pat[off + 1] = 0;
3112        pat[off + 2] = 2u8 << 4; // sample lo = 2, effect 0
3113        pat[off + 3] = 0;
3114        // Row 2, ch 0: D-2 (381), no sample number — should consume pending swap.
3115        let off = 2 * 4 * 4;
3116        pat[off] = ((381u16 >> 8) & 0x0F) as u8;
3117        pat[off + 1] = (381u16 & 0xFF) as u8;
3118        pat[off + 2] = 0; // no sample number
3119        pat[off + 3] = 0;
3120        bytes.extend(pat);
3121
3122        // Sample 1 body: 32 bytes of +50.
3123        bytes.extend(std::iter::repeat_n(50i8 as u8, 32));
3124        // Sample 2 body: 32 bytes of -50.
3125        bytes.extend(std::iter::repeat_n((-50i8) as u8, 32));
3126
3127        let mut player = make_player(&bytes);
3128
3129        // After row 0: channel plays sample 1, volume 64.
3130        for _ in 0..6 {
3131            step_one_tick(&mut player);
3132        }
3133        assert_eq!(player.channels[0].sample_index, 1, "row 0: sample 1 active");
3134        assert_eq!(player.channels[0].volume, 64, "row 0: vol from sample 1");
3135
3136        // Row 1: sample 2 written, no note. Active sample stays at 1,
3137        // but volume + finetune update to sample 2's defaults.
3138        for _ in 0..6 {
3139            step_one_tick(&mut player);
3140        }
3141        assert_eq!(
3142            player.channels[0].sample_index, 1,
3143            "row 1: sample 2 written without note — active sample MUST still be 1"
3144        );
3145        assert_eq!(
3146            player.channels[0].volume, 32,
3147            "row 1: sample 2's default volume must apply immediately"
3148        );
3149        assert_eq!(
3150            player.channels[0].finetune, 3,
3151            "row 1: sample 2's finetune must apply immediately"
3152        );
3153        assert_eq!(
3154            player.channels[0].pending_sample, 2,
3155            "row 1: pending sample swap should be queued"
3156        );
3157
3158        // Row 2: D-2 with no sample number — consumes the pending swap.
3159        for _ in 0..6 {
3160            step_one_tick(&mut player);
3161        }
3162        assert_eq!(
3163            player.channels[0].sample_index, 2,
3164            "row 2: pending sample 2 swap must be consumed by note trigger"
3165        );
3166        assert_eq!(
3167            player.channels[0].pending_sample, 0,
3168            "row 2: pending_sample must clear on consumption"
3169        );
3170    }
3171
3172    // ---------- Round-15 PT-fidelity regression tests ----------
3173
3174    #[test]
3175    fn period_clamp_constants_match_pt_spec() {
3176        // Cross-check the four published period limits.
3177        // Standard porta range: B-3 = 113, C-1 = 856 (Protracker-v1.1B-mod.txt
3178        // Cmd 1/2). Extended range covering finetune ±8: 108..907 per
3179        // Protracker-effects-MODFIL12.txt §3.2 + the period table's
3180        // first/last cells.
3181        assert_eq!(PERIOD_MIN, 113);
3182        assert_eq!(PERIOD_MAX, 856);
3183        assert_eq!(PERIOD_MIN_EXT, 108);
3184        assert_eq!(PERIOD_MAX_EXT, 907);
3185        // Period table corners must agree.
3186        assert_eq!(PERIOD_TABLE[7][35], 108, "FT +7 B-3 must equal 108");
3187        assert_eq!(PERIOD_TABLE[8][0], 907, "FT -8 C-1 must equal 907");
3188    }
3189
3190    #[test]
3191    fn porta_up_clamps_at_period_113() {
3192        // Trigger a note near B-3 (period 120, A#3), then porta-up
3193        // aggressively for a couple of rows. The period must clamp at
3194        // 113 (B-3) and not overshoot below it. Per
3195        // `Protracker-v1.1B-mod.txt` Cmd 1: "You can NOT slide higher
3196        // than B-3! (Period 113)".
3197        let bytes = synth_mod_with_pattern(&[
3198            (
3199                0,
3200                0,
3201                Note {
3202                    period: 120,
3203                    sample: 1,
3204                    effect: 0,
3205                    effect_param: 0,
3206                },
3207            ),
3208            // 1xx with param 0xFF: tries to slide period down by 255
3209            // every tick — would overshoot massively without the clamp.
3210            (
3211                1,
3212                0,
3213                Note {
3214                    period: 0,
3215                    sample: 0,
3216                    effect: 0x1,
3217                    effect_param: 0xFF,
3218                },
3219            ),
3220        ]);
3221        let mut player = make_player(&bytes);
3222        for _ in 0..12 {
3223            step_one_tick(&mut player);
3224        }
3225        assert_eq!(
3226            player.channels[0].period, 113,
3227            "1xx must clamp at period 113 (B-3)"
3228        );
3229    }
3230
3231    #[test]
3232    fn porta_down_clamps_at_period_856() {
3233        // Symmetric test for 2xx — must clamp at 856 (C-1).
3234        let bytes = synth_mod_with_pattern(&[
3235            (
3236                0,
3237                0,
3238                Note {
3239                    period: 800,
3240                    sample: 1,
3241                    effect: 0,
3242                    effect_param: 0,
3243                },
3244            ),
3245            (
3246                1,
3247                0,
3248                Note {
3249                    period: 0,
3250                    sample: 0,
3251                    effect: 0x2,
3252                    effect_param: 0xFF,
3253                },
3254            ),
3255        ]);
3256        let mut player = make_player(&bytes);
3257        for _ in 0..12 {
3258            step_one_tick(&mut player);
3259        }
3260        assert_eq!(
3261            player.channels[0].period, 856,
3262            "2xx must clamp at period 856 (C-1)"
3263        );
3264    }
3265
3266    #[test]
3267    fn effective_period_accepts_finetune_extreme_below_113() {
3268        // A note at FT +7 B-3 has period 108. Without the extended
3269        // clamp the mixer would force-clamp to 113 and detune the note
3270        // by ~5%. With the new clamp at PERIOD_MIN_EXT = 108 the
3271        // effective_period must pass 108 through unchanged.
3272        let mut ch = Channel {
3273            period: 108,
3274            ..Channel::default()
3275        };
3276        // No vibrato active.
3277        ch.effect = 0;
3278        ch.mem_vibrato = 0;
3279        let eff = ch.effective_period(0);
3280        assert_eq!(eff, 108, "FT +7 B-3 (period 108) must not be clamped");
3281    }
3282
3283    #[test]
3284    fn led_filter_alpha_matches_one_pole_lowpass_at_cutoff() {
3285        // Sanity-check the analytical filter coefficient.
3286        // The LED-controlled second pole's alpha at 44.1 kHz with
3287        // cutoff `LED_FILTER_CUTOFF_HZ`.
3288        let a = PlayerState::compute_alpha(44_100, PlayerState::LED_FILTER_CUTOFF_HZ);
3289        let two_pi = 2.0 * std::f32::consts::PI;
3290        let expected = 1.0 - (-two_pi * PlayerState::LED_FILTER_CUTOFF_HZ / 44_100.0).exp();
3291        let diff = (a - expected).abs();
3292        assert!(
3293            diff < 1e-6,
3294            "LED alpha mismatch: got {a}, expected {expected}"
3295        );
3296        // The 1-pole IIR is stable iff 0 < alpha <= 1.
3297        assert!(a > 0.0 && a <= 1.0, "alpha {a} outside (0, 1]");
3298    }
3299
3300    #[test]
3301    fn led_filter_attenuates_nyquist_input() {
3302        // Drive a +1/-1 alternating signal (the worst-case Nyquist
3303        // content at 44.1 kHz) through the filter directly. The
3304        // 2-pole model: the always-on first RC pole at
3305        // `FIXED_RC_CUTOFF_HZ` attenuates HF whether the LED is on
3306        // or off; the second LED-controlled pole adds further
3307        // attenuation when ON. Both branches therefore reduce the
3308        // alternating signal — but the LED-ON branch must reduce
3309        // it strictly MORE than the LED-OFF branch.
3310        let make_player_with_led = |led: bool| -> PlayerState {
3311            let bytes = synth_square_mod();
3312            let mut player = make_player(&bytes);
3313            player.led_filter = led;
3314            player.led_filter_state.clear();
3315            player.led_filter_state2.clear();
3316            player.led_filter_alpha = f32::NAN;
3317            player.led_filter_alpha2 = f32::NAN;
3318            // Force alpha computation now.
3319            player.ensure_led_filter(2);
3320            player
3321        };
3322
3323        let drive = |player: &mut PlayerState, n: usize| -> Vec<f32> {
3324            let mut out = Vec::with_capacity(n);
3325            for i in 0..n {
3326                let x = if i % 2 == 0 { 1.0 } else { -1.0 };
3327                out.push(player.led_filter_step(0, x));
3328            }
3329            out
3330        };
3331
3332        let mut p_on = make_player_with_led(true);
3333        let mut p_off = make_player_with_led(false);
3334        let on = drive(&mut p_on, 256);
3335        let off = drive(&mut p_off, 256);
3336
3337        // Use the second half of the trace (after transient).
3338        let on_pp: f32 = on[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
3339        let off_pp: f32 = off[128..].iter().map(|x| x.abs()).fold(0.0f32, f32::max);
3340
3341        // Both branches attenuate Nyquist (the always-on first RC
3342        // pole cuts HF unconditionally). LED-ON adds the second
3343        // pole and must attenuate strictly more.
3344        assert!(
3345            off_pp < 1.0,
3346            "filter-off magnitude {off_pp} should attenuate via the \
3347             always-on RC pole even when LED is off"
3348        );
3349        assert!(
3350            on_pp < off_pp,
3351            "filter-on magnitude {on_pp} should be strictly < \
3352             filter-off magnitude {off_pp} (LED adds a second pole)"
3353        );
3354    }
3355
3356    #[test]
3357    fn led_filter_default_is_on_at_song_start() {
3358        // Real Amiga power-on default leaves LED on. PT inherits this
3359        // and assumes the filter is engaged for the very first note.
3360        // See `Protracker-v1.1B-mod.txt` Cmd E0 ("E00 connects filter
3361        // (turns power LED on)").
3362        let bytes = synth_square_mod();
3363        let player = make_player(&bytes);
3364        assert!(
3365            player.led_filter,
3366            "LED filter must default to ON (Amiga power-on state)"
3367        );
3368    }
3369
3370    #[test]
3371    fn e0x_toggles_led_filter() {
3372        // Row 0: trigger a note. Row 1: E01 (LED OFF). Row 2: E00
3373        // (LED ON). The player.led_filter flag should follow.
3374        let bytes = synth_mod_with_pattern(&[
3375            (
3376                0,
3377                0,
3378                Note {
3379                    period: 428,
3380                    sample: 1,
3381                    effect: 0,
3382                    effect_param: 0,
3383                },
3384            ),
3385            (
3386                1,
3387                0,
3388                Note {
3389                    period: 0,
3390                    sample: 0,
3391                    effect: 0xE,
3392                    effect_param: 0x01,
3393                },
3394            ),
3395            (
3396                2,
3397                0,
3398                Note {
3399                    period: 0,
3400                    sample: 0,
3401                    effect: 0xE,
3402                    effect_param: 0x00,
3403                },
3404            ),
3405        ]);
3406        let mut player = make_player(&bytes);
3407        // Row 0 — LED should remain ON (default).
3408        for _ in 0..6 {
3409            step_one_tick(&mut player);
3410        }
3411        assert!(player.led_filter, "row 0: LED still ON");
3412        // Row 1 — E01 fires on tick 0, LED goes OFF.
3413        step_one_tick(&mut player);
3414        assert!(!player.led_filter, "row 1: E01 must clear LED");
3415        for _ in 0..5 {
3416            step_one_tick(&mut player);
3417        }
3418        // Row 2 — E00 fires, LED back ON.
3419        step_one_tick(&mut player);
3420        assert!(player.led_filter, "row 2: E00 must restore LED");
3421    }
3422
3423    #[test]
3424    fn fxx_speed_bpm_split_at_0x20() {
3425        // Per the convention noted in `Pro-Noise-Soundtracker-rev4.txt`
3426        // (lines 362-365) and `Protracker-v1.1B-mod.txt` Cmd F:
3427        //   z < 0x20 → set ticks/division (speed)
3428        //   z >= 0x20 → set BPM
3429        // 0x1F (= 31) is the largest speed value, 0x20 (= 32) is the
3430        // smallest BPM value. Verify both by running the song-level
3431        // resolution path from `enter_row`.
3432        let bytes_speed = synth_mod_with_pattern(&[(
3433            0,
3434            0,
3435            Note {
3436                period: 0,
3437                sample: 0,
3438                effect: 0xF,
3439                effect_param: 0x1F,
3440            },
3441        )]);
3442        let mut player = make_player(&bytes_speed);
3443        step_one_tick(&mut player);
3444        assert_eq!(player.speed, 0x1F, "F1F must set speed to 31");
3445        assert_eq!(player.bpm, DEFAULT_BPM, "F1F must NOT touch BPM");
3446
3447        let bytes_bpm = synth_mod_with_pattern(&[(
3448            0,
3449            0,
3450            Note {
3451                period: 0,
3452                sample: 0,
3453                effect: 0xF,
3454                effect_param: 0x20,
3455            },
3456        )]);
3457        let mut player = make_player(&bytes_bpm);
3458        step_one_tick(&mut player);
3459        assert_eq!(player.bpm, 0x20, "F20 must set BPM to 32");
3460        assert_eq!(player.speed, DEFAULT_SPEED, "F20 must NOT touch speed");
3461    }
3462
3463    #[test]
3464    fn e6_dxy_same_row_last_channel_wins() {
3465        // Place E6x on channel 0 (would loop) and Dxy on channel 1
3466        // (forces pattern break to specified row). Per the per-channel
3467        // dispatch in `apply_tick0_effect`, both write to
3468        // `pending_jump`; the later channel wins (PT idiom).
3469        // Verify Dxy on a higher channel takes precedence over E6 on
3470        // a lower channel — the song advances to the break-row, not
3471        // to the loop-target.
3472        let bytes = synth_mod_with_pattern(&[
3473            // Row 0: trigger note on ch0.
3474            (
3475                0,
3476                0,
3477                Note {
3478                    period: 428,
3479                    sample: 1,
3480                    effect: 0,
3481                    effect_param: 0,
3482                },
3483            ),
3484            // Row 1: E60 on ch0 (set loop start = row 1).
3485            (
3486                1,
3487                0,
3488                Note {
3489                    period: 0,
3490                    sample: 0,
3491                    effect: 0xE,
3492                    effect_param: 0x60,
3493                },
3494            ),
3495            // Row 2: E61 on ch0 + D05 on ch1. E61 would loop to row 1
3496            // (jump to row 1 in current order); D05 jumps to row 5 in
3497            // next order. Per PT (channel 1 > channel 0), Dxy wins.
3498            (
3499                2,
3500                0,
3501                Note {
3502                    period: 0,
3503                    sample: 0,
3504                    effect: 0xE,
3505                    effect_param: 0x61,
3506                },
3507            ),
3508            (
3509                2,
3510                1,
3511                Note {
3512                    period: 0,
3513                    sample: 0,
3514                    effect: 0xD,
3515                    effect_param: 0x05,
3516                },
3517            ),
3518        ]);
3519        let mut player = make_player(&bytes);
3520        // Walk rows 0..=2 (3 rows × 6 ticks).
3521        for _ in 0..18 {
3522            step_one_tick(&mut player);
3523        }
3524        // Then advance one more tick to fire next_row().
3525        step_one_tick(&mut player);
3526        // Pattern-break wins: we should now be on row 5 of the next
3527        // order (and `ended` since there's only one pattern).
3528        // For our 1-pattern synth, advancing past order 0 sets `ended`.
3529        assert!(
3530            player.ended || player.row == 5,
3531            "Dxy must override E6x when on a higher-numbered channel; \
3532             got row={}, order={}, ended={}",
3533            player.row,
3534            player.order_index,
3535            player.ended,
3536        );
3537    }
3538
3539    #[test]
3540    fn vibrato_first_half_lowers_pitch_per_firelight_pseudocode() {
3541        // FireLight §5.5 says the sine-table values are ADDED to the
3542        // "AMIGA frequency" (= the period) in the first half-cycle,
3543        // and SUBTRACTED in the second. Adding to the period LOWERS
3544        // the audible pitch (pitch ∝ PAULA_CLOCK / period). Verify
3545        // our implementation matches this convention.
3546        //
3547        // This test pins down the canonical interpretation we follow,
3548        // disambiguating against the alternative C-snippet reading
3549        // some legacy docs cite.
3550        let mut ch = Channel {
3551            period: 428,
3552            sample_index: 1,
3553            volume: 64,
3554            active: true,
3555            effect: 0x4,
3556            mem_vibrato: 0x84, // rate=8, depth=4
3557            vib_pos: 8,        // mid first half-cycle (positive)
3558            ..Channel::default()
3559        };
3560        ch.vib_wave.shape = 0; // sine
3561        let off = PlayerState::vibrato_offset(&ch);
3562        assert!(
3563            off > 0,
3564            "Per FireLight §5.5: positive vib_pos must ADD to period \
3565             (lowering pitch). Got offset {off}."
3566        );
3567        // Effective period should be > base period.
3568        let eff = ch.effective_period(off);
3569        assert!(
3570            eff > ch.period,
3571            "effective_period must rise on positive vib_pos"
3572        );
3573
3574        // Now drive it negative — second half-cycle subtracts.
3575        ch.vib_pos = -8;
3576        let off = PlayerState::vibrato_offset(&ch);
3577        assert!(
3578            off < 0,
3579            "Per FireLight §5.5: negative vib_pos must SUBTRACT from period \
3580             (raising pitch). Got offset {off}."
3581        );
3582    }
3583
3584    #[test]
3585    fn loop_metadata_clamped_when_out_of_range() {
3586        // Defensive: real-world MOD rips sometimes have repeat metadata
3587        // that extends past the actual sample length. extract_samples
3588        // must clamp to keep the mixer from reading past the buffer.
3589        let mut bytes = vec![0u8; crate::header::HEADER_FIXED_SIZE];
3590        bytes[0..4].copy_from_slice(b"clmp");
3591        // Sample length = 16 words = 32 frames.
3592        bytes[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes());
3593        bytes[20 + 25] = 64;
3594        // Repeat start = 100 words (way past the sample). Repeat length = 200.
3595        bytes[20 + 26..20 + 28].copy_from_slice(&100u16.to_be_bytes());
3596        bytes[20 + 28..20 + 30].copy_from_slice(&200u16.to_be_bytes());
3597        bytes[950] = 1;
3598        bytes[951] = 0x7F;
3599        bytes[952] = 0;
3600        bytes[1080..1084].copy_from_slice(b"M.K.");
3601        bytes.extend(std::iter::repeat_n(0u8, 64 * 4 * 4));
3602        bytes.extend(std::iter::repeat_n(0u8, 32));
3603        let header = crate::header::parse_header(&bytes).unwrap();
3604        let samples = crate::samples::extract_samples(&header, &bytes);
3605        // After clamping, loop_start/length must fit within pcm.len() = 32.
3606        let s = &samples[0];
3607        let end = s.loop_start + s.loop_length;
3608        assert!(
3609            end as usize <= s.pcm.len(),
3610            "loop_end ({end}) must be clamped to pcm.len() ({})",
3611            s.pcm.len()
3612        );
3613    }
3614
3615    /// Regression for the round-105 cyber.mod arpeggio bug:
3616    ///
3617    /// When effect 0xy (arpeggio) carries across rows — i.e. row N
3618    /// triggers a note + arpeggio, and row N+1 has no new note but
3619    /// continues the same effect 0xy — the channel's
3620    /// `arp_base_period` MUST stay anchored to the original triggered
3621    /// note. Without the fix, the previous row's last tick left
3622    /// `ch.period` at a semitone-shifted value (FireLight §5.1
3623    /// pseudo: tick%3==2 → +y); the "no note" branch in `enter_row`
3624    /// then captured that modulated period as the NEW arp base, so
3625    /// every continuation row shifted the chord up another (x, y)
3626    /// step. On `cyber.mod` pat-1 ch-2 (rows 32-58, sample 5
3627    /// `st-07:buzzshot`) this produced an audibly out-of-tune lead
3628    /// in the 12-14 s region, which the user reported as "effects a
3629    /// bit off". See `Protracker-effects-MODFIL12.txt` 0:Arpeggio
3630    /// ("This effect means to play the note specified, then the
3631    /// note+xxxx half-steps, then the note+yyyy half-steps, and then
3632    /// return to the original note"; the "original note" is the
3633    /// triggered note, not the previous tick's modulated value) and
3634    /// FireLight-MOD-Player-Tutorial.txt §5.1 ("Tick 0 set frequency
3635    /// to normal value").
3636    #[test]
3637    fn arpeggio_base_persists_across_rows_without_new_note() {
3638        // Row 0: trigger C-2 (period 428) on channel 0 with arpeggio
3639        //        x=3, y=4 (effect 034).
3640        // Row 1: NO new note, same arpeggio 034 still active.
3641        // Expected at row 1, tick 1 (after enter_row + 1 tickN): the
3642        // period must equal `PERIOD_TABLE[0][12 + 3]` (C-2 + 3 semis
3643        // = D#-2 = 339), NOT the bug-shifted value
3644        // `PERIOD_TABLE[0][15 + 3]` (which would be a fifth higher).
3645        // Row 1 tick 0: must equal the un-modulated base (period 428),
3646        // not the leftover modulated period from row 0 tick 5
3647        // (which would be 339 = base + x semis).
3648        let bytes = synth_mod_with_pattern(&[
3649            (
3650                0,
3651                0,
3652                Note {
3653                    period: 428,
3654                    sample: 1,
3655                    effect: 0,
3656                    effect_param: 0x34,
3657                },
3658            ),
3659            (
3660                1,
3661                0,
3662                Note {
3663                    period: 0,
3664                    sample: 0,
3665                    effect: 0,
3666                    effect_param: 0x34,
3667                },
3668            ),
3669        ]);
3670        let mut player = make_player(&bytes);
3671
3672        // Step 8 ticks: ticks 0..5 of row 0 (6 ticks) + ticks 0..1 of
3673        // row 1 (2 ticks). step_one_tick advances state at the end of
3674        // each call; the 7th call's `advance_tick` calls
3675        // `enter_row(row=1)` (where the round-105 fix restores period
3676        // to the un-modulated base), and the 8th call's
3677        // `advance_tick` runs `apply_tickn` for tick 1 of row 1
3678        // (which applies the +x arpeggio offset).
3679        for _ in 0..8 {
3680            step_one_tick(&mut player);
3681        }
3682        // After the round-105 fix, `arp_base_period` must still be
3683        // anchored to the original triggered period (428). Pre-fix
3684        // this would equal the previous row's last-tick modulated
3685        // period (e.g. 320 = C-2 + y=4 semis = E-2), causing the
3686        // chord to shift up by (x, y) on every continuation row.
3687        assert_eq!(
3688            player.channels[0].arp_base_period, 428,
3689            "arpeggio base must persist across rows that have no new \
3690             note — got {}, expected 428 (the original note period). \
3691             Pre-fix this would equal the previous row's last-tick \
3692             modulated period.",
3693            player.channels[0].arp_base_period
3694        );
3695
3696        // After 7 ticks total: tick 1 of row 1 has just been rendered
3697        // (tick=2 in the player's internal counter). Arpeggio sets
3698        // period = base + x semitones = `PERIOD_TABLE[0][12 + 3]`
3699        // = 360 (D#-2 in finetune-0 row). With the bug this would be
3700        // `PERIOD_TABLE[0][15 + 3]` ≈ 285 — a fifth higher than the
3701        // intended +3 semis.
3702        assert_eq!(
3703            player.channels[0].period,
3704            PERIOD_TABLE[0][12 + 3],
3705            "row 1 tick 1 of a continuation arpeggio must land on \
3706             base + x semitones (PERIOD_TABLE[0][15] = {}), not on \
3707             a doubly-shifted value",
3708            PERIOD_TABLE[0][12 + 3]
3709        );
3710    }
3711
3712    /// Default per-channel pan in a freshly-built `PlayerState` follows
3713    /// the Amiga LRRL hard-pan convention (channels 0 & 3 → 0/LEFT,
3714    /// 1 & 2 → 255/RIGHT, repeating every 4) so that a MOD with no
3715    /// 8xx / E8x commands renders identically to the pre-r75 build.
3716    /// Cited in `Protracker-effects-MODFIL12.txt` §11 (the same hard-pan
3717    /// layout that motivates `pan_separation < 1.0` for headphone
3718    /// listeners).
3719    #[test]
3720    fn channel_pan_defaults_to_amiga_lrrl() {
3721        let bytes = synth_square_mod();
3722        let player = make_player(&bytes);
3723        // 4-channel synth: ch 0 & 3 → 0 (LEFT), ch 1 & 2 → 255 (RIGHT).
3724        assert_eq!(player.channels[0].pan, 0, "ch 0 default = LEFT (0)");
3725        assert_eq!(player.channels[1].pan, 255, "ch 1 default = RIGHT (255)");
3726        assert_eq!(player.channels[2].pan, 255, "ch 2 default = RIGHT (255)");
3727        assert_eq!(player.channels[3].pan, 0, "ch 3 default = LEFT (0)");
3728    }
3729
3730    /// `8xx` is the FT-extension Set Fine Panning command:
3731    /// `Protracker-effects-MODFIL12.txt` lines 1201-1207
3732    ///   "Command 8: Set FINE Panning. xxxxyyyy = panning position.
3733    ///    (0=Most left, 255=most right.)"
3734    /// Verify tick-0 dispatch latches the byte verbatim into `ch.pan`.
3735    #[test]
3736    fn effect_8xx_sets_per_channel_pan() {
3737        let bytes = synth_mod_with_pattern(&[
3738            // Row 0: trigger note on ch 1, then 8xx with param 0x40 on ch 1.
3739            (
3740                0,
3741                1,
3742                Note {
3743                    period: 428,
3744                    sample: 1,
3745                    effect: 0x8,
3746                    effect_param: 0x40,
3747                },
3748            ),
3749        ]);
3750        let mut player = make_player(&bytes);
3751        // Sanity — Amiga default for ch 1 = RIGHT (255).
3752        assert_eq!(player.channels[1].pan, 255);
3753        // Tick 0 dispatches the row's tick-0 effects.
3754        step_one_tick(&mut player);
3755        assert_eq!(
3756            player.channels[1].pan, 0x40,
3757            "8xx must overwrite ch.pan with the raw param byte"
3758        );
3759        // Endpoints both directions.
3760        let bytes_left = synth_mod_with_pattern(&[(
3761            0,
3762            2,
3763            Note {
3764                period: 0,
3765                sample: 0,
3766                effect: 0x8,
3767                effect_param: 0x00,
3768            },
3769        )]);
3770        let mut p2 = make_player(&bytes_left);
3771        step_one_tick(&mut p2);
3772        assert_eq!(p2.channels[2].pan, 0x00, "800 = hard LEFT");
3773
3774        let bytes_right = synth_mod_with_pattern(&[(
3775            0,
3776            0,
3777            Note {
3778                period: 0,
3779                sample: 0,
3780                effect: 0x8,
3781                effect_param: 0xFF,
3782            },
3783        )]);
3784        let mut p3 = make_player(&bytes_right);
3785        step_one_tick(&mut p3);
3786        assert_eq!(p3.channels[0].pan, 0xFF, "8FF = hard RIGHT");
3787    }
3788
3789    /// `E8x` is the rough nibble-pan extension:
3790    /// `Protracker-effects-MODFIL12.txt` lines 1503-1505
3791    ///   "Command $E8: Set (Rough) Panning. yyyy = panning value.
3792    ///    $0 = most left, $F = most right."
3793    /// The nibble is replicated into both halves of the byte
3794    /// (`y << 4 | y`) so E80 → 0x00, E8F → 0xFF, E87 → 0x77, E88 →
3795    /// 0x88 — matching the same endpoint mapping as 8xx and the
3796    /// monotonic "rough" 16-step ramp documented in
3797    /// `multimedia-cx-protracker.html` E8x.
3798    #[test]
3799    fn effect_e8x_sets_rough_pan_from_nibble() {
3800        let table = [
3801            (0x0u8, 0x00u8),
3802            (0x1, 0x11),
3803            (0x7, 0x77),
3804            (0x8, 0x88),
3805            (0xF, 0xFF),
3806        ];
3807        for (nibble, expected) in table {
3808            let bytes = synth_mod_with_pattern(&[(
3809                0,
3810                1,
3811                Note {
3812                    period: 0,
3813                    sample: 0,
3814                    effect: 0xE,
3815                    effect_param: 0x80 | nibble,
3816                },
3817            )]);
3818            let mut player = make_player(&bytes);
3819            step_one_tick(&mut player);
3820            assert_eq!(
3821                player.channels[1].pan, expected,
3822                "E8{:X} must set ch.pan to 0x{:02X} (nibble replicated)",
3823                nibble, expected,
3824            );
3825        }
3826    }
3827
3828    /// `pan_gains` must collapse to the pre-r75 hard-pan formula at the
3829    /// LRRL endpoints (pan = 0 or 255) for any `s` — that's the
3830    /// invariant that keeps the libmodplug calibration in
3831    /// `sample_all_channels` valid bit-for-bit on MODs that don't use
3832    /// 8xx / E8x. The centre (pan = 128) must split evenly regardless
3833    /// of `s`; that's the property that lets per-channel pan coexist
3834    /// with the global `pan_separation` narrow.
3835    #[test]
3836    fn pan_gains_matches_legacy_hard_pan_at_endpoints() {
3837        for s_int in 0..=10 {
3838            let s = s_int as f32 / 10.0;
3839            // p = 0: hard LEFT. Legacy formula for a hard-LEFT channel:
3840            //   l_gain = (1 + s) / 2, r_gain = (1 - s) / 2.
3841            let (l, r) = pan_gains(0, s);
3842            let expected_l = (1.0 + s) * 0.5;
3843            let expected_r = (1.0 - s) * 0.5;
3844            assert!(
3845                (l - expected_l).abs() < 1e-6 && (r - expected_r).abs() < 1e-6,
3846                "pan=0 s={s}: got ({l},{r}), expected ({expected_l},{expected_r})",
3847            );
3848            // p = 255: hard RIGHT. Symmetric: l = (1 - s) / 2,
3849            // r = (1 + s) / 2.
3850            let (l, r) = pan_gains(255, s);
3851            assert!(
3852                (l - (1.0 - s) * 0.5).abs() < 1e-6 && (r - (1.0 + s) * 0.5).abs() < 1e-6,
3853                "pan=255 s={s}: got ({l},{r}), expected hard-RIGHT mirror"
3854            );
3855        }
3856        // Centre (p = 128 ≈ 0.5 + ε) splits roughly evenly for any s.
3857        for s_int in 0..=10 {
3858            let s = s_int as f32 / 10.0;
3859            let (l, r) = pan_gains(128, s);
3860            // Drift from exact 0.5 is tiny (128/255 ≠ 0.5 exactly).
3861            let drift = (l - r).abs();
3862            assert!(
3863                drift < 0.01,
3864                "centred pan must yield near-equal L/R for s={s}; got ({l},{r}) drift={drift}",
3865            );
3866        }
3867    }
3868
3869    /// End-to-end smoke: a synth MOD with no 8xx / E8x commands must
3870    /// render exactly the same bytes after the per-channel-pan rework
3871    /// as before (the LRRL initialisation + endpoint collapse of
3872    /// `pan_gains` guarantees this). We verify by checking that
3873    /// `render` produces non-trivial signal on BOTH stereo lanes for
3874    /// a 4-ch MOD whose only triggered channel is ch 0 (LEFT-panned)
3875    /// — the `pan_separation = 0.5` default bleeds it to the right
3876    /// at 25 % gain, which is exactly the prior behaviour.
3877    #[test]
3878    fn render_with_no_pan_commands_preserves_legacy_bleed() {
3879        let bytes = synth_square_mod();
3880        let mut player = make_player(&bytes);
3881        let mut buf = vec![0i16; 1024 * 2];
3882        let produced = player.render(&mut buf);
3883        assert!(produced > 0, "must render some samples");
3884        let peak_l = buf
3885            .chunks_exact(2)
3886            .map(|c| c[0].unsigned_abs() as u32)
3887            .max()
3888            .unwrap_or(0);
3889        let peak_r = buf
3890            .chunks_exact(2)
3891            .map(|c| c[1].unsigned_abs() as u32)
3892            .max()
3893            .unwrap_or(0);
3894        assert!(peak_l > 0, "LEFT lane must carry signal");
3895        assert!(
3896            peak_r > 0,
3897            "RIGHT lane must carry bleed signal (default pan_separation = 0.5)"
3898        );
3899        // Bleed ratio: hard-LEFT ch under separation 0.5 → 25 % on R,
3900        // 75 % on L. Tolerate ramp + filter / interpolation slack.
3901        let ratio = peak_r as f32 / peak_l as f32;
3902        assert!(
3903            (0.25..=0.4).contains(&ratio),
3904            "RIGHT-to-LEFT bleed ratio for hard-LEFT ch at s=0.5 must be ~0.33; got {ratio}"
3905        );
3906    }
3907}