Skip to main content

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}