Skip to main content

oxideav_mod/
xm.rs

1//! FastTracker 2 Extended Module ("XM") structural parser.
2//!
3//! XM is a descendant of the Amiga MOD / ScreamTracker lineage, introduced
4//! with FastTracker 2 in 1994. Compared to MOD / STM it adds:
5//!
6//! - Up to 32 channels (even counts, 2..=32).
7//! - A 256-entry pattern order table with a "restart position".
8//! - Up to 256 patterns, 1..=256 rows each, with variable-length
9//!   bit-packed cells (note / instrument / volume / effect / effect param
10//!   columns are individually optional).
11//! - Up to 128 instruments, each with a volume envelope (up to 12 points),
12//!   panning envelope (up to 12 points), per-note sample mapping (96
13//!   entries), vibrato table, fadeout, and multiple samples (sample
14//!   header size 0x28) whose data is stored as 8- or 16-bit delta values.
15//! - Two frequency tables (Amiga and Linear), selectable per-file.
16//!
17//! Layout summary, derived from
18//! `docs/audio/trackers/xm/FastTracker-2-v2.04-xm.txt` and corrected
19//! against `FastTracker-2-xm-alt.txt`:
20//!
21//! ```text
22//! Offset  Len  Field
23//! -------------------
24//!   0     17   ASCII banner "Extended Module: " (note: capital M, trailing colon+space)
25//!  17     20   Module name (space / zero padded)
26//!  37      1   0x1A (tracker DOS-EOF marker)
27//!  38     20   Tracker name
28//!  58      2   Version (LE, typically 0x0104)
29//!  60      4   Header size (usually 0x114 including these 4 bytes' worth of
30//!              "size field" starting at offset 60+4=64, i.e. the size
31//!              includes the song_length..order[0..256] run)
32//!  64      2   Song length (in pattern order table, 1..256)
33//!  66      2   Restart position
34//!  68      2   Number of channels (2..=32, always even)
35//!  70      2   Number of patterns (max 256)
36//!  72      2   Number of instruments (max 128)
37//!  74      2   Flags: bit 0 = 0 Amiga / 1 Linear frequency table
38//!  76      2   Default tempo (speed, ticks/row)
39//!  78      2   Default BPM
40//!  80    256   Pattern order table
41//! 336 = 60 + 0x114  (first pattern header begins here for a standard file)
42//! ```
43//!
44//! The structural parser here mirrors the STM parser's policy:
45//! spec-derived offsets + length-tolerant readers (short packets =
46//! `Error::NeedMore`, malformed marker bytes = `Error::invalid`). No
47//! playback; callers that want to render XM music can walk the
48//! [`XmHeader`] / [`XmPattern`] / [`XmInstrument`] outputs themselves.
49
50use oxideav_core::{Error, Result};
51
52/// XM magic banner. Note the capital `M` and trailing `": "` —
53/// FT2 rejects lowercase `"Extended module: "` as corrupt.
54pub const XM_BANNER: &[u8; 17] = b"Extended Module: ";
55
56/// Offset of the `0x1A` tracker-DOS-EOF marker.
57pub const XM_ID_BYTE_OFFSET: usize = 37;
58
59/// Expected version word at offset 58 (little-endian).
60pub const XM_VERSION_0104: u16 = 0x0104;
61
62/// Offset of the 4-byte `header_size` dword.
63pub const XM_HEADER_SIZE_OFFSET: usize = 60;
64
65/// Offset of the 256-byte pattern order table. The header size dword at
66/// offset 60 spans from offset 64 through to the end of the order table
67/// (see `FastTracker-2-xm-alt.txt`: the typical value is 0x114 =
68/// 2+2+2+2+2+2+2+2+256 = 276).
69pub const XM_ORDER_TABLE_OFFSET: usize = 80;
70
71/// Size of the XM pattern order table.
72pub const XM_ORDER_TABLE_SIZE: usize = 256;
73
74/// Minimum plausible file size: banner + header prefix + order table.
75pub const XM_MIN_HEADER_LEN: usize = XM_ORDER_TABLE_OFFSET + XM_ORDER_TABLE_SIZE;
76
77/// Pattern header length field: always 9 bytes including the 4-byte
78/// length field itself (per `FastTracker-2-xm-alt.txt`). The 9 bytes
79/// decompose as: 4 length + 1 packing + 2 rows + 2 packed-size.
80pub const XM_PATTERN_HEADER_SIZE: u32 = 9;
81
82/// Standard sample header size (0x28 = 40 bytes, per the alt doc).
83pub const XM_SAMPLE_HEADER_SIZE: u32 = 0x28;
84
85/// Standard instrument header size when the instrument carries at least
86/// one sample (0x107 per the alt doc). Instruments with zero samples
87/// write an instrument-size of 0x21 (29 + 4 of the size field).
88pub const XM_INSTRUMENT_HEADER_SIZE_WITH_SAMPLES: u32 = 0x107;
89
90/// Frequency-table flag bit.
91pub const XM_FLAG_LINEAR_FREQ_TABLE: u16 = 0x0001;
92
93/// Frequency table selection (XM header `flags` bit 0).
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum XmFrequencyTable {
96    /// Bit 0 = 0: Amiga-style period table.
97    Amiga,
98    /// Bit 0 = 1: Linear period table.
99    Linear,
100}
101
102/// Top-level XM header fields (everything reachable without walking
103/// patterns / instruments).
104#[derive(Clone, Debug)]
105pub struct XmHeader {
106    pub module_name: String,
107    pub tracker_name: String,
108    /// Raw version word; typically `0x0104`.
109    pub version: u16,
110    /// Header size field (bytes; counts from offset 64 onwards).
111    pub header_size: u32,
112    pub song_length: u16,
113    pub restart_position: u16,
114    pub num_channels: u16,
115    pub num_patterns: u16,
116    pub num_instruments: u16,
117    pub flags: u16,
118    pub frequency_table: XmFrequencyTable,
119    pub default_tempo: u16,
120    pub default_bpm: u16,
121    /// 256-entry order table (full width even if `song_length < 256`).
122    pub order: Vec<u8>,
123}
124
125/// One pattern header + its decoded row data.
126#[derive(Clone, Debug)]
127pub struct XmPattern {
128    pub header_length: u32,
129    /// Packing type; the spec says "always 0".
130    pub packing_type: u8,
131    pub num_rows: u16,
132    pub packed_size: u16,
133    /// Decoded rows: `rows[row][channel]` = one cell.
134    pub rows: Vec<Vec<XmCell>>,
135}
136
137/// A decoded pattern cell. The raw XM packing collapses an all-empty
138/// cell into a single zero byte; we unpack to this wide representation
139/// so downstream consumers don't need to re-do the bit math.
140#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
141pub struct XmCell {
142    /// Note: 1..=96 = C-0..B-7, 97 = KeyOff, 0 = empty.
143    pub note: u8,
144    /// Instrument index 1..=128; 0 = no instrument.
145    pub instrument: u8,
146    /// Raw volume column byte (see [`XmVolume`]).
147    pub volume: u8,
148    /// Standard effect type byte (0..0x22, or letter-coded G..Z).
149    pub effect_type: u8,
150    /// Standard effect parameter byte.
151    pub effect_param: u8,
152}
153
154/// Semantic interpretation of the volume-column byte.
155///
156/// The raw byte's high nibble selects the effect; the low nibble is the
157/// parameter (except for the "set volume" range, where the byte minus
158/// 0x10 is the 0..=0x40 target volume). Values outside 0x10..=0xFF are
159/// treated as "do nothing".
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum XmVolume {
162    Empty,
163    /// 0x10..=0x50 → volume 0..=0x40.
164    SetVolume(u8),
165    /// 0x60..=0x6F.
166    VolumeSlideDown(u8),
167    /// 0x70..=0x7F.
168    VolumeSlideUp(u8),
169    /// 0x80..=0x8F.
170    FineVolumeSlideDown(u8),
171    /// 0x90..=0x9F.
172    FineVolumeSlideUp(u8),
173    /// 0xA0..=0xAF.
174    SetVibratoSpeed(u8),
175    /// 0xB0..=0xBF.
176    Vibrato(u8),
177    /// 0xC0..=0xCF.
178    SetPanning(u8),
179    /// 0xD0..=0xDF.
180    PanningSlideLeft(u8),
181    /// 0xE0..=0xEF.
182    PanningSlideRight(u8),
183    /// 0xF0..=0xFF.
184    TonePorta(u8),
185}
186
187impl XmCell {
188    /// Decode the volume-column byte into its semantic form.
189    pub fn volume_kind(&self) -> XmVolume {
190        match self.volume {
191            0 => XmVolume::Empty,
192            v @ 0x10..=0x50 => XmVolume::SetVolume(v - 0x10),
193            v @ 0x60..=0x6F => XmVolume::VolumeSlideDown(v & 0x0F),
194            v @ 0x70..=0x7F => XmVolume::VolumeSlideUp(v & 0x0F),
195            v @ 0x80..=0x8F => XmVolume::FineVolumeSlideDown(v & 0x0F),
196            v @ 0x90..=0x9F => XmVolume::FineVolumeSlideUp(v & 0x0F),
197            v @ 0xA0..=0xAF => XmVolume::SetVibratoSpeed(v & 0x0F),
198            v @ 0xB0..=0xBF => XmVolume::Vibrato(v & 0x0F),
199            v @ 0xC0..=0xCF => XmVolume::SetPanning(v & 0x0F),
200            v @ 0xD0..=0xDF => XmVolume::PanningSlideLeft(v & 0x0F),
201            v @ 0xE0..=0xEF => XmVolume::PanningSlideRight(v & 0x0F),
202            v @ 0xF0..=0xFF => XmVolume::TonePorta(v & 0x0F),
203            _ => XmVolume::Empty,
204        }
205    }
206
207    /// True if this cell carries an XM "note off" (raw note 97).
208    pub fn is_note_off(&self) -> bool {
209        self.note == 97
210    }
211
212    /// True if a musical note is present (1..=96).
213    pub fn has_note(&self) -> bool {
214        (1..=96).contains(&self.note)
215    }
216}
217
218/// Envelope shape — up to 12 points, each `(x_tick, y_value 0..=64)`.
219#[derive(Clone, Debug, Default)]
220pub struct XmEnvelope {
221    pub points: Vec<(u16, u16)>,
222    pub sustain_point: u8,
223    pub loop_start_point: u8,
224    pub loop_end_point: u8,
225    /// Envelope type bit-field: 0: On, 1: Sustain, 2: Loop.
226    pub type_bits: u8,
227}
228
229impl XmEnvelope {
230    pub fn is_on(&self) -> bool {
231        self.type_bits & 0x01 != 0
232    }
233    pub fn has_sustain(&self) -> bool {
234        self.type_bits & 0x02 != 0
235    }
236    pub fn has_loop(&self) -> bool {
237        self.type_bits & 0x04 != 0
238    }
239}
240
241/// Loop mode encoded in a sample header's `type` byte.
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
243pub enum XmSampleLoopMode {
244    #[default]
245    None,
246    Forward,
247    PingPong,
248}
249
250impl XmSampleLoopMode {
251    pub fn from_type_byte(b: u8) -> Self {
252        match b & 0x03 {
253            1 => XmSampleLoopMode::Forward,
254            2 => XmSampleLoopMode::PingPong,
255            _ => XmSampleLoopMode::None,
256        }
257    }
258}
259
260impl crate::mixer::SampleSource for XmSampleHeader {
261    fn len(&self) -> usize {
262        if self.is_16_bit {
263            self.pcm16.len()
264        } else {
265            self.pcm8.len()
266        }
267    }
268    fn loop_start(&self) -> usize {
269        // loop_start/loop_length are byte offsets in the XM file; for
270        // 16-bit samples convert to frame indices.
271        let div = if self.is_16_bit { 2 } else { 1 };
272        if matches!(self.loop_mode, XmSampleLoopMode::None) {
273            0
274        } else {
275            (self.loop_start as usize / div).min(self.len())
276        }
277    }
278    fn loop_end(&self) -> usize {
279        let div = if self.is_16_bit { 2 } else { 1 };
280        if matches!(self.loop_mode, XmSampleLoopMode::None) {
281            self.len()
282        } else {
283            let end = (self.loop_start + self.loop_length) as usize / div;
284            end.min(self.len())
285        }
286    }
287    fn loop_kind(&self) -> crate::mixer::LoopKind {
288        match self.loop_mode {
289            XmSampleLoopMode::None => crate::mixer::LoopKind::None,
290            XmSampleLoopMode::Forward => crate::mixer::LoopKind::Forward,
291            XmSampleLoopMode::PingPong => crate::mixer::LoopKind::PingPong,
292        }
293    }
294    fn at(&self, idx: usize) -> f32 {
295        if self.is_16_bit {
296            self.pcm16.get(idx).copied().unwrap_or(0) as f32 / 32768.0
297        } else {
298            self.pcm8.get(idx).copied().unwrap_or(0) as f32 / 128.0
299        }
300    }
301}
302
303/// Decoded sample header (40 bytes + variable-length PCM body following
304/// all sample headers of the instrument).
305#[derive(Clone, Debug, Default)]
306pub struct XmSampleHeader {
307    pub name: String,
308    /// Sample length **in bytes** (not frames). For 16-bit samples the
309    /// frame count is `length / 2`.
310    pub length: u32,
311    pub loop_start: u32,
312    pub loop_length: u32,
313    pub volume: u8,
314    /// Signed finetune -128..+127.
315    pub finetune: i8,
316    pub type_byte: u8,
317    pub panning: u8,
318    /// Signed relative-note, -96..+95 (0 => C-4 = C-4).
319    pub relative_note: i8,
320    pub loop_mode: XmSampleLoopMode,
321    pub is_16_bit: bool,
322    /// Decoded PCM samples. Empty until `extract_samples` is called.
323    /// For 16-bit samples the values are `i16` halves, one per frame.
324    pub pcm16: Vec<i16>,
325    /// Decoded 8-bit PCM samples (if `!is_16_bit`). Empty otherwise.
326    pub pcm8: Vec<i8>,
327}
328
329/// Decoded instrument: header + per-note sample mapping + envelopes +
330/// sample headers. Sample PCM bodies are attached by
331/// [`extract_sample_bodies`].
332#[derive(Clone, Debug, Default)]
333pub struct XmInstrument {
334    pub name: String,
335    /// Size of the instrument header block this record came from
336    /// (raw bytes, includes the 4-byte size field).
337    pub header_size: u32,
338    /// Raw instrument-type byte (spec says "always 0").
339    pub instrument_type: u8,
340    pub num_samples: u16,
341    /// Sample-header size from the instrument's extended header
342    /// (typically 0x28); absent when `num_samples == 0`.
343    pub sample_header_size: u32,
344    /// `sample_map[note]` = which of this instrument's samples plays for
345    /// `note` (0..=95). Only populated when `num_samples > 0`.
346    pub sample_map: Vec<u8>,
347    pub volume_envelope: XmEnvelope,
348    pub panning_envelope: XmEnvelope,
349    pub vibrato_type: u8,
350    pub vibrato_sweep: u8,
351    pub vibrato_depth: u8,
352    pub vibrato_rate: u8,
353    pub volume_fadeout: u16,
354    pub samples: Vec<XmSampleHeader>,
355    /// Byte offset in the source blob where this instrument's sample
356    /// PCM bodies start (sum of all preceding sample-header sizes).
357    pub sample_data_offset: usize,
358}
359
360// ---------- probe + header ----------
361
362/// Test whether `bytes` starts with the 17-byte XM banner. Matches the
363/// exact, case-sensitive `"Extended Module: "` string that FT2 requires.
364pub fn is_xm(bytes: &[u8]) -> bool {
365    bytes.len() >= XM_BANNER.len() && &bytes[..XM_BANNER.len()] == XM_BANNER.as_slice()
366}
367
368fn read_u16_le(bytes: &[u8], off: usize) -> u16 {
369    u16::from_le_bytes([bytes[off], bytes[off + 1]])
370}
371
372fn read_u32_le(bytes: &[u8], off: usize) -> u32 {
373    u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
374}
375
376fn trim_fixed_string(bytes: &[u8]) -> String {
377    // XM writes zero-padded or space-padded fixed-width strings. Strip
378    // trailing NULs and trailing whitespace; don't try to strip internal
379    // control characters since some demoscene tracks put ANSI art there.
380    let end = bytes
381        .iter()
382        .rposition(|&b| b != 0 && b != b' ')
383        .map(|i| i + 1)
384        .unwrap_or(0);
385    String::from_utf8_lossy(&bytes[..end]).to_string()
386}
387
388/// Parse the XM file header + order table. Does not parse patterns or
389/// instruments — those follow `XM_HEADER_SIZE_OFFSET + 4 + header_size`
390/// in the source blob; see [`parse_patterns`] / [`parse_instruments`].
391///
392/// Returns `Error::NeedMore` when the buffer can't reach the order
393/// table, and `Error::invalid` when the banner / id byte / channel
394/// count / instrument count look structurally wrong.
395pub fn parse_header(bytes: &[u8]) -> Result<XmHeader> {
396    if bytes.len() < XM_MIN_HEADER_LEN {
397        return Err(Error::NeedMore);
398    }
399    if !is_xm(bytes) {
400        return Err(Error::invalid(
401            "XM: missing 'Extended Module: ' banner at offset 0",
402        ));
403    }
404    if bytes[XM_ID_BYTE_OFFSET] != 0x1A {
405        return Err(Error::invalid("XM: missing 0x1A marker byte at offset 37"));
406    }
407
408    let module_name = trim_fixed_string(&bytes[17..37]);
409    let tracker_name = trim_fixed_string(&bytes[38..58]);
410    let version = read_u16_le(bytes, 58);
411    let header_size = read_u32_le(bytes, XM_HEADER_SIZE_OFFSET);
412    let song_length = read_u16_le(bytes, 64);
413    let restart_position = read_u16_le(bytes, 66);
414    let num_channels = read_u16_le(bytes, 68);
415    let num_patterns = read_u16_le(bytes, 70);
416    let num_instruments = read_u16_le(bytes, 72);
417    let flags = read_u16_le(bytes, 74);
418    let default_tempo = read_u16_le(bytes, 76);
419    let default_bpm = read_u16_le(bytes, 78);
420
421    if !(1..=32).contains(&num_channels) {
422        return Err(Error::invalid(format!(
423            "XM: implausible channel count {num_channels} (expected 1..=32)"
424        )));
425    }
426    if num_patterns > 256 {
427        return Err(Error::invalid(format!(
428            "XM: implausible pattern count {num_patterns} (expected <=256)"
429        )));
430    }
431    if num_instruments > 128 {
432        return Err(Error::invalid(format!(
433            "XM: implausible instrument count {num_instruments} (expected <=128)"
434        )));
435    }
436
437    let frequency_table = if flags & XM_FLAG_LINEAR_FREQ_TABLE != 0 {
438        XmFrequencyTable::Linear
439    } else {
440        XmFrequencyTable::Amiga
441    };
442
443    let order = bytes[XM_ORDER_TABLE_OFFSET..XM_ORDER_TABLE_OFFSET + XM_ORDER_TABLE_SIZE].to_vec();
444
445    Ok(XmHeader {
446        module_name,
447        tracker_name,
448        version,
449        header_size,
450        song_length,
451        restart_position,
452        num_channels,
453        num_patterns,
454        num_instruments,
455        flags,
456        frequency_table,
457        default_tempo,
458        default_bpm,
459        order,
460    })
461}
462
463/// Absolute byte offset in the source blob where pattern data begins,
464/// i.e. just past the file header + order table.
465///
466/// The spec's `header_size` dword at offset 60 is measured from offset
467/// 60 itself (inclusive of the size dword) — so the block spans
468/// `[60 .. 60 + header_size)`. For a canonical file with
469/// `header_size == 0x114`, this lands at offset `0x150 = 336`, exactly
470/// at the end of the 256-byte order table that starts at offset 80.
471pub fn pattern_data_offset(header: &XmHeader) -> usize {
472    XM_HEADER_SIZE_OFFSET + header.header_size as usize
473}
474
475// ---------- pattern unpacking ----------
476
477/// Decode one packed pattern cell starting at `cur` in `data`. Returns
478/// `(cell, bytes_consumed)`. If `cur` is out of bounds, returns an
479/// empty cell consuming zero bytes.
480///
481/// Packing rule (XM spec, reproduced here):
482/// - If the high bit of the first byte is set, the other low 5 bits form
483///   a mask selecting which of {note, instrument, volume, effect_type,
484///   effect_param} are present. Missing fields are implicit zero.
485/// - Otherwise the first byte **is** the note, and the remaining four
486///   bytes (instrument, volume, effect_type, effect_param) follow in
487///   order.
488fn decode_packed_cell(data: &[u8], cur: usize) -> (XmCell, usize) {
489    if cur >= data.len() {
490        return (XmCell::default(), 0);
491    }
492    let first = data[cur];
493    let mut cell = XmCell::default();
494    if first & 0x80 != 0 {
495        let mask = first & 0x7F;
496        let mut off = 1usize;
497        let grab = |off: &mut usize, data: &[u8]| -> u8 {
498            let p = cur + *off;
499            *off += 1;
500            if p < data.len() {
501                data[p]
502            } else {
503                0
504            }
505        };
506        if mask & 0x01 != 0 {
507            cell.note = grab(&mut off, data);
508        }
509        if mask & 0x02 != 0 {
510            cell.instrument = grab(&mut off, data);
511        }
512        if mask & 0x04 != 0 {
513            cell.volume = grab(&mut off, data);
514        }
515        if mask & 0x08 != 0 {
516            cell.effect_type = grab(&mut off, data);
517        }
518        if mask & 0x10 != 0 {
519            cell.effect_param = grab(&mut off, data);
520        }
521        (cell, off)
522    } else {
523        // Unpacked 5-byte form: first byte is the note itself.
524        cell.note = first;
525        let grab = |rel: usize| -> u8 {
526            let p = cur + rel;
527            if p < data.len() {
528                data[p]
529            } else {
530                0
531            }
532        };
533        cell.instrument = grab(1);
534        cell.volume = grab(2);
535        cell.effect_type = grab(3);
536        cell.effect_param = grab(4);
537        (cell, 5)
538    }
539}
540
541/// Parse all patterns starting from the end of the file header.
542///
543/// For each pattern:
544///  - Reads the 9-byte pattern header.
545///  - If `packed_size == 0`, the pattern is "all empty" — emits
546///    `num_rows` rows of `num_channels` default cells without advancing
547///    into packed data.
548///  - Otherwise, decodes `num_rows * num_channels` cells from the packed
549///    block, tolerating truncation by emitting default cells for any
550///    leftover slots.
551///
552/// Returns the decoded patterns and the absolute byte offset **after**
553/// the last pattern (where the instrument table begins).
554pub fn parse_patterns(header: &XmHeader, bytes: &[u8]) -> Result<(Vec<XmPattern>, usize)> {
555    let mut cur = pattern_data_offset(header);
556    let mut out = Vec::with_capacity(header.num_patterns as usize);
557    let num_channels = header.num_channels as usize;
558
559    for pat_idx in 0..header.num_patterns as usize {
560        if cur + 9 > bytes.len() {
561            return Err(Error::invalid(format!(
562                "XM: truncated pattern header #{pat_idx} at offset {cur}"
563            )));
564        }
565        let header_length = read_u32_le(bytes, cur);
566        let packing_type = bytes[cur + 4];
567        let num_rows = read_u16_le(bytes, cur + 5);
568        let packed_size = read_u16_le(bytes, cur + 7);
569
570        // Skip over the pattern header *using its declared length*, so
571        // nonstandard writers (that pad extra bytes) don't desync us.
572        let data_start = cur
573            .checked_add(header_length as usize)
574            .ok_or_else(|| Error::invalid("XM: pattern header_length overflow"))?;
575
576        let mut rows: Vec<Vec<XmCell>> = Vec::with_capacity(num_rows as usize);
577
578        if packed_size == 0 {
579            // All-empty pattern: synthesize default cells.
580            for _ in 0..num_rows {
581                rows.push(vec![XmCell::default(); num_channels]);
582            }
583        } else {
584            // Clamp BOTH ends against `bytes.len()` — a hostile
585            // `header_length` can push `data_start` past EOF, and the
586            // bare `&bytes[data_start..data_end]` slice index would
587            // panic with "slice index out of bounds" before the
588            // `min(bytes.len())` on `data_end` had a chance to
589            // matter. Caught by `oxideav-mod-fuzz/xm_decode`
590            // (crash-212b2111).
591            let start = data_start.min(bytes.len());
592            let data_end = data_start
593                .saturating_add(packed_size as usize)
594                .min(bytes.len());
595            let slice = &bytes[start..data_end];
596            let mut inner = 0usize;
597            for _ in 0..num_rows {
598                let mut row = Vec::with_capacity(num_channels);
599                for _ in 0..num_channels {
600                    let (cell, consumed) = decode_packed_cell(slice, inner);
601                    inner += consumed;
602                    row.push(cell);
603                }
604                rows.push(row);
605            }
606        }
607
608        // `data_start` already sits past the pattern header. When the
609        // packed block is empty, `packed_size == 0` so `cur` lands right
610        // after the 9-byte header — matching FT2's "empty patterns
611        // still write a header" invariant.
612        cur = data_start.saturating_add(packed_size as usize);
613
614        out.push(XmPattern {
615            header_length,
616            packing_type,
617            num_rows,
618            packed_size,
619            rows,
620        });
621    }
622
623    Ok((out, cur))
624}
625
626// ---------- instruments ----------
627
628/// Parse the envelope points buffer (48 bytes = 12 × `(x:u16, y:u16)`,
629/// all little-endian). `num_points` selects how many entries are valid;
630/// the remainder are ignored.
631fn parse_envelope_points(bytes: &[u8; 48], num_points: u8) -> Vec<(u16, u16)> {
632    let n = num_points.min(12) as usize;
633    let mut out = Vec::with_capacity(n);
634    for i in 0..n {
635        let off = i * 4;
636        let x = u16::from_le_bytes([bytes[off], bytes[off + 1]]);
637        let y = u16::from_le_bytes([bytes[off + 2], bytes[off + 3]]);
638        out.push((x, y));
639    }
640    out
641}
642
643/// Parse one instrument starting at `cur` in `bytes`. Returns the
644/// decoded instrument and the byte offset **after** all of its sample
645/// headers (i.e. where its sample PCM bodies begin).
646fn parse_one_instrument(bytes: &[u8], cur: usize) -> Result<(XmInstrument, usize)> {
647    if cur + 29 > bytes.len() {
648        return Err(Error::invalid(format!(
649            "XM: truncated instrument header at offset {cur}"
650        )));
651    }
652    let header_size = read_u32_le(bytes, cur);
653    if header_size < 29 {
654        return Err(Error::invalid(format!(
655            "XM: nonsensical instrument header_size {header_size} at {cur}"
656        )));
657    }
658    let name = trim_fixed_string(&bytes[cur + 4..cur + 26]);
659    let instrument_type = bytes[cur + 26];
660    let num_samples = read_u16_le(bytes, cur + 27);
661
662    let mut inst = XmInstrument {
663        name,
664        header_size,
665        instrument_type,
666        num_samples,
667        ..Default::default()
668    };
669
670    if num_samples == 0 {
671        // Instrument has no samples: the next instrument starts
672        // immediately after this block of size `header_size`.
673        let next = cur.saturating_add(header_size as usize).min(bytes.len());
674        inst.sample_data_offset = next;
675        return Ok((inst, next));
676    }
677
678    // Extended instrument block (starts at cur+29).
679    if cur + header_size as usize > bytes.len() {
680        return Err(Error::invalid(format!(
681            "XM: truncated extended instrument block (need {} bytes at {cur})",
682            header_size
683        )));
684    }
685    let ext_base = cur + 29;
686    // Sample header size lives at extended offset +0 (file offset ext_base + 0 = cur+29).
687    inst.sample_header_size = read_u32_le(bytes, ext_base);
688
689    // Sample map (96 bytes) at ext_base+4.
690    let map_start = ext_base + 4;
691    if map_start + 96 > bytes.len() {
692        return Err(Error::invalid("XM: truncated instrument sample-number map"));
693    }
694    inst.sample_map = bytes[map_start..map_start + 96].to_vec();
695
696    // Volume envelope points: 48 bytes at ext_base + 100.
697    let vol_env_start = ext_base + 100;
698    // Panning envelope points: 48 bytes at ext_base + 148.
699    let pan_env_start = ext_base + 148;
700    if pan_env_start + 48 > bytes.len() {
701        return Err(Error::invalid("XM: truncated instrument envelope tables"));
702    }
703    let vol_env_raw: [u8; 48] = bytes[vol_env_start..vol_env_start + 48].try_into().unwrap();
704    let pan_env_raw: [u8; 48] = bytes[pan_env_start..pan_env_start + 48].try_into().unwrap();
705
706    // Byte fields starting at ext_base + 196.
707    let f = ext_base + 196;
708    if f + 16 > bytes.len() {
709        return Err(Error::invalid("XM: truncated instrument fixed-byte block"));
710    }
711    let num_vol_points = bytes[f];
712    let num_pan_points = bytes[f + 1];
713    let vol_sustain = bytes[f + 2];
714    let vol_loop_start = bytes[f + 3];
715    let vol_loop_end = bytes[f + 4];
716    let pan_sustain = bytes[f + 5];
717    let pan_loop_start = bytes[f + 6];
718    let pan_loop_end = bytes[f + 7];
719    let vol_type = bytes[f + 8];
720    let pan_type = bytes[f + 9];
721    inst.vibrato_type = bytes[f + 10];
722    inst.vibrato_sweep = bytes[f + 11];
723    inst.vibrato_depth = bytes[f + 12];
724    inst.vibrato_rate = bytes[f + 13];
725    inst.volume_fadeout = read_u16_le(bytes, f + 14);
726    // f+16..f+18 reserved.
727
728    inst.volume_envelope = XmEnvelope {
729        points: parse_envelope_points(&vol_env_raw, num_vol_points),
730        sustain_point: vol_sustain,
731        loop_start_point: vol_loop_start,
732        loop_end_point: vol_loop_end,
733        type_bits: vol_type,
734    };
735    inst.panning_envelope = XmEnvelope {
736        points: parse_envelope_points(&pan_env_raw, num_pan_points),
737        sustain_point: pan_sustain,
738        loop_start_point: pan_loop_start,
739        loop_end_point: pan_loop_end,
740        type_bits: pan_type,
741    };
742
743    // Sample headers follow the instrument header block (`header_size`
744    // bytes from `cur`). Each sample header is `sample_header_size`
745    // bytes (normally 0x28). We accept a nonstandard value but require
746    // it to be at least 40 to contain the known fields.
747    if inst.sample_header_size < 40 {
748        return Err(Error::invalid(format!(
749            "XM: sample_header_size {} too small (expected >=40)",
750            inst.sample_header_size
751        )));
752    }
753
754    let headers_start = cur + header_size as usize;
755    let mut hcur = headers_start;
756    for i in 0..num_samples as usize {
757        if hcur + inst.sample_header_size as usize > bytes.len() {
758            return Err(Error::invalid(format!(
759                "XM: truncated sample header #{i} in instrument"
760            )));
761        }
762        let length = read_u32_le(bytes, hcur);
763        let loop_start = read_u32_le(bytes, hcur + 4);
764        let loop_length = read_u32_le(bytes, hcur + 8);
765        let volume = bytes[hcur + 12];
766        let finetune = bytes[hcur + 13] as i8;
767        let type_byte = bytes[hcur + 14];
768        let panning = bytes[hcur + 15];
769        let relative_note = bytes[hcur + 16] as i8;
770        // hcur + 17 reserved.
771        let name = if hcur + 18 + 22 <= bytes.len() {
772            trim_fixed_string(&bytes[hcur + 18..hcur + 40])
773        } else {
774            String::new()
775        };
776        let loop_mode = XmSampleLoopMode::from_type_byte(type_byte);
777        let is_16_bit = type_byte & 0x10 != 0;
778        inst.samples.push(XmSampleHeader {
779            name,
780            length,
781            loop_start,
782            loop_length,
783            volume,
784            finetune,
785            type_byte,
786            panning,
787            relative_note,
788            loop_mode,
789            is_16_bit,
790            pcm16: Vec::new(),
791            pcm8: Vec::new(),
792        });
793        hcur += inst.sample_header_size as usize;
794    }
795
796    inst.sample_data_offset = hcur;
797    Ok((inst, hcur))
798}
799
800/// Parse all instruments starting at `instruments_offset`, returning
801/// the decoded instruments and the offset where the first sample body
802/// would begin (i.e. end of the last instrument's sample headers).
803///
804/// This is the offset immediately after all instrument blocks' sample
805/// **headers**; sample **bodies** are interleaved per-instrument, so the
806/// separate per-instrument `sample_data_offset` fields are the useful
807/// anchors for PCM extraction.
808pub fn parse_instruments(
809    header: &XmHeader,
810    bytes: &[u8],
811    instruments_offset: usize,
812) -> Result<Vec<XmInstrument>> {
813    let mut out = Vec::with_capacity(header.num_instruments as usize);
814    let mut cur = instruments_offset;
815    for i in 0..header.num_instruments as usize {
816        let (inst, _next) = parse_one_instrument(bytes, cur)
817            .map_err(|e| Error::invalid(format!("XM: failed to parse instrument #{i}: {e}")))?;
818        // Advance to the byte just past all sample bodies of this
819        // instrument. Sample headers live between `header_size`-end and
820        // `inst.sample_data_offset`; PCM bodies of length
821        // `sum(sample.length)` follow and belong to this instrument.
822        let pcm_bytes: usize = inst.samples.iter().map(|s| s.length as usize).sum();
823        cur = inst.sample_data_offset.saturating_add(pcm_bytes);
824        out.push(inst);
825    }
826    Ok(out)
827}
828
829/// Decode all sample PCM bodies in place on `instruments`, reading
830/// their delta-encoded PCM from `bytes` and converting to absolute
831/// samples per the XM spec.
832///
833/// Mutates each `XmSampleHeader` by populating `pcm8` or `pcm16`. The
834/// decoder is tolerant of truncation: if a sample body runs past end of
835/// file, we decode as many frames as are available and stop cleanly.
836pub fn extract_sample_bodies(instruments: &mut [XmInstrument], bytes: &[u8]) {
837    for inst in instruments.iter_mut() {
838        let mut cur = inst.sample_data_offset;
839        for sample in inst.samples.iter_mut() {
840            let length_bytes = (sample.length as usize).min(bytes.len().saturating_sub(cur));
841            let slice = &bytes[cur..cur + length_bytes];
842            if sample.is_16_bit {
843                let n_frames = length_bytes / 2;
844                let mut out = Vec::with_capacity(n_frames);
845                let mut old: i16 = 0;
846                for i in 0..n_frames {
847                    let delta = i16::from_le_bytes([slice[i * 2], slice[i * 2 + 1]]);
848                    old = old.wrapping_add(delta);
849                    out.push(old);
850                }
851                sample.pcm16 = out;
852            } else {
853                let mut out = Vec::with_capacity(length_bytes);
854                let mut old: i8 = 0;
855                for &b in slice {
856                    let delta = b as i8;
857                    old = old.wrapping_add(delta);
858                    out.push(old);
859                }
860                sample.pcm8 = out;
861            }
862            cur += length_bytes;
863        }
864    }
865}
866
867/// Rough upper-bound duration estimate in microseconds, loosely
868/// analogous to the MOD / STM helpers. XM uses `tempo * ticks/row`
869/// pacing and `bpm` scales the tick rate. Real songs use Fxx effects to
870/// reshape tempo mid-song, so this is a coarse envelope only.
871pub fn estimate_duration_micros(header: &XmHeader, patterns: &[XmPattern]) -> i64 {
872    if patterns.is_empty() {
873        return 0;
874    }
875    let bpm = header.default_bpm.max(1) as i64;
876    let tempo = header.default_tempo.max(1) as i64;
877    // 2.5 * bpm = ticks per second (classic Amiga/FT2 formula).
878    let ticks_per_sec = (5 * bpm) / 2;
879    if ticks_per_sec < 1 {
880        return 0;
881    }
882    let song_length = header.song_length.max(1) as usize;
883    let mut total_rows: u64 = 0;
884    for idx in 0..song_length {
885        let pat_idx = *header.order.get(idx).unwrap_or(&0) as usize;
886        let rows = patterns
887            .get(pat_idx)
888            .map(|p| p.num_rows as u64)
889            .unwrap_or(64);
890        total_rows = total_rows.saturating_add(rows);
891    }
892    // microseconds = rows * tempo (ticks/row) * 1e6 / ticks/sec.
893    (total_rows as i64).saturating_mul(tempo) * 1_000_000 / ticks_per_sec.max(1)
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    // ---- builders for hand-constructed XM blobs ----
901
902    /// Build a minimal 336-byte XM header with `num_patterns` patterns
903    /// declared, `num_instruments` instruments, and the given channel
904    /// count. Caller is expected to append pattern + instrument blocks
905    /// as appropriate for the test.
906    fn build_header(
907        num_channels: u16,
908        num_patterns: u16,
909        num_instruments: u16,
910        linear: bool,
911    ) -> Vec<u8> {
912        let mut out = vec![0u8; XM_MIN_HEADER_LEN];
913        out[0..17].copy_from_slice(XM_BANNER);
914        let name = b"hello xm          ";
915        out[17..17 + name.len()].copy_from_slice(name);
916        out[XM_ID_BYTE_OFFSET] = 0x1A;
917        let tracker = b"oxideav-test        ";
918        out[38..38 + tracker.len()].copy_from_slice(tracker);
919        // version 0x0104
920        out[58..60].copy_from_slice(&XM_VERSION_0104.to_le_bytes());
921        // header_size 0x114
922        out[60..64].copy_from_slice(&0x114u32.to_le_bytes());
923        out[64..66].copy_from_slice(&1u16.to_le_bytes()); // song_length=1
924        out[66..68].copy_from_slice(&0u16.to_le_bytes()); // restart
925        out[68..70].copy_from_slice(&num_channels.to_le_bytes());
926        out[70..72].copy_from_slice(&num_patterns.to_le_bytes());
927        out[72..74].copy_from_slice(&num_instruments.to_le_bytes());
928        let flags = if linear { 1u16 } else { 0u16 };
929        out[74..76].copy_from_slice(&flags.to_le_bytes());
930        out[76..78].copy_from_slice(&6u16.to_le_bytes()); // default tempo
931        out[78..80].copy_from_slice(&125u16.to_le_bytes()); // default BPM
932                                                            // order[0] = 0, rest stays 0 or we write 255 like STM does
933        for i in 1..XM_ORDER_TABLE_SIZE {
934            out[XM_ORDER_TABLE_OFFSET + i] = 0xFF;
935        }
936        out
937    }
938
939    /// Build a pattern block with a header followed by `packed` data.
940    fn build_pattern_block(num_rows: u16, packed: &[u8]) -> Vec<u8> {
941        let mut out = Vec::new();
942        out.extend_from_slice(&XM_PATTERN_HEADER_SIZE.to_le_bytes()); // header length = 9
943        out.push(0); // packing type = 0
944        out.extend_from_slice(&num_rows.to_le_bytes());
945        out.extend_from_slice(&(packed.len() as u16).to_le_bytes());
946        out.extend_from_slice(packed);
947        out
948    }
949
950    /// Build an instrument record with zero samples (header size 0x21 =
951    /// 33 bytes: 4 size + 22 name + 1 type + 2 samples + 4 trailing
952    /// padding to reach 0x21).
953    fn build_empty_instrument(name: &[u8]) -> Vec<u8> {
954        let mut out = Vec::new();
955        // Pick a no-samples header size that actually writes the 29 known
956        // fields (4 size + 22 name + 1 type + 2 num_samples). Many
957        // writers emit 0x21; we mirror that here to exercise the "skip
958        // over unknown trailing bytes" path in the parser.
959        const HSIZE: u32 = 0x21;
960        out.extend_from_slice(&HSIZE.to_le_bytes());
961        let mut nbuf = [0u8; 22];
962        let n = name.len().min(22);
963        nbuf[..n].copy_from_slice(&name[..n]);
964        out.extend_from_slice(&nbuf);
965        out.push(0); // instrument type
966        out.extend_from_slice(&0u16.to_le_bytes()); // num_samples
967        while out.len() < HSIZE as usize {
968            out.push(0);
969        }
970        out
971    }
972
973    /// Build an instrument record with a single 8-bit sample of
974    /// `sample_body` bytes (delta-encoded), using the standard 0x107
975    /// instrument header size.
976    fn build_one_sample_instrument(name: &[u8], sample_body: &[u8]) -> Vec<u8> {
977        let mut out = Vec::new();
978        const HSIZE: u32 = XM_INSTRUMENT_HEADER_SIZE_WITH_SAMPLES; // 0x107
979        out.extend_from_slice(&HSIZE.to_le_bytes());
980        let mut nbuf = [0u8; 22];
981        let n = name.len().min(22);
982        nbuf[..n].copy_from_slice(&name[..n]);
983        out.extend_from_slice(&nbuf);
984        out.push(0); // instrument type
985        out.extend_from_slice(&1u16.to_le_bytes()); // num_samples = 1
986
987        // Extended instrument block starts here (file-offset +29 from
988        // the 4-byte size field's start).
989        // +29: sample_header_size = 0x28
990        out.extend_from_slice(&XM_SAMPLE_HEADER_SIZE.to_le_bytes());
991        // +33..+129: sample_map (96 bytes) — all zeros (all notes → sample 0)
992        out.extend(std::iter::repeat_n(0u8, 96));
993        // +129..+177: volume envelope points (48 bytes) — one (0,0) + (64,64)
994        let mut vol_env = [0u8; 48];
995        vol_env[0..2].copy_from_slice(&0u16.to_le_bytes()); // x=0
996        vol_env[2..4].copy_from_slice(&0u16.to_le_bytes()); // y=0
997        vol_env[4..6].copy_from_slice(&64u16.to_le_bytes()); // x=64
998        vol_env[6..8].copy_from_slice(&64u16.to_le_bytes()); // y=64
999        out.extend_from_slice(&vol_env);
1000        // +177..+225: panning envelope points (48 bytes) — all zero
1001        out.extend_from_slice(&[0u8; 48]);
1002        // +225: num_vol_points = 2
1003        out.push(2);
1004        // +226: num_pan_points = 0
1005        out.push(0);
1006        // +227..+229: vol sustain/loop start/loop end
1007        out.push(0);
1008        out.push(0);
1009        out.push(0);
1010        // +230..+232: pan sustain/loop start/loop end
1011        out.push(0);
1012        out.push(0);
1013        out.push(0);
1014        // +233: vol type = On (bit 0)
1015        out.push(0x01);
1016        // +234: pan type
1017        out.push(0);
1018        // +235..+238: vibrato (type/sweep/depth/rate)
1019        out.push(0);
1020        out.push(0);
1021        out.push(0);
1022        out.push(0);
1023        // +239..+240: vol fadeout
1024        out.extend_from_slice(&512u16.to_le_bytes());
1025        // +241..+242: reserved
1026        out.extend_from_slice(&0u16.to_le_bytes());
1027        // Pad the extended block so total = HSIZE (0x107) bytes.
1028        while out.len() < HSIZE as usize {
1029            out.push(0);
1030        }
1031
1032        // Sample header (0x28 = 40 bytes).
1033        out.extend_from_slice(&(sample_body.len() as u32).to_le_bytes()); // length
1034        out.extend_from_slice(&0u32.to_le_bytes()); // loop_start
1035        out.extend_from_slice(&0u32.to_le_bytes()); // loop_length
1036        out.push(0x40); // volume = 64
1037        out.push(0); // finetune
1038        out.push(0); // type byte — no loop, 8-bit
1039        out.push(128); // panning (center)
1040        out.push(0); // relative note (C-4 = C-4)
1041        out.push(0); // reserved
1042        let mut sname = [0u8; 22];
1043        let s = b"snd";
1044        sname[..s.len()].copy_from_slice(s);
1045        out.extend_from_slice(&sname);
1046
1047        // Sample body.
1048        out.extend_from_slice(sample_body);
1049        out
1050    }
1051
1052    #[test]
1053    fn is_xm_accepts_canonical_banner() {
1054        let bytes = build_header(4, 0, 0, false);
1055        assert!(is_xm(&bytes));
1056    }
1057
1058    #[test]
1059    fn is_xm_rejects_lowercase_banner() {
1060        let mut bytes = build_header(4, 0, 0, false);
1061        // "module" (lowercase m) — matches the original (buggy) XM spec
1062        // text but FT2 rejects it; we mirror FT2's behaviour.
1063        bytes[9] = b'm';
1064        assert!(!is_xm(&bytes));
1065    }
1066
1067    #[test]
1068    fn is_xm_rejects_short_buffer() {
1069        assert!(!is_xm(b"Extended Module"));
1070    }
1071
1072    #[test]
1073    fn parse_header_populates_core_fields() {
1074        let bytes = build_header(8, 2, 3, true);
1075        let h = parse_header(&bytes).unwrap();
1076        assert_eq!(h.module_name, "hello xm");
1077        assert_eq!(h.tracker_name, "oxideav-test");
1078        assert_eq!(h.version, XM_VERSION_0104);
1079        assert_eq!(h.header_size, 0x114);
1080        assert_eq!(h.song_length, 1);
1081        assert_eq!(h.num_channels, 8);
1082        assert_eq!(h.num_patterns, 2);
1083        assert_eq!(h.num_instruments, 3);
1084        assert_eq!(h.default_tempo, 6);
1085        assert_eq!(h.default_bpm, 125);
1086        assert_eq!(h.frequency_table, XmFrequencyTable::Linear);
1087        assert_eq!(h.order.len(), XM_ORDER_TABLE_SIZE);
1088        assert_eq!(h.order[0], 0);
1089        assert_eq!(h.order[1], 0xFF);
1090    }
1091
1092    #[test]
1093    fn parse_header_rejects_missing_id_byte() {
1094        let mut bytes = build_header(4, 0, 0, false);
1095        bytes[XM_ID_BYTE_OFFSET] = 0;
1096        assert!(parse_header(&bytes).is_err());
1097    }
1098
1099    #[test]
1100    fn parse_header_rejects_zero_channels() {
1101        let mut bytes = build_header(0, 0, 0, false);
1102        // We stored 0 channels — parse_header should catch this.
1103        bytes[68..70].copy_from_slice(&0u16.to_le_bytes());
1104        assert!(parse_header(&bytes).is_err());
1105    }
1106
1107    #[test]
1108    fn parse_header_needs_full_order_table() {
1109        let bytes = build_header(4, 0, 0, false);
1110        let short = &bytes[..XM_ORDER_TABLE_OFFSET];
1111        matches!(parse_header(short), Err(Error::NeedMore));
1112    }
1113
1114    #[test]
1115    fn parse_patterns_all_empty_synthesizes_defaults() {
1116        let mut bytes = build_header(4, 1, 0, false);
1117        // One empty pattern: packed_size=0, 8 rows.
1118        bytes.extend(build_pattern_block(8, &[]));
1119        let h = parse_header(&bytes).unwrap();
1120        let (pats, end) = parse_patterns(&h, &bytes).unwrap();
1121        assert_eq!(pats.len(), 1);
1122        assert_eq!(pats[0].num_rows, 8);
1123        assert_eq!(pats[0].packed_size, 0);
1124        assert_eq!(pats[0].rows.len(), 8);
1125        assert_eq!(pats[0].rows[0].len(), 4);
1126        for row in &pats[0].rows {
1127            for cell in row {
1128                assert_eq!(*cell, XmCell::default());
1129            }
1130        }
1131        // No packed data → end = header_end + pattern_header_size (9).
1132        assert_eq!(end, pattern_data_offset(&h) + 9);
1133    }
1134
1135    #[test]
1136    fn parse_patterns_hostile_header_length_does_not_panic() {
1137        // Regression for fuzz find `xm_decode/crash-212b2111…`: a
1138        // hostile pattern `header_length` that pushes `data_start`
1139        // past EOF must clamp the packed-data slice rather than
1140        // index `&bytes[oob..…]` directly and panic with "slice
1141        // index out of bounds".
1142        //
1143        // We construct a 1-pattern XM whose pattern header declares
1144        // `header_length = 0xFFFF` (well past the end of our test
1145        // file) and `packed_size = 1` (non-zero so the parser takes
1146        // the packed-slice branch instead of the
1147        // `packed_size == 0` synth path).
1148        let mut bytes = build_header(1, 1, 0, false);
1149        // Pattern header: header_length=0xFFFF, packing=0, rows=1,
1150        // packed_size=1.
1151        let mut block = Vec::new();
1152        block.extend_from_slice(&0xFFFFu32.to_le_bytes()); // header_length
1153        block.push(0); // packing type
1154        block.extend_from_slice(&1u16.to_le_bytes()); // num_rows
1155        block.extend_from_slice(&1u16.to_le_bytes()); // packed_size
1156                                                      // No packed body bytes — the slice should be empty after
1157                                                      // clamping. `decode_packed_cell` against an empty slice
1158                                                      // returns the default cell with consumed == 0.
1159        bytes.extend(block);
1160
1161        let h = parse_header(&bytes).unwrap();
1162        let (pats, _end) = parse_patterns(&h, &bytes)
1163            .expect("hostile header_length must clamp rather than panic on the slice index");
1164        assert_eq!(pats.len(), 1);
1165        assert_eq!(pats[0].rows.len(), 1);
1166        assert_eq!(pats[0].rows[0].len(), 1);
1167        assert_eq!(pats[0].rows[0][0], XmCell::default());
1168    }
1169
1170    #[test]
1171    fn parse_patterns_unpacked_cell_form() {
1172        let mut bytes = build_header(2, 1, 0, false);
1173        // One row, two channels. First cell: unpacked (5-byte) form
1174        // containing (note=48, inst=1, vol=0x40, fx=0x0C, fxp=0x20).
1175        // Second cell: packed single-byte empty (0x80) with mask=0.
1176        let mut packed = Vec::new();
1177        packed.extend_from_slice(&[48, 1, 0x40, 0x0C, 0x20]);
1178        packed.push(0x80); // empty packed cell
1179        bytes.extend(build_pattern_block(1, &packed));
1180
1181        let h = parse_header(&bytes).unwrap();
1182        let (pats, _end) = parse_patterns(&h, &bytes).unwrap();
1183        let c0 = pats[0].rows[0][0];
1184        assert_eq!(c0.note, 48);
1185        assert_eq!(c0.instrument, 1);
1186        assert_eq!(c0.volume, 0x40);
1187        assert_eq!(c0.effect_type, 0x0C);
1188        assert_eq!(c0.effect_param, 0x20);
1189        assert!(c0.has_note());
1190        assert!(!c0.is_note_off());
1191        let c1 = pats[0].rows[0][1];
1192        assert_eq!(c1, XmCell::default());
1193    }
1194
1195    #[test]
1196    fn parse_patterns_packed_cell_selective_mask() {
1197        let mut bytes = build_header(1, 1, 0, false);
1198        // One row, one channel. Packed cell selecting note+volume only:
1199        // mask = 0x01 | 0x04 = 0x05 → first byte 0x85.
1200        let mut packed = Vec::new();
1201        packed.extend_from_slice(&[0x80 | 0x05, 50, 0x12]); // note=50, vol=0x12
1202        bytes.extend(build_pattern_block(1, &packed));
1203
1204        let h = parse_header(&bytes).unwrap();
1205        let (pats, _end) = parse_patterns(&h, &bytes).unwrap();
1206        let c = pats[0].rows[0][0];
1207        assert_eq!(c.note, 50);
1208        assert_eq!(c.instrument, 0);
1209        assert_eq!(c.volume, 0x12);
1210        assert_eq!(c.effect_type, 0);
1211        assert_eq!(c.effect_param, 0);
1212    }
1213
1214    #[test]
1215    fn xm_volume_kinds_classify_correctly() {
1216        let mk = |v: u8| XmCell {
1217            volume: v,
1218            ..XmCell::default()
1219        };
1220        assert_eq!(mk(0).volume_kind(), XmVolume::Empty);
1221        assert_eq!(mk(0x10).volume_kind(), XmVolume::SetVolume(0));
1222        assert_eq!(mk(0x50).volume_kind(), XmVolume::SetVolume(0x40));
1223        assert_eq!(mk(0x63).volume_kind(), XmVolume::VolumeSlideDown(3));
1224        assert_eq!(mk(0x77).volume_kind(), XmVolume::VolumeSlideUp(7));
1225        assert_eq!(mk(0x8F).volume_kind(), XmVolume::FineVolumeSlideDown(0x0F));
1226        assert_eq!(mk(0x9A).volume_kind(), XmVolume::FineVolumeSlideUp(0x0A));
1227        assert_eq!(mk(0xA4).volume_kind(), XmVolume::SetVibratoSpeed(4));
1228        assert_eq!(mk(0xB5).volume_kind(), XmVolume::Vibrato(5));
1229        assert_eq!(mk(0xC0).volume_kind(), XmVolume::SetPanning(0));
1230        assert_eq!(mk(0xD2).volume_kind(), XmVolume::PanningSlideLeft(2));
1231        assert_eq!(mk(0xE8).volume_kind(), XmVolume::PanningSlideRight(8));
1232        assert_eq!(mk(0xF9).volume_kind(), XmVolume::TonePorta(9));
1233    }
1234
1235    #[test]
1236    fn parse_instruments_zero_samples() {
1237        let mut bytes = build_header(4, 0, 2, false);
1238        bytes.extend(build_empty_instrument(b"empty1"));
1239        bytes.extend(build_empty_instrument(b"another"));
1240        let h = parse_header(&bytes).unwrap();
1241        let offset = pattern_data_offset(&h);
1242        let insts = parse_instruments(&h, &bytes, offset).unwrap();
1243        assert_eq!(insts.len(), 2);
1244        assert_eq!(insts[0].name, "empty1");
1245        assert_eq!(insts[0].num_samples, 0);
1246        assert!(insts[0].samples.is_empty());
1247        assert_eq!(insts[1].name, "another");
1248    }
1249
1250    #[test]
1251    fn parse_instrument_with_one_sample_decodes_envelope_and_sample_header() {
1252        let mut bytes = build_header(4, 0, 1, false);
1253        // A 4-byte sample body; deltas decode to [1, 3, 6, 10].
1254        let body = [1i8, 2, 3, 4];
1255        let body_bytes: Vec<u8> = body.iter().map(|&b| b as u8).collect();
1256        bytes.extend(build_one_sample_instrument(b"kick", &body_bytes));
1257
1258        let h = parse_header(&bytes).unwrap();
1259        let offset = pattern_data_offset(&h);
1260        let mut insts = parse_instruments(&h, &bytes, offset).unwrap();
1261        assert_eq!(insts.len(), 1);
1262        let inst = &insts[0];
1263        assert_eq!(inst.name, "kick");
1264        assert_eq!(inst.num_samples, 1);
1265        assert_eq!(inst.sample_header_size, XM_SAMPLE_HEADER_SIZE);
1266        assert_eq!(inst.sample_map.len(), 96);
1267        assert_eq!(inst.volume_envelope.points.len(), 2);
1268        assert_eq!(inst.volume_envelope.points[1], (64, 64));
1269        assert!(inst.volume_envelope.is_on());
1270        assert!(!inst.volume_envelope.has_sustain());
1271        assert_eq!(inst.samples.len(), 1);
1272        let s = &inst.samples[0];
1273        assert_eq!(s.length, body.len() as u32);
1274        assert_eq!(s.volume, 0x40);
1275        assert_eq!(s.panning, 128);
1276        assert!(!s.is_16_bit);
1277        assert_eq!(s.loop_mode, XmSampleLoopMode::None);
1278        assert_eq!(s.name, "snd");
1279
1280        // Now decode the delta PCM.
1281        extract_sample_bodies(&mut insts, &bytes);
1282        let decoded = &insts[0].samples[0].pcm8;
1283        assert_eq!(decoded, &[1, 3, 6, 10]);
1284    }
1285
1286    #[test]
1287    fn extract_sample_bodies_handles_truncated_body() {
1288        let mut bytes = build_header(4, 0, 1, false);
1289        let body_bytes = vec![1u8, 2, 3, 4, 5];
1290        bytes.extend(build_one_sample_instrument(b"s", &body_bytes));
1291        // Chop off the last 2 bytes of the sample body.
1292        let drop = 2;
1293        bytes.truncate(bytes.len() - drop);
1294
1295        let h = parse_header(&bytes).unwrap();
1296        let mut insts = parse_instruments(&h, &bytes, pattern_data_offset(&h)).unwrap();
1297        extract_sample_bodies(&mut insts, &bytes);
1298        assert_eq!(insts[0].samples[0].pcm8.len(), body_bytes.len() - drop);
1299    }
1300
1301    #[test]
1302    fn decode_packed_cell_empty_mask_byte() {
1303        // 0x80 alone = all masks clear = one-byte empty cell.
1304        let (cell, used) = decode_packed_cell(&[0x80], 0);
1305        assert_eq!(cell, XmCell::default());
1306        assert_eq!(used, 1);
1307    }
1308
1309    #[test]
1310    fn pattern_data_offset_is_standard_for_default_header_size() {
1311        let bytes = build_header(4, 0, 0, false);
1312        let h = parse_header(&bytes).unwrap();
1313        // header_size is measured from offset 60 inclusive; for the
1314        // canonical value 0x114 this lands at 60 + 0x114 = 0x150 (336),
1315        // which is exactly the end of the 256-byte order table that
1316        // starts at offset 80.
1317        assert_eq!(pattern_data_offset(&h), 0x150);
1318        assert_eq!(0x150, 336);
1319    }
1320
1321    #[test]
1322    fn estimate_duration_micros_is_positive_for_nonempty_song() {
1323        let mut bytes = build_header(4, 1, 0, false);
1324        bytes.extend(build_pattern_block(64, &[]));
1325        let h = parse_header(&bytes).unwrap();
1326        let (pats, _) = parse_patterns(&h, &bytes).unwrap();
1327        let us = estimate_duration_micros(&h, &pats);
1328        assert!(us > 0, "estimate_duration_micros returned {us}");
1329    }
1330}