use oxideav_core::{Error, Result};
pub const XM_BANNER: &[u8; 17] = b"Extended Module: ";
pub const XM_ID_BYTE_OFFSET: usize = 37;
pub const XM_VERSION_0104: u16 = 0x0104;
pub const XM_HEADER_SIZE_OFFSET: usize = 60;
pub const XM_ORDER_TABLE_OFFSET: usize = 80;
pub const XM_ORDER_TABLE_SIZE: usize = 256;
pub const XM_MIN_HEADER_LEN: usize = XM_ORDER_TABLE_OFFSET + XM_ORDER_TABLE_SIZE;
pub const XM_PATTERN_HEADER_SIZE: u32 = 9;
pub const XM_SAMPLE_HEADER_SIZE: u32 = 0x28;
pub const XM_INSTRUMENT_HEADER_SIZE_WITH_SAMPLES: u32 = 0x107;
pub const XM_FLAG_LINEAR_FREQ_TABLE: u16 = 0x0001;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XmFrequencyTable {
Amiga,
Linear,
}
#[derive(Clone, Debug)]
pub struct XmHeader {
pub module_name: String,
pub tracker_name: String,
pub version: u16,
pub header_size: u32,
pub song_length: u16,
pub restart_position: u16,
pub num_channels: u16,
pub num_patterns: u16,
pub num_instruments: u16,
pub flags: u16,
pub frequency_table: XmFrequencyTable,
pub default_tempo: u16,
pub default_bpm: u16,
pub order: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct XmPattern {
pub header_length: u32,
pub packing_type: u8,
pub num_rows: u16,
pub packed_size: u16,
pub rows: Vec<Vec<XmCell>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct XmCell {
pub note: u8,
pub instrument: u8,
pub volume: u8,
pub effect_type: u8,
pub effect_param: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XmVolume {
Empty,
SetVolume(u8),
VolumeSlideDown(u8),
VolumeSlideUp(u8),
FineVolumeSlideDown(u8),
FineVolumeSlideUp(u8),
SetVibratoSpeed(u8),
Vibrato(u8),
SetPanning(u8),
PanningSlideLeft(u8),
PanningSlideRight(u8),
TonePorta(u8),
}
impl XmCell {
pub fn volume_kind(&self) -> XmVolume {
match self.volume {
0 => XmVolume::Empty,
v @ 0x10..=0x50 => XmVolume::SetVolume(v - 0x10),
v @ 0x60..=0x6F => XmVolume::VolumeSlideDown(v & 0x0F),
v @ 0x70..=0x7F => XmVolume::VolumeSlideUp(v & 0x0F),
v @ 0x80..=0x8F => XmVolume::FineVolumeSlideDown(v & 0x0F),
v @ 0x90..=0x9F => XmVolume::FineVolumeSlideUp(v & 0x0F),
v @ 0xA0..=0xAF => XmVolume::SetVibratoSpeed(v & 0x0F),
v @ 0xB0..=0xBF => XmVolume::Vibrato(v & 0x0F),
v @ 0xC0..=0xCF => XmVolume::SetPanning(v & 0x0F),
v @ 0xD0..=0xDF => XmVolume::PanningSlideLeft(v & 0x0F),
v @ 0xE0..=0xEF => XmVolume::PanningSlideRight(v & 0x0F),
v @ 0xF0..=0xFF => XmVolume::TonePorta(v & 0x0F),
_ => XmVolume::Empty,
}
}
pub fn is_note_off(&self) -> bool {
self.note == 97
}
pub fn has_note(&self) -> bool {
(1..=96).contains(&self.note)
}
}
#[derive(Clone, Debug, Default)]
pub struct XmEnvelope {
pub points: Vec<(u16, u16)>,
pub sustain_point: u8,
pub loop_start_point: u8,
pub loop_end_point: u8,
pub type_bits: u8,
}
impl XmEnvelope {
pub fn is_on(&self) -> bool {
self.type_bits & 0x01 != 0
}
pub fn has_sustain(&self) -> bool {
self.type_bits & 0x02 != 0
}
pub fn has_loop(&self) -> bool {
self.type_bits & 0x04 != 0
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum XmSampleLoopMode {
#[default]
None,
Forward,
PingPong,
}
impl XmSampleLoopMode {
pub fn from_type_byte(b: u8) -> Self {
match b & 0x03 {
1 => XmSampleLoopMode::Forward,
2 => XmSampleLoopMode::PingPong,
_ => XmSampleLoopMode::None,
}
}
}
impl crate::mixer::SampleSource for XmSampleHeader {
fn len(&self) -> usize {
if self.is_16_bit {
self.pcm16.len()
} else {
self.pcm8.len()
}
}
fn loop_start(&self) -> usize {
let div = if self.is_16_bit { 2 } else { 1 };
if matches!(self.loop_mode, XmSampleLoopMode::None) {
0
} else {
(self.loop_start as usize / div).min(self.len())
}
}
fn loop_end(&self) -> usize {
let div = if self.is_16_bit { 2 } else { 1 };
if matches!(self.loop_mode, XmSampleLoopMode::None) {
self.len()
} else {
let end = (self.loop_start + self.loop_length) as usize / div;
end.min(self.len())
}
}
fn loop_kind(&self) -> crate::mixer::LoopKind {
match self.loop_mode {
XmSampleLoopMode::None => crate::mixer::LoopKind::None,
XmSampleLoopMode::Forward => crate::mixer::LoopKind::Forward,
XmSampleLoopMode::PingPong => crate::mixer::LoopKind::PingPong,
}
}
fn at(&self, idx: usize) -> f32 {
if self.is_16_bit {
self.pcm16.get(idx).copied().unwrap_or(0) as f32 / 32768.0
} else {
self.pcm8.get(idx).copied().unwrap_or(0) as f32 / 128.0
}
}
}
#[derive(Clone, Debug, Default)]
pub struct XmSampleHeader {
pub name: String,
pub length: u32,
pub loop_start: u32,
pub loop_length: u32,
pub volume: u8,
pub finetune: i8,
pub type_byte: u8,
pub panning: u8,
pub relative_note: i8,
pub loop_mode: XmSampleLoopMode,
pub is_16_bit: bool,
pub pcm16: Vec<i16>,
pub pcm8: Vec<i8>,
}
#[derive(Clone, Debug, Default)]
pub struct XmInstrument {
pub name: String,
pub header_size: u32,
pub instrument_type: u8,
pub num_samples: u16,
pub sample_header_size: u32,
pub sample_map: Vec<u8>,
pub volume_envelope: XmEnvelope,
pub panning_envelope: XmEnvelope,
pub vibrato_type: u8,
pub vibrato_sweep: u8,
pub vibrato_depth: u8,
pub vibrato_rate: u8,
pub volume_fadeout: u16,
pub samples: Vec<XmSampleHeader>,
pub sample_data_offset: usize,
}
pub fn is_xm(bytes: &[u8]) -> bool {
bytes.len() >= XM_BANNER.len() && &bytes[..XM_BANNER.len()] == XM_BANNER.as_slice()
}
fn read_u16_le(bytes: &[u8], off: usize) -> u16 {
u16::from_le_bytes([bytes[off], bytes[off + 1]])
}
fn read_u32_le(bytes: &[u8], off: usize) -> u32 {
u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
}
fn trim_fixed_string(bytes: &[u8]) -> String {
let end = bytes
.iter()
.rposition(|&b| b != 0 && b != b' ')
.map(|i| i + 1)
.unwrap_or(0);
String::from_utf8_lossy(&bytes[..end]).to_string()
}
pub fn parse_header(bytes: &[u8]) -> Result<XmHeader> {
if bytes.len() < XM_MIN_HEADER_LEN {
return Err(Error::NeedMore);
}
if !is_xm(bytes) {
return Err(Error::invalid(
"XM: missing 'Extended Module: ' banner at offset 0",
));
}
if bytes[XM_ID_BYTE_OFFSET] != 0x1A {
return Err(Error::invalid("XM: missing 0x1A marker byte at offset 37"));
}
let module_name = trim_fixed_string(&bytes[17..37]);
let tracker_name = trim_fixed_string(&bytes[38..58]);
let version = read_u16_le(bytes, 58);
let header_size = read_u32_le(bytes, XM_HEADER_SIZE_OFFSET);
let song_length = read_u16_le(bytes, 64);
let restart_position = read_u16_le(bytes, 66);
let num_channels = read_u16_le(bytes, 68);
let num_patterns = read_u16_le(bytes, 70);
let num_instruments = read_u16_le(bytes, 72);
let flags = read_u16_le(bytes, 74);
let default_tempo = read_u16_le(bytes, 76);
let default_bpm = read_u16_le(bytes, 78);
if !(1..=32).contains(&num_channels) {
return Err(Error::invalid(format!(
"XM: implausible channel count {num_channels} (expected 1..=32)"
)));
}
if num_patterns > 256 {
return Err(Error::invalid(format!(
"XM: implausible pattern count {num_patterns} (expected <=256)"
)));
}
if num_instruments > 128 {
return Err(Error::invalid(format!(
"XM: implausible instrument count {num_instruments} (expected <=128)"
)));
}
let frequency_table = if flags & XM_FLAG_LINEAR_FREQ_TABLE != 0 {
XmFrequencyTable::Linear
} else {
XmFrequencyTable::Amiga
};
let order = bytes[XM_ORDER_TABLE_OFFSET..XM_ORDER_TABLE_OFFSET + XM_ORDER_TABLE_SIZE].to_vec();
Ok(XmHeader {
module_name,
tracker_name,
version,
header_size,
song_length,
restart_position,
num_channels,
num_patterns,
num_instruments,
flags,
frequency_table,
default_tempo,
default_bpm,
order,
})
}
pub fn pattern_data_offset(header: &XmHeader) -> usize {
XM_HEADER_SIZE_OFFSET + header.header_size as usize
}
fn decode_packed_cell(data: &[u8], cur: usize) -> (XmCell, usize) {
if cur >= data.len() {
return (XmCell::default(), 0);
}
let first = data[cur];
let mut cell = XmCell::default();
if first & 0x80 != 0 {
let mask = first & 0x7F;
let mut off = 1usize;
let grab = |off: &mut usize, data: &[u8]| -> u8 {
let p = cur + *off;
*off += 1;
if p < data.len() {
data[p]
} else {
0
}
};
if mask & 0x01 != 0 {
cell.note = grab(&mut off, data);
}
if mask & 0x02 != 0 {
cell.instrument = grab(&mut off, data);
}
if mask & 0x04 != 0 {
cell.volume = grab(&mut off, data);
}
if mask & 0x08 != 0 {
cell.effect_type = grab(&mut off, data);
}
if mask & 0x10 != 0 {
cell.effect_param = grab(&mut off, data);
}
(cell, off)
} else {
cell.note = first;
let grab = |rel: usize| -> u8 {
let p = cur + rel;
if p < data.len() {
data[p]
} else {
0
}
};
cell.instrument = grab(1);
cell.volume = grab(2);
cell.effect_type = grab(3);
cell.effect_param = grab(4);
(cell, 5)
}
}
pub fn parse_patterns(header: &XmHeader, bytes: &[u8]) -> Result<(Vec<XmPattern>, usize)> {
let mut cur = pattern_data_offset(header);
let mut out = Vec::with_capacity(header.num_patterns as usize);
let num_channels = header.num_channels as usize;
for pat_idx in 0..header.num_patterns as usize {
if cur + 9 > bytes.len() {
return Err(Error::invalid(format!(
"XM: truncated pattern header #{pat_idx} at offset {cur}"
)));
}
let header_length = read_u32_le(bytes, cur);
let packing_type = bytes[cur + 4];
let num_rows = read_u16_le(bytes, cur + 5);
let packed_size = read_u16_le(bytes, cur + 7);
let data_start = cur
.checked_add(header_length as usize)
.ok_or_else(|| Error::invalid("XM: pattern header_length overflow"))?;
let mut rows: Vec<Vec<XmCell>> = Vec::with_capacity(num_rows as usize);
if packed_size == 0 {
for _ in 0..num_rows {
rows.push(vec![XmCell::default(); num_channels]);
}
} else {
let data_end = data_start
.saturating_add(packed_size as usize)
.min(bytes.len());
let slice = &bytes[data_start..data_end];
let mut inner = 0usize;
for _ in 0..num_rows {
let mut row = Vec::with_capacity(num_channels);
for _ in 0..num_channels {
let (cell, consumed) = decode_packed_cell(slice, inner);
inner += consumed;
row.push(cell);
}
rows.push(row);
}
}
cur = data_start.saturating_add(packed_size as usize);
out.push(XmPattern {
header_length,
packing_type,
num_rows,
packed_size,
rows,
});
}
Ok((out, cur))
}
fn parse_envelope_points(bytes: &[u8; 48], num_points: u8) -> Vec<(u16, u16)> {
let n = num_points.min(12) as usize;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let off = i * 4;
let x = u16::from_le_bytes([bytes[off], bytes[off + 1]]);
let y = u16::from_le_bytes([bytes[off + 2], bytes[off + 3]]);
out.push((x, y));
}
out
}
fn parse_one_instrument(bytes: &[u8], cur: usize) -> Result<(XmInstrument, usize)> {
if cur + 29 > bytes.len() {
return Err(Error::invalid(format!(
"XM: truncated instrument header at offset {cur}"
)));
}
let header_size = read_u32_le(bytes, cur);
if header_size < 29 {
return Err(Error::invalid(format!(
"XM: nonsensical instrument header_size {header_size} at {cur}"
)));
}
let name = trim_fixed_string(&bytes[cur + 4..cur + 26]);
let instrument_type = bytes[cur + 26];
let num_samples = read_u16_le(bytes, cur + 27);
let mut inst = XmInstrument {
name,
header_size,
instrument_type,
num_samples,
..Default::default()
};
if num_samples == 0 {
let next = cur.saturating_add(header_size as usize).min(bytes.len());
inst.sample_data_offset = next;
return Ok((inst, next));
}
if cur + header_size as usize > bytes.len() {
return Err(Error::invalid(format!(
"XM: truncated extended instrument block (need {} bytes at {cur})",
header_size
)));
}
let ext_base = cur + 29;
inst.sample_header_size = read_u32_le(bytes, ext_base);
let map_start = ext_base + 4;
if map_start + 96 > bytes.len() {
return Err(Error::invalid("XM: truncated instrument sample-number map"));
}
inst.sample_map = bytes[map_start..map_start + 96].to_vec();
let vol_env_start = ext_base + 100;
let pan_env_start = ext_base + 148;
if pan_env_start + 48 > bytes.len() {
return Err(Error::invalid("XM: truncated instrument envelope tables"));
}
let vol_env_raw: [u8; 48] = bytes[vol_env_start..vol_env_start + 48].try_into().unwrap();
let pan_env_raw: [u8; 48] = bytes[pan_env_start..pan_env_start + 48].try_into().unwrap();
let f = ext_base + 196;
if f + 16 > bytes.len() {
return Err(Error::invalid("XM: truncated instrument fixed-byte block"));
}
let num_vol_points = bytes[f];
let num_pan_points = bytes[f + 1];
let vol_sustain = bytes[f + 2];
let vol_loop_start = bytes[f + 3];
let vol_loop_end = bytes[f + 4];
let pan_sustain = bytes[f + 5];
let pan_loop_start = bytes[f + 6];
let pan_loop_end = bytes[f + 7];
let vol_type = bytes[f + 8];
let pan_type = bytes[f + 9];
inst.vibrato_type = bytes[f + 10];
inst.vibrato_sweep = bytes[f + 11];
inst.vibrato_depth = bytes[f + 12];
inst.vibrato_rate = bytes[f + 13];
inst.volume_fadeout = read_u16_le(bytes, f + 14);
inst.volume_envelope = XmEnvelope {
points: parse_envelope_points(&vol_env_raw, num_vol_points),
sustain_point: vol_sustain,
loop_start_point: vol_loop_start,
loop_end_point: vol_loop_end,
type_bits: vol_type,
};
inst.panning_envelope = XmEnvelope {
points: parse_envelope_points(&pan_env_raw, num_pan_points),
sustain_point: pan_sustain,
loop_start_point: pan_loop_start,
loop_end_point: pan_loop_end,
type_bits: pan_type,
};
if inst.sample_header_size < 40 {
return Err(Error::invalid(format!(
"XM: sample_header_size {} too small (expected >=40)",
inst.sample_header_size
)));
}
let headers_start = cur + header_size as usize;
let mut hcur = headers_start;
for i in 0..num_samples as usize {
if hcur + inst.sample_header_size as usize > bytes.len() {
return Err(Error::invalid(format!(
"XM: truncated sample header #{i} in instrument"
)));
}
let length = read_u32_le(bytes, hcur);
let loop_start = read_u32_le(bytes, hcur + 4);
let loop_length = read_u32_le(bytes, hcur + 8);
let volume = bytes[hcur + 12];
let finetune = bytes[hcur + 13] as i8;
let type_byte = bytes[hcur + 14];
let panning = bytes[hcur + 15];
let relative_note = bytes[hcur + 16] as i8;
let name = if hcur + 18 + 22 <= bytes.len() {
trim_fixed_string(&bytes[hcur + 18..hcur + 40])
} else {
String::new()
};
let loop_mode = XmSampleLoopMode::from_type_byte(type_byte);
let is_16_bit = type_byte & 0x10 != 0;
inst.samples.push(XmSampleHeader {
name,
length,
loop_start,
loop_length,
volume,
finetune,
type_byte,
panning,
relative_note,
loop_mode,
is_16_bit,
pcm16: Vec::new(),
pcm8: Vec::new(),
});
hcur += inst.sample_header_size as usize;
}
inst.sample_data_offset = hcur;
Ok((inst, hcur))
}
pub fn parse_instruments(
header: &XmHeader,
bytes: &[u8],
instruments_offset: usize,
) -> Result<Vec<XmInstrument>> {
let mut out = Vec::with_capacity(header.num_instruments as usize);
let mut cur = instruments_offset;
for i in 0..header.num_instruments as usize {
let (inst, _next) = parse_one_instrument(bytes, cur)
.map_err(|e| Error::invalid(format!("XM: failed to parse instrument #{i}: {e}")))?;
let pcm_bytes: usize = inst.samples.iter().map(|s| s.length as usize).sum();
cur = inst.sample_data_offset.saturating_add(pcm_bytes);
out.push(inst);
}
Ok(out)
}
pub fn extract_sample_bodies(instruments: &mut [XmInstrument], bytes: &[u8]) {
for inst in instruments.iter_mut() {
let mut cur = inst.sample_data_offset;
for sample in inst.samples.iter_mut() {
let length_bytes = (sample.length as usize).min(bytes.len().saturating_sub(cur));
let slice = &bytes[cur..cur + length_bytes];
if sample.is_16_bit {
let n_frames = length_bytes / 2;
let mut out = Vec::with_capacity(n_frames);
let mut old: i16 = 0;
for i in 0..n_frames {
let delta = i16::from_le_bytes([slice[i * 2], slice[i * 2 + 1]]);
old = old.wrapping_add(delta);
out.push(old);
}
sample.pcm16 = out;
} else {
let mut out = Vec::with_capacity(length_bytes);
let mut old: i8 = 0;
for &b in slice {
let delta = b as i8;
old = old.wrapping_add(delta);
out.push(old);
}
sample.pcm8 = out;
}
cur += length_bytes;
}
}
}
pub fn estimate_duration_micros(header: &XmHeader, patterns: &[XmPattern]) -> i64 {
if patterns.is_empty() {
return 0;
}
let bpm = header.default_bpm.max(1) as i64;
let tempo = header.default_tempo.max(1) as i64;
let ticks_per_sec = (5 * bpm) / 2;
if ticks_per_sec < 1 {
return 0;
}
let song_length = header.song_length.max(1) as usize;
let mut total_rows: u64 = 0;
for idx in 0..song_length {
let pat_idx = *header.order.get(idx).unwrap_or(&0) as usize;
let rows = patterns
.get(pat_idx)
.map(|p| p.num_rows as u64)
.unwrap_or(64);
total_rows = total_rows.saturating_add(rows);
}
(total_rows as i64).saturating_mul(tempo) * 1_000_000 / ticks_per_sec.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_header(
num_channels: u16,
num_patterns: u16,
num_instruments: u16,
linear: bool,
) -> Vec<u8> {
let mut out = vec![0u8; XM_MIN_HEADER_LEN];
out[0..17].copy_from_slice(XM_BANNER);
let name = b"hello xm ";
out[17..17 + name.len()].copy_from_slice(name);
out[XM_ID_BYTE_OFFSET] = 0x1A;
let tracker = b"oxideav-test ";
out[38..38 + tracker.len()].copy_from_slice(tracker);
out[58..60].copy_from_slice(&XM_VERSION_0104.to_le_bytes());
out[60..64].copy_from_slice(&0x114u32.to_le_bytes());
out[64..66].copy_from_slice(&1u16.to_le_bytes()); out[66..68].copy_from_slice(&0u16.to_le_bytes()); out[68..70].copy_from_slice(&num_channels.to_le_bytes());
out[70..72].copy_from_slice(&num_patterns.to_le_bytes());
out[72..74].copy_from_slice(&num_instruments.to_le_bytes());
let flags = if linear { 1u16 } else { 0u16 };
out[74..76].copy_from_slice(&flags.to_le_bytes());
out[76..78].copy_from_slice(&6u16.to_le_bytes()); out[78..80].copy_from_slice(&125u16.to_le_bytes()); for i in 1..XM_ORDER_TABLE_SIZE {
out[XM_ORDER_TABLE_OFFSET + i] = 0xFF;
}
out
}
fn build_pattern_block(num_rows: u16, packed: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&XM_PATTERN_HEADER_SIZE.to_le_bytes()); out.push(0); out.extend_from_slice(&num_rows.to_le_bytes());
out.extend_from_slice(&(packed.len() as u16).to_le_bytes());
out.extend_from_slice(packed);
out
}
fn build_empty_instrument(name: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
const HSIZE: u32 = 0x21;
out.extend_from_slice(&HSIZE.to_le_bytes());
let mut nbuf = [0u8; 22];
let n = name.len().min(22);
nbuf[..n].copy_from_slice(&name[..n]);
out.extend_from_slice(&nbuf);
out.push(0); out.extend_from_slice(&0u16.to_le_bytes()); while out.len() < HSIZE as usize {
out.push(0);
}
out
}
fn build_one_sample_instrument(name: &[u8], sample_body: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
const HSIZE: u32 = XM_INSTRUMENT_HEADER_SIZE_WITH_SAMPLES; out.extend_from_slice(&HSIZE.to_le_bytes());
let mut nbuf = [0u8; 22];
let n = name.len().min(22);
nbuf[..n].copy_from_slice(&name[..n]);
out.extend_from_slice(&nbuf);
out.push(0); out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&XM_SAMPLE_HEADER_SIZE.to_le_bytes());
out.extend(std::iter::repeat_n(0u8, 96));
let mut vol_env = [0u8; 48];
vol_env[0..2].copy_from_slice(&0u16.to_le_bytes()); vol_env[2..4].copy_from_slice(&0u16.to_le_bytes()); vol_env[4..6].copy_from_slice(&64u16.to_le_bytes()); vol_env[6..8].copy_from_slice(&64u16.to_le_bytes()); out.extend_from_slice(&vol_env);
out.extend_from_slice(&[0u8; 48]);
out.push(2);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.push(0x01);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.push(0);
out.extend_from_slice(&512u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
while out.len() < HSIZE as usize {
out.push(0);
}
out.extend_from_slice(&(sample_body.len() as u32).to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.push(0x40); out.push(0); out.push(0); out.push(128); out.push(0); out.push(0); let mut sname = [0u8; 22];
let s = b"snd";
sname[..s.len()].copy_from_slice(s);
out.extend_from_slice(&sname);
out.extend_from_slice(sample_body);
out
}
#[test]
fn is_xm_accepts_canonical_banner() {
let bytes = build_header(4, 0, 0, false);
assert!(is_xm(&bytes));
}
#[test]
fn is_xm_rejects_lowercase_banner() {
let mut bytes = build_header(4, 0, 0, false);
bytes[9] = b'm';
assert!(!is_xm(&bytes));
}
#[test]
fn is_xm_rejects_short_buffer() {
assert!(!is_xm(b"Extended Module"));
}
#[test]
fn parse_header_populates_core_fields() {
let bytes = build_header(8, 2, 3, true);
let h = parse_header(&bytes).unwrap();
assert_eq!(h.module_name, "hello xm");
assert_eq!(h.tracker_name, "oxideav-test");
assert_eq!(h.version, XM_VERSION_0104);
assert_eq!(h.header_size, 0x114);
assert_eq!(h.song_length, 1);
assert_eq!(h.num_channels, 8);
assert_eq!(h.num_patterns, 2);
assert_eq!(h.num_instruments, 3);
assert_eq!(h.default_tempo, 6);
assert_eq!(h.default_bpm, 125);
assert_eq!(h.frequency_table, XmFrequencyTable::Linear);
assert_eq!(h.order.len(), XM_ORDER_TABLE_SIZE);
assert_eq!(h.order[0], 0);
assert_eq!(h.order[1], 0xFF);
}
#[test]
fn parse_header_rejects_missing_id_byte() {
let mut bytes = build_header(4, 0, 0, false);
bytes[XM_ID_BYTE_OFFSET] = 0;
assert!(parse_header(&bytes).is_err());
}
#[test]
fn parse_header_rejects_zero_channels() {
let mut bytes = build_header(0, 0, 0, false);
bytes[68..70].copy_from_slice(&0u16.to_le_bytes());
assert!(parse_header(&bytes).is_err());
}
#[test]
fn parse_header_needs_full_order_table() {
let bytes = build_header(4, 0, 0, false);
let short = &bytes[..XM_ORDER_TABLE_OFFSET];
matches!(parse_header(short), Err(Error::NeedMore));
}
#[test]
fn parse_patterns_all_empty_synthesizes_defaults() {
let mut bytes = build_header(4, 1, 0, false);
bytes.extend(build_pattern_block(8, &[]));
let h = parse_header(&bytes).unwrap();
let (pats, end) = parse_patterns(&h, &bytes).unwrap();
assert_eq!(pats.len(), 1);
assert_eq!(pats[0].num_rows, 8);
assert_eq!(pats[0].packed_size, 0);
assert_eq!(pats[0].rows.len(), 8);
assert_eq!(pats[0].rows[0].len(), 4);
for row in &pats[0].rows {
for cell in row {
assert_eq!(*cell, XmCell::default());
}
}
assert_eq!(end, pattern_data_offset(&h) + 9);
}
#[test]
fn parse_patterns_unpacked_cell_form() {
let mut bytes = build_header(2, 1, 0, false);
let mut packed = Vec::new();
packed.extend_from_slice(&[48, 1, 0x40, 0x0C, 0x20]);
packed.push(0x80); bytes.extend(build_pattern_block(1, &packed));
let h = parse_header(&bytes).unwrap();
let (pats, _end) = parse_patterns(&h, &bytes).unwrap();
let c0 = pats[0].rows[0][0];
assert_eq!(c0.note, 48);
assert_eq!(c0.instrument, 1);
assert_eq!(c0.volume, 0x40);
assert_eq!(c0.effect_type, 0x0C);
assert_eq!(c0.effect_param, 0x20);
assert!(c0.has_note());
assert!(!c0.is_note_off());
let c1 = pats[0].rows[0][1];
assert_eq!(c1, XmCell::default());
}
#[test]
fn parse_patterns_packed_cell_selective_mask() {
let mut bytes = build_header(1, 1, 0, false);
let mut packed = Vec::new();
packed.extend_from_slice(&[0x80 | 0x05, 50, 0x12]); bytes.extend(build_pattern_block(1, &packed));
let h = parse_header(&bytes).unwrap();
let (pats, _end) = parse_patterns(&h, &bytes).unwrap();
let c = pats[0].rows[0][0];
assert_eq!(c.note, 50);
assert_eq!(c.instrument, 0);
assert_eq!(c.volume, 0x12);
assert_eq!(c.effect_type, 0);
assert_eq!(c.effect_param, 0);
}
#[test]
fn xm_volume_kinds_classify_correctly() {
let mk = |v: u8| XmCell {
volume: v,
..XmCell::default()
};
assert_eq!(mk(0).volume_kind(), XmVolume::Empty);
assert_eq!(mk(0x10).volume_kind(), XmVolume::SetVolume(0));
assert_eq!(mk(0x50).volume_kind(), XmVolume::SetVolume(0x40));
assert_eq!(mk(0x63).volume_kind(), XmVolume::VolumeSlideDown(3));
assert_eq!(mk(0x77).volume_kind(), XmVolume::VolumeSlideUp(7));
assert_eq!(mk(0x8F).volume_kind(), XmVolume::FineVolumeSlideDown(0x0F));
assert_eq!(mk(0x9A).volume_kind(), XmVolume::FineVolumeSlideUp(0x0A));
assert_eq!(mk(0xA4).volume_kind(), XmVolume::SetVibratoSpeed(4));
assert_eq!(mk(0xB5).volume_kind(), XmVolume::Vibrato(5));
assert_eq!(mk(0xC0).volume_kind(), XmVolume::SetPanning(0));
assert_eq!(mk(0xD2).volume_kind(), XmVolume::PanningSlideLeft(2));
assert_eq!(mk(0xE8).volume_kind(), XmVolume::PanningSlideRight(8));
assert_eq!(mk(0xF9).volume_kind(), XmVolume::TonePorta(9));
}
#[test]
fn parse_instruments_zero_samples() {
let mut bytes = build_header(4, 0, 2, false);
bytes.extend(build_empty_instrument(b"empty1"));
bytes.extend(build_empty_instrument(b"another"));
let h = parse_header(&bytes).unwrap();
let offset = pattern_data_offset(&h);
let insts = parse_instruments(&h, &bytes, offset).unwrap();
assert_eq!(insts.len(), 2);
assert_eq!(insts[0].name, "empty1");
assert_eq!(insts[0].num_samples, 0);
assert!(insts[0].samples.is_empty());
assert_eq!(insts[1].name, "another");
}
#[test]
fn parse_instrument_with_one_sample_decodes_envelope_and_sample_header() {
let mut bytes = build_header(4, 0, 1, false);
let body = [1i8, 2, 3, 4];
let body_bytes: Vec<u8> = body.iter().map(|&b| b as u8).collect();
bytes.extend(build_one_sample_instrument(b"kick", &body_bytes));
let h = parse_header(&bytes).unwrap();
let offset = pattern_data_offset(&h);
let mut insts = parse_instruments(&h, &bytes, offset).unwrap();
assert_eq!(insts.len(), 1);
let inst = &insts[0];
assert_eq!(inst.name, "kick");
assert_eq!(inst.num_samples, 1);
assert_eq!(inst.sample_header_size, XM_SAMPLE_HEADER_SIZE);
assert_eq!(inst.sample_map.len(), 96);
assert_eq!(inst.volume_envelope.points.len(), 2);
assert_eq!(inst.volume_envelope.points[1], (64, 64));
assert!(inst.volume_envelope.is_on());
assert!(!inst.volume_envelope.has_sustain());
assert_eq!(inst.samples.len(), 1);
let s = &inst.samples[0];
assert_eq!(s.length, body.len() as u32);
assert_eq!(s.volume, 0x40);
assert_eq!(s.panning, 128);
assert!(!s.is_16_bit);
assert_eq!(s.loop_mode, XmSampleLoopMode::None);
assert_eq!(s.name, "snd");
extract_sample_bodies(&mut insts, &bytes);
let decoded = &insts[0].samples[0].pcm8;
assert_eq!(decoded, &[1, 3, 6, 10]);
}
#[test]
fn extract_sample_bodies_handles_truncated_body() {
let mut bytes = build_header(4, 0, 1, false);
let body_bytes = vec![1u8, 2, 3, 4, 5];
bytes.extend(build_one_sample_instrument(b"s", &body_bytes));
let drop = 2;
bytes.truncate(bytes.len() - drop);
let h = parse_header(&bytes).unwrap();
let mut insts = parse_instruments(&h, &bytes, pattern_data_offset(&h)).unwrap();
extract_sample_bodies(&mut insts, &bytes);
assert_eq!(insts[0].samples[0].pcm8.len(), body_bytes.len() - drop);
}
#[test]
fn decode_packed_cell_empty_mask_byte() {
let (cell, used) = decode_packed_cell(&[0x80], 0);
assert_eq!(cell, XmCell::default());
assert_eq!(used, 1);
}
#[test]
fn pattern_data_offset_is_standard_for_default_header_size() {
let bytes = build_header(4, 0, 0, false);
let h = parse_header(&bytes).unwrap();
assert_eq!(pattern_data_offset(&h), 0x150);
assert_eq!(0x150, 336);
}
#[test]
fn estimate_duration_micros_is_positive_for_nonempty_song() {
let mut bytes = build_header(4, 1, 0, false);
bytes.extend(build_pattern_block(64, &[]));
let h = parse_header(&bytes).unwrap();
let (pats, _) = parse_patterns(&h, &bytes).unwrap();
let us = estimate_duration_micros(&h, &pats);
assert!(us > 0, "estimate_duration_micros returned {us}");
}
}