use super::amiga_sample::AmigaSample;
use super::patternslot::PatternSlot;
use crate::core::cell_note::CellNote;
use crate::tracker::import::bin_reader::ImportError;
use crate::core::fixed::units::Volume;
use crate::prelude::*;
use crate::tracker::codepage::Codepage;
use crate::tracker::import::memory::ImportMemory;
use crate::tracker::import::memory::MemoryType;
use alloc::format;
use alloc::string::String;
use alloc::string::ToString;
use alloc::{vec, vec::Vec};
const MOD_15_HEADER_SIZE: usize = 600;
const MOD_31_HEADER_SIZE: usize = 1084;
const AMIGA_SAMPLE_RECORD_SIZE: usize = 30;
const POSITION_TABLE_SIZE: usize = 128;
const AMIGA_ROWS_PER_PATTERN: usize = 64;
const AMIGA_SLOT_SIZE: usize = 4;
fn tag_str_to_num_tracks(tag: &str) -> Option<u8> {
match tag {
"TDZ1" => Some(1),
"2CHN" | "TDZ2" => Some(2),
"TDZ3" => Some(3),
"M.K." | "M!K!" | "FLT4" | "NSMS" | "LARD" | "PATT" | "EXO4" | "N.T." | "M&K!" | "FEST"
| "CD61" => Some(4),
"5CHN" => Some(5),
"6CHN" => Some(6),
"7CHN" => Some(7),
"8CHN" | "CD81" | "OKTA" | "OCTA" | "FLT8" | "EXO8" => Some(8),
"9CHN" => Some(9),
t if t.ends_with("CH") || t.ends_with("CN") => {
match t[..t.len() - 2].parse::<u8>().unwrap_or(0) {
0 => None,
v => Some(v),
}
}
_ => None,
}
}
#[derive(Copy, Clone, Debug, Default)]
struct ModDialect {
is_noisetracker: bool,
is_hmnt: bool,
is_startrekker: bool,
is_flt8: bool,
}
fn tag_dialect(tag: &str) -> ModDialect {
let is_hmnt = matches!(tag, "M&K!" | "FEST");
ModDialect {
is_noisetracker: is_hmnt || tag == "N.T.",
is_hmnt,
is_startrekker: matches!(tag, "FLT4" | "FLT8" | "EXO4" | "EXO8"),
is_flt8: matches!(tag, "FLT8" | "EXO8"),
}
}
fn apply_mod_dialect(slot: &mut PatternSlot, dialect: &ModDialect) {
const PATTERN_BREAK: u8 = 0x0D;
const EXTENDED: u8 = 0x0E;
const SET_SPEED: u8 = 0x0F;
if dialect.is_noisetracker && slot.effect_type == PATTERN_BREAK {
slot.effect_parameter = 0;
}
if dialect.is_startrekker {
if slot.effect_type == EXTENDED {
slot.effect_type = 0;
slot.effect_parameter = 0;
} else if slot.effect_type == SET_SPEED && slot.effect_parameter > 0x1F {
slot.effect_parameter = 0x1F;
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum StkVersion {
Ust1_00,
Ust1_80,
St2_00Exterminator,
StIii,
StIx,
Mst1_00,
St2_00,
}
fn remap_stk_slot(slot: &mut PatternSlot, version: StkVersion, use_auto_slides: bool) {
let mut command = slot.effect_type & 0x0F;
let mut param = slot.effect_parameter;
if command == 0 && param == 0 {
return;
}
if command == 0x0D {
if version != StkVersion::St2_00 {
command = 0x0A;
} else {
param = 0; }
} else if command == 0x0C {
param &= 0x7F; } else if command == 0x0E && (param > 0x01 || version < StkVersion::StIx) && use_auto_slides {
slot.effect_type = 0x0A;
slot.effect_parameter = param;
return;
} else if command == 0x0F {
param &= 0x0F; }
if version <= StkVersion::Ust1_80 {
match command {
0 => {
if param < 0x03 {
command = 0;
param = 0;
}
}
1 => {
command = 0;
}
2 => {
if param & 0x0F != 0 {
command = 1; param &= 0x0F;
} else if param >> 4 != 0 {
command = 2; param >>= 4;
} else {
command = 0;
param = 0;
}
}
_ => {
command = 0;
param = 0;
}
}
}
slot.effect_type = command;
slot.effect_parameter = param;
}
fn tag_bytes_to_num_tracks(tag: &[u8]) -> Option<u8> {
if tag.len() != 4 {
return None;
}
let s = core::str::from_utf8(tag).ok()?;
tag_str_to_num_tracks(s)
}
fn is_clean_text(bytes: &[u8]) -> bool {
bytes.iter().all(|&b| !matches!(b, 0x01..=0x1F | 0x7F))
}
#[derive(Copy, Clone, Debug)]
enum AmigaVariant {
Fifteen,
ThirtyOne { num_tracks: u8 },
}
#[derive(Default, Debug)]
pub struct AmigaModule {
title: String,
samples: Vec<AmigaSample>, song_length: u8,
restart_position: u8,
positions: Vec<u8>, tag: String,
patterns: Vec<Vec<Vec<PatternSlot>>>, audio: Vec<Vec<i8>>,
is_hmnt: bool,
}
impl AmigaModule {
fn get_number_of_tracks(&self) -> Option<u8> {
tag_str_to_num_tracks(self.tag.as_str())
}
fn get_number_of_samples(&self) -> usize {
match self.get_number_of_tracks() {
None => 15,
_ => 31,
}
}
fn get_number_of_patterns(&self) -> usize {
1 + *self.positions.iter().max().unwrap_or(&0) as usize
}
fn is_mods_grave(&self, file_len: usize) -> bool {
if self.tag != "M.K." || self.restart_position != 0 {
return false;
}
let mut total_sample_bytes = 0usize;
for s in &self.samples {
if s.finetune != 0 {
return false;
}
if s.length_div2 != 0 && s.volume != 64 {
return false;
}
total_sample_bytes += 2 * s.length_div2 as usize;
}
let num_patterns = self.get_number_of_patterns();
let eight_ch_bytes = 8 * AMIGA_ROWS_PER_PATTERN * AMIGA_SLOT_SIZE; MOD_31_HEADER_SIZE + total_sample_bytes + num_patterns * eight_ch_bytes == (file_len & !1)
}
fn detect_stk_version(&self) -> (StkVersion, bool) {
use StkVersion::*;
let mut version = Ust1_00;
let mut no_disk_names = true;
for s in &self.samples {
let n = s.name.as_bytes();
let is_disk = n.len() >= 6 && n[..3].eq_ignore_ascii_case(b"st-") && n[5] == b':';
if is_disk {
no_disk_names = false;
}
if s.length_div2 > 4999 || s.repeat_offset_div2 > 9999 {
version = version.max(Mst1_00);
}
}
let restart = if self.restart_position == 0 {
0x78
} else {
self.restart_position
};
if restart != 0x78 {
if version > Ust1_80 {
version = version.max(if no_disk_names { StIx } else { Mst1_00 });
} else {
version = version.max(if no_disk_names {
Ust1_80
} else {
St2_00Exterminator
});
}
}
let num_patterns = self.get_number_of_patterns();
let mut use_auto_slides = false;
let mut total_num_dxx: u32 = 0;
for pat in &self.patterns {
let mut num_dxx: u32 = 0;
let mut auto_slides: u32 = 0;
let mut empty_run: u32 = 0;
for (row_idx, row) in pat.iter().enumerate() {
for slot in row {
let eff = slot.effect_type & 0x0F;
let param = slot.effect_parameter;
let is_empty = matches!(slot.note, CellNote::Empty)
&& slot.instrument.is_none()
&& slot.effect_type == 0
&& slot.effect_parameter == 0;
if empty_run != 0 && is_empty {
empty_run += 1;
if empty_run > 32 {
version = St2_00;
}
} else {
empty_run = 0;
}
match eff {
1 | 2 => {
if param > 0x1F && version == Ust1_80 {
version = if no_disk_names { Ust1_80 } else { Ust1_00 };
} else if eff == 1 && (1..0x03).contains(¶m) {
version = version.max(St2_00Exterminator);
} else if eff == 1
&& (param == 0x37 || param == 0x47)
&& version <= St2_00Exterminator
{
version = if no_disk_names { Ust1_80 } else { Ust1_00 };
}
}
0x0B => version = St2_00,
0x0C..=0x0E => {
version = version.max(St2_00Exterminator);
if eff == 0x0D {
empty_run = 1;
if !(param == 0 && row_idx == 0) {
num_dxx += 1;
}
} else if eff == 0x0E {
auto_slides += 1;
if param > 1 || auto_slides > 1 {
use_auto_slides = true;
}
}
}
0x0F => version = version.max(StIii),
_ => {}
}
}
}
if (1..3).contains(&num_dxx) {
version = St2_00;
}
total_num_dxx += num_dxx;
}
if total_num_dxx > num_patterns as u32 + 32 && version == St2_00 {
version = Mst1_00;
}
(version, use_auto_slides)
}
fn detect_variant(data: &[u8]) -> Result<AmigaVariant, ImportError> {
if data.len() >= MOD_31_HEADER_SIZE {
if let Some(num_tracks) = tag_bytes_to_num_tracks(&data[0x438..0x438 + 4]) {
let v = AmigaVariant::ThirtyOne { num_tracks };
if Self::validate_structure(data, v).is_ok() {
return Ok(v);
}
}
}
if Self::validate_structure(data, AmigaVariant::Fifteen).is_ok() {
return Ok(AmigaVariant::Fifteen);
}
Err(ImportError::Other("Not an Amiga MOD module"))
}
fn validate_structure(data: &[u8], variant: AmigaVariant) -> Result<(), ImportError> {
let (num_samples, num_tracks, header_size, tagged) = match variant {
AmigaVariant::Fifteen => (15usize, 4u8, MOD_15_HEADER_SIZE, false),
AmigaVariant::ThirtyOne { num_tracks } => (31, num_tracks, MOD_31_HEADER_SIZE, true),
};
if data.len() < header_size {
return Err(ImportError::Other("File too short for MOD header"));
}
if !tagged && !is_clean_text(&data[..20]) {
return Err(ImportError::Other("MOD title is not ASCII"));
}
let mut total_sample_bytes: u32 = 0;
for i in 0..num_samples {
let off = 20 + i * AMIGA_SAMPLE_RECORD_SIZE;
if !tagged && !is_clean_text(&data[off..off + 22]) {
return Err(ImportError::Other("MOD sample name is not ASCII"));
}
let length_div2 = u16::from_be_bytes([data[off + 22], data[off + 23]]);
if !tagged {
let finetune = data[off + 24];
let volume = data[off + 25];
if finetune > 0x0F {
return Err(ImportError::Other("MOD sample finetune > 15"));
}
if volume > 0x40 {
return Err(ImportError::Other("MOD sample volume > 64"));
}
}
total_sample_bytes = total_sample_bytes.saturating_add(2 * length_div2 as u32);
}
let song_length_off = 20 + num_samples * AMIGA_SAMPLE_RECORD_SIZE;
let song_length = data[song_length_off];
if song_length == 0 || song_length > 128 {
return Err(ImportError::Other("MOD song_length out of range"));
}
let positions_off = song_length_off + 2;
let positions = &data[positions_off..positions_off + POSITION_TABLE_SIZE];
let max_pos = *positions.iter().max().unwrap_or(&0);
if max_pos >= 128 {
return Err(ImportError::Other("MOD position byte out of range"));
}
let num_patterns: u32 = max_pos as u32 + 1;
let is_flt8 = tagged
&& matches!(
core::str::from_utf8(&data[0x438..0x438 + 4]),
Ok("FLT8") | Ok("EXO8")
);
let block: u32 = AMIGA_ROWS_PER_PATTERN as u32 * AMIGA_SLOT_SIZE as u32;
let pattern_bytes: u32 = if is_flt8 {
num_patterns.saturating_add(1).saturating_mul(4 * block)
} else {
num_patterns.saturating_mul(num_tracks as u32 * block)
};
let patterns_end: u32 = (header_size as u32).saturating_add(pattern_bytes);
if patterns_end as usize > data.len() {
return Err(ImportError::Other("MOD file shorter than declared content"));
}
if !tagged {
let expected = patterns_end.saturating_add(total_sample_bytes);
if expected as usize > data.len() {
return Err(ImportError::Other("MOD file shorter than declared content"));
}
}
Ok(())
}
pub fn load(ser_amiga_module: &[u8]) -> Result<AmigaModule, ImportError> {
let variant = Self::detect_variant(ser_amiga_module)?;
let mut amiga = AmigaModule {
..Default::default()
};
let num_samples = match variant {
AmigaVariant::Fifteen => 15,
AmigaVariant::ThirtyOne { .. } => 31,
};
let title_bytes = &ser_amiga_module[0..20];
let mut text_fields: Vec<&[u8]> = Vec::with_capacity(1 + num_samples);
text_fields.push(title_bytes);
for i in 0..num_samples {
let off = 0x14 + i * 30;
text_fields.push(&ser_amiga_module[off..off + 22]);
}
let codepage = Codepage::detect_from_fields(&text_fields);
amiga.title = codepage.decode_name(title_bytes);
amiga.tag = match variant {
AmigaVariant::ThirtyOne { .. } => {
codepage.decode_name(&ser_amiga_module[0x438..0x438 + 4])
}
AmigaVariant::Fifteen => String::new(),
};
let mut data = &ser_amiga_module[0x14..];
for i in 0..num_samples {
let (d2, sample) = AmigaSample::load(data)?;
data = d2;
amiga.samples.push(sample);
let off = 0x14 + i * 30;
amiga.samples[i].name = codepage.decode_name(&ser_amiga_module[off..off + 22]);
}
let trailer_len = 2
+ 128
+ if amiga.get_number_of_samples() != 15 {
4
} else {
0
};
if data.len() < trailer_len {
return Err(ImportError::UnexpectedEof);
}
amiga.song_length = data[0];
amiga.restart_position = data[1];
data = &data[2..];
amiga.positions.extend_from_slice(&data[..128]);
data = &data[128..];
if amiga.get_number_of_samples() != 15 {
data = &data[4..];
}
let dialect = tag_dialect(&amiga.tag);
amiga.is_hmnt = dialect.is_hmnt;
let nominal_tracks = match amiga.get_number_of_tracks() {
Some(n) => n as usize,
None => 4, };
let number_of_tracks = if amiga.is_mods_grave(ser_amiga_module.len()) {
8
} else {
nominal_tracks
};
let number_of_patterns = amiga.get_number_of_patterns();
if dialect.is_flt8 {
let stored_count = number_of_patterns + 1;
let num_logical = stored_count.div_ceil(2);
amiga.patterns =
vec![vec![vec![PatternSlot::default(); 8]; AMIGA_ROWS_PER_PATTERN]; num_logical];
for stored in 0..stored_count {
let lp = stored / 2;
let ch_base = if stored % 2 == 0 { 0 } else { 4 };
for row in 0..AMIGA_ROWS_PER_PATTERN {
for ch in 0..4 {
let e = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let mut slot = PatternSlot::deserialize(e);
apply_mod_dialect(&mut slot, &dialect);
amiga.patterns[lp][row][ch_base + ch] = slot;
data = &data[4..];
}
}
}
for p in amiga.positions.iter_mut() {
*p /= 2;
}
} else {
for _p in 0..number_of_patterns {
let mut pattern: Vec<Vec<PatternSlot>> = vec![];
for _row in 0..AMIGA_ROWS_PER_PATTERN {
let mut row: Vec<PatternSlot> = vec![];
for _elt in 0..number_of_tracks {
let e = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let mut element = PatternSlot::deserialize(e);
apply_mod_dialect(&mut element, &dialect);
row.push(element);
data = &data[4..];
}
pattern.push(row);
}
amiga.patterns.push(pattern);
}
}
if matches!(variant, AmigaVariant::Fifteen) {
let (stk_version, use_auto_slides) = amiga.detect_stk_version();
for pat in amiga.patterns.iter_mut() {
for row in pat.iter_mut() {
for slot in row.iter_mut() {
remap_stk_slot(slot, stk_version, use_auto_slides);
}
}
}
}
for i_spl in 0..amiga.samples.len() {
let l = if 2 * amiga.samples[i_spl].length_div2 as usize <= data.len() {
2 * amiga.samples[i_spl].length_div2 as usize
} else {
data.len()
};
let s = &data[0..l];
let vec_i8: Vec<i8> = s.iter().map(|&x| x as i8).collect();
amiga.audio.push(vec_i8);
data = &data[l..];
}
Result::Ok(amiga)
}
fn to_instr(&self, sample_index: usize) -> Instrument {
let mut instr: Instrument = Instrument::default();
let mut sample: Sample = self.samples[sample_index].to_sample();
if self.is_hmnt {
let ft = self.samples[sample_index].finetune;
sample.finetune = Finetune::from_ratio(-(((ft << 3) as i8) as i32), 127);
}
let mut skip_bytes = 0usize;
if self.get_number_of_samples() == 15 {
let s = &self.samples[sample_index];
let len = 2 * s.length_div2 as u32;
let start = s.repeat_offset_div2 as u32; let length = 2 * s.repeat_length_div2 as u32; if length > 2 && start + length <= len {
skip_bytes = start as usize;
sample.loop_flag = LoopType::Forward;
sample.loop_start = 0;
sample.loop_length = length;
} else {
sample.loop_flag = LoopType::No;
sample.loop_start = 0;
sample.loop_length = 0;
}
}
let audio = if skip_bytes > 0 && skip_bytes < self.audio[sample_index].len() {
self.audio[sample_index][skip_bytes..].to_vec()
} else {
self.audio[sample_index].clone()
};
sample.data = Some(SampleDataType::Mono8(audio.into()));
instr.name = sample.name.clone();
let mut idef = InstrDefault::default();
idef.sample.push(Some(sample));
idef.keyboard.sample_for_pitch = [Some(0); 120];
instr.instr_type = InstrumentType::Default(idef);
instr
}
pub fn to_module(&self) -> Module {
let mut module = Module::default();
module.name = self.title.clone();
module.comment = if self.get_number_of_samples() == 15 {
"Soundtracker (15 samples, no tag)".to_string()
} else {
format!("MOD tag: {}", self.tag)
};
module.quirks = crate::tracker::profiles::pt();
module.origin = Some(crate::tracker::format::ModuleFormat::Mod);
module.frequency_type = FrequencyType::AmigaFrequencies;
module.mix_volume = Volume::from_ratio(64, 128);
module.default_tempo = 6;
module.default_bpm = 125;
if self.get_number_of_samples() == 15 {
let restart = self.restart_position as usize;
if restart != 0 && restart != 0x78 && restart < 240 {
let denom = 6100 * (240 - restart); module.default_bpm = (88_672_375 + denom / 2) / denom;
}
}
let pattern_order = vec![self.positions[..usize::from(self.song_length)]
.to_vec()
.iter()
.map(|&x| x as usize)
.collect()];
let mut im = ImportMemory::default();
let pattern = im.unpack_patterns(
FrequencyType::AmigaFrequencies,
MemoryType::Mod,
&pattern_order,
&self.patterns,
);
for i in 0..self.samples.len() {
let instr = self.to_instr(i);
module.instrument.push(instr);
}
let channels = self
.patterns
.first()
.and_then(|p| p.first())
.map_or(0, |row| row.len());
module.channel_defaults = (0..channels)
.map(|ch| {
let right = matches!(ch & 3, 1 | 2);
crate::core::module::ChannelDefault {
panning: Some(Panning::from_byte_255(if right { 0xC0 } else { 0x40 })),
..Default::default()
}
})
.collect();
crate::tracker::import::build::build_timeline_layer(&mut module, &pattern_order, &pattern);
let restart_loop = if self.get_number_of_samples() == 15 {
0
} else {
self.restart_position as usize
};
crate::tracker::import::build::set_song_loop_to_restart_byte(&mut module, 0, restart_loop);
module
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_text_accepts_plain_ascii() {
assert!(is_clean_text(b"hello world"));
assert!(is_clean_text(b"=======[AMiGA]======="));
assert!(is_clean_text(b"Composed and performe"));
}
#[test]
fn clean_text_accepts_nul_padding() {
let name = b"hello\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
assert_eq!(name.len(), 22);
assert!(is_clean_text(name));
assert!(is_clean_text(&[0u8; 22]));
}
#[test]
fn clean_text_accepts_cp437_box_art() {
let name: &[u8] = &[
0xdc, 0xdb, 0xdb, 0xdb, 0xdf, 0xdb, 0xdb, 0xdc, 0x20, 0xdc, 0xdb, 0xdb, 0xdc, 0x20,
0xdc, 0xdb, 0xdb, 0xdb, 0xdf, 0xdb, 0xdb, 0x00,
];
assert_eq!(name.len(), 22);
assert!(is_clean_text(name));
let rule = [0xc4u8; 22];
assert!(is_clean_text(&rule));
}
#[test]
fn clean_text_accepts_latin1_accents() {
let name: &[u8] = b"Caf\xe9 au lait\0\0\0\0\0\0\0\0\0\0";
assert_eq!(name.len(), 22);
assert!(is_clean_text(name));
let name: &[u8] = b"\xc4\xc4 Fran\xe7ois \xc4\xc4\0\0\0\0\0\0\0\0";
assert_eq!(name.len(), 22);
assert!(is_clean_text(name));
}
#[test]
fn clean_text_rejects_c0_controls() {
assert!(!is_clean_text(b"hello\tworld"));
assert!(!is_clean_text(b"line\nbreak"));
assert!(!is_clean_text(b"line\rbreak"));
assert!(!is_clean_text(&[0x1b, b'a', b'b']));
assert!(!is_clean_text(&[0x01]));
assert!(!is_clean_text(&[0x1f]));
}
#[test]
fn clean_text_rejects_del() {
assert!(!is_clean_text(b"hello\x7fworld"));
}
#[test]
fn clean_text_accepts_full_byte_range_minus_controls() {
for b in 0u8..=255 {
let expected = !matches!(b, 0x01..=0x1F | 0x7F);
assert_eq!(
is_clean_text(&[b]),
expected,
"byte 0x{b:02x} should be {}",
if expected { "accepted" } else { "rejected" }
);
}
}
use crate::core::cell_note::CellNote;
fn build_mod31(
tag: &[u8; 4],
song_len: u8,
restart: u8,
order: &[u8],
samples: &[(u16, u8, u8)],
pattern_bytes: &[u8],
) -> Vec<u8> {
let mut v = vec![0u8; MOD_31_HEADER_SIZE];
for (i, &(len_div2, finetune, vol)) in samples.iter().enumerate() {
let off = 20 + i * AMIGA_SAMPLE_RECORD_SIZE;
v[off + 22..off + 24].copy_from_slice(&len_div2.to_be_bytes());
v[off + 24] = finetune;
v[off + 25] = vol;
}
let song_len_off = 20 + 31 * AMIGA_SAMPLE_RECORD_SIZE;
v[song_len_off] = song_len;
v[song_len_off + 1] = restart;
for (i, &o) in order.iter().enumerate() {
v[song_len_off + 2 + i] = o;
}
v[0x438..0x438 + 4].copy_from_slice(tag);
v.extend_from_slice(pattern_bytes);
let audio_bytes: usize = samples.iter().map(|&(l, _, _)| 2 * l as usize).sum();
v.resize(v.len() + audio_bytes, 0);
v
}
fn cell_period(period: u16) -> [u8; 4] {
[(period >> 8) as u8, (period & 0xFF) as u8, 0, 0]
}
fn cell_effect(effect: u8, param: u8) -> [u8; 4] {
[0, 0, effect & 0x0F, param]
}
fn pattern_block(channels: usize, row0: &[[u8; 4]]) -> Vec<u8> {
let mut p = vec![0u8; channels * AMIGA_ROWS_PER_PATTERN * AMIGA_SLOT_SIZE];
for (ch, cell) in row0.iter().enumerate() {
p[ch * 4..ch * 4 + 4].copy_from_slice(cell);
}
p
}
#[test]
fn flt8_interleaves_two_stored_halves() {
let mut patterns = pattern_block(4, &[cell_period(428)]); patterns.extend(pattern_block(4, &[cell_period(480)])); let img = build_mod31(b"FLT8", 1, 0, &[0], &[], &patterns);
let m = AmigaModule::load(&img).expect("FLT8 loads");
assert_eq!(
m.patterns.len(),
1,
"two stored halves → one logical pattern"
);
let row0 = &m.patterns[0][0];
assert_eq!(row0.len(), 8, "logical pattern is 8 channels wide");
assert!(
matches!(row0[0].note, CellNote::Play(_)),
"half A note on ch0"
);
assert!(
matches!(row0[4].note, CellNote::Play(_)),
"half B note on ch4"
);
assert_ne!(
row0[0].note, row0[4].note,
"the two halves are distinct notes"
);
for ch in [1usize, 2, 3, 5, 6, 7] {
assert_eq!(row0[ch].note, CellNote::Empty, "ch{ch} stays empty");
}
}
#[test]
fn mods_grave_wow_widens_to_eight_channels() {
let patterns = pattern_block(8, &[cell_period(428)]);
let img = build_mod31(b"M.K.", 1, 0, &[0], &[], &patterns);
assert_eq!(img.len(), MOD_31_HEADER_SIZE + 8 * 64 * 4);
let m = AmigaModule::load(&img).expect("WOW loads");
assert_eq!(m.patterns[0][0].len(), 8, "M.K. widened to 8 channels");
let patterns4 = pattern_block(4, &[cell_period(428)]);
let img4 = build_mod31(b"M.K.", 1, 0, &[0], &[], &patterns4);
let m4 = AmigaModule::load(&img4).expect("4ch M.K. loads");
assert_eq!(m4.patterns[0][0].len(), 4, "real M.K. stays 4 channels");
}
#[test]
fn noisetracker_pattern_break_drops_parameter() {
let patterns = {
let mut p = pattern_block(4, &[cell_effect(0x0D, 0x20)]);
p.extend(pattern_block(4, &[]));
p
};
let nt = build_mod31(b"N.T.", 1, 0, &[0, 1], &[], &patterns);
let m = AmigaModule::load(&nt).expect("N.T. loads");
assert_eq!(m.patterns[0][0][0].effect_type, 0x0D);
assert_eq!(
m.patterns[0][0][0].effect_parameter, 0,
"NoiseTracker Dxx param dropped"
);
let pt = build_mod31(b"M.K.", 1, 0, &[0, 1], &[], &patterns);
let mp = AmigaModule::load(&pt).expect("M.K. loads");
assert_eq!(
mp.patterns[0][0][0].effect_parameter, 0x20,
"ProTracker keeps Dxx param"
);
}
fn first_sample_finetune(m: &Module) -> Finetune {
match &m.instrument[0].instr_type {
InstrumentType::Default(idef) => idef.sample[0].as_ref().unwrap().finetune,
_ => panic!("expected default instrument"),
}
}
#[test]
fn his_masters_noise_finetune_differs_from_standard() {
let patterns = pattern_block(4, &[]);
let hmn = build_mod31(b"M&K!", 1, 0, &[0], &[(1, 0x01, 64)], &patterns);
let mk = build_mod31(b"M.K.", 1, 0, &[0], &[(1, 0x01, 64)], &patterns);
let mhmn = AmigaModule::load(&hmn).unwrap();
assert!(mhmn.is_hmnt, "M&K! flagged as His Master's Noise");
let f_hmn = first_sample_finetune(&mhmn.to_module()).to_signed_byte(127);
let f_mk =
first_sample_finetune(&AmigaModule::load(&mk).unwrap().to_module()).to_signed_byte(127);
assert_ne!(f_hmn, f_mk, "HMN finetune mapping diverges from standard");
assert!(
f_hmn < 0,
"HMN finetune is negative for byte 0x01 (got {f_hmn})"
);
assert!(
f_mk > 0,
"standard finetune is positive for nibble 0x01 (got {f_mk})"
);
}
fn build_mod15(
song_len: u8,
restart: u8,
order: &[u8],
samples: &[(u16, u8, u8, u16, u16)],
pattern_bytes: &[u8],
) -> Vec<u8> {
let mut v = vec![0u8; MOD_15_HEADER_SIZE];
for (i, &(len, ft, vol, ro, rl)) in samples.iter().enumerate() {
let off = 20 + i * AMIGA_SAMPLE_RECORD_SIZE;
v[off + 22..off + 24].copy_from_slice(&len.to_be_bytes());
v[off + 24] = ft;
v[off + 25] = vol;
v[off + 26..off + 28].copy_from_slice(&ro.to_be_bytes());
v[off + 28..off + 30].copy_from_slice(&rl.to_be_bytes());
}
let song_len_off = 20 + 15 * AMIGA_SAMPLE_RECORD_SIZE;
v[song_len_off] = song_len;
v[song_len_off + 1] = restart;
for (i, &o) in order.iter().enumerate() {
v[song_len_off + 2 + i] = o;
}
v.extend_from_slice(pattern_bytes);
let audio: usize = samples.iter().map(|&(l, _, _, _, _)| 2 * l as usize).sum();
v.resize(v.len() + audio, 0);
v
}
fn cell_cmd(cmd: u8, param: u8) -> [u8; 4] {
[0, 0, cmd & 0x0F, param]
}
#[test]
fn ultimate_soundtracker_remaps_arpeggio_and_pitchbend() {
let row0 = [cell_cmd(1, 0x47), cell_cmd(2, 0x30), [0; 4], [0; 4]];
let patterns = pattern_block(4, &row0);
let img = build_mod15(1, 0, &[0], &[(10, 0, 64, 0, 1)], &patterns);
let m = AmigaModule::load(&img).expect("UST loads");
assert_eq!(m.patterns[0][0][0].effect_type, 0x00);
assert_eq!(m.patterns[0][0][0].effect_parameter, 0x47);
assert_eq!(m.patterns[0][0][1].effect_type, 0x02);
assert_eq!(m.patterns[0][0][1].effect_parameter, 0x03);
}
#[test]
fn later_soundtracker_keeps_protracker_effects() {
let row0 = [cell_cmd(1, 0x47), [0; 4], [0; 4], [0; 4]];
let patterns = pattern_block(4, &row0);
let img = build_mod15(1, 0, &[0], &[(6000, 0, 64, 0, 1)], &patterns);
let m = AmigaModule::load(&img).expect("MST loads");
assert_eq!(
m.patterns[0][0][0].effect_type, 0x01,
"command 1 stays portamento up for non-UST SoundTracker"
);
assert_eq!(m.patterns[0][0][0].effect_parameter, 0x47);
}
#[test]
fn amiga_channels_pan_lrrl() {
let patterns = pattern_block(4, &[]);
let img = build_mod31(b"M.K.", 1, 0, &[0], &[], &patterns);
let m = AmigaModule::load(&img).unwrap().to_module();
assert_eq!(m.channel_defaults.len(), 4);
let left = Panning::from_byte_255(0x40);
let right = Panning::from_byte_255(0xC0);
assert_eq!(m.channel_defaults[0].panning, Some(left));
assert_eq!(m.channel_defaults[1].panning, Some(right));
assert_eq!(m.channel_defaults[2].panning, Some(right));
assert_eq!(m.channel_defaults[3].panning, Some(left));
}
#[test]
fn soundtracker_loop_start_is_bytes_with_attack_dropped() {
let patterns = pattern_block(4, &[]);
let img = build_mod15(1, 0, &[0], &[(10, 0, 64, 4, 3)], &patterns);
let m = AmigaModule::load(&img).unwrap().to_module();
match &m.instrument[0].instr_type {
InstrumentType::Default(idef) => {
let s = idef.sample[0].as_ref().unwrap();
assert_eq!(s.loop_flag, LoopType::Forward);
assert_eq!(s.loop_start, 0, "loop re-based to 0 after dropping attack");
assert_eq!(s.loop_length, 6, "loop length = rep_len words → bytes");
}
_ => panic!("expected default instrument"),
}
}
}