xmrs/tracker/import/dw/dw_module.rs
1#![forbid(unsafe_code)]
2
3//! `DwModule::load` — entry point of the `import_dw` feature.
4//!
5//! ## Pipeline
6//!
7//! 1. [`super::detect::detect`] scans the payload for the canonical
8//! entry-point stubs and the init-time PC-relative opcodes, and
9//! returns a [`DwLayout`] holding the file offsets of every
10//! data table.
11//! 2. `parse_samples` walks the sample-info/sample-data tables to
12//! recover per-sample metadata (loop, length, volume) and copy
13//! the PCM; `parse_tracks` / `parse_sub_songs` / the envelope and
14//! arpeggio parsers lift the rest.
15//! 3. [`DwModule::to_module`] runs the [`super::runtime`] simulator
16//! over the parsed data and materialises the full DAW layer:
17//! instrument bank, per-`(channel, sample)` tracks, clips and a
18//! timeline — one xmrs song per sub-song.
19
20use alloc::format;
21use alloc::sync::Arc;
22use alloc::vec;
23use alloc::vec::Vec;
24
25use alloc::collections::BTreeMap;
26
27use super::detect::{detect, DwLayout, DwVariant};
28use super::header::{
29 DwArpeggio, DwPositionList, DwSample, DwSubSong, DwTrack, DwVolumeEnvelope, DW_NUM_CHANNELS,
30};
31use super::tables::PeriodTable;
32use crate::core::fixed::units::{ChannelVolume, EnvValue, Finetune, Panning, Volume};
33use crate::prelude::*;
34use crate::tracker::import::bin_reader::{BinReader, ImportError};
35
36/// One placed note collected from the simulator trace during DAW
37/// projection, as a row-quantised tuple:
38/// `(row, effective_note, sample_index, volume, envelope_index,
39/// arpeggio_index)`. The segmentation pass groups consecutive
40/// `NoteRow`s by `(sample[, envelope, arpeggio])` into
41/// [`ChannelSegment`]s.
42type NoteRow = (usize, i16, usize, Option<u8>, Option<u16>, Option<u16>);
43
44/// A simulated sub-song ready for clip emission:
45/// `(song_index, row-grid speed in frames, the frame-stamped event
46/// trace)`. The speed is carried so the post-dedup lane walk
47/// quantises on the same grid the clips were built on.
48type SongTrace = (u16, u32, Vec<(u32, super::runtime::TickEvent)>);
49
50/// Parsed contents of a `.dw` file. Self-sufficient — once a
51/// [`DwModule`] is built the original payload bytes can be dropped.
52#[derive(Debug, Clone)]
53pub struct DwModule {
54 /// Replayer family the file targets.
55 pub variant: DwVariant,
56 /// Which period table the replayer uses.
57 pub period_table: PeriodTable,
58 /// `true` when `AUDxPER` is computed the new-player way — full-range
59 /// note index into [`Self::period_table`] scaled by the per-sample
60 /// fine-tune (`table[note] × mult >> 10`) — rather than the qball-era
61 /// one-octave composite `PERIODS1[note % 12]`. Always `true` on the
62 /// new player and on the old-stream players that carry the fine-tune
63 /// idiom (leviathan, empire); `false` on genuine qball composite
64 /// players. See [`super::detect::DwLayout::period_via_finetune`].
65 pub period_via_finetune: bool,
66 /// The full sample bank. Indices match the on-disk `SetSample`
67 /// command argument minus the per-module `newSampleCmd`
68 /// threshold (which the runtime layer applies).
69 pub samples: Vec<DwSample>,
70 /// Currently selected sub-song descriptor. `None` when no
71 /// usable sub-song could be located.
72 ///
73 /// Multi-song `.dw` files are common: most game soundtracks
74 /// ship every track in a single binary, indexed by a small
75 /// integer the host passes via D0. The replayer multiplies
76 /// that index by the row width and uses the result as offset
77 /// into the sub-song table. The importer parses every entry
78 /// it can validate into [`Self::sub_songs`], then picks the
79 /// one most likely to be the "main" song (heuristic — see
80 /// `pick_main_sub_song_index`). Callers that want a
81 /// different sub-song can read [`Self::sub_songs`] and
82 /// re-derive position lists via [`crate::tracker::import::dw::dw_module::position_lists_for`].
83 pub sub_song: Option<DwSubSong>,
84
85 /// Every sub-song the importer could decode from the
86 /// `sub_song_list_offset` row table. Order matches the
87 /// on-disk index (sub-song 0 = first row); the chosen
88 /// "primary" sub-song is exposed separately as
89 /// [`Self::sub_song`] / [`Self::selected_sub_song`]. Empty
90 /// when no sub-song header was located.
91 pub sub_songs: Vec<DwSubSong>,
92
93 /// Index into [`Self::sub_songs`] of the sub-song mirrored
94 /// into [`Self::sub_song`] / [`Self::position_lists`].
95 /// `None` when no sub-song is available.
96 pub selected_sub_song: Option<usize>,
97
98 /// One position list per Paula channel for the **selected**
99 /// sub-song, in channel order. Empty when the sub-song header
100 /// could not be located or when the channel is intentionally
101 /// silent for this song.
102 pub position_lists: [DwPositionList; DW_NUM_CHANNELS],
103
104 /// Position lists for **every** sub-song, indexed to match
105 /// [`Self::sub_songs`]. [`Self::position_lists`] is a clone of
106 /// `all_position_lists[selected_sub_song]`. The DAW projection
107 /// walks this to render each sub-song as its own xmrs song.
108 pub all_position_lists: Vec<[DwPositionList; DW_NUM_CHANNELS]>,
109
110 /// Every distinct track byte stream referenced by at least one
111 /// position list, parsed up to its `EndOfTrack` terminator.
112 /// De-duplicated by file offset so a single track played by
113 /// many channels / many times contributes a single entry.
114 pub tracks: Vec<DwTrack>,
115
116 /// Per-channel volume envelopes — indexed by `(track_byte -
117 /// volume_envelope_threshold)` at runtime. Empty when the module
118 /// either lacks the `0xA0..` dispatcher bracket or when the
119 /// table-base probe couldn't locate the envelope-pointer
120 /// table. See [`DwVolumeEnvelope`] for the per-entry shape.
121 pub volume_envelopes: Vec<DwVolumeEnvelope>,
122
123 /// Per-channel pitch arpeggios — indexed by `(track_byte -
124 /// pitch_arpeggio_threshold)` at runtime (the `0x90..` dispatcher
125 /// bracket). Empty when the module has no `0x90..` bracket or
126 /// the table-base probe failed. See [`DwArpeggio`].
127 pub arpeggios: Vec<DwArpeggio>,
128
129 /// Per-module command dispatcher thresholds — kept on the
130 /// `DwModule` so the simulator can resolve raw track bytes
131 /// (e.g. `0xA5`) back to envelope-table indices without
132 /// re-reading the binary.
133 pub dispatcher: super::detect::DwDispatcher,
134
135 /// Detect-time feature flags lifted directly from
136 /// [`super::detect::DwLayout::features`]. The simulator gates
137 /// module-dependent semantics (e.g. `Effect8` → channel
138 /// transpose vs. global volume fade) on these.
139 pub features: super::detect::DwFeatures,
140
141 /// When `true`, the `SetVolumeEnvelope` dispatcher bracket arms a
142 /// **pitch arpeggio** (indexing [`Self::arpeggios`]) instead of
143 /// a volume envelope — see
144 /// [`super::detect::DwLayout::volume_bracket_is_pitch`]. tetris.dw
145 /// is the canonical case.
146 pub volume_bracket_is_pitch: bool,
147
148 /// Old-player per-channel static volume (0..=64), indexed by
149 /// Paula channel. The qball-era replayer loads this into
150 /// `AUDxVOL` on every note trigger and never scales it by a
151 /// sample or envelope value — it *is* the channel's loudness.
152 /// Read from [`super::detect::DwLayout::channel_volume_offset`];
153 /// defaults to full scale on new-player modules (whose loudness
154 /// comes from the volume envelope) and when the table couldn't be
155 /// located.
156 pub channel_volumes: [u16; DW_NUM_CHANNELS],
157
158 /// Static global master volume (`1..=64`), or `None` when the
159 /// module has no master-volume scaling. The empire-era replayer
160 /// multiplies every Paula volume by this before a `>> 6`
161 /// (`AUDxVOL = volByte × master >> 6`), at both note-trigger and
162 /// per-tick envelope animation; the importer reproduces it by
163 /// scaling all emitted Paula volumes by `master / 64`. Read from
164 /// [`super::detect::DwLayout::master_volume_offset`]. `None` leaves
165 /// volumes unscaled (the common case — new players use the
166 /// identity `master = 64`, qball writes channel volumes raw).
167 pub master_volume: Option<u16>,
168
169 /// When `true`, the **volume** envelope is baked per-tick on a
170 /// synthetic per-`(sample, envelope)` instrument (frame-resolution
171 /// loudness), as opposed to the row-resolution cell `Volume`
172 /// animation. Set for the jump-table (`command_map`) family —
173 /// bubble bobble & kin. (Historically this flag also drove the
174 /// *pitch* arpeggio via a looping pitch envelope; that role has
175 /// moved to the per-frame `use_arpeggio_pitch_lane` Points lane,
176 /// so this now governs only the volume-envelope split.)
177 pub use_pitch_arpeggio: bool,
178
179 /// When `true`, per-frame **pitch arpeggios** are baked as a
180 /// `TrackPitch` `LaneKind::Points` curve of
181 /// [`crate::core::daw::automation::AutomationValue::Pitch`] points
182 /// — one per arpeggio-offset change, evaluated every player tick.
183 /// This is the faithful projection of the replayer's per-channel,
184 /// per-frame arpeggio pointer (Ghidra `play_tick` held-note
185 /// branch: offset added to the period-table *index* = semitone
186 /// space, pointer continuous across notes), as opposed to the
187 /// classic three-step `TrackEffect::Arpeggio` (mod-3, trigger-row
188 /// only) or the old per-note pitch-envelope path. Set for **every**
189 /// DW module — the lane is the single arpeggio path for DW, so the
190 /// classic effect is suppressed and the arpeggio segment-split /
191 /// pitch-envelope are skipped. No-op for modules that never arm an
192 /// arpeggio (no points emitted). (`use_pitch_arpeggio` now governs
193 /// only the *volume*-envelope synthetic-instrument path, not the
194 /// arpeggio.)
195 pub use_arpeggio_pitch_lane: bool,
196}
197
198impl DwModule {
199 /// Parse a `.dw` payload. Returns `InvalidMagic("dw_detect")`
200 /// when the payload does not look like a David Whittaker
201 /// module.
202 pub fn load(source: &[u8]) -> Result<Self, ImportError> {
203 let layout = detect(source).ok_or(ImportError::InvalidMagic("dw_detect"))?;
204 let samples = Self::parse_samples(source, &layout)?;
205 let sub_songs = Self::parse_sub_songs(source, &layout);
206 let selected = Self::pick_main_sub_song_index(
207 source,
208 &sub_songs,
209 layout.uses_32bit_pointers,
210 layout.start_offset,
211 // The jump-table (command_map) family — bubble bobble and
212 // kin — multiplexes every game tune into one binary and the
213 // replayer plays sub-song 0 on the default `D0 = 0` entry.
214 // Their sub-song 0 is the real in-game music, so the
215 // "longest position list wins" heuristic (which suits the
216 // canonical player, where sub-song 0 is sometimes a short
217 // jingle) picks the wrong tune. Prefer index 0 for this
218 // cluster to match the replayer's own default.
219 layout.command_map.is_some(),
220 );
221 // Decode the position lists of every sub-song up front, so
222 // the DAW projection can render each tune as its own xmrs
223 // song. Tracks are parsed from the union of all of them.
224 let mut all_position_lists: Vec<[DwPositionList; DW_NUM_CHANNELS]> = sub_songs
225 .iter()
226 .map(|s| position_lists_for(source, s, layout.uses_32bit_pointers, layout.start_offset))
227 .collect();
228 // Resolve `SeqPtr` (cmd 0x89) loops: a channel whose intro track
229 // redirects the sequencer to a sub-sequence loops THAT, not back
230 // to the intro. Done before `parse_tracks` so the sub-sequence's
231 // tracks are collected. (See `follow_seq_ptr_loops`.)
232 for lists in all_position_lists.iter_mut() {
233 follow_seq_ptr_loops(
234 source,
235 lists,
236 &layout.dispatcher,
237 &layout.features,
238 layout.command_map.as_ref(),
239 layout.uses_32bit_pointers,
240 layout.start_offset,
241 );
242 }
243 let position_lists = selected
244 .map(|i| all_position_lists[i].clone())
245 .unwrap_or_default();
246 let sub_song = selected.map(|i| sub_songs[i].clone());
247 let tracks = Self::parse_tracks(
248 source,
249 &all_position_lists,
250 &layout.dispatcher,
251 &layout.features,
252 layout.command_map.as_ref(),
253 );
254 let volume_envelopes = Self::parse_volume_envelopes(source, &layout);
255 let arpeggios = Self::parse_arpeggios(source, &layout);
256 let channel_volumes = Self::parse_channel_volumes(source, &layout);
257 let master_volume = layout.master_volume_offset.and_then(|off| {
258 source
259 .get(off..off + 2)
260 .map(|b| u16::from_be_bytes([b[0], b[1]]))
261 });
262
263 // Reject payloads that are DW-shaped (the LEA A3 anchor
264 // matched) but carry no playable song: no samples, no
265 // sub-song header, and no tracks. These are sound-effect
266 // banks and placeholder/"fake" files (`*-sfx.dw`,
267 // `* fake.dw`) — they trigger individual sounds from host
268 // code rather than running a song timeline, so there's
269 // nothing for the importer to build a `Module` from.
270 // Returning an error here keeps `Module::load` autodetect
271 // from producing a hollow Module.
272 if samples.is_empty() && sub_songs.is_empty() && tracks.is_empty() {
273 return Err(ImportError::InvalidMagic("dw_no_song"));
274 }
275
276 Ok(Self {
277 variant: layout.variant,
278 period_table: layout.period_table,
279 period_via_finetune: layout.period_via_finetune,
280 samples,
281 sub_song,
282 sub_songs,
283 selected_sub_song: selected,
284 position_lists,
285 all_position_lists,
286 tracks,
287 volume_envelopes,
288 arpeggios,
289 dispatcher: layout.dispatcher.clone(),
290 features: layout.features,
291 volume_bracket_is_pitch: layout.volume_bracket_is_pitch,
292 channel_volumes,
293 master_volume,
294 // Governs the volume-envelope synth path; on the C4
295 // oscillator family (grimblood/cosmic pirate) it ALSO keeps
296 // the old per-note pitch-envelope arpeggio (see lane gate).
297 use_pitch_arpeggio: layout.command_map.is_some(),
298 // Per-frame `TrackPitch` Points lane = the arpeggio path for
299 // DW, EXCEPT the C4 "oscillator" replayer family (Ghidra
300 // `grimblood FUN_00000d6e`): its per-frame pitch is a
301 // period-space 2-word oscillator, NOT a period-table-index
302 // arpeggio, so the semitone Points lane mis-renders it
303 // (grimblood ch0 octave spikes, MAE 6.8→21.9). That family
304 // is the only `command_map` + `P2` shape in the corpus
305 // (grimblood; cosmic pirate is `P3` but arms no arpeggio, so
306 // it is unaffected either way) — keep it on its prior
307 // pitch-envelope path. Every other DW module (canonical
308 // jump-table, tetris, bubble bobble) uses the faithful lane.
309 // No-op when no arpeggio is ever armed.
310 use_arpeggio_pitch_lane: !(layout.command_map.is_some()
311 && matches!(layout.period_table, PeriodTable::P2)),
312 })
313 }
314
315 /// Scale a Paula volume byte (`0..=64`) by the module's global
316 /// master volume, reproducing the empire-era replayer's
317 /// `AUDxVOL = volByte × master >> 6`. A no-op when no master is
318 /// set or it is the identity `64`; the result is clamped to
319 /// Paula's `0..=64` range.
320 #[inline]
321 fn scale_master_volume(&self, v: u8) -> u8 {
322 match self.master_volume {
323 Some(master) if master != 64 => (((v as u32) * (master as u32)) >> 6).min(64) as u8,
324 _ => v,
325 }
326 }
327
328 /// Read the old-player per-channel volume table: `DW_NUM_CHANNELS`
329 /// big-endian `u16`s at
330 /// [`super::detect::DwLayout::channel_volume_offset`], each clamped
331 /// to Paula's 0..=64 range. Returns full scale on every channel
332 /// when the offset is absent (new-player modules) or unreadable.
333 fn parse_channel_volumes(
334 source: &[u8],
335 layout: &super::detect::DwLayout,
336 ) -> [u16; DW_NUM_CHANNELS] {
337 let mut vols = [64u16; DW_NUM_CHANNELS];
338 if let Some(base) = layout.channel_volume_offset {
339 for (ch, slot) in vols.iter_mut().enumerate() {
340 let at = base + ch * 2;
341 if let Some(bytes) = source.get(at..at + 2) {
342 *slot = u16::from_be_bytes([bytes[0], bytes[1]]).min(64);
343 }
344 }
345 }
346 vols
347 }
348
349 /// Pick the "main" sub-song from a list. Heuristic:
350 /// largest cumulative position-list length across the four
351 /// channels — degenerate sub-songs (the leading test/jingle
352 /// entries seen on beast1.* with 1 track per channel) lose
353 /// to the real music. Ties break by lowest index. Returns
354 /// `None` when the list is empty.
355 fn pick_main_sub_song_index(
356 source: &[u8],
357 sub_songs: &[DwSubSong],
358 uses_32bit: bool,
359 start_offset: isize,
360 prefer_first: bool,
361 ) -> Option<usize> {
362 if sub_songs.is_empty() {
363 return None;
364 }
365 // Investigation/override hook: `DW_SUBSONG=<n>` forces a
366 // specific sub-song so a `.dw` with several tunes (title /
367 // in-game / variations) can be auditioned one at a time.
368 // Out-of-range values fall through to the heuristic.
369 #[cfg(feature = "std")]
370 if let Ok(v) = std::env::var("DW_SUBSONG") {
371 if let Ok(i) = v.trim().parse::<usize>() {
372 if i < sub_songs.len() {
373 return Some(i);
374 }
375 }
376 }
377 // Jump-table family: the replayer's default entry is sub-song 0
378 // (verified against the Paula oracle on bubble bobble — song 0
379 // matches the golden at 96 % gate-agree, the heuristic's pick
380 // at 50 %). Don't run the "longest list" heuristic for them.
381 if prefer_first {
382 return Some(0);
383 }
384 let score = |s: &DwSubSong| -> usize {
385 position_lists_for(source, s, uses_32bit, start_offset)
386 .iter()
387 .map(|l| l.len())
388 .sum()
389 };
390 // The replayer's *true* default is sub-song 0: `init` is entered
391 // with the game's `d0` selecting the tune, and the canonical
392 // "play the main music" call passes `d0 = 0` (Ghidra: kickstart
393 // `init` reads `d0` straight into the song index; `FUN_ac`
394 // doesn't choose one). So prefer sub-song 0 unless it is a
395 // *degenerate* leading stub — the beast1.* test/jingle case this
396 // heuristic was built for carries ~1 track per channel. Only
397 // then fall back to the longest-list pick.
398 //
399 // The old unconditional "longest list" rule mis-picked exactly
400 // two corpus modules (kickstart ii → sub-song 1, xenon →
401 // sub-song 2) whose sub-song 0 is the real, longer-playing tune;
402 // forcing sub-song 0 drops both from period-MAE ~150-240 to
403 // ~9-11 (Paula-oracle verified), with no change to the other 111
404 // modules (which already resolve to sub-song 0).
405 const DEGENERATE_MAX: usize = 8; // ≈2 entries/channel
406 if score(&sub_songs[0]) > DEGENERATE_MAX {
407 return Some(0);
408 }
409 let mut best = 0usize;
410 let mut best_score = 0usize;
411 for (i, s) in sub_songs.iter().enumerate() {
412 let sc = score(s);
413 if sc > best_score {
414 best_score = sc;
415 best = i;
416 }
417 }
418 Some(best)
419 }
420
421 /// Convert into the editor-friendly [`Module`] representation.
422 ///
423 /// Populates the full DAW layer: the instrument bank (one
424 /// [`InstrDefault`] per parsed sample), one [`Track::Notes`]
425 /// per `(Paula channel, current sample)` run discovered by
426 /// the simulator, matching [`Clip`]s placed on each channel
427 /// lane, and a [`TimelineMap`] covering the song length
428 /// yielded by [`Self::simulate`]. After segment creation
429 /// the standard xmrs [`crate::tracker::import::build::dedupe_tracks_by_content`]
430 /// pass fuses bit-identical segments so a single phrase
431 /// shared by several channels lives in one `Track`.
432 ///
433 /// Quantisation: one row = one channel speed-tick (`speed`
434 /// frames at 50 Hz PAL). Notes whose Whittaker byte exceeds
435 /// the 120-position xmrs `Pitch` range are clamped to the
436 /// top — DW's period tables cover at most 6 octaves while
437 /// xmrs has 10, so the clamp only fires on out-of-band
438 /// values.
439 pub fn to_module(&self) -> Module {
440 use crate::core::cell::{Cell, CellEvent};
441 use crate::core::daw::clip::Clip;
442 use crate::core::daw::sorted_clips::SortedClips;
443 use crate::core::daw::timeline::{TimelineEntry, TimelineMap};
444 use crate::core::daw::track::Track;
445 use crate::core::fixed::units::Volume;
446 use crate::core::pitch::Pitch;
447 use alloc::string::String;
448 use core::convert::TryFrom;
449
450 let mut module = Module::default();
451 module.name = String::from("David Whittaker (.dw)");
452 module.origin = Some(crate::tracker::format::ModuleFormat::Dw);
453 // Whittaker ticks at 50 Hz PAL. Mapping that onto the
454 // (tempo, bpm) tracker idiom: one tracker row = `speed`
455 // Paula frames. Without these overrides the runtime
456 // assumes the global default (tempo 6, BPM 125) and plays
457 // every row twice as slow as the simulator intended.
458 // BPM stays at the canonical 125 — same default `Module`
459 // already uses, restated here for clarity.
460 // Set below once `speed` is known.
461
462 // Whittaker drives Paula directly, so the runtime
463 // period-to-frequency math must use the Amiga model.
464 // The pitch helper below also depends on this choice.
465 module.frequency_type = crate::tracker::period::FrequencyType::AmigaFrequencies;
466
467 // The Whittaker pitch slide runs in `DoFrameStuff` on every
468 // frame of the held note (1 frame = 1 player tick here), so
469 // its `TrackPitch` Slide lane must advance on tick 0 too —
470 // unlike the tracker formats that reserve tick 0 for the row
471 // trigger. See `attach_slide_lanes`.
472 module.quirks.pitch_slide_ticks_at_row_zero = true;
473
474 // Whittaker vibrato (cmd 0x81, and cmd 0x86 on the jump-table
475 // family) likewise runs in `DoFrameStuff` every frame until an
476 // explicit stop — so its `TrackPitch` LFO lane must keep
477 // advancing each tick once armed, not only on rows that
478 // re-issue the vibrato. Without this the wobble collapses to
479 // near-flat (verified vs the Paula oracle: ch0 stays at ~356
480 // instead of oscillating 347..359). See `advance_lfos_from_lanes`.
481 module.quirks.pitch_vibrato_ticks_continuously = true;
482
483 // Paula's hardware panning: channels 0+3 land on the
484 // left output, channels 1+2 on the right (the classic
485 // "LRRL" layout). Whittaker mixes for this image — every
486 // melodic lead and bass placement on the original
487 // assumes channel 0 ≠ channel 1 acoustically. Without
488 // this `channel_defaults` block all four voices play
489 // dead-centre and the stereo separation Whittaker
490 // arranged for collapses.
491 use crate::core::module::ChannelDefault;
492 module.channel_defaults = alloc::vec![
493 ChannelDefault {
494 panning: Some(Panning::LEFT),
495 ..Default::default()
496 },
497 ChannelDefault {
498 panning: Some(Panning::RIGHT),
499 ..Default::default()
500 },
501 ChannelDefault {
502 panning: Some(Panning::RIGHT),
503 ..Default::default()
504 },
505 ChannelDefault {
506 panning: Some(Panning::LEFT),
507 ..Default::default()
508 },
509 ];
510 let helper = crate::tracker::period::PeriodHelper::new(module.frequency_type, false);
511
512 // ---- instruments ----
513 module.instrument.reserve(self.samples.len());
514 for s in &self.samples {
515 module.instrument.push(sample_to_instrument(s));
516 }
517
518 // ---- timing / quantisation (per-sub-song grid) ----
519 //
520 // One tracker row = `dw_speed` Paula frames (50 Hz PAL) — the
521 // sub-song's OWN Whittaker speed byte, NOT a fixed grid. Every
522 // note lasts `(LongWait − 0xDF) × dw_speed` frames, so a row
523 // grid of exactly `dw_speed` frames lands every event on a row
524 // boundary with zero quantisation jitter, whatever the speed.
525 // A fixed 3-frame grid (the previous choice) only stayed exact
526 // when `dw_speed` was a multiple of 3; on sub-songs at speed
527 // 4/5 (e.g. bubble bobble songs 2/3) `floor((frame−1)/3)` spread
528 // notes over an irregular 1/2-row spacing — the tempo mean was
529 // right but each note jittered ±1 row (±60 ms), audible as a
530 // "rushed"/unsteady rhythm.
531 //
532 // The grid is therefore chosen per sub-song inside the render
533 // loop below (`speed`/`speed_u32` = that song's `dw_speed`).
534 // This is the exact tracker idiom: tempo = ticks/row = the
535 // Whittaker speed, BPM = tick rate (which keeps carrying the
536 // delay-counter slowdown, see `bpm_for`). The runtime honours a
537 // per-song tempo via `TimelineEntry::speed_at_row` /
538 // `bpm_at_row` (sequencer reads them at each row start), so no
539 // core change is needed. `default_tempo` is set to the primary
540 // song's speed once `order` is known (below).
541
542 // BPM models the global delay counter. The replayer skips
543 // ~`delay/256` of every frame (Ghidra `play_tick` head — an
544 // 8-bit accumulator that drops a frame on overflow), a UNIFORM
545 // slowdown to `(256 − delay)/256 × 50 Hz`. We render notes on
546 // the clean row grid (the simulator no longer skips frames, so
547 // spacing stays integer rows) and fold that slowdown into BPM
548 // instead: PAL 125 BPM = 50 Hz, so `125 × (256 − delay)/256`.
549 // Doing it as frame-skips + row quantisation jittered the row
550 // spacing ±1 (each row arriving "hesitantly"); a flat BPM is
551 // both steadier and the perceptually-faithful result. Computed
552 // per sub-song from its `delay_speed` (×16 when
553 // `enable_delay_multiply`); clamped so a large delay can't zero
554 // the tempo.
555 let bpm_for = |ss_idx: usize| -> usize {
556 let eff_delay = if self.features.enable_delay_counter {
557 let d = self.sub_songs[ss_idx].delay_speed as u32;
558 if self.features.enable_delay_multiply {
559 d.saturating_mul(16)
560 } else {
561 d
562 }
563 } else {
564 0
565 }
566 .min(224); // keep ≥ ~14% of full rate
567 ((125 * (256 - eff_delay)) / 256).max(32) as usize
568 };
569
570 let instr_count = module.instrument.len();
571 let clamp_sample = |s: u16| -> usize {
572 let s = s as usize;
573 if instr_count == 0 {
574 0
575 } else {
576 s.min(instr_count - 1)
577 }
578 };
579
580 // Period-exact note → xmrs `Pitch`, for both replayer
581 // families (see the long rationale below — hoisted above
582 // the per-song loop since it depends only on the module,
583 // not the sub-song).
584 let note_to_pitch = |note_byte: i16, sample_freq: u16, sample_transpose: i8| -> Pitch {
585 // qball-era composite players map the note byte's low part
586 // into a single octave (`PERIODS1[note % 12]`, no fine-tune).
587 // The new player *and* the old-stream fine-tune players
588 // (leviathan, empire) index a full-range word table scaled by
589 // the per-sample fine-tune — `period_via_finetune` selects it.
590 let period: u32 = if matches!(self.variant, DwVariant::Old) && !self.period_via_finetune
591 {
592 let idx = note_byte.clamp(0, 11) as usize;
593 super::tables::PERIODS1[idx] as u32
594 } else {
595 let max_idx = match self.period_table {
596 PeriodTable::P1 => 11,
597 PeriodTable::P2 => super::tables::PERIODS2.len() - 1,
598 PeriodTable::P3 => super::tables::PERIODS3.len() - 1,
599 };
600 let idx = (note_byte + sample_transpose as i16).clamp(0, max_idx as i16) as usize;
601 let base = self.period_table.period(idx).unwrap_or(0) as u32;
602 let finetune = 0x0036_9E99u32 / (sample_freq.max(1) as u32);
603 (base.saturating_mul(finetune)) >> 10
604 };
605 if period == 0 {
606 return Pitch::C5;
607 }
608 let p = crate::core::fixed::units::Period::from_raw(period.clamp(1, 0xFFFF) as u16);
609 let pitch_q8_8 = helper.period_to_pitch(p).as_q8_8_i32();
610 let semitone = ((pitch_q8_8 + 0x80) >> 8).clamp(0, 119) as u8;
611 Pitch::try_from(semitone).unwrap_or(Pitch::C5)
612 };
613
614 // ---- render every sub-song as its own xmrs song ----
615 //
616 // Song 0 is the auto-selected "main" tune (so the player's
617 // default `-s 0` plays it); the remaining sub-songs follow
618 // in on-disk index order. The Track bank is shared across
619 // songs; each Clip / TimelineEntry carries its song index.
620 // Cap the number of rendered songs. Some modules are game
621 // music banks carrying a dozen-plus sub-songs — one main tune
622 // plus short cues, jingles and entry-point variations
623 // (grimblood ships 18, loopz / gold-of-the-aztecs 21). The cap
624 // is purely a runaway guard against a pathological file: the
625 // measured cost of exposing every sub-song is modest (the
626 // heaviest 21-song modules render in ~20 ms / ~5400 clips —
627 // looping-song simulation is already frame-bounded), so it is
628 // set well above the known corpus maximum. The order is
629 // [selected, 0, 1, 2, …], so the first entries (notably song 0,
630 // the auto-selected main tune used by the fidelity oracles) are
631 // identical regardless of this value — raising it only appends
632 // the remaining sub-songs. Modules with ≤ this many sub-songs
633 // are unaffected.
634 const MAX_SONGS: usize = 32;
635 let mut order: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
636 if let Some(sel) = self.selected_sub_song {
637 order.push(sel);
638 }
639 for i in 0..self.all_position_lists.len() {
640 if order.len() >= MAX_SONGS {
641 break;
642 }
643 if Some(i) != self.selected_sub_song {
644 order.push(i);
645 }
646 }
647
648 // Module default tempo / BPM = the primary (auto-selected)
649 // song's. Tempo is that song's Whittaker speed (ticks per row =
650 // frames per row); BPM carries the delay-counter slowdown. The
651 // per-song values below override these at each row.
652 module.default_tempo = order
653 .first()
654 .map(|&i| self.sub_songs[i].speed.max(1) as usize)
655 .unwrap_or(6);
656 module.default_bpm = order.first().map(|&i| bpm_for(i)).unwrap_or(125);
657
658 let mut clips_vec: Vec<Clip> = Vec::new();
659 let mut timeline_entries: Vec<TimelineEntry> = Vec::new();
660 // (song index, that song's grid `speed_u32`, trace) — the
661 // per-song speed is carried so the post-dedup lane walk below
662 // quantises with the same grid the clips were built on.
663 let mut song_traces: Vec<SongTrace> = Vec::new();
664 // Synthetic per-`(sample, arpeggio)` instruments (pitch-envelope
665 // path), shared across every song so a `(sample, arp)` pair
666 // reused by several tunes contributes a single instrument.
667 let mut arp_instr_cache: BTreeMap<(usize, Option<u16>, Option<u16>), usize> =
668 BTreeMap::new();
669
670 for (song_u, &ss_idx) in order.iter().enumerate() {
671 let song = song_u as u16;
672 let lists = &self.all_position_lists[ss_idx];
673 let dw_speed = self.sub_songs[ss_idx].speed.max(1);
674 // Per-sub-song row grid (Option A): one row = `dw_speed`
675 // frames, so every event lands exactly on a row boundary.
676 let speed: u8 = dw_speed;
677 let speed_u32 = dw_speed as u32;
678 // This song's delay-counter tempo (see `bpm_for`). The
679 // simulator runs at the clean 50 Hz grid; the delay's
680 // uniform slowdown is carried entirely by this BPM.
681 let song_bpm = bpm_for(ss_idx) as u16;
682 let mut sim = super::runtime::Simulator::new_for_subsong(self, lists, dw_speed);
683 // The primary song (rendered first) renders exactly ONE
684 // seamless loop period — each Whittaker channel loops its
685 // own position list, and they realign at the LCM of the
686 // per-channel pass lengths (10725 frames for xenon2, whose
687 // four voices share one period). `Module::song_loop_to` is
688 // then set so the player repeats the song like the
689 // original instead of stopping after one pass. Falls back
690 // to the full 20-minute cap with no loop marker when no
691 // clean period is found inside the cap. Audition songs
692 // (> 0) stay bounded short (~2 min at speed 3 = 6000
693 // frames) so a module with many looping variations doesn't
694 // blow up the Module, and don't carry their own loop mark.
695 // Render to the song's **seamless loop period** — the LCM
696 // of the per-channel position-list pass lengths, where every
697 // voice is simultaneously back at list entry 0. Truncating
698 // the trace to that exact frame count is what keeps the
699 // rendered loop length a whole multiple of the real period;
700 // the previous `run_capped(6000)` for songs > 0 cut at an
701 // arbitrary frame and the `last_frame/speed` row-rounding
702 // then shaved a few frames off, so those songs looped a few
703 // rows short and drifted against the original (e.g. song 1
704 // looped at 2295 frames instead of the true 2304, audible as
705 // a seam at the order wrap). `run_with_loop` finds the true
706 // period for songs 1-3 (2304 / 1344 / 1920); song 0 has no
707 // clean LCM inside the cap (independent channel lengths) and
708 // returns `None`, falling back to the full cap.
709 //
710 // `song_loop_to` is module-global, so only the auto-selected
711 // primary song (song_u 0) sets it — the secondary audition
712 // songs still render one faithful loop but don't claim the
713 // single module-level wrap marker.
714 let cap = if song_u == 0 {
715 super::runtime::MAX_SIMULATION_FRAMES
716 } else {
717 // Bound secondary songs so a module with many looping
718 // variations can't blow up: one period, or ~2 min.
719 6000
720 };
721 let (mut trace, period) = sim.run_with_loop(cap);
722 // Drifting-voices fallback (Whittaker title tunes). When
723 // `run_with_loop` hits the cap (`period == cap`), the four
724 // voices share NO common loop within ~20 min: each loops its
725 // own position list at its own length and they drift forever
726 // (xenon2: 9888/9888/9912/10014 frames — confirmed against
727 // the real 68k replayer: cmd 0x80 wraps each channel to its
728 // OWN list start, no global restart). A single global wrap
729 // can't be seamless for all of them. Wrap at the LONGEST
730 // voice's pass length so NO voice's material is ever cut — a
731 // shorter voice is merely mid-loop at the wrap (a small
732 // stutter). This replaces the old blunt 20-min cap wrap,
733 // which reset all four voices mid-phrase at once. (The
734 // per-lane loop increment — `Module::channel_loops` — will
735 // later remove even this residual stutter by looping each
736 // voice independently; see `daw/LOOP_REGION_RFC.md`.)
737 let drift_len: Option<u32> = if song_u == 0 && period == Some(cap) {
738 sim.first_wrap_frame
739 .iter()
740 .filter_map(|w| w.map(|f| f.saturating_sub(1)))
741 .max()
742 .filter(|&m| m > 0 && m < cap)
743 } else {
744 None
745 };
746 if let Some(dl) = drift_len {
747 // Per-lane truncation: keep each voice's events only
748 // within ITS OWN pass `[0, first_wrap[ch])`. The player's
749 // per-lane fold replays each region, so tiling a voice
750 // past its own wrap is dead weight the fold never reaches.
751 // Voices that never wrapped (finished) keep up to the
752 // global span `dl`.
753 let pass_end = |ch: usize| -> u32 {
754 sim.first_wrap_frame
755 .get(ch)
756 .and_then(|w| *w)
757 .map(|f| f.saturating_sub(1))
758 .unwrap_or(dl)
759 };
760 trace.retain(|(f, ev)| *f <= pass_end(ev.channel() as usize));
761 // Per-lane loops: each voice loops its own pass
762 // independently (Ghidra-confirmed: cmd 0x80 wraps each
763 // channel to its OWN list start). The player free-runs
764 // the timeline and folds each lane by its region, so the
765 // voices drift forever with NO global snap — exactly the
766 // hardware. The longest voice's region end equals the
767 // timeline span, so it wraps seamlessly; shorter voices
768 // fold continuously across the replay. Voices that never
769 // wrapped (finished) get no region and stay silent after
770 // their content. See `daw/LOOP_REGION_RFC.md`.
771 for (ch, w) in sim.first_wrap_frame.iter().enumerate() {
772 if let Some(end) = w.map(|f| f.saturating_sub(1)).filter(|&e| e > 0) {
773 // Loop body starts where the channel first reached
774 // its `loop_to` target — tick 0 for whole-pass
775 // loops, but past the intro for `SeqPtr`-redirected
776 // channels (`follow_seq_ptr_loops`), so the intro
777 // plays once and only the body loops (bad company
778 // ch0/1/3). Clamp below `end` so the body is never
779 // empty/inverted.
780 let start = sim.loop_start_frame[ch]
781 .map(|f| f.saturating_sub(1))
782 .unwrap_or(0)
783 .min(end);
784 module
785 .channel_loops
786 .push(crate::core::daw::loop_region::ChannelLoop {
787 song,
788 channel: ch as u8,
789 start_tick: start,
790 end_tick: end,
791 });
792 }
793 }
794 }
795 // Song-start loop point. Two cases set it:
796 // - a clean per-channel loop period was found (`period`), or
797 // - the song HALTED via StopSong (cmd 0x84 = `DMACON = 0x000F`).
798 // On the hardware that kills the replayer (plays once); a
799 // tracker has no silent-stop, so the faithful idiom is the
800 // order-end loop-to-start (like a `Bxx`/restart) — the song
801 // replays from the top. The Paula oracle, which never
802 // re-inits, just shows silence after the stop, so its golden
803 // must be captured truncated at the stop (the `f0`
804 // playing-flag option), exactly as `bubble_bobble.csv` is.
805 let halted_via_stop = trace
806 .iter()
807 .any(|(_, ev)| matches!(ev, super::runtime::TickEvent::SongEnd { .. }));
808 if song_u == 0 && (period.is_some() || halted_via_stop) {
809 // List loops wholly from entry 0, so the realignment
810 // point is the song start (tick 0).
811 module.song_loop_to = Some(0);
812 }
813 if trace.is_empty() {
814 continue;
815 }
816 // Song length in rows. When a seamless loop period was found,
817 // derive the row count from the **period** itself, not from
818 // the last event's frame: the last NoteOn often lands a few
819 // frames before the period boundary (a held note fills the
820 // gap), so `last_frame` undershoots the true loop length and
821 // the row-rounding then shaved the loop short (song 1 looped
822 // at 765 rows / 2295 frames instead of 768 / 2304, drifting
823 // against the original at the wrap). The period is a whole
824 // multiple of the channel pass lengths, so `period / speed`
825 // is exact. Without a period (song 0), fall back to the last
826 // rendered frame.
827 let last_frame = trace.iter().map(|(f, _)| *f).max().unwrap_or(0);
828 // Longest-voice length in the drift case, else the clean
829 // loop period, else the last rendered frame.
830 let length_frames = drift_len.or(period).unwrap_or(last_frame);
831 let total_rows = (length_frames.saturating_sub(1) / speed_u32 + 1) as usize;
832 // Per-lane row count for segment building. In the drift case
833 // a looping voice's clips end at ITS OWN pass, not the global
834 // span — otherwise `build_segments` would extend the last
835 // segment's held note to `total_rows`, leaving a trailing
836 // clip in `[end_ch, span)` that the fold never reaches. The
837 // longest voice (and finished/non-drift voices) use the full
838 // `total_rows`.
839 let lane_rows: [usize; 4] = {
840 let mut lr = [total_rows; 4];
841 if drift_len.is_some() {
842 for (slot, &w) in lr.iter_mut().zip(sim.first_wrap_frame.iter()) {
843 if let Some(end) = w.map(|f| f.saturating_sub(1)).filter(|&e| e > 0) {
844 *slot = (end.saturating_sub(1) / speed_u32 + 1) as usize;
845 }
846 }
847 }
848 lr
849 };
850
851 // ---- collect per-channel notes with their sample
852 // context. The trace already carries
853 // `NoteOn.sample_index` (the channel's current
854 // sample at the moment of the note), so we don't
855 // need a separate SetSample pass.
856 // Per-note tuple: (row, effective_note, sample_index,
857 // paula_volume_peak, envelope_index, arpeggio_index).
858 // `volume = None` means "no envelope armed" → keep
859 // `Volume::FULL`. The envelope index, when present, lets
860 // the segment build walk the full step sequence row-by-row.
861 // `arpeggio_index` is only populated on the pitch-envelope
862 // path (`use_pitch_arpeggio`); it becomes a segment key so
863 // each `(sample, arpeggio)` run gets its own synthetic
864 // pitch-envelope instrument. `None` on every other module
865 // leaves segmentation (and the classic `TrackEffect::Arpeggio`
866 // path below) byte-for-byte unchanged.
867 let mut per_ch_notes: [Vec<NoteRow>; 4] = Default::default();
868 // Pitch arpeggio armed per (channel, row), looked up at
869 // cell-emission time for the classic-effect path. The
870 // arpeggio is a per-note effect, not a segmentation key,
871 // there.
872 let mut arp_by_row: [BTreeMap<usize, u16>; 4] = Default::default();
873 for (frame, ev) in &trace {
874 if let super::runtime::TickEvent::NoteOn {
875 channel,
876 effective_note,
877 sample_index,
878 volume,
879 envelope_index,
880 arpeggio_index,
881 ..
882 } = ev
883 {
884 let row = ((frame.saturating_sub(1)) / speed_u32) as usize;
885 if row >= total_rows {
886 continue;
887 }
888 let ch = *channel as usize;
889 if ch >= 4 {
890 continue;
891 }
892 let sample = sample_index.map(clamp_sample).unwrap_or(0);
893 // The arpeggio is never a segment key now: it rides
894 // the per-frame `TrackPitch` Points lane
895 // (`use_arpeggio_pitch_lane`, always on for DW), not a
896 // per-(sample,arpeggio) synthetic pitch envelope. Kept
897 // as an explicit `None` (the old pitch-envelope split
898 // only fired when the lane was off, which no DW module
899 // does anymore).
900 let arp_for_seg = if self.use_pitch_arpeggio && !self.use_arpeggio_pitch_lane {
901 *arpeggio_index
902 } else {
903 None
904 };
905 per_ch_notes[ch].push((
906 row,
907 *effective_note,
908 sample,
909 *volume,
910 *envelope_index,
911 arp_for_seg,
912 ));
913 if let Some(arp) = arpeggio_index {
914 // Last write wins per row, matching the note
915 // dedup below.
916 arp_by_row[ch].insert(row, *arp);
917 }
918 }
919 }
920
921 // Quantising frames to rows can land two notes on the same
922 // row (a channel that re-triggers within one `speed`-frame
923 // window). The DAW model is one cell per row, so collapse
924 // each run of same-row notes to its **last** entry — the
925 // tracker "later write wins" convention, which also matches
926 // the runtime (the last sample armed on the channel is what
927 // keeps sounding through the row). Without this,
928 // `build_segments` can open a sample-change segment at a
929 // row equal to the previous segment's start, producing two
930 // clips that share a `position_tick` and trip
931 // `verify_layers_consistent`'s ClipsOverlap check (seen on
932 // alfred chicken.dw / snow strike.dw). The trace is emitted
933 // in frame order, so `row` is non-decreasing: keep the last
934 // tuple of each equal-row run.
935 for notes in per_ch_notes.iter_mut() {
936 let mut deduped: Vec<NoteRow> = Vec::with_capacity(notes.len());
937 for &tuple in notes.iter() {
938 if let Some(last) = deduped.last_mut() {
939 if last.0 == tuple.0 {
940 *last = tuple; // same row → later note wins
941 continue;
942 }
943 }
944 deduped.push(tuple);
945 }
946 *notes = deduped;
947 }
948
949 // ---- build segments + tracks + clips (this song) ----
950 for (ch, notes) in per_ch_notes.iter().enumerate() {
951 // Build sample-change segments first, then split each
952 // multi-pattern segment so every clip fits in one
953 // pattern (xmrs's `Module::row_at` indexes track rows
954 // from 0 relative to the clip's pattern — a clip that
955 // spans patterns is silently skipped past its first
956 // pattern boundary).
957 let segments = split_at_pattern_boundaries(build_segments(
958 notes,
959 lane_rows[ch],
960 self.use_pitch_arpeggio,
961 ));
962 for (seg_idx, seg) in segments.iter().enumerate() {
963 let seg_len = seg.end_row + 1 - seg.start_row;
964 let mut rows = vec![Cell::default(); seg_len];
965 // The period-exact pitch map needs the active
966 // sample's header frequency for its finetune (new
967 // player). All notes in a segment share one sample.
968 let (seg_sample_freq, seg_sample_transpose) = self
969 .samples
970 .get(seg.sample_index)
971 .map(|s| (s.frequency, s.transpose))
972 .unwrap_or((8372, 0));
973 for &(abs_row, note_byte, paula_vol, _env_idx) in &seg.notes {
974 if abs_row < seg.start_row || abs_row > seg.end_row {
975 continue;
976 }
977 let rel_row = abs_row - seg.start_row;
978 // Whittaker note bytes index `Periods3`
979 // directly: each step is one semitone, byte 0
980 // is the lowest playable period. The
981 // `Sample.relative_pitch` from
982 // `sample_to_instrument` already aligns each
983 // `note_byte` is the simulator's
984 // `effective_note` (raw + global + channel
985 // transposes already combined). Saturate to
986 // the xmrs playable range so a transpose that
987 // pushes a note below 0 or above 119 doesn't
988 // wrap into the wrong octave.
989 let pitch = note_to_pitch(note_byte, seg_sample_freq, seg_sample_transpose);
990 // Whittaker envelope bytes are already in
991 // Paula's 0..=64 range (the on-disk values
992 // observed on `dw.xenon 2` peak at 0x40, never
993 // breaching Paula's 6-bit hardware limit). The
994 // replayer's `MULU global_vol ; LSR #6` step
995 // just scales by the song's master volume; at
996 // master = 64 (the default) the multiplication
997 // is the identity, so the envelope byte is the
998 // final Paula level. We saturate at 64 in case
999 // a less canonical module reaches 65..=127.
1000 // `None` (no envelope armed) keeps the
1001 // historical full-scale default so samples
1002 // without paired `SetVolumeEnvelope` stay audible.
1003 // Per-note loudness source, in priority order:
1004 // 1. the runtime's envelope value (`paula_vol`,
1005 // new-player volume envelopes);
1006 // 2. the old-player per-channel static volume
1007 // (`channel_volumes[ch]`) — the qball-era
1008 // replayer's `SetAmigaVolume(channelVolumes[ch])`
1009 // with no sample/envelope scaling;
1010 // 3. full scale (un-shaped channel).
1011 let effective_vol = paula_vol
1012 .map(|v| v.min(64))
1013 .or_else(|| {
1014 if self.period_via_finetune {
1015 // leviathan-family: the per-note Paula
1016 // volume is the sample's static
1017 // per-instrument volume (record +0xE),
1018 // written raw on every note — NOT the
1019 // qball per-channel table. (Empire is
1020 // also `period_via_finetune` but its
1021 // sample volumes are full scale, so this
1022 // matches its old behaviour; its shaped
1023 // notes carry `paula_vol` above.)
1024 Some(
1025 self.samples
1026 .get(seg.sample_index)
1027 .map(|s| s.volume.min(64) as u8)
1028 .unwrap_or(64),
1029 )
1030 } else if matches!(self.variant, DwVariant::Old) {
1031 Some(self.channel_volumes[ch].min(64) as u8)
1032 } else {
1033 None
1034 }
1035 })
1036 // Empire-family global master scale
1037 // (`× master / 64`); identity elsewhere.
1038 .map(|v| self.scale_master_volume(v));
1039 let velocity = effective_vol
1040 .map(Volume::from_byte_64)
1041 .unwrap_or(Volume::FULL);
1042 // The `velocity` slot on `CellEvent::NoteOn`
1043 // is metadata only — xmrsplayer's hot path
1044 // (see `xmrsplayer/src/channel/triggers.rs`)
1045 // seeds `self.volume` from the sample's
1046 // static `Sample.volume`, not the cell. To
1047 // actually shape per-note loudness we also
1048 // emit a [`TrackEffect::Volume`] at tick 0
1049 // of the row; the channel-tick handler at
1050 // `tick.rs:264` assigns `self.volume = v`
1051 // when `current_tick == 0`, which lands the
1052 // envelope's peak just as the note triggers.
1053 let mut effects: Vec<TrackEffect> = Vec::new();
1054 // When the segment's volume envelope is baked
1055 // per-tick on the synthetic instrument
1056 // (`env_index`), the instrument's full-scale
1057 // sample + volume envelope already produce the
1058 // loudness — emitting a cell `Volume` here would
1059 // double-shape it. Otherwise (the common path)
1060 // set the channel volume from the envelope's
1061 // first step at the trigger tick.
1062 if effective_vol.is_some() && seg.env_index.is_none() {
1063 effects.push(TrackEffect::Volume {
1064 value: velocity,
1065 tick: 0,
1066 });
1067 }
1068 // Pitch arpeggio (classic-effect path only).
1069 // On the pitch-envelope path the arpeggio rides
1070 // the synthetic instrument's looping pitch
1071 // envelope instead (see `resolve_arp_instrument`
1072 // / `arpeggio_to_pitch_envelope`); on the
1073 // pitch-lane path it rides a `TrackPitch` Points
1074 // curve (`attach_arpeggio_pitch_lanes`). Both are
1075 // frame-faithful, so skip the lossy classic
1076 // effect for them. The classic three-step
1077 // `Arpeggio { half1, half2 }` (xmrs cycles base /
1078 // base+half1 / base+half2 once per tick) now only
1079 // ever fires for non-DW formats — for DW one of
1080 // the two faithful paths is always active.
1081 if !self.use_pitch_arpeggio && !self.use_arpeggio_pitch_lane {
1082 if let Some(&arp_idx) = arp_by_row[ch].get(&abs_row) {
1083 if let Some(arp) = self.arpeggios.get(arp_idx as usize) {
1084 let (half1, half2) = arp.classic_pair();
1085 if half1 != 0 || half2 != 0 {
1086 effects.push(TrackEffect::Arpeggio { half1, half2 });
1087 }
1088 }
1089 }
1090 }
1091 rows[rel_row] = Cell {
1092 event: CellEvent::NoteOn { pitch, velocity },
1093 effects,
1094 expression: crate::core::cell::NoteExpression::NEUTRAL,
1095 };
1096 }
1097
1098 // Per-row envelope animation: for every note that
1099 // carries an envelope index, walk the rows between
1100 // this trigger and the next trigger and emit
1101 // ghost `TrackEffect::Volume` cells reflecting the
1102 // envelope's current step. Implementation note:
1103 // xmrsplayer's hot path only acts on
1104 // `TrackEffect::Volume { tick: 0 }` (it sets
1105 // `self.volume = v` at that tick), so even an
1106 // otherwise-empty `Cell` with the effect attached
1107 // will land the new volume without retriggering
1108 // the sample.
1109 // Row-cell envelope animation — only when the volume
1110 // envelope is NOT baked per-tick on the instrument
1111 // (`env_index` is `None`). When it IS baked, the
1112 // instrument's volume envelope replaces this coarse
1113 // row-grid animation (which aliased the decay).
1114 if seg.env_index.is_none() {
1115 attach_envelope_animation(
1116 &mut rows,
1117 &seg.notes,
1118 seg.start_row,
1119 speed_u32,
1120 &self.volume_envelopes,
1121 self.master_volume,
1122 );
1123 }
1124
1125 // Resolve the instrument: the plain per-sample one,
1126 // or a synthetic `(sample, arpeggio, volume-env)`
1127 // instrument carrying the arpeggio pitch envelope
1128 // and/or the per-tick volume envelope. Computed
1129 // before the `tracks.push` so the `&mut
1130 // module.instrument` borrow doesn't overlap.
1131 let instr_idx = resolve_synth_instrument(
1132 &mut module.instrument,
1133 &mut arp_instr_cache,
1134 &self.samples,
1135 &self.arpeggios,
1136 &self.volume_envelopes,
1137 self.master_volume,
1138 seg.sample_index,
1139 seg.arp_index,
1140 seg.env_index,
1141 );
1142 let track_idx = module.tracks.len() as u32;
1143 module.tracks.push(Track::Notes {
1144 name: format!("ch{} seg{:02} smp{:02}", ch, seg_idx, seg.sample_index),
1145 instrument: instr_idx,
1146 rows,
1147 muted: false,
1148 });
1149 // `source_start_row` is pattern-relative per
1150 // `Module::row_at` semantics (`row_in_track =
1151 // row_u32 - source_start_row` where `row_u32`
1152 // is the pattern-relative row 0..63). After the
1153 // pattern-boundary split above, the segment lives
1154 // inside one pattern, so this modulo is its
1155 // first row within that pattern.
1156 let pat_rel_start = (seg.start_row % ROWS_PER_PATTERN as usize) as u32;
1157 clips_vec.push(Clip {
1158 track: track_idx,
1159 song,
1160 target_channel: ch as u8,
1161 position_tick: (seg.start_row as u32) * speed_u32,
1162 speed_at_start: speed,
1163 track_row_offset: 0,
1164 source_start_row: pat_rel_start,
1165 end_tick: ((seg.end_row + 1) as u32) * speed_u32,
1166 });
1167 }
1168 }
1169
1170 // ---- timeline for this song ----
1171 //
1172 // Whittaker songs have no native concept of "pattern"
1173 // the way XM / MOD do — the runtime walks one big flat
1174 // list of notes per channel. We synthesise 64-row
1175 // patterns (`ROWS_PER_PATTERN`) so `-d` debug dumps and
1176 // editor views read like a classic tracker. The segment
1177 // builder above splits Tracks at the same boundary so
1178 // each clip fits in one pattern. Each song's `tick` /
1179 // `pattern` numbering restarts at 0.
1180 for r in 0..total_rows {
1181 let r_u32 = r as u32;
1182 let pattern = r_u32 / ROWS_PER_PATTERN;
1183 timeline_entries.push(TimelineEntry {
1184 song,
1185 order_idx: pattern,
1186 pattern_idx: pattern,
1187 row_idx: r_u32 % ROWS_PER_PATTERN,
1188 loop_iter: 0,
1189 tick: r_u32 * speed_u32,
1190 speed_at_row: speed,
1191 bpm_at_row: song_bpm,
1192 });
1193 }
1194
1195 song_traces.push((song, speed_u32, trace));
1196 }
1197
1198 module.clips = SortedClips::from_unsorted(clips_vec);
1199 module.timeline_map = TimelineMap {
1200 entries: timeline_entries,
1201 };
1202
1203 // ---- dedup ----
1204 // After the pattern-boundary split many adjacent segments
1205 // contain identical short cell sequences (especially the
1206 // long silent runs between musical phrases). Dedup fuses
1207 // them so the on-disk Track count stays manageable; the
1208 // Clips that referenced the dropped Tracks are re-pointed
1209 // and continue to play. Run once over all songs' tracks.
1210 crate::tracker::import::build::dedupe_tracks_by_content(&mut module);
1211
1212 // ---- per-song automation lanes (vibrato / slide) ----
1213 // `StartVibrato`/`StopVibrato` → `LfoEvent` and `Slide` →
1214 // `SlideEvent` on the active Track's `TrackPitch` lane.
1215 // Walked *after* dedup so the resolved clip.track indices
1216 // are final, and per song so the clip lookup uses the
1217 // right timeline.
1218 for (song, song_speed, trace) in &song_traces {
1219 attach_vibrato_lanes(&mut module, *song, trace, *song_speed);
1220 attach_slide_lanes(&mut module, *song, trace, *song_speed);
1221 if self.use_arpeggio_pitch_lane {
1222 attach_arpeggio_pitch_lanes(&mut module, *song, trace);
1223 }
1224 }
1225 collapse_dead_duplicate_modulation_lanes(&mut module);
1226
1227 module
1228 }
1229
1230 /// Diagnostic helper: returns the number of `(channel, sample)`
1231 /// segments the conversion would emit before dedup. Used by
1232 /// the integration test to verify the split actually fires.
1233 #[doc(hidden)]
1234 pub fn segment_count_for_diagnostics(&self) -> usize {
1235 let speed = self.sub_song.as_ref().map(|s| s.speed.max(1)).unwrap_or(6);
1236 let trace = self.simulate();
1237 if trace.is_empty() {
1238 return 0;
1239 }
1240 let last_frame = trace.iter().map(|(f, _)| *f).max().unwrap_or(0);
1241 let total_rows = (last_frame.saturating_sub(1) / speed as u32 + 1) as usize;
1242 let speed_u32 = speed as u32;
1243 let instr_count = self.samples.len();
1244 let clamp = |s: u16| -> usize {
1245 let s = s as usize;
1246 if instr_count == 0 {
1247 0
1248 } else {
1249 s.min(instr_count - 1)
1250 }
1251 };
1252 let mut per_ch_notes: [Vec<NoteRow>; 4] = Default::default();
1253 for (frame, ev) in &trace {
1254 if let super::runtime::TickEvent::NoteOn {
1255 channel,
1256 effective_note,
1257 sample_index,
1258 volume,
1259 envelope_index,
1260 ..
1261 } = ev
1262 {
1263 let row = ((frame.saturating_sub(1)) / speed_u32) as usize;
1264 if row >= total_rows {
1265 continue;
1266 }
1267 let ch = *channel as usize;
1268 if ch >= 4 {
1269 continue;
1270 }
1271 per_ch_notes[ch].push((
1272 row,
1273 *effective_note,
1274 sample_index.map(clamp).unwrap_or(0),
1275 *volume,
1276 *envelope_index,
1277 None,
1278 ));
1279 }
1280 }
1281 let mut total = 0;
1282 for notes in &per_ch_notes {
1283 total += build_segments(notes, total_rows, false).len();
1284 }
1285 total
1286 }
1287
1288 // ---------- internals ----------
1289
1290 fn parse_samples(source: &[u8], layout: &DwLayout) -> Result<Vec<DwSample>, ImportError> {
1291 // Detection is best-effort: when the probes can't lock onto
1292 // the loader pattern we degrade to "no samples" rather than
1293 // erroring out, so the caller can still keep the
1294 // `DwModule` for diagnostics or layer in its own probes.
1295 //
1296 // The on-disk layout (verified in Ghidra on dw.xenon 2)
1297 // splits sample metadata across two contiguous tables:
1298 //
1299 // - `sample_data_offset` (0x1B6C in this module): per-
1300 // sample concatenated blocks of the form
1301 // u32 length_bytes
1302 // u16 frequency
1303 // i8[length_bytes] pcm
1304 //
1305 // - `sample_info_offset` (0x79A): `N × 12` bytes laid out
1306 // as a runtime channel-info struct. Most of the struct
1307 // is rewritten by the loader at boot from `sample_data`
1308 // (PCM pointer, length, period). The only field that is
1309 // **read** rather than overwritten is the `s32 loop_start`
1310 // at byte offset +4 — that field is what we lift here.
1311 //
1312 // Per-sample volume does not live in either table for this
1313 // variant of the new player. Paula gets its level from the
1314 // playback layer (per-note in the track stream and/or
1315 // hardcoded defaults inside the replayer). The importer
1316 // therefore reports `volume = 64` (Paula full scale) for
1317 // every sample; a future pass that tracks the runtime
1318 // mixer state can refine this.
1319 let (Some(n_raw), Some(data_off)) = (layout.number_of_samples, layout.sample_data_offset)
1320 else {
1321 return Ok(Vec::new());
1322 };
1323 let n = n_raw as usize;
1324 if n == 0 {
1325 return Ok(Vec::new());
1326 }
1327 let info_off = layout.sample_info_offset;
1328
1329 // ---- sample-data table ----
1330 //
1331 // Concatenated per-sample blocks at `data_off`:
1332 // u32 length_bytes ; big-endian
1333 // u16 frequency ; big-endian, Paula native rate hint
1334 // i8[length_bytes] pcm
1335 //
1336 // Iterated `n` times. We stop early at the first short
1337 // read rather than erroring, so a slightly mis-tuned `n`
1338 // still yields the samples up to that point.
1339 let mut data_reader = open_reader(source, data_off)?;
1340 let mut samples: Vec<DwSample> = Vec::with_capacity(n);
1341 // RFC §1A shared audio pool: identical PCM payloads collapse to
1342 // a single `Arc<[i8]>`. Bucketed by an FNV-1a hash with a
1343 // byte-exact tie-break, so a hash collision can never alias two
1344 // different buffers.
1345 let mut pcm_pool: BTreeMap<u64, Vec<Arc<[i8]>>> = BTreeMap::new();
1346 for idx in 0..n {
1347 let Ok(length_bytes) = data_reader.read_u32_be() else {
1348 break;
1349 };
1350 let Ok(frequency) = data_reader.read_u16_be() else {
1351 break;
1352 };
1353 // Reject implausible lengths (a stray non-loader
1354 // signature would otherwise consume gigabytes here).
1355 if length_bytes as usize > data_reader.remaining() {
1356 break;
1357 }
1358 let Ok(pcm_bytes) = data_reader.read_slice(length_bytes as usize) else {
1359 break;
1360 };
1361 let pcm_vec: Vec<i8> = pcm_bytes.iter().map(|&b| b as i8).collect();
1362 let pcm: Arc<[i8]> = dedup_pcm(&mut pcm_pool, pcm_vec);
1363
1364 // Per-sample loop-start.
1365 //
1366 // **New player**: the `s32` at +4 inside the 12-byte
1367 // sample-info row is genuine on-disk data — `0xFFFFFFFF`
1368 // (= -1) means one-shot, any non-negative value loops
1369 // forward from that byte offset.
1370 //
1371 // **Old player (qball-era)**: the sample-info table is
1372 // *runtime-built* — the loader writes the PCM pointer,
1373 // word length and base period into it at boot
1374 // (Ghidra: qball `Init` stores `length>>1` at info+4,
1375 // never a loop point). On disk those bytes are blank
1376 // padding, so reading "+4" yields a meaningless `0`
1377 // that would loop every sample from its start. There
1378 // is no loop metadata anywhere in the old format, so
1379 // the samples are treated as one-shot.
1380 // Sample-info row stride: the tetris-family loader
1381 // (the `enable_sample_transpose` modules) uses 16-byte
1382 // entries — `[ptr, loop_start(s32), len, finetune,
1383 // volume, transpose, pad]` — whereas xenon2/beast1 pack
1384 // 12-byte rows. `read_loop_start` indexes by this
1385 // stride, so getting it wrong reads later samples'
1386 // loop fields from the wrong offset (it surfaced as
1387 // spurious `loop=0` on tetris's odd samples).
1388 let info_stride = if layout.features.enable_sample_transpose {
1389 16
1390 } else {
1391 12
1392 };
1393 let loop_start = if matches!(layout.variant, DwVariant::Old) {
1394 None
1395 } else {
1396 info_off.and_then(|base| read_loop_start(source, base, idx, info_stride))
1397 };
1398
1399 // Per-sample note transpose (tetris-family loader): a
1400 // signed semitone offset at sample-info byte +14 in a
1401 // 16-byte entry, added to the period-table index of
1402 // every note that plays this sample. Only read when the
1403 // module sets `enable_sample_transpose` — xenon2 / beast1
1404 // leave it 0 (their note path has no `+= transpose`
1405 // step). Mis-reading it as non-zero on those modules
1406 // would detune every note, so the feature gate matters.
1407 let transpose = if layout.features.enable_sample_transpose {
1408 info_off
1409 .and_then(|base| source.get(base + idx * 16 + 14).copied())
1410 .map(|b| b as i8)
1411 .unwrap_or(0)
1412 } else {
1413 0
1414 };
1415
1416 // Per-sample base volume (tetris-family 16-byte loader): a
1417 // big-endian word at sample-info byte +12, loaded straight
1418 // into `AUDxVOL` on every note trigger. Ghidra tetris
1419 // `PlayTick`: `AUDxVOL = *(short *)(sampleInfo + 0xC) -
1420 // DAT_0000041a` (the `Effect8` global offset, normally 0).
1421 // The on-disk words vary per sample (e.g. 64 / 56 / 54 / 52
1422 // / 30) — this is what makes tetris's lead/bass voices sit
1423 // at 56 while its percussion stays at 64; the previous
1424 // hard-coded `64` flattened them all to full scale. Only
1425 // read it on `enable_sample_transpose` modules: the 12-byte
1426 // xenon2/beast1 rows have no such field and their loudness
1427 // comes from the volume envelope instead. Clamp to Paula's
1428 // 0..=64 range.
1429 let volume = if layout.features.enable_sample_transpose {
1430 info_off
1431 .and_then(|base| source.get(base + idx * 16 + 12..base + idx * 16 + 14))
1432 .map(|b| u16::from_be_bytes([b[0], b[1]]).min(64))
1433 .unwrap_or(64)
1434 } else if layout.period_via_finetune {
1435 // leviathan-era old-stream fine-tune player (C7): a
1436 // *static* per-instrument volume word lives at record
1437 // byte +0xE in the 16-byte instrument table (the
1438 // `(0xE,A5)` AUDxVOL write at every note trigger; no
1439 // envelope). Records are `instr_base + 0x10*idx`. When
1440 // the table isn't located (e.g. empire, whose 12-byte
1441 // records use a volume envelope instead), fall back to
1442 // full scale. Clamp to Paula's 0..=64.
1443 layout
1444 .instrument_volume_offset
1445 .and_then(|base| source.get(base + idx * 0x10 + 0xE..base + idx * 0x10 + 0x10))
1446 .map(|b| u16::from_be_bytes([b[0], b[1]]).min(64))
1447 .unwrap_or(64)
1448 } else {
1449 64
1450 };
1451
1452 samples.push(DwSample {
1453 index: idx as u16,
1454 loop_start: loop_start.filter(|&s| s < length_bytes),
1455 length: length_bytes,
1456 frequency,
1457 volume,
1458 transpose,
1459 pcm,
1460 });
1461 }
1462
1463 Ok(samples)
1464 }
1465}
1466
1467impl DwModule {
1468 /// Read the sub-song header and follow each channel's position
1469 /// list. Both pieces degrade gracefully — when the probe
1470 /// hasn't located `sub_song_list_offset` (or any of the
1471 /// referenced position-list starts is out of bounds) the
1472 /// affected fields fall back to defaults.
1473 /// Parse every sub-song the importer can validate from the
1474 /// `sub_song_list_offset` row table. Stops when:
1475 ///
1476 /// - the next row would overlap the first position list (the
1477 /// smallest channel offset observed so far — position
1478 /// lists are always placed right after the sub-song
1479 /// table), or
1480 /// - any channel offset in the row is `0` or points outside
1481 /// the file, or
1482 /// - the speed byte is `0` (the spec's "speed > 255" sentinel
1483 /// maps to a 0 byte at the next row's start when the table
1484 /// is padded out).
1485 ///
1486 /// Returns an empty `Vec` when the sub-song table couldn't be
1487 /// located at all.
1488 fn parse_sub_songs(source: &[u8], layout: &DwLayout) -> Vec<DwSubSong> {
1489 // Row width is set by the detect probe. Three layouts are
1490 // currently recognised:
1491 //
1492 // - **8 bytes** — two shapes (disambiguated by the detected
1493 // `sub_song_header`): a 4-voice `[ch0..ch3]` (header 0,
1494 // e.g. sentinel) and a 3-voice `[param, ch0, ch1, ch2]`
1495 // (header 2, e.g. archipelagos / fright night). Speed
1496 // defaults to the historical Whittaker tempo of 6.
1497 // - **10 bytes** — `[speed, delay_speed, 4 × u16]`, the
1498 // canonical new player (xenon2, beast1.*, speedball).
1499 // - **18 bytes** — `[reserved, speed, 4 × u32]`, the
1500 // qball-era 32-bit build.
1501 //
1502 // Other row widths fall back to 10 to stay safe.
1503 let row = match layout.sub_song_row_width {
1504 8 | 10 | 18 => layout.sub_song_row_width,
1505 _ => 10,
1506 };
1507 let header = layout.sub_song_header;
1508 let ptr_width = if layout.uses_32bit_pointers { 4 } else { 2 };
1509 // Voice count = the channel pointers that fit after the
1510 // header, exactly what `init_main`'s setup loop iterates
1511 // (`(row - header) / ptr_width`; its `CMP.W #voices` bound).
1512 // Channels beyond it are unused and left at offset 0 → an
1513 // empty (silent) position list, NOT a phantom voice.
1514 let voices = row
1515 .saturating_sub(header)
1516 .checked_div(ptr_width)
1517 .unwrap_or(DW_NUM_CHANNELS)
1518 .clamp(1, DW_NUM_CHANNELS);
1519 let mut out = Vec::new();
1520 let Some(off) = layout.sub_song_list_offset else {
1521 return out;
1522 };
1523
1524 let mut min_list_off: usize = source.len();
1525 let mut cursor = off;
1526 // Hard cap as a defensive bound — every Whittaker module
1527 // observed ships ≤ a dozen sub-songs.
1528 for _ in 0..64 {
1529 if cursor + row > source.len() {
1530 break;
1531 }
1532 if cursor + row > min_list_off {
1533 break;
1534 }
1535 // Header layout differs by row width and by player:
1536 // - 8-byte rows have no header (use a default speed
1537 // of 6, the Whittaker historical tempo);
1538 // - 18-byte qball-era rows pad byte 0 to zero for
1539 // longword alignment, so speed sits at `row + 1`;
1540 // - 10-byte rows come in two new-player flavours:
1541 // * `[u8 speed, u8 delay, 4×u16]` — xenon2,
1542 // beast1 (speed in byte 0, delay in byte 1);
1543 // * `[u16 speed, 4×u16]` — tetris and kin, which
1544 // read the speed as a full word (Ghidra:
1545 // `MOVE.W (A0,D0), $41C(A3)` at tetris+0x5A).
1546 // Disambiguate by byte 0: a real per-row speed byte
1547 // is never 0, so byte0==0 means the word form, where
1548 // the speed is the low byte (byte 1) and there is no
1549 // delay.
1550 // The header carries the speed/delay (when present). A
1551 // header-0 row (8-byte 4-voice) has none → Whittaker
1552 // default 6. A header-2 row reads them like the canonical
1553 // 10-byte layout — including the 3-voice 8-byte shape,
1554 // whose param byte IS the speed (Ghidra `init_main`:
1555 // `DAT_624 = byte[table + ss*row]`, the LongWait
1556 // note-duration multiplier — archipelagos = 7, not 6).
1557 let (speed, delay_speed) = if header == 0 {
1558 (6u8, 0u8)
1559 } else if layout.uses_32bit_pointers {
1560 (source[cursor + 1], source[cursor])
1561 } else if source[cursor] != 0 {
1562 (source[cursor], source[cursor + 1])
1563 } else {
1564 (source[cursor + 1], 0u8)
1565 };
1566 let mut offs = [0u32; DW_NUM_CHANNELS];
1567 let mut valid = true;
1568 // Read only the real `voices` channel pointers; the rest
1569 // stay 0 (→ empty position list = silent). On 3-voice
1570 // modules this stops the param word being read as ch0's
1571 // base and stops a phantom ch3 from playing.
1572 // `i` drives byte-pointer arithmetic (`ptr_width * i`), not
1573 // just the `offs[i]` write — an iterator rewrite would
1574 // obscure the pointer walk.
1575 #[allow(clippy::needless_range_loop)]
1576 for i in 0..voices {
1577 let p = cursor + header + ptr_width * i;
1578 let o = if ptr_width == 4 {
1579 u32::from_be_bytes([source[p], source[p + 1], source[p + 2], source[p + 3]])
1580 } else {
1581 u16::from_be_bytes([source[p], source[p + 1]]) as u32
1582 };
1583 if o == 0 || (o as usize) >= source.len() {
1584 valid = false;
1585 break;
1586 }
1587 offs[i] = o;
1588 }
1589 // First row is treated as authoritative even when
1590 // `speed == 0`, because some modules use that slot
1591 // as a runtime-only "current sub-song" cache (the
1592 // host overwrites it before calling `Play`); ignoring
1593 // it would drop legitimate music. From row 2 onward
1594 // we use `speed == 0` as the loop sentinel.
1595 if !valid || (!out.is_empty() && speed == 0) {
1596 break;
1597 }
1598 let row_min = offs.iter().copied().min().unwrap_or(0) as usize;
1599 min_list_off = min_list_off.min(row_min);
1600 out.push(DwSubSong {
1601 speed,
1602 delay_speed,
1603 channel_position_offsets: offs,
1604 });
1605 cursor += row;
1606 }
1607 out
1608 }
1609}
1610
1611/// Decode the four per-channel position lists for a given
1612/// sub-song. Re-runs `read_position_list` against each of the
1613/// sub-song's channel offsets, so callers that want to switch
1614/// the active sub-song can rebuild the lists without re-parsing
1615/// the rest of the module.
1616pub fn position_lists_for(
1617 source: &[u8],
1618 sub_song: &DwSubSong,
1619 uses_32bit_pointers: bool,
1620 start_offset: isize,
1621) -> [DwPositionList; DW_NUM_CHANNELS] {
1622 let mut lists: [DwPositionList; DW_NUM_CHANNELS] = Default::default();
1623 for (ch, &off) in sub_song.channel_position_offsets.iter().enumerate() {
1624 lists[ch] = read_position_list(source, off as usize, uses_32bit_pointers, start_offset);
1625 }
1626 lists
1627}
1628
1629impl DwModule {
1630 /// Collect every unique track offset referenced by any
1631 /// position list, then scan each one until its `EndOfTrack`
1632 /// (`0x80`) command. De-duplication keeps the on-disk-order
1633 /// sort that a `BTreeMap` provides, which makes the resulting
1634 /// `Vec` stable across re-imports.
1635 /// Walk the per-channel volume-envelope pointer table and
1636 /// decode each entry into a [`DwVolumeEnvelope`].
1637 ///
1638 /// On-disk layout (verified on dw.xenon 2 at file offset
1639 /// 0x1A48):
1640 ///
1641 /// ```text
1642 /// [step_interval byte][envelope bytes... terminator]
1643 /// [step_interval byte][envelope bytes... terminator]
1644 /// ...
1645 /// ```
1646 ///
1647 /// where each pointer-table entry is a big-endian `u16` whose
1648 /// value is the file offset of the first **envelope byte**
1649 /// (i.e. the step-interval byte sits at `entry - 1`). An
1650 /// envelope ends at the first byte with the MSB set; the
1651 /// terminator's low 7 bits are the sustain volume and become
1652 /// the last entry of [`DwVolumeEnvelope::steps`]. Reading is
1653 /// bounded at `MAX_STEPS` to defend against pointers into
1654 /// non-envelope regions.
1655 fn parse_volume_envelopes(
1656 source: &[u8],
1657 layout: &super::detect::DwLayout,
1658 ) -> Vec<DwVolumeEnvelope> {
1659 const MAX_STEPS: usize = 128;
1660
1661 let (Some(table_off), Some(n)) = (
1662 layout.volume_envelope_table_offset,
1663 layout.volume_envelope_table_len,
1664 ) else {
1665 return Vec::new();
1666 };
1667 let n = n as usize;
1668 let mut out = Vec::with_capacity(n);
1669 for idx in 0..n {
1670 let entry_off = table_off + idx * 2;
1671 if entry_off + 2 > source.len() {
1672 break;
1673 }
1674 // The pointer-table entries are A3-relative (the player
1675 // does `MOVEA.W (table,Dn),A2 / ADDA.L A3,A2`), so rebase
1676 // through `start_offset` to a real file offset. No-op on
1677 // modules whose A3 sits at file 0 (almost all of them).
1678 let raw = u16::from_be_bytes([source[entry_off], source[entry_off + 1]]) as usize;
1679 let env_start = if raw == 0 {
1680 0
1681 } else {
1682 match (raw as isize).checked_add(layout.start_offset) {
1683 Some(f) if f >= 0 && (f as usize) < source.len() => f as usize,
1684 _ => 0,
1685 }
1686 };
1687 // The step-interval byte sits one byte before the
1688 // envelope. A zero `env_start` would indicate the
1689 // entry is unused — skip it but keep the slot so
1690 // indices stay aligned with the dispatcher's
1691 // `(byte - threshold)` calculation.
1692 let step_interval = if env_start > 0 && env_start - 1 < source.len() {
1693 source[env_start - 1]
1694 } else {
1695 0
1696 };
1697 let mut steps = Vec::new();
1698 let mut cursor = env_start;
1699 while cursor < source.len() && steps.len() < MAX_STEPS {
1700 let b = source[cursor];
1701 steps.push(b & 0x7F);
1702 cursor += 1;
1703 if b & 0x80 != 0 {
1704 break;
1705 }
1706 }
1707 out.push(DwVolumeEnvelope {
1708 index: idx as u16,
1709 step_interval,
1710 steps,
1711 });
1712 }
1713 out
1714 }
1715
1716 /// Walk the pitch-arpeggio pointer table (the `0x90..` bracket
1717 /// LEA target) and decode each entry into a [`DwArpeggio`].
1718 ///
1719 /// On-disk layout mirrors the volume-envelope table: a flat
1720 /// array of big-endian `u16` PC-relative offsets, each
1721 /// pointing at a semitone-offset byte stream terminated by a
1722 /// byte with its `0x80` bit set (the terminator's low 7 bits
1723 /// are the final offset). Offsets are read as signed `i8`.
1724 ///
1725 /// Validation: an entry whose decoded offsets all exceed
1726 /// ±0x30 semitones is almost certainly a mis-resolved pointer
1727 /// into non-arpeggio data, so the whole table is discarded
1728 /// (returns empty) rather than emitting nonsense pitch
1729 /// effects. This keeps a wrong probe from corrupting playback.
1730 fn parse_arpeggios(source: &[u8], layout: &DwLayout) -> Vec<DwArpeggio> {
1731 const MAX_STEPS: usize = 64;
1732
1733 let (Some(table_off), Some(n)) = (layout.arpeggio_table_offset, layout.arpeggio_table_len)
1734 else {
1735 return Vec::new();
1736 };
1737 let n = n as usize;
1738 let mut out = Vec::with_capacity(n);
1739 let mut sane_entries = 0usize;
1740 for idx in 0..n {
1741 let entry_off = table_off + idx * 2;
1742 if entry_off + 2 > source.len() {
1743 break;
1744 }
1745 // A3-relative entry (same as the envelope table) — rebase
1746 // through `start_offset` to a file offset.
1747 let raw = u16::from_be_bytes([source[entry_off], source[entry_off + 1]]) as usize;
1748 let arp_start = if raw == 0 {
1749 0
1750 } else {
1751 match (raw as isize).checked_add(layout.start_offset) {
1752 Some(f) if f >= 0 && (f as usize) < source.len() => f as usize,
1753 _ => 0,
1754 }
1755 };
1756 let mut offsets = Vec::new();
1757 let mut cursor = arp_start;
1758 while cursor < source.len() && offsets.len() < MAX_STEPS {
1759 let b = source[cursor];
1760 // Low 7 bits are the semitone offset, sign-extended
1761 // from the 7-bit field (bit 6 is the sign). The
1762 // 0x80 bit marks the terminator (still a valid
1763 // offset).
1764 let raw7 = (b & 0x7F) as i16;
1765 let signed = if raw7 >= 0x40 {
1766 (raw7 - 0x80) as i8
1767 } else {
1768 raw7 as i8
1769 };
1770 offsets.push(signed);
1771 cursor += 1;
1772 if b & 0x80 != 0 {
1773 break;
1774 }
1775 }
1776 if !offsets.is_empty() && offsets.iter().all(|&o| (-0x30..=0x30).contains(&o)) {
1777 sane_entries += 1;
1778 }
1779 out.push(DwArpeggio {
1780 index: idx as u16,
1781 offsets,
1782 });
1783 }
1784 // If fewer than half the entries decoded to plausible
1785 // semitone ranges, the table base is probably wrong —
1786 // drop the lot so the projection emits no arpeggio rather
1787 // than garbage pitch jumps.
1788 if out.is_empty() || sane_entries * 2 < out.len() {
1789 return Vec::new();
1790 }
1791 out
1792 }
1793
1794 /// Collect every unique track referenced by **any** sub-song's
1795 /// position lists (de-duplicated by file offset), so the shared
1796 /// [`Self::tracks`] bank covers every tune the module can play.
1797 fn parse_tracks(
1798 source: &[u8],
1799 all_lists: &[[DwPositionList; DW_NUM_CHANNELS]],
1800 dispatcher: &super::detect::DwDispatcher,
1801 features: &super::detect::DwFeatures,
1802 command_map: Option<&super::command_map::DwCommandMap>,
1803 ) -> Vec<DwTrack> {
1804 let mut seen: BTreeMap<u32, DwTrack> = BTreeMap::new();
1805 for lists in all_lists {
1806 for list in lists {
1807 for &offset in &list.entries {
1808 seen.entry(offset).or_insert_with(|| {
1809 // Read a generous raw window, then let the event
1810 // decoder (the single authority on parameter
1811 // widths) report exactly how far the track ran and
1812 // trim the stored bytes to that span.
1813 let mut bytes = read_track_bytes(source, offset as usize);
1814 let (events, used) = super::event::decode_track_counted(
1815 &bytes,
1816 dispatcher,
1817 features,
1818 command_map,
1819 );
1820 bytes.truncate(used);
1821 DwTrack {
1822 offset,
1823 bytes,
1824 events,
1825 }
1826 });
1827 }
1828 }
1829 }
1830 seen.into_values().collect()
1831 }
1832}
1833
1834/// Translate every `VibratoStart` / `VibratoStop` event in the
1835/// simulator trace into `LfoEvent::Set` / `LfoEvent::Clear`
1836/// entries on an `AutomationLane { target: TrackPitch(tidx),
1837/// kind: Lfo, .. }` for the Track active on the channel at the
1838/// event's tick.
1839///
1840/// Multi-Track vibrato (one that starts on Track A and continues
1841/// past a clip boundary into Track B) is **not yet** propagated
1842/// to B — only the Track containing the original `StartVibrato`
1843/// row gets armed. A follow-up that re-arms the LFO at every
1844/// clip transition will be needed to fully match the original's
1845/// modulation continuity.
1846fn attach_vibrato_lanes(
1847 module: &mut Module,
1848 song: u16,
1849 trace: &[(u32, super::runtime::TickEvent)],
1850 speed: u32,
1851) {
1852 use crate::core::daw::automation::{AutomationLane, AutomationTarget, LaneKind, LfoEvent};
1853 use crate::core::fixed::fixed::{Q15, Q8_8};
1854 use crate::core::waveform::Waveform;
1855
1856 // A vibrato is a per-CHANNEL effect, but the xmrs LFO lane is
1857 // per-Track and a held note crosses several clips/tracks (the
1858 // segment builder splits at every 64-row pattern boundary). The
1859 // old code armed only the track active at the `VibratoStart`
1860 // tick, so a sustained, looping lead note (xenon2 ch1, sample 35
1861 // loops) lost its wobble the moment playback crossed into the
1862 // next pattern's clip. Instead, project each vibrato **span**
1863 // (`Start` until the next `Start`/`Stop` on that channel) onto
1864 // *every* clip it overlaps, emitting a self-contained
1865 // `Set..Clear` pair bounded to each clip's tick window. Bounding
1866 // every span keeps it safe against `dedupe_tracks_by_content`
1867 // sharing one Track across clips/channels — a shared Track's lane
1868 // is only "armed" inside the exact windows we wrote.
1869 #[derive(Clone, Copy)]
1870 struct VibParams {
1871 speed: Q8_8,
1872 depth: Q15,
1873 }
1874 // Per-channel sorted (tick, Some(params)=Start | None=Stop).
1875 let mut vib_by_ch: [alloc::vec::Vec<(u32, Option<VibParams>)>; 4] = Default::default();
1876 for (frame, ev) in trace {
1877 let tick = (frame.saturating_sub(1) / speed) * speed;
1878 match ev {
1879 super::runtime::TickEvent::VibratoStart {
1880 channel,
1881 speed: vspd,
1882 depth,
1883 } if (*channel as usize) < 4 => {
1884 // Whittaker vibrato is a PERIOD triangle (Ghidra
1885 // `play_tick` / ref `DoFrameStuff` 2169): `VibratoValue`
1886 // ramps `0→max→0` by `vspd` each frame and the Paula
1887 // period is nudged ±VibratoValue, sign flipping every
1888 // half-cycle. Full triangle = `4·max/vspd` frames; one
1889 // LFO tick = one Whittaker frame, so cycles/tick =
1890 // `vspd/(4·max)` and the Q8.8 phase speed (raw 256 = 1
1891 // cycle/tick, `lanes.rs`) is `64·vspd/max`. (xenon2's
1892 // leads use `vspd = max/2` → raw 32, a constant ~8-
1893 // frame wobble whose depth varies.)
1894 //
1895 // Depth: peak deviation is `max` **Paula-period units**.
1896 // The player now applies the effect vibrato in period
1897 // space for Amiga modules (`update_frequency`:
1898 // `outPeriod = realPeriod ± lfo`, matching FT2/Protracker),
1899 // so stash `max` straight in the depth raw — the LFO peaks
1900 // at `depth.raw() = max` period units. (Was `max << 3`, a
1901 // semitone hack for the old pitch-space path that scaled
1902 // the wobble by the note's register — wrong by design.)
1903 // Waveform = `BipolarTriangle`: the DW vibrato modulates
1904 // the period with a symmetric triangle
1905 // (`0 → +max → 0 → −max → 0`), exactly
1906 // `Waveform::BipolarTriangle` — bipolar, swinging the
1907 // period both ways like the original.
1908 let denom = (*depth as u32).max(1);
1909 let params = VibParams {
1910 speed: Q8_8::from_raw(
1911 ((64 * *vspd as u32) / denom).clamp(1, i16::MAX as u32) as i16
1912 ),
1913 depth: Q15::from_raw((*depth as i32).clamp(0, i16::MAX as i32) as i16),
1914 };
1915 vib_by_ch[*channel as usize].push((tick, Some(params)));
1916 }
1917 super::runtime::TickEvent::VibratoStop { channel } if (*channel as usize) < 4 => {
1918 vib_by_ch[*channel as usize].push((tick, None));
1919 }
1920 _ => {}
1921 }
1922 }
1923
1924 let mut events_per_track: BTreeMap<u32, Vec<LfoEvent>> = BTreeMap::new();
1925 for ch in 0..4u8 {
1926 let evs = &mut vib_by_ch[ch as usize];
1927 if evs.is_empty() {
1928 continue;
1929 }
1930 evs.sort_by_key(|(t, _)| *t);
1931 let clips = module.clips.lane(song, ch);
1932 for (i, &(start_tick, params)) in evs.iter().enumerate() {
1933 // Only `Start`s open a span; `Stop`s just end the
1934 // previous one (handled via `span_end`).
1935 let Some(params) = params else { continue };
1936 let span_end = evs.get(i + 1).map(|(t, _)| *t).unwrap_or(u32::MAX);
1937 if span_end <= start_tick {
1938 continue; // zero-width (two events same tick)
1939 }
1940 for clip in clips {
1941 let cs = clip.position_tick.max(start_tick);
1942 let ce = clip.end_tick.min(span_end);
1943 if cs >= ce {
1944 continue;
1945 }
1946 let lane = events_per_track.entry(clip.track).or_default();
1947 lane.push(LfoEvent::Set {
1948 tick: cs,
1949 speed: params.speed,
1950 depth: params.depth,
1951 waveform: Waveform::BipolarTriangle,
1952 retrig: false,
1953 });
1954 lane.push(LfoEvent::Clear { tick: ce });
1955 }
1956 }
1957 }
1958
1959 for (track_idx, mut events) in events_per_track {
1960 if events.is_empty() {
1961 continue;
1962 }
1963 // Lane invariant: events sorted by tick ascending. At an
1964 // equal tick a `Clear` must precede a `Set` so a span that
1965 // ends exactly where the next begins (adjacent clips sharing
1966 // a Track after dedup) stays armed — the later `Set` wins in
1967 // `lfo_state_at`'s forward walk.
1968 events.sort_by_key(|e| {
1969 let (tick, is_set) = match e {
1970 LfoEvent::Set { tick, .. } => (*tick, 1u8),
1971 LfoEvent::DepthOnly { tick, .. } => (*tick, 1),
1972 LfoEvent::SpeedOnly { tick, .. } => (*tick, 1),
1973 LfoEvent::WaveformOnly { tick, .. } => (*tick, 1),
1974 LfoEvent::Clear { tick } => (*tick, 0),
1975 };
1976 (tick, is_set)
1977 });
1978 module.automation.push(
1979 AutomationLane::new_with_kind(
1980 AutomationTarget::TrackPitch(track_idx),
1981 LaneKind::Lfo { events },
1982 )
1983 .with_song(song),
1984 );
1985 }
1986}
1987
1988/// Translate `SlideStart` / `SlideStop` events in the simulator
1989/// trace into `SlideEvent::Set` / `SlideEvent::Clear` entries on
1990/// a per-Track `AutomationLane { kind: Slide }` targeting
1991/// `TrackPitch`. The active Track at the slide's tick is the one
1992/// that hosts the slide.
1993///
1994/// `dw.xenon 2` only has one `Slide` event but the conversion
1995/// infrastructure mirrors the vibrato path so future modules
1996/// with denser slide usage benefit too.
1997fn attach_slide_lanes(
1998 module: &mut Module,
1999 song: u16,
2000 trace: &[(u32, super::runtime::TickEvent)],
2001 speed: u32,
2002) {
2003 use crate::core::daw::automation::{AutomationLane, AutomationTarget, LaneKind, SlideEvent};
2004 use crate::core::fixed::fixed::Q15;
2005
2006 let mut events_per_track: BTreeMap<u32, Vec<SlideEvent>> = BTreeMap::new();
2007 // Track the lane each open slide lives on, per channel, so the
2008 // bounding `Clear` lands on the SAME `TrackPitch(n)` lane as its
2009 // `Set` — at the `SlideStop` tick a new note's clip is already
2010 // active, so resolving the track from that tick would attach the
2011 // `Clear` to the wrong lane and leave the slide lane armed.
2012 let mut open_slide_track: BTreeMap<u8, u32> = BTreeMap::new();
2013 for (frame, ev) in trace {
2014 let row = frame.saturating_sub(1) / speed;
2015 let tick = row * speed;
2016 let (ch, slide_ev) = match ev {
2017 super::runtime::TickEvent::SlideStart {
2018 channel,
2019 speed: spd,
2020 counter,
2021 } => {
2022 // Whittaker slide, read from Ghidra `play_tick`
2023 // (effect `case 0x81` arm + the per-frame apply):
2024 //
2025 // arm: SlideValue = 0; SlideSpeed = speed;
2026 // SlideCounter = counter; SlideEnabled = true
2027 // tick: if SlideCounter == 0 {
2028 // SlideValue += SlideSpeed;
2029 // period = base - SlideValue // base re-
2030 // } else { SlideCounter-- } // -read each
2031 // // frame
2032 //
2033 // `base` (the note period) is recomputed fresh every
2034 // frame, so `period - SlideValue` with `SlideValue =
2035 // k·speed` is a *LINEAR* period ramp of `-speed` units
2036 // per frame (NOT accelerating — the old `N(N+1)/2`
2037 // average was wrong), preceded by a `counter`-frame
2038 // pre-engage delay.
2039 //
2040 // The player's TrackPitch Slide lane already ACCUMULATES
2041 // (`lanes.rs`: `period = period.saturating_add_signed(
2042 // rate.raw())` every tick → `period + k·rate`), and a
2043 // player tick maps 1:1 to a DW frame here (`speed` ticks
2044 // per row = `speed` frames per row). So the faithful
2045 // mapping is `rate = -speed` (×1, period units) with the
2046 // Set delayed by `counter` ticks. The lane is told to run
2047 // on tick 0 too (`pitch_slide_ticks_at_row_zero`), so it
2048 // advances on every frame of the held note just like the
2049 // replayer — no `(speed-1)/speed` undercount. `SlideStop`
2050 // (emitted by the runtime at the next row read) bounds it
2051 // with a `Clear`. Residual: `tick` is row-quantised, so
2052 // the engage point can be off by < 1 row.
2053 let rate_raw = -(*spd as i32);
2054 let rate = Q15::from_raw(rate_raw.clamp(i16::MIN as i32, i16::MAX as i32) as i16);
2055 (
2056 *channel,
2057 SlideEvent::Set {
2058 tick: tick + *counter as u32,
2059 rate,
2060 fine: false,
2061 },
2062 )
2063 }
2064 super::runtime::TickEvent::SlideStop { channel } => {
2065 (*channel, SlideEvent::Clear { tick })
2066 }
2067 _ => continue,
2068 };
2069
2070 let target_track = match &slide_ev {
2071 SlideEvent::Set { .. } => {
2072 let t = module.clips.active_at(song, ch, tick).map(|(_, c)| c.track);
2073 if let Some(t) = t {
2074 open_slide_track.insert(ch, t);
2075 }
2076 t
2077 }
2078 SlideEvent::Clear { .. } => open_slide_track
2079 .remove(&ch)
2080 .or_else(|| module.clips.active_at(song, ch, tick).map(|(_, c)| c.track)),
2081 };
2082 if let Some(track) = target_track {
2083 events_per_track.entry(track).or_default().push(slide_ev);
2084 }
2085 }
2086
2087 for (track_idx, mut events) in events_per_track {
2088 if events.is_empty() {
2089 continue;
2090 }
2091 events.sort_by_key(|e| match e {
2092 SlideEvent::Set { tick, .. } => *tick,
2093 SlideEvent::Clear { tick } => *tick,
2094 });
2095 module.automation.push(
2096 AutomationLane::new_with_kind(
2097 AutomationTarget::TrackPitch(track_idx),
2098 LaneKind::Slide { events },
2099 )
2100 .with_song(song),
2101 );
2102 }
2103}
2104
2105/// Translate the simulator's per-frame [`TickEvent::Arpeggio`] stream
2106/// into a per-Track `AutomationLane { kind: Points }` of
2107/// [`AutomationValue::Pitch`] points on `TrackPitch` — the faithful
2108/// projection of the replayer's per-channel arpeggio pointer.
2109///
2110/// The replayer advances the pointer one offset per **frame** (not per
2111/// row) and adds the offset to the note's period-table *index*, so the
2112/// points must land at frame resolution. A DW frame maps 1:1 to a
2113/// player tick (`speed` ticks per row = `speed` frames per row), and
2114/// the player's absolute tick at frame `f` is `f - 1`; place each point
2115/// there. The runtime already emits only on *change* (and a `0` when a
2116/// note ends or no arpeggio is armed), and the lane latches a value
2117/// forward, so the point set is the minimal change-stream. The active
2118/// clip at the point's tick supplies the host Track (its content is
2119/// what carries the arpeggio commands).
2120///
2121/// [`TickEvent::Arpeggio`]: super::runtime::TickEvent::Arpeggio
2122fn attach_arpeggio_pitch_lanes(
2123 module: &mut Module,
2124 song: u16,
2125 trace: &[(u32, super::runtime::TickEvent)],
2126) {
2127 use crate::core::daw::automation::{
2128 AutomationLane, AutomationPoint, AutomationTarget, AutomationValue, LaneKind,
2129 };
2130 use crate::core::fixed::units::PitchDelta;
2131
2132 // Per-Track tick→offset. A BTreeMap keyed by tick deduplicates the
2133 // rare case of two channels sharing one deduped Track at the same
2134 // tick (identical content → identical offset, so the survivor is
2135 // correct); it also keeps the points sorted for free.
2136 let mut by_track: BTreeMap<u32, BTreeMap<u32, i8>> = BTreeMap::new();
2137 for (frame, ev) in trace {
2138 let super::runtime::TickEvent::Arpeggio { channel, semitones } = ev else {
2139 continue;
2140 };
2141 let tick = frame.saturating_sub(1);
2142 let Some((_, clip)) = module.clips.active_at(song, *channel, tick) else {
2143 continue;
2144 };
2145 by_track
2146 .entry(clip.track)
2147 .or_default()
2148 .insert(tick, *semitones);
2149 }
2150
2151 for (track_idx, offsets) in by_track {
2152 if offsets.is_empty() {
2153 continue;
2154 }
2155 let points: Vec<AutomationPoint> = offsets
2156 .into_iter()
2157 .map(|(tick, semis)| AutomationPoint {
2158 tick,
2159 value: AutomationValue::Pitch(PitchDelta::from_semitones(semis as i16)),
2160 })
2161 .collect();
2162 module.automation.push(
2163 AutomationLane::new_with_kind(
2164 AutomationTarget::TrackPitch(track_idx),
2165 LaneKind::Points(points),
2166 )
2167 .with_song(song),
2168 );
2169 }
2170}
2171
2172/// Read a generous **raw window** of one track byte stream starting
2173/// at `start`, capped at [`TRACK_BYTE_CAP`] bytes (or end-of-file).
2174///
2175/// This deliberately does NOT try to find the track's `EndOfTrack`
2176/// terminator or account for per-command parameter widths: that
2177/// knowledge lives in exactly one place,
2178/// [`super::event::decode_track_counted`], which the caller
2179/// ([`DwModule::parse_tracks`]) runs over this window and then uses
2180/// to trim the buffer to the real consumed span. Keeping the
2181/// parameter-width table out of here means the byte delimiter and
2182/// the event decoder can never disagree (they used to: the old
2183/// hand-rolled table here assumed `Effect9` took 1 byte and was
2184/// blind to the jump-table command remap, so a parameter byte equal
2185/// to `0x80` could truncate a track early).
2186fn read_track_bytes(source: &[u8], start: usize) -> Vec<u8> {
2187 if start == 0 || start >= source.len() {
2188 return Vec::new();
2189 }
2190 let end = (start + TRACK_BYTE_CAP).min(source.len());
2191 source[start..end].to_vec()
2192}
2193
2194/// Defensive cap on a single track's byte span — well past any real
2195/// Whittaker track, but bounds a misaligned `start` from scanning
2196/// off into the rest of the image.
2197const TRACK_BYTE_CAP: usize = 0x800;
2198
2199/// Walk a position list starting at `start`. Reads
2200/// `u16` BE entries until either:
2201///
2202/// - a `0x0000` terminator (plain end-of-list);
2203/// - a value with bit 15 set (loop back to `value & 0x7FFF` —
2204/// stored separately in [`DwPositionList::loop_to`]).
2205///
2206/// Returns an empty list when `start` is out of bounds or no
2207/// terminator is reached within a defensive cap (the replayer
2208/// itself doesn't bound list length, but we do — otherwise a
2209/// misaligned `start` could chase u16s for the rest of the file).
2210fn read_position_list(
2211 source: &[u8],
2212 start: usize,
2213 uses_32bit: bool,
2214 start_offset: isize,
2215) -> DwPositionList {
2216 const MAX_ENTRIES: usize = 1024;
2217
2218 let mut list = DwPositionList::default();
2219 let width = if uses_32bit { 4 } else { 2 };
2220 // `start` and every stored track offset are A3-relative; rebase
2221 // through `start_offset` to obtain real file positions. Entries
2222 // are stored already-rebased (file offsets) so downstream track
2223 // parsing can use them directly. `rebase` returns `None` when a
2224 // value falls outside the file (corrupt / sentinel).
2225 let rebase = |v: u32| -> Option<usize> {
2226 let f = v as isize + start_offset;
2227 (f >= 0 && (f as usize) < source.len()).then_some(f as usize)
2228 };
2229 let Some(mut cursor) = rebase(start as u32) else {
2230 return list;
2231 };
2232 if start == 0 || cursor + width > source.len() {
2233 return list;
2234 }
2235 let loop_mask: u32 = if uses_32bit { 0x8000_0000 } else { 0x0000_8000 };
2236 let value_mask: u32 = !loop_mask;
2237 for _ in 0..MAX_ENTRIES {
2238 if cursor + width > source.len() {
2239 break;
2240 }
2241 let raw: u32 = if uses_32bit {
2242 u32::from_be_bytes([
2243 source[cursor],
2244 source[cursor + 1],
2245 source[cursor + 2],
2246 source[cursor + 3],
2247 ])
2248 } else {
2249 u16::from_be_bytes([source[cursor], source[cursor + 1]]) as u32
2250 };
2251 cursor += width;
2252 if raw == 0 {
2253 // Ghidra `play_tick` case 0x80 (EndOfTrack): a `0` entry
2254 // is the list terminator and resets the channel to entry
2255 // 0 (`RestartPosition + 1` with the default restart 0 in
2256 // `HandleEndOfTrackEffect`). So a plain-`0`-terminated
2257 // list LOOPS to its start, it does not fall silent — the
2258 // earlier `loop_to = None` here made every such channel
2259 // finish after one pass (xenon2 played once then stopped).
2260 // The Effect9-set `RestartPosition` refinement (a mid-list
2261 // jump target) isn't modelled yet; the corpus modules seen
2262 // so far restart at 0.
2263 list.loop_to = Some(0);
2264 break;
2265 }
2266 if raw & loop_mask != 0 {
2267 // The on-disk value (with the loop-flag bit cleared)
2268 // is the **track offset** to resume from — Whittaker
2269 // encodes the loop target the same way as a regular
2270 // entry, just with the MSB set as a sentinel. The
2271 // runtime expects a 0-based **index** into
2272 // [`Self::entries`], so resolve the target by scanning
2273 // for the first matching entry. When no entry matches
2274 // (rare — usually means the loop points one past the
2275 // last entry, restarting from the top) we fall back
2276 // to position 0.
2277 // Rebase the loop target into the same (file-offset)
2278 // space as `entries` before resolving it to an index.
2279 let target_offset = rebase(raw & value_mask).unwrap_or(0) as u32;
2280 let target_index = list
2281 .entries
2282 .iter()
2283 .position(|&e| e == target_offset)
2284 .unwrap_or(0) as u32;
2285 list.loop_to = Some(target_index);
2286 break;
2287 }
2288 // Store the track offset already rebased to a file offset so
2289 // downstream track parsing can dereference it directly. A
2290 // value that rebases out of range terminates the list.
2291 let Some(file_off) = rebase(raw) else {
2292 break;
2293 };
2294 list.entries.push(file_off as u32);
2295 }
2296 list
2297}
2298
2299/// Resolve each channel's real loop target by following a `SeqPtr`
2300/// (`cmd 0x89`) in its **intro** (first) track.
2301///
2302/// The new player starts a channel on its `channel_position_offset`
2303/// array, but a track ending `SeqPtr(X) ; SeqAdvance` (cmd `0x89`,
2304/// Ghidra `Play` case 0x89: `chan+0x600 = X ; index = 0`) redirects the
2305/// sequencer to the sub-sequence at `X`, which then loops on *itself* —
2306/// so the intro plays ONCE and the channel loops the sub-sequence, never
2307/// back to the intro. [`read_position_list`] can't see this: it stops at
2308/// the initial array's own terminator and loops to entry 0, making the
2309/// channel re-play its intro every cycle. On bad company all of
2310/// ch0/1/3 are intro-`SeqPtr` channels (e.g. ch0 intro `…89 08 94 80` →
2311/// sub-sequence `0x894 = [body, loop]`), so the importer's wrap-to-intro
2312/// diverged from the oracle the moment its loop fired (~t560: it replays
2313/// the slid intro note while the oracle is mid-body). Here we decode the
2314/// intro, and if it sets a `SeqPtr(X)`, rebuild the list as
2315/// `[intro] ++ sub-sequence(X)` with the loop pointing inside the
2316/// sub-sequence (`sub.loop_to + 1`, past the intro). A no-op for
2317/// channels whose intro sets no `SeqPtr`.
2318fn follow_seq_ptr_loops(
2319 source: &[u8],
2320 lists: &mut [DwPositionList; DW_NUM_CHANNELS],
2321 dispatcher: &super::detect::DwDispatcher,
2322 features: &super::detect::DwFeatures,
2323 command_map: Option<&super::command_map::DwCommandMap>,
2324 uses_32bit: bool,
2325 start_offset: isize,
2326) {
2327 // `lists[ch]` is both read at the top and reassigned at the bottom
2328 // of the body; an `iter_mut()` rewrite would fight the borrow on the
2329 // intervening `read_position_list` reads.
2330 #[allow(clippy::needless_range_loop)]
2331 for ch in 0..DW_NUM_CHANNELS {
2332 let Some(&intro_off) = lists[ch].entries.first() else {
2333 continue;
2334 };
2335 let bytes = read_track_bytes(source, intro_off as usize);
2336 let (events, _) =
2337 super::event::decode_track_counted(&bytes, dispatcher, features, command_map);
2338 let Some(x_raw) = events.iter().find_map(|e| match e {
2339 super::event::DwTrackEvent::SeqPtr(x) => Some(*x),
2340 _ => None,
2341 }) else {
2342 continue;
2343 };
2344 let sub = read_position_list(source, x_raw as usize, uses_32bit, start_offset);
2345 if sub.entries.is_empty() {
2346 continue;
2347 }
2348 let mut entries = alloc::vec![intro_off];
2349 entries.extend_from_slice(&sub.entries);
2350 lists[ch] = DwPositionList {
2351 entries,
2352 loop_to: Some(sub.loop_to.unwrap_or(0) + 1),
2353 };
2354 }
2355}
2356
2357/// Walk every (already-placed) NoteOn cell in `rows` and emit
2358/// ghost `TrackEffect::Volume` cells between triggers, reflecting
2359/// the envelope's current value at each row's frame.
2360///
2361/// Whittaker's envelope ticks at `Play+0x412`:
2362///
2363/// ```text
2364/// SUBQ.B #1, chan[+0x2B] ; decrement counter
2365/// BCC.B skip ; if no underflow, hold
2366/// MOVE.B chan[+0x2A], chan[+0x2B] ; reset counter
2367/// MOVEA.L chan[+0x26], A2 ; fetch advance pointer
2368/// MOVE.B (A2)+, D1 ; consume next byte
2369/// BMI.B sustain ; if byte >= 0x80, freeze
2370/// MOVE.L A2, chan[+0x26] ; else advance
2371/// ANDI.W #0x7F, D1 ; mask sustain bit
2372/// ; Paula AUD0VOL ← (D1 * global_vol) >> 6
2373/// ```
2374///
2375/// so step `k` covers frames `k*(step_interval+1) ..
2376/// (k+1)*(step_interval+1) - 1` from the note trigger, with the
2377/// last step's value held forever (sustain bit). Mapping that to
2378/// row resolution: `step = ((row - trigger_row) * speed) /
2379/// (step_interval + 1)`. The function compares each row's value
2380/// against the previous emitted volume and only writes a ghost
2381/// cell when the value actually changes — keeps the cell count
2382/// down and matches the xmrs runtime semantic (last
2383/// `TrackEffect::Volume` persists on the channel until
2384/// overwritten).
2385fn attach_envelope_animation(
2386 rows: &mut [Cell],
2387 notes: &[(usize, i16, Option<u8>, Option<u16>)],
2388 seg_start: usize,
2389 speed: u32,
2390 envelopes: &[super::header::DwVolumeEnvelope],
2391 master_volume: Option<u16>,
2392) {
2393 // Empire-family global master scale (`× master / 64`); identity
2394 // elsewhere. Mirrors `DwModule::scale_master_volume` so the
2395 // envelope-animation ghost cells track the same Paula level the
2396 // replayer's per-tick `MULU master ; LSR #6` produces.
2397 let scale = |v: u8| -> u8 {
2398 match master_volume {
2399 Some(master) if master != 64 => (((v as u32) * (master as u32)) >> 6).min(64) as u8,
2400 _ => v,
2401 }
2402 };
2403 if notes.is_empty() || rows.is_empty() {
2404 return;
2405 }
2406 let seg_end = seg_start + rows.len() - 1;
2407 let speed = speed.max(1);
2408 for (i, &(note_abs_row, _, _peak, env_idx)) in notes.iter().enumerate() {
2409 let Some(env_idx) = env_idx else { continue };
2410 let Some(env) = envelopes.get(env_idx as usize) else {
2411 continue;
2412 };
2413 if env.steps.is_empty() {
2414 continue;
2415 }
2416 // Compute the note's lifetime within this segment:
2417 // [note_abs_row, next_note_abs_row) intersected with the
2418 // segment's row span. The last note in a segment runs to
2419 // `seg_end + 1` so the sustain volume reaches the
2420 // segment's final row.
2421 let next_abs_row = notes
2422 .get(i + 1)
2423 .map(|&(r, _, _, _)| r)
2424 .unwrap_or(seg_end + 1);
2425 if note_abs_row > seg_end {
2426 continue;
2427 }
2428 let lifetime_end = next_abs_row.min(seg_end + 1);
2429 // The trigger row's volume is already set by the NoteOn
2430 // cell to the envelope's first step (`initial()`, matching
2431 // the runtime); the animation writes ghost cells starting
2432 // at row `note_abs_row + 1`. Track `prev_paula` against
2433 // that same first step (`step_for_row(note_abs_row)` =
2434 // `steps[0]`) so the first ghost only fires once the
2435 // envelope actually advances past step 0 — the on-disk
2436 // semantic at trigger time is "Paula gets `env[0]`".
2437 let step_for_row = |r: usize| -> u8 {
2438 let frames = (r - note_abs_row) as u32 * speed;
2439 let step_idx = (frames / (env.step_interval as u32 + 1)) as usize;
2440 env.steps[step_idx.min(env.steps.len() - 1)]
2441 };
2442 let mut prev_paula: u8 = step_for_row(note_abs_row);
2443 for r in (note_abs_row + 1)..lifetime_end {
2444 let paula = step_for_row(r);
2445 if paula == prev_paula {
2446 continue;
2447 }
2448 prev_paula = paula;
2449 let rel = r - seg_start;
2450 if rel >= rows.len() {
2451 break;
2452 }
2453 // Only overwrite empty cells — if a later note
2454 // already placed a NoteOn at this row, that trigger
2455 // wins (the envelope for the new note will rearm
2456 // from scratch).
2457 if !matches!(rows[rel].event, CellEvent::None) {
2458 continue;
2459 }
2460 let velocity = Volume::from_byte_64(scale(paula.min(64)));
2461 rows[rel] = Cell {
2462 event: CellEvent::None,
2463 effects: vec![TrackEffect::Volume {
2464 value: velocity,
2465 tick: 0,
2466 }],
2467 expression: crate::core::cell::NoteExpression::NEUTRAL,
2468 };
2469 }
2470 }
2471}
2472
2473/// A `(channel, sample)` segment produced by [`build_segments`].
2474/// Each segment becomes one xmrs `Track::Notes` + one `Clip`.
2475#[derive(Debug)]
2476struct ChannelSegment {
2477 /// First absolute song row this segment covers (inclusive).
2478 start_row: usize,
2479 /// Last absolute song row this segment covers (inclusive).
2480 end_row: usize,
2481 /// Sample index every `NoteOn` in this segment uses — the base
2482 /// for the `Track::Notes::instrument` value (a per-`(sample,
2483 /// arpeggio)` synthetic instrument when [`Self::arp_index`] is
2484 /// set, else the plain per-sample instrument).
2485 sample_index: usize,
2486 /// Pitch arpeggio armed across this segment, when on the
2487 /// pitch-envelope path. `None` everywhere else (and on segments
2488 /// with no arpeggio). A change in this value opens a new segment
2489 /// so each run gets the right synthetic instrument.
2490 arp_index: Option<u16>,
2491 /// Volume envelope armed across this segment, when baking the
2492 /// volume envelope per-tick on the synthetic instrument
2493 /// (`split_by_env`). `None` everywhere else — there the volume is
2494 /// animated via row cells (`attach_envelope_animation`). A change
2495 /// opens a new segment (different instrument).
2496 env_index: Option<u16>,
2497 /// Absolute-row note events captured during the run. The
2498 /// builder converts them into relative `Cell` positions at
2499 /// the segment's `start_row`. Each tuple is
2500 /// `(absolute_row, effective_note, paula_volume_peak,
2501 /// envelope_index)` where `effective_note` already has
2502 /// global + per-channel transposes applied (so it can be
2503 /// `< 0` or `> 127` near the period-table edges; the pitch
2504 /// builder clamps). `paula_volume = None` means "no envelope
2505 /// armed at trigger time" — the Cell falls back to
2506 /// `Volume::FULL`. `envelope_index` (when `Some`) lets the
2507 /// cell builder walk the full per-tick step sequence and
2508 /// emit ghost `TrackEffect::Volume` cells between triggers.
2509 notes: Vec<(usize, i16, Option<u8>, Option<u16>)>,
2510}
2511
2512/// Rows per synthesized pattern. xmrs's `Module::row_at` resolves
2513/// a cell via `track.rows[pattern_row - source_start_row]`, so each
2514/// clip must stay strictly inside one pattern — clips that span a
2515/// pattern boundary are silently skipped past the boundary. Setting
2516/// the value to a tracker-conventional 64 keeps the debug dump
2517/// readable while letting the splitter below fit every segment into
2518/// one pattern.
2519const ROWS_PER_PATTERN: u32 = 64;
2520
2521/// Split each segment whose row range crosses a pattern boundary
2522/// into one segment per pattern it touches. Each output segment
2523/// fits entirely within one pattern, with its notes filtered to
2524/// that pattern's row range. The sub-segments inherit the parent's
2525/// `sample_index` so the dedup pass downstream can still fuse
2526/// identical contiguous chunks.
2527fn split_at_pattern_boundaries(segs: Vec<ChannelSegment>) -> Vec<ChannelSegment> {
2528 let rpp = ROWS_PER_PATTERN as usize;
2529 let mut out: Vec<ChannelSegment> = Vec::with_capacity(segs.len());
2530 for seg in segs {
2531 let mut start = seg.start_row;
2532 while start <= seg.end_row {
2533 let pat = start / rpp;
2534 let pat_last = ((pat + 1) * rpp).saturating_sub(1);
2535 let chunk_end = pat_last.min(seg.end_row);
2536 let notes_in_chunk: Vec<(usize, i16, Option<u8>, Option<u16>)> = seg
2537 .notes
2538 .iter()
2539 .filter(|(r, _, _, _)| *r >= start && *r <= chunk_end)
2540 .copied()
2541 .collect();
2542 out.push(ChannelSegment {
2543 start_row: start,
2544 end_row: chunk_end,
2545 sample_index: seg.sample_index,
2546 arp_index: seg.arp_index,
2547 env_index: seg.env_index,
2548 notes: notes_in_chunk,
2549 });
2550 start = chunk_end + 1;
2551 }
2552 }
2553 out
2554}
2555
2556/// Walk the per-channel `(row, note, sample)` stream and split
2557/// it at every sample change. Returns a contiguous, non-overlapping
2558/// sequence of segments covering `0..total_rows`. An empty input
2559/// yields an empty result — channels with no `NoteOn` events
2560/// produce no segments (the caller can choose whether to emit an
2561/// empty stub or skip them entirely; we skip).
2562fn build_segments(notes: &[NoteRow], total_rows: usize, split_by_env: bool) -> Vec<ChannelSegment> {
2563 let mut out: Vec<ChannelSegment> = Vec::new();
2564 if notes.is_empty() || total_rows == 0 {
2565 return out;
2566 }
2567 // When `split_by_env`, the volume envelope is baked per-tick on a
2568 // synthetic instrument, so a change in the armed envelope must open
2569 // a new segment (different instrument). When false (every non-
2570 // jump-table module), the envelope is animated via row cells and is
2571 // NOT a segmentation key — `seg_env` stays `None` so segmentation is
2572 // byte-for-byte unchanged.
2573 let key_env = |e: Option<u16>| if split_by_env { e } else { None };
2574 let mut seg_start = 0usize;
2575 let mut seg_sample = notes[0].2;
2576 let mut seg_arp = notes[0].5;
2577 let mut seg_env = key_env(notes[0].4);
2578 let mut buf: Vec<(usize, i16, Option<u8>, Option<u16>)> = Vec::new();
2579
2580 for (i, &(row, note_byte, sample, volume, env_idx, arp)) in notes.iter().enumerate() {
2581 if i > 0 && (sample != seg_sample || arp != seg_arp || key_env(env_idx) != seg_env) {
2582 // Close the running segment one row before the sample /
2583 // arpeggio / envelope change, then start a fresh one.
2584 let end_row = row.saturating_sub(1).max(seg_start);
2585 out.push(ChannelSegment {
2586 start_row: seg_start,
2587 end_row,
2588 sample_index: seg_sample,
2589 arp_index: seg_arp,
2590 env_index: seg_env,
2591 notes: core::mem::take(&mut buf),
2592 });
2593 seg_start = row;
2594 seg_sample = sample;
2595 seg_arp = arp;
2596 seg_env = key_env(env_idx);
2597 }
2598 buf.push((row, note_byte, volume, env_idx));
2599 }
2600
2601 // Trailing segment runs to the end of the song.
2602 out.push(ChannelSegment {
2603 start_row: seg_start,
2604 end_row: total_rows.saturating_sub(1).max(seg_start),
2605 sample_index: seg_sample,
2606 arp_index: seg_arp,
2607 env_index: seg_env,
2608 notes: buf,
2609 });
2610 out
2611}
2612
2613fn open_reader(source: &[u8], offset: usize) -> Result<BinReader<'_>, ImportError> {
2614 if offset >= source.len() {
2615 return Err(ImportError::OutOfRange("dw_table_offset"));
2616 }
2617 Ok(BinReader::new(&source[offset..]))
2618}
2619
2620/// Read the `loop_start` field for sample `idx` from the on-disk
2621/// sample-info table. Each row is 12 bytes; `loop_start` is a
2622/// big-endian signed i32 at byte offset +4. Returns `None` for
2623/// negative sentinels or out-of-bounds rows.
2624/// Drop dead duplicate Lfo/Slide/Glide automation lanes — the RFC §1B
2625/// invariant that an importer emits at most one lane per
2626/// `(target, kind)`. The DW importer attaches vibrato/slide lanes
2627/// *per sub-song*, so a Track that plays in more than one sub-song
2628/// accumulates several lanes on the same `TrackPitch(track)` target.
2629/// The player only ever read the **first**: its `find_map` over
2630/// `lanes_for(target)` returns lane[0], and an Lfo/Slide/Glide lane
2631/// always yields `Some` state, so later same-kind lanes were
2632/// unreachable. Removing them is therefore bit-identical, and it
2633/// restores the single-lane invariant that lets the §1B summed fold
2634/// treat any *remaining* multiplicity as deliberate (authored)
2635/// modulator stacking rather than an import artifact.
2636///
2637/// `Points` lanes are deliberately left intact: the player **sums**
2638/// them (e.g. the DW per-frame arpeggio transpose in
2639/// `track_pitch_points_delta`), so they are not "dead duplicates".
2640fn collapse_dead_duplicate_modulation_lanes(module: &mut Module) {
2641 use crate::core::daw::automation::{AutomationTarget, LaneKind};
2642 let mut seen: Vec<(AutomationTarget, u8)> = Vec::new();
2643 module.automation.retain(|l| {
2644 let disc: u8 = match &l.kind {
2645 LaneKind::Points(_) => return true,
2646 LaneKind::Lfo { .. } => 1,
2647 LaneKind::Slide { .. } => 2,
2648 LaneKind::Glide { .. } => 3,
2649 };
2650 let key = (l.target, disc);
2651 if seen.contains(&key) {
2652 false
2653 } else {
2654 seen.push(key);
2655 true
2656 }
2657 });
2658}
2659
2660/// Intern a freshly-decoded PCM buffer into the shared pool (RFC §1A):
2661/// return an existing `Arc<[i8]>` when the exact same bytes were seen
2662/// before, otherwise store and return a new one. Bucketed by FNV-1a
2663/// with a byte-exact comparison inside the bucket, so the dedup is
2664/// content-correct and collision-proof — never merely hash-equal.
2665fn dedup_pcm(pool: &mut BTreeMap<u64, Vec<Arc<[i8]>>>, pcm: Vec<i8>) -> Arc<[i8]> {
2666 let mut h: u64 = 0xcbf29ce484222325;
2667 for &b in &pcm {
2668 h ^= b as u8 as u64;
2669 h = h.wrapping_mul(0x100000001b3);
2670 }
2671 let bucket = pool.entry(h).or_default();
2672 if let Some(existing) = bucket.iter().find(|a| a.as_ref() == pcm.as_slice()) {
2673 return Arc::clone(existing);
2674 }
2675 let arc: Arc<[i8]> = Arc::from(pcm);
2676 bucket.push(Arc::clone(&arc));
2677 arc
2678}
2679
2680fn read_loop_start(source: &[u8], base: usize, idx: usize, stride: usize) -> Option<u32> {
2681 let row = base.checked_add(idx.checked_mul(stride)?)?;
2682 let field = row.checked_add(4)?;
2683 let bytes: [u8; 4] = source.get(field..field + 4)?.try_into().ok()?;
2684 let v = i32::from_be_bytes(bytes);
2685 if v >= 0 {
2686 Some(v as u32)
2687 } else {
2688 None
2689 }
2690}
2691
2692/// Promote a single Whittaker sample into an [`Instrument`] with
2693/// one [`Sample`] slot. The instrument is named after its source
2694/// index so editors can tell them apart at a glance.
2695///
2696/// `helper` is the [`PeriodHelper`] of the parent module — used
2697/// to convert the sample's native Hz into the
2698/// `(relative_pitch, finetune)` pair that xmrs's runtime
2699/// expects. Reusing the canonical helper instead of an ad-hoc
2700/// `f32::log2` keeps the conversion bit-identical to every other
2701/// importer in the crate.
2702fn sample_to_instrument(s: &DwSample) -> Instrument {
2703 let loop_flag = if s.is_looping() {
2704 LoopType::Forward
2705 } else {
2706 LoopType::No
2707 };
2708 // `relative_pitch = 0` for every variant: the cell pitch is
2709 // mapped *period-exactly* in `to_module::note_to_pitch` (the
2710 // Paula period — including the new player's
2711 // `× 0x369E99/freq >> 10` finetune — is reproduced and
2712 // converted straight to a pitch). The sample must therefore
2713 // play at the literal Amiga rate `3_546_894 / period` with no
2714 // extra shift; any non-zero `relative_pitch` would double-count
2715 // the header frequency and detune the result.
2716 let rp = 0;
2717
2718 let sample = Sample {
2719 name: format!("dw#{:02}", s.index),
2720 relative_pitch: rp,
2721 finetune: Finetune::ZERO,
2722 volume: ChannelVolume::from_byte_64(s.volume.min(64) as u8),
2723 default_note_volume: Volume::FULL,
2724 panning: Panning::CENTER,
2725 loop_flag,
2726 loop_start: s.loop_start.unwrap_or(0),
2727 loop_length: s.loop_length().unwrap_or(0),
2728 sustain_loop_flag: LoopType::No,
2729 sustain_loop_start: 0,
2730 sustain_loop_length: 0,
2731 data: Some(SampleDataType::Mono8(s.pcm.clone())),
2732 };
2733
2734 let mut instr = InstrDefault::default();
2735 instr.sample = vec![Some(sample)];
2736 // Without this the playback runtime gets `None` from
2737 // `keyboard.sample_for_pitch[pitch]` for every NoteOn and
2738 // silently drops the trigger — same behaviour the Amiga MOD
2739 // importer needs (see `amiga_module.rs`, the `map_all_to(0)`
2740 // call right after the sample is wired into the instrument).
2741 instr.keyboard.map_all_to(0);
2742
2743 Instrument {
2744 name: format!("Whittaker sample {} ({} Hz)", s.index, s.frequency),
2745 instr_type: InstrumentType::Default(instr),
2746 ..Default::default()
2747 }
2748}
2749
2750/// Build a looping **pitch envelope** that reproduces a David
2751/// Whittaker per-tick arpeggio (an offset stream read one entry
2752/// per frame, wrapping at its terminator). Here 1 DW frame = 1
2753/// player tick, and the player walks one envelope point per tick,
2754/// so the envelope *is* the arpeggio:
2755///
2756/// - `point[0]` = the note's plain pitch (offset 0): the replayer's
2757/// trigger frame applies no arpeggio, only the sustain frames do.
2758/// - `point[1..=N]` = the cycle, **looped forever** (`loop` over
2759/// `[1, N]`, skipping the one-shot trigger frame). The arpeggio
2760/// therefore runs for the note's whole duration — unlike
2761/// `TrackEffect::Arpeggio`, which only lives on the trigger row
2762/// and is capped at 3 steps / `0..15` semitones.
2763///
2764/// Offset `o` semitones is encoded exactly as
2765/// `EnvValue::from_signed_byte_64(2·o)` — the player's
2766/// `get_pitch_envelope_offset` maps an envelope byte back to
2767/// `byte/2` semitones — so the representable range is ±16
2768/// semitones; a wider step (e.g. the `+36` in one bubble-bobble
2769/// arpeggio) saturates there. Returns `None` for a trivial
2770/// (empty / all-zero) arpeggio, which keeps the plain per-sample
2771/// instrument.
2772fn arpeggio_to_pitch_envelope(arp: &DwArpeggio) -> Option<Envelope> {
2773 if arp.offsets.is_empty() || arp.offsets.iter().all(|&o| o == 0) {
2774 return None;
2775 }
2776 let enc = |o: i8| -> EnvValue {
2777 EnvValue::from_signed_byte_64(((o as i16) * 2).clamp(-32, 32) as i8)
2778 };
2779 let mut point = Vec::with_capacity(arp.offsets.len() + 1);
2780 point.push(EnvelopePoint {
2781 frame: 0,
2782 value: enc(0),
2783 });
2784 for (k, &o) in arp.offsets.iter().enumerate() {
2785 point.push(EnvelopePoint {
2786 frame: k + 1,
2787 value: enc(o),
2788 });
2789 }
2790 let last = point.len() - 1;
2791 // The cycle must run while the note is HELD. The player's
2792 // envelope tick uses the **sustain** loop while `sustained`
2793 // (and only falls back to the plain loop once released), so a
2794 // held DW note that never keys off would otherwise walk to the
2795 // last point and freeze there. Arm the sustain loop over the
2796 // cycle `[1, last]` (frame 0 = the one-shot trigger base);
2797 // mirror it on the plain loop so a released note keeps cycling
2798 // too.
2799 Some(Envelope {
2800 enabled: true,
2801 point,
2802 sustain_enabled: true,
2803 sustain_start_point: 1,
2804 sustain_end_point: last,
2805 loop_enabled: true,
2806 loop_start_point: 1,
2807 loop_end_point: last,
2808 })
2809}
2810
2811/// Build a per-tick **volume envelope** reproducing a David Whittaker
2812/// volume envelope (`step_interval` frames between advances, then the
2813/// `steps` values, the last held as the sustain level). The replayer
2814/// advances the channel volume every `step_interval + 1` frames; the
2815/// row-grid `attach_envelope_animation` could only sample that on the
2816/// 3-frame row boundaries, aliasing the decay (skipping steps,
2817/// landing too loud — the audible "violent" volume). Here 1 frame =
2818/// 1 player tick, so a point per frame reproduces the staircase
2819/// exactly. A **sustain loop on the last point** holds the sustain
2820/// level while the note is held (without it, walking past the last
2821/// point arms the fadeout and the voice decays to silence).
2822///
2823/// `master` applies the empire-family `× master / 64` scale (identity
2824/// elsewhere). Returns `None` for an empty envelope (no shaping).
2825fn volume_to_volume_envelope(env: &DwVolumeEnvelope, master: Option<u16>) -> Option<Envelope> {
2826 if env.steps.is_empty() {
2827 return None;
2828 }
2829 let scale = |v: u8| -> u8 {
2830 match master {
2831 Some(m) if m != 64 => (((v as u32) * (m as u32)) >> 6).min(64) as u8,
2832 _ => v.min(64),
2833 }
2834 };
2835 let si = env.step_interval as usize + 1; // frames per step
2836 // One point per frame over the decay (cap defends against a
2837 // pathological step count); the tail holds via the sustain loop.
2838 let total = (env.steps.len() * si).clamp(1, 256);
2839 let mut point = Vec::with_capacity(total);
2840 for f in 0..total {
2841 let k = (f / si).min(env.steps.len() - 1);
2842 point.push(EnvelopePoint {
2843 frame: f,
2844 value: EnvValue::from_byte_64(scale(env.steps[k])),
2845 });
2846 }
2847 let last = point.len() - 1;
2848 Some(Envelope {
2849 enabled: true,
2850 point,
2851 // Hold the last (sustain) step while the note is held.
2852 sustain_enabled: true,
2853 sustain_start_point: last,
2854 sustain_end_point: last,
2855 loop_enabled: false,
2856 loop_start_point: 0,
2857 loop_end_point: 0,
2858 })
2859}
2860
2861/// Resolve the `Track::Notes::instrument` index for a `(sample,
2862/// arpeggio, volume-envelope)` triple. Absent/trivial arpeggio AND
2863/// envelope reuse the plain per-sample instrument at `sample_index`.
2864/// Otherwise lazily build a synthetic instrument carrying the
2865/// arpeggio's **pitch** envelope and/or the DW **volume** envelope
2866/// (per-tick, frame-exact), caching one per distinct triple actually
2867/// used. When a volume envelope is attached the sample's static
2868/// volume is forced to full scale so the envelope alone shapes the
2869/// loudness (matching the replayer, whose channel volume *is* the
2870/// envelope byte).
2871#[allow(clippy::too_many_arguments)] // resolves one note's full (sample, arp, env, master) context
2872fn resolve_synth_instrument(
2873 instruments: &mut Vec<Instrument>,
2874 cache: &mut BTreeMap<(usize, Option<u16>, Option<u16>), usize>,
2875 samples: &[DwSample],
2876 arpeggios: &[DwArpeggio],
2877 volume_envelopes: &[DwVolumeEnvelope],
2878 master_volume: Option<u16>,
2879 sample_index: usize,
2880 arp_index: Option<u16>,
2881 env_index: Option<u16>,
2882) -> usize {
2883 let base = sample_index.min(samples.len().saturating_sub(1));
2884 let pitch_env = arp_index
2885 .and_then(|a| arpeggios.get(a as usize))
2886 .and_then(arpeggio_to_pitch_envelope);
2887 let vol_env = env_index
2888 .and_then(|e| volume_envelopes.get(e as usize))
2889 .and_then(|e| volume_to_volume_envelope(e, master_volume));
2890 if pitch_env.is_none() && vol_env.is_none() {
2891 return base;
2892 }
2893 let key = (
2894 base,
2895 arp_index.filter(|_| pitch_env.is_some()),
2896 env_index.filter(|_| vol_env.is_some()),
2897 );
2898 if let Some(&idx) = cache.get(&key) {
2899 return idx;
2900 }
2901 // `Instrument` isn't `Clone`, so rebuild the base from its sample.
2902 let Some(s) = samples.get(base) else {
2903 return base;
2904 };
2905 let mut instr = sample_to_instrument(s);
2906 if let InstrumentType::Default(ref mut d) = instr.instr_type {
2907 if let Some(pe) = pitch_env {
2908 d.voice.pitch_envelope = pe;
2909 d.voice.pitch_envelope_as_low_pass_filter = false;
2910 }
2911 if let Some(ve) = vol_env {
2912 d.voice.volume_envelope = ve;
2913 // The envelope carries the full Paula loudness, so the
2914 // sample's own static volume must not also scale it.
2915 if let Some(Some(smp)) = d.sample.get_mut(0) {
2916 smp.volume = ChannelVolume::from_byte_64(64);
2917 }
2918 }
2919 }
2920 // Append a readable tag for whichever modulation(s) this synthetic
2921 // instrument carries, e.g. "… +arp12", "… +env7", "… +arp12+env7".
2922 let mut tag = alloc::string::String::new();
2923 if let Some(a) = key.1 {
2924 tag.push_str(&format!(" +arp{}", a));
2925 }
2926 if let Some(e) = key.2 {
2927 tag.push_str(&format!(" +env{}", e));
2928 }
2929 instr.name = format!("{}{}", instr.name, tag);
2930 let idx = instruments.len();
2931 instruments.push(instr);
2932 cache.insert(key, idx);
2933 idx
2934}
2935
2936#[cfg(test)]
2937mod tests {
2938 use super::*;
2939
2940 #[test]
2941 fn rejects_garbage() {
2942 let buf = [0xFFu8; 256];
2943 assert!(matches!(
2944 DwModule::load(&buf),
2945 Err(ImportError::InvalidMagic("dw_detect"))
2946 ));
2947 }
2948
2949 #[test]
2950 fn rejects_truncated() {
2951 assert!(DwModule::load(&[0u8; 16]).is_err());
2952 }
2953}