Skip to main content

systemless/
sound.rs

1//! Sound Manager state and mixing engine.
2//!
3//! Holds per-channel playback state and produces mixed PCM output each frame.
4//! Reference: *Inside Macintosh: Sound* (1994).
5
6/// Output sample rate in Hz.
7pub const OUTPUT_RATE: u32 = 22050;
8/// Classic Sound Manager `rate22khz` fixed-point value (22254.54545 Hz).
9pub const RATE_22KHZ_FIXED: u32 = 0x56EE_8BA3;
10/// Classic Sound Manager `rate11khz` fixed-point value (11127.27273 Hz).
11pub const RATE_11KHZ_FIXED: u32 = 0x2B77_45D1;
12
13/// Standard command queue depth (Sound 1994, 2-93).
14const STD_Q_LENGTH: usize = 128;
15/// Full volume for one speaker in volumeCmd units (Sound 1994, 2-96).
16const FULL_VOLUME: u16 = 0x0100;
17/// Packed full-volume stereo value: high word = right, low word = left.
18const FULL_STEREO_VOLUME: u32 = ((FULL_VOLUME as u32) << 16) | FULL_VOLUME as u32;
19/// Unity playback rate in Sound Manager Fixed units (Sound 1994, 2-97).
20const UNITY_RATE_FIXED: u32 = 0x0001_0000;
21/// Maximum decoded double-buffer frames retained for waveform diagnostics.
22pub(crate) const DEBUG_DOUBLE_BUFFER_CAPTURE_LIMIT: usize = OUTPUT_RATE as usize * 60;
23
24/// Sound command constants (Sound 1994, 2-92 to 2-97).
25pub mod cmd {
26    pub const NULL: u16 = 0;
27    pub const QUIET: u16 = 3;
28    pub const FLUSH: u16 = 4;
29    pub const CALLBACK: u16 = 13;
30    pub const AVAILABLE: u16 = 24;
31    pub const VERSION: u16 = 25;
32    pub const TOTAL_LOAD: u16 = 26;
33    pub const LOAD: u16 = 27;
34    /// restCmd ($2B = 43) inserts a rest of `param1` half-frames in
35    /// a sequence-channel (note channel for note/freq/wave synth).
36    /// Sound 1994, 2-95. Sample-mixing channels (Marathon 1's case)
37    /// receive restCmd as part of envelope sequencing but it has
38    /// no effect on raw PCM playback — we accept it as a recognised
39    /// no-op so the unhandled-cmds sentinel doesn't trip.
40    pub const REST: u16 = 43;
41    pub const VOLUME: u16 = 46;
42    pub const SOUND: u16 = 80;
43    pub const BUFFER: u16 = 81;
44    pub const RATE: u16 = 82;
45    pub const GET_RATE: u16 = 85;
46}
47
48/// A sound command extracted from a snd resource or queued via SndDoCommand.
49/// Sound 1994, 2-92
50#[derive(Clone, Debug)]
51pub struct SndCommand {
52    pub cmd: u16,
53    pub param1: i16,
54    pub param2: u32,
55}
56
57/// One host-side unsigned 8-bit stereo PCM frame (silence = 0x80).
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub(crate) struct StereoSample {
60    pub left: u8,
61    pub right: u8,
62}
63
64impl StereoSample {
65    pub(crate) const SILENCE: Self = Self {
66        left: 0x80,
67        right: 0x80,
68    };
69
70    pub(crate) fn mono(sample: u8) -> Self {
71        Self {
72            left: sample,
73            right: sample,
74        }
75    }
76
77    pub(crate) fn downmix(self) -> u8 {
78        let left = self.left as i32 - 0x80;
79        let right = self.right as i32 - 0x80;
80        ((left + right) / 2 + 0x80).clamp(0, 255) as u8
81    }
82}
83
84/// Host-side copy of sample data currently being played on a channel.
85#[derive(Clone, Debug)]
86struct PlayingBuffer {
87    /// Unsigned 8-bit stereo PCM frames (Mac format: silence = 0x80).
88    samples: Vec<StereoSample>,
89    /// Source sample rate as Mac Fixed 16.16.
90    sample_rate_fixed: u32,
91    /// Current playback position in samples (fixed-point 32.32).
92    position: u64,
93    /// Resampling step: source_rate / output_rate in fixed-point 32.32.
94    step: u64,
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub(crate) enum PlaybackKind {
99    Buffer,
100    File,
101}
102
103/// Double-buffer playback state for SndPlayDoubleBuffer.
104/// Sound 1994, 2-111 to 2-113
105#[derive(Clone, Debug)]
106pub struct DoubleBufferState {
107    /// Guest pointer to the SndDoubleBufferHeader record.
108    pub header_ptr: u32,
109    /// Index of the buffer currently being played (0 or 1).
110    pub current_buffer: usize,
111    /// Guest pointer to the doubleback callback procedure.
112    pub callback_addr: u32,
113    /// Guest pointer to the channel.
114    pub chan_ptr: u32,
115    /// Sample rate as Mac Fixed 16.16.
116    pub sample_rate: u32,
117    /// Number of interleaved channels in each buffer.
118    pub num_channels: usize,
119    /// Bits per sample in each channel.
120    pub sample_size: usize,
121    /// Whether we've seen dbLastBuffer and should stop after this buffer.
122    pub last_buffer_seen: bool,
123    /// Whether we're waiting for the callback to finish refilling.
124    pub waiting_for_callback: bool,
125    /// Which buffer slots currently have an outstanding doubleback refill.
126    pub pending_callback_buffers: [bool; 2],
127}
128
129impl DoubleBufferState {
130    fn buffer_index(index: usize) -> usize {
131        index & 1
132    }
133
134    fn callback_pending_for(&self, index: usize) -> bool {
135        self.pending_callback_buffers[Self::buffer_index(index)]
136    }
137
138    fn arm_callback_for(&mut self, index: usize) -> bool {
139        let index = Self::buffer_index(index);
140        if self.pending_callback_buffers[index] {
141            return false;
142        }
143        self.pending_callback_buffers[index] = true;
144        self.waiting_for_callback = true;
145        true
146    }
147
148    pub(crate) fn complete_callback_for(&mut self, index: usize) {
149        let index = Self::buffer_index(index);
150        self.pending_callback_buffers[index] = false;
151        self.waiting_for_callback = self.pending_callback_buffers.iter().any(|pending| *pending);
152    }
153}
154
155/// A pending double-buffer callback that the runner should fire.
156#[derive(Clone, Debug)]
157pub struct PendingDoubleBackCallback {
158    /// Guest pointer to the doubleback procedure.
159    pub callback_addr: u32,
160    /// Guest pointer to the SndChannel record.
161    pub chan_ptr: u32,
162    /// Guest pointer to the SndDoubleBufferHeader.
163    pub header_ptr: u32,
164    /// Index of the exhausted buffer (0 or 1).
165    pub exhausted_buffer_index: usize,
166}
167
168/// A pending callback or completion routine to fire from interrupt context.
169#[derive(Clone, Debug)]
170pub enum PendingSoundCallback {
171    /// Callback procedure associated with a channel via SndNewChannel.
172    /// Signature (Sound 1994, 2-152):
173    ///   PROCEDURE MyCallbackProcedure(theChan: SndChannelPtr; theCmd: SndCommand);
174    Command {
175        callback_addr: u32,
176        chan_ptr: u32,
177        cmd: SndCommand,
178    },
179    /// Completion routine associated with SndStartFilePlay.
180    /// Signature (Sound 1994, 2-151):
181    ///   PROCEDURE MyFilePlayCompletionRoutine(chan: SndChannelPtr);
182    FileCompletion { callback_addr: u32, chan_ptr: u32 },
183}
184
185/// Per-channel state.
186#[derive(Clone, Debug)]
187pub struct SndChannel {
188    /// Guest memory pointer for this channel (returned to the game).
189    pub guest_ptr: u32,
190    /// Whether we allocated this channel (vs game-provided).
191    pub allocated: bool,
192    /// Command queue (circular buffer).
193    queue: Vec<SndCommand>,
194    q_head: usize,
195    q_tail: usize,
196    /// Currently playing buffer, if any.
197    playing: Option<PlayingBuffer>,
198    /// Whether the current playback came from bufferCmd/SndPlay or SndStartFilePlay.
199    playback_kind: Option<PlaybackKind>,
200    /// Callback procedure installed by SndNewChannel in the guest channel record.
201    pub callback_addr: u32,
202    /// Current channel volume as packed stereo values (right in high word).
203    volume: u32,
204    /// Current playback rate relative to the channel's base sample rate.
205    rate_fixed: u32,
206    /// callBackCmd commands waiting for the current playback to complete.
207    pending_callback_cmds: Vec<SndCommand>,
208    /// Completion routine for the current asynchronous SndStartFilePlay.
209    file_completion_addr: u32,
210    /// Whether file playback is currently paused.
211    file_paused: bool,
212    /// Active double-buffer state, if SndPlayDoubleBuffer is in use.
213    pub double_buffer: Option<DoubleBufferState>,
214    /// Number of SndDoubleBuffer records decoded into this channel.
215    pub debug_double_buffer_loads: u32,
216    /// Number of decoded SndDoubleBuffer records that contained at least one
217    /// non-silent stereo frame.
218    pub debug_double_buffer_non_silent_loads: u32,
219    /// Total decoded SndDoubleBuffer frames for this channel.
220    pub debug_double_buffer_frames_loaded: u64,
221    /// Total decoded non-silent SndDoubleBuffer frames for this channel.
222    pub debug_double_buffer_non_silent_frames: u64,
223    /// Decoded SndDoubleBuffer frames captured as mono unsigned 8-bit PCM for
224    /// waveform probes. This is diagnostic data; normal playback uses
225    /// `playing`.
226    pub debug_double_buffer_captured_samples: Vec<u8>,
227    /// Sound Manager-owned temporary channels created for high-level calls
228    /// such as NIL-channel SndPlay/SysBeep. These should be released after
229    /// their queued playback drains, not immediately after the trap returns.
230    auto_dispose_when_idle: bool,
231}
232
233impl SndChannel {
234    pub(crate) fn set_file_paused(&mut self, paused: bool) {
235        self.file_paused = paused;
236    }
237    pub fn new(guest_ptr: u32, allocated: bool) -> Self {
238        Self {
239            guest_ptr,
240            allocated,
241            queue: Vec::with_capacity(STD_Q_LENGTH),
242            q_head: 0,
243            q_tail: 0,
244            playing: None,
245            playback_kind: None,
246            callback_addr: 0,
247            volume: FULL_STEREO_VOLUME,
248            rate_fixed: UNITY_RATE_FIXED,
249            pending_callback_cmds: Vec::new(),
250            file_completion_addr: 0,
251            file_paused: false,
252            double_buffer: None,
253            debug_double_buffer_loads: 0,
254            debug_double_buffer_non_silent_loads: 0,
255            debug_double_buffer_frames_loaded: 0,
256            debug_double_buffer_non_silent_frames: 0,
257            debug_double_buffer_captured_samples: Vec::new(),
258            auto_dispose_when_idle: false,
259        }
260    }
261
262    /// Enqueue a command. Returns false if queue is full.
263    pub fn enqueue(&mut self, cmd: SndCommand) -> bool {
264        if self.queue.len() < STD_Q_LENGTH {
265            self.queue.push(cmd);
266            true
267        } else {
268            false
269        }
270    }
271
272    /// Dequeue the next command, if any.
273    fn dequeue(&mut self) -> Option<SndCommand> {
274        if self.queue.is_empty() {
275            None
276        } else {
277            Some(self.queue.remove(0))
278        }
279    }
280
281    /// Clear the command queue.
282    pub fn flush(&mut self) {
283        self.queue.clear();
284        self.q_head = 0;
285        self.q_tail = 0;
286    }
287
288    /// Stop playback without flushing queued commands.
289    pub fn quiet(&mut self) {
290        self.playing = None;
291        self.playback_kind = None;
292        self.pending_callback_cmds.clear();
293        self.file_completion_addr = 0;
294        self.file_paused = false;
295        self.rate_fixed = UNITY_RATE_FIXED;
296        self.double_buffer = None;
297    }
298
299    /// Start playing a buffer of unsigned 8-bit samples.
300    pub(crate) fn play_buffer(
301        &mut self,
302        samples: Vec<u8>,
303        sample_rate_fixed: u32,
304        kind: PlaybackKind,
305        file_completion_addr: u32,
306    ) {
307        let samples = samples.into_iter().map(StereoSample::mono).collect();
308        self.play_stereo_buffer(samples, sample_rate_fixed, kind, file_completion_addr);
309    }
310
311    /// Start playing a buffer of unsigned 8-bit stereo frames.
312    pub(crate) fn play_stereo_buffer(
313        &mut self,
314        samples: Vec<StereoSample>,
315        sample_rate_fixed: u32,
316        kind: PlaybackKind,
317        file_completion_addr: u32,
318    ) {
319        self.rate_fixed = UNITY_RATE_FIXED;
320        self.playing = Some(PlayingBuffer {
321            samples,
322            sample_rate_fixed,
323            position: 0,
324            step: fixed_div(sample_rate_fixed as u64, (OUTPUT_RATE as u64) << 16),
325        });
326        self.playback_kind = Some(kind);
327        self.file_completion_addr = file_completion_addr;
328        self.file_paused = false;
329    }
330
331    /// Returns true if this channel is currently producing audio.
332    pub fn is_playing(&self) -> bool {
333        self.playing.is_some()
334    }
335
336    pub fn has_active_playback(&self) -> bool {
337        self.playing.is_some() || self.file_paused
338    }
339
340    pub fn queue_callback(&mut self, cmd: SndCommand) {
341        self.pending_callback_cmds.push(cmd);
342    }
343
344    pub fn take_pending_callback_cmds(&mut self) -> Vec<SndCommand> {
345        std::mem::take(&mut self.pending_callback_cmds)
346    }
347
348    pub fn set_volume(&mut self, packed_volume: u32) {
349        self.volume = packed_volume;
350    }
351
352    pub fn set_rate(&mut self, rate_fixed: u32) {
353        self.rate_fixed = rate_fixed;
354        if let Some(ref mut playing) = self.playing {
355            playing.step = playback_step(playing.sample_rate_fixed, rate_fixed);
356        }
357    }
358
359    pub fn current_rate(&self) -> u32 {
360        self.rate_fixed
361    }
362
363    pub fn pause_file_playback_toggle(&mut self) {
364        if self.playback_kind == Some(PlaybackKind::File) {
365            self.file_paused = !self.file_paused;
366        }
367    }
368
369    pub(crate) fn mark_auto_dispose_when_idle(&mut self) {
370        self.auto_dispose_when_idle = true;
371    }
372
373    fn is_ready_for_auto_dispose(&self) -> bool {
374        self.auto_dispose_when_idle
375            && self.playing.is_none()
376            && !self.file_paused
377            && self.double_buffer.is_none()
378            && self.queue.is_empty()
379            && self.pending_callback_cmds.is_empty()
380    }
381}
382
383/// Top-level sound manager state, owned by TrapDispatcher.
384#[derive(Clone, Debug)]
385pub struct SoundManager {
386    pub channels: Vec<SndChannel>,
387    /// Pending double-buffer callbacks to fire on the next frame.
388    pub pending_callbacks: Vec<PendingDoubleBackCallback>,
389    /// Pending sound callback procedures / completion routines.
390    pub pending_sound_callbacks: Vec<PendingSoundCallback>,
391    /// Debug counters for diagnosing sound issues.
392    pub debug_cmd_count: u32,
393    pub debug_buffer_cmd_count: u32,
394    /// `SndPlayDoubleBuffer` submissions (SoundDispatch routine
395    /// `$20`). Separate counter because double-buffered playback and
396    /// bufferCmd-based games like EV both feed `mix_frame`, but through
397    /// different dispatch.
398    pub debug_double_buffer_count: u32,
399    pub debug_samples_mixed: u64,
400    pub debug_unhandled_cmds: Vec<u16>,
401    /// Deduplicated list of distinct `SndCommand` cmd codes seen via
402    /// `execute_sound_command`. Sibling of `debug_unhandled_cmds`
403    /// (which only tracks the `_ =>` arm); this tracks ALL cmd codes
404    /// including the matched ones.
405    pub debug_cmd_codes_seen: Vec<u16>,
406    /// `SndStartFilePlay` (SoundDispatch routine `$00`) submissions.
407    /// Resource-backed calls can later execute bufferCmd internally,
408    /// but they still count as reaching the file-play trap family.
409    /// M2's primary audio path goes through this trap and DOES NOT
410    /// increment `debug_buffer_cmd_count` or `debug_double_buffer_count`.
411    /// Per-path visibility: EV uses bufferCmd
412    /// (`debug_buffer_cmd_count`), M2 uses `SndStartFilePlay`
413    /// (`debug_file_play_count`), other games can use
414    /// `SndPlayDoubleBuffer` (`debug_double_buffer_count`).
415    pub debug_file_play_count: u32,
416    /// System alert volume exposed through Get/SetSysBeepVolume.
417    sys_beep_volume: u32,
418    /// Output-device default volume exposed through Get/SetDefaultOutputVolume.
419    /// Sound 1994, 2-141 to 2-142 describes this as the device's default
420    /// setting, distinct from channel `volumeCmd` gain and current
421    /// output-port volume.
422    default_output_volume: u32,
423}
424
425impl Default for SoundManager {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431impl SoundManager {
432    pub fn new() -> Self {
433        Self {
434            channels: Vec::new(),
435            pending_callbacks: Vec::new(),
436            pending_sound_callbacks: Vec::new(),
437            debug_cmd_count: 0,
438            debug_buffer_cmd_count: 0,
439            debug_double_buffer_count: 0,
440            debug_samples_mixed: 0,
441            debug_unhandled_cmds: Vec::new(),
442            debug_cmd_codes_seen: Vec::new(),
443            debug_file_play_count: 0,
444            sys_beep_volume: FULL_STEREO_VOLUME,
445            default_output_volume: FULL_STEREO_VOLUME,
446        }
447    }
448
449    pub fn sys_beep_volume(&self) -> u32 {
450        self.sys_beep_volume
451    }
452
453    pub fn set_sys_beep_volume(&mut self, volume: u32) {
454        self.sys_beep_volume = volume;
455    }
456
457    pub fn default_output_volume(&self) -> u32 {
458        self.default_output_volume
459    }
460
461    pub fn set_default_output_volume(&mut self, volume: u32) {
462        self.default_output_volume = volume;
463    }
464
465    /// Find a channel by its guest pointer.
466    pub fn find_channel_mut(&mut self, guest_ptr: u32) -> Option<&mut SndChannel> {
467        self.channels.iter_mut().find(|c| c.guest_ptr == guest_ptr)
468    }
469
470    /// Remove and return a channel by guest pointer.
471    pub fn take_channel(&mut self, guest_ptr: u32) -> Option<SndChannel> {
472        self.channels
473            .iter()
474            .position(|c| c.guest_ptr == guest_ptr)
475            .map(|idx| self.channels.remove(idx))
476    }
477
478    /// Remove a channel by guest pointer. Returns true if found.
479    pub fn remove_channel(&mut self, guest_ptr: u32) -> bool {
480        self.take_channel(guest_ptr).is_some()
481    }
482
483    pub(crate) fn idle_auto_dispose_channel_ptrs(&self) -> Vec<u32> {
484        self.channels
485            .iter()
486            .filter(|chan| chan.is_ready_for_auto_dispose())
487            .map(|chan| chan.guest_ptr)
488            .collect()
489    }
490
491    /// Process pending commands on all channels, then mix `num_samples`
492    /// of mono output into a buffer of unsigned 8-bit PCM (silence = 0x80).
493    pub fn mix_frame(&mut self, num_samples: usize) -> Vec<u8> {
494        self.mix_frame_stereo_frames(num_samples)
495            .into_iter()
496            .map(StereoSample::downmix)
497            .collect()
498    }
499
500    /// Process pending commands on all channels, then mix `num_samples`
501    /// of stereo output into interleaved unsigned 8-bit PCM
502    /// (left, right, left, right; silence = 0x80).
503    pub fn mix_frame_stereo(&mut self, num_samples: usize) -> Vec<u8> {
504        let frames = self.mix_frame_stereo_frames(num_samples);
505        let mut out = Vec::with_capacity(frames.len() * 2);
506        for frame in frames {
507            out.push(frame.left);
508            out.push(frame.right);
509        }
510        out
511    }
512
513    fn mix_frame_stereo_frames(&mut self, num_samples: usize) -> Vec<StereoSample> {
514        // Process queued commands only on idle channels. SndDoCommand feeds
515        // the channel FIFO; SndDoImmediate is the bypass path for stopping
516        // playback immediately.
517        let mut queued_callbacks = Vec::new();
518        for chan in &mut self.channels {
519            if chan.has_active_playback() || chan.double_buffer.is_some() {
520                continue;
521            }
522
523            while let Some(cmd) = chan.dequeue() {
524                match cmd.cmd {
525                    cmd::NULL => {}
526                    cmd::QUIET => chan.quiet(),
527                    cmd::FLUSH => chan.flush(),
528                    cmd::CALLBACK => {
529                        if chan.callback_addr != 0 {
530                            queued_callbacks.push(PendingSoundCallback::Command {
531                                callback_addr: chan.callback_addr,
532                                chan_ptr: chan.guest_ptr,
533                                cmd,
534                            });
535                        }
536                    }
537                    cmd::BUFFER | cmd::SOUND => {
538                        // Buffer data should already be loaded by the trap handler.
539                    }
540                    _ => {}
541                }
542            }
543        }
544        self.pending_sound_callbacks.extend(queued_callbacks);
545
546        // Mix all playing channels into the output buffer.
547        let mut output = vec![StereoSample::SILENCE; num_samples];
548        let mut any_active = false;
549
550        // Collect double-buffer exhaustion events to process after mixing.
551        let mut exhausted: Vec<(u32, u32, u32, usize)> = Vec::new(); // (callback, chan_ptr, header_ptr, exhausted_buf_idx)
552
553        for chan in &mut self.channels {
554            // If channel has a double-buffer but nothing playing, it means
555            // we're waiting for the next buffer to be ready. Keep the stream
556            // alive with silence, and if no refill callback is outstanding,
557            // request one so an underrun cannot wedge the channel forever.
558            if chan.playing.is_none() {
559                let mut clear_double_buffer = false;
560                if let Some(ref mut db) = chan.double_buffer {
561                    if db.last_buffer_seen {
562                        clear_double_buffer = true;
563                    } else {
564                        any_active = true;
565                        if !db.callback_pending_for(db.current_buffer) {
566                            db.arm_callback_for(db.current_buffer);
567                            exhausted.push((
568                                db.callback_addr,
569                                db.chan_ptr,
570                                db.header_ptr,
571                                db.current_buffer,
572                            ));
573                        }
574                    }
575                }
576                if clear_double_buffer {
577                    chan.double_buffer = None;
578                }
579            }
580            if chan.file_paused {
581                any_active = true;
582                continue;
583            }
584
585            if let Some(ref mut buf) = chan.playing {
586                any_active = true;
587                for slot in output.iter_mut().take(num_samples) {
588                    let Some(source_sample) =
589                        resampled_sample(&buf.samples, buf.position, buf.step)
590                    else {
591                        break;
592                    };
593                    let sample = apply_volume_stereo(source_sample, chan.volume);
594                    let mixed_left = slot.left as i16 + sample.left as i16 - 0x80;
595                    let mixed_right = slot.right as i16 + sample.right as i16 - 0x80;
596                    slot.left = mixed_left.clamp(0, 255) as u8;
597                    slot.right = mixed_right.clamp(0, 255) as u8;
598                    buf.position += buf.step;
599                }
600                let final_idx = (buf.position >> 32) as usize;
601                if final_idx >= buf.samples.len() {
602                    let playback_kind = chan.playback_kind;
603                    let callback_addr = chan.callback_addr;
604                    let chan_ptr = chan.guest_ptr;
605                    let file_completion_addr = chan.file_completion_addr;
606                    let callback_cmds = chan.take_pending_callback_cmds();
607                    chan.playing = None;
608                    chan.playback_kind = None;
609                    chan.file_completion_addr = 0;
610                    // If this channel has a double-buffer, request callback for
611                    // the exhausted buffer and switch to the other one.
612                    if let Some(ref mut db) = chan.double_buffer {
613                        if !db.last_buffer_seen {
614                            let exhausted_idx = db.current_buffer;
615                            db.current_buffer ^= 1; // switch to other buffer
616                            if db.arm_callback_for(exhausted_idx) {
617                                exhausted.push((
618                                    db.callback_addr,
619                                    db.chan_ptr,
620                                    db.header_ptr,
621                                    exhausted_idx,
622                                ));
623                            }
624                        }
625                    }
626                    if callback_addr != 0 {
627                        for cmd in callback_cmds {
628                            self.pending_sound_callbacks
629                                .push(PendingSoundCallback::Command {
630                                    callback_addr,
631                                    chan_ptr,
632                                    cmd,
633                                });
634                        }
635                    }
636                    if playback_kind == Some(PlaybackKind::File) && file_completion_addr != 0 {
637                        self.pending_sound_callbacks
638                            .push(PendingSoundCallback::FileCompletion {
639                                callback_addr: file_completion_addr,
640                                chan_ptr,
641                            });
642                    }
643                }
644            }
645        }
646
647        // Queue pending callbacks for exhausted double buffers.
648        for (callback_addr, chan_ptr, header_ptr, exhausted_buf_idx) in exhausted {
649            // dbhBufferPtr[0] at header+12, dbhBufferPtr[1] at header+16
650            // Sound 1994, 2-111
651            self.pending_callbacks.push(PendingDoubleBackCallback {
652                callback_addr,
653                chan_ptr,
654                header_ptr,
655                exhausted_buffer_index: exhausted_buf_idx,
656            });
657        }
658
659        if any_active {
660            self.debug_samples_mixed += output.len() as u64;
661            output
662        } else {
663            Vec::new()
664        }
665    }
666
667    /// Return the nearest output-sample boundary where an active playback
668    /// buffer will exhaust. Callers that can load a queued follow-up buffer
669    /// should split mixing at this point to avoid emitting silence between
670    /// back-to-back Sound Manager buffers.
671    pub fn samples_until_next_exhaustion(&self) -> Option<usize> {
672        self.channels
673            .iter()
674            .filter_map(|chan| {
675                let playing = chan.playing.as_ref()?;
676                if playing.step == 0 {
677                    return None;
678                }
679                let end = (playing.samples.len() as u128) << 32;
680                let position = playing.position as u128;
681                if position >= end {
682                    return Some(0);
683                }
684                let step = playing.step as u128;
685                let samples = (end - position).div_ceil(step);
686                Some(samples.min(usize::MAX as u128) as usize)
687            })
688            .min()
689    }
690}
691
692/// Fixed-point division: (x / y) with 32 fractional bits.
693/// Reference: executor sound.cpp snd_fixed_div
694fn fixed_div(x: u64, y: u64) -> u64 {
695    if y == 0 {
696        return 0;
697    }
698    let int_part = x / y;
699    let remainder = x - y * int_part;
700    let frac_part = (remainder << 32) / y;
701    (int_part << 32) + frac_part
702}
703
704fn playback_step(sample_rate_fixed: u32, rate_fixed: u32) -> u64 {
705    let base = fixed_div(sample_rate_fixed as u64, (OUTPUT_RATE as u64) << 16);
706    ((base as u128 * rate_fixed as u128) >> 16) as u64
707}
708
709fn resampled_sample(samples: &[StereoSample], position: u64, step: u64) -> Option<StereoSample> {
710    // Sound 1994 defines drop-sample conversion as using an existing
711    // sample instead of a linear interpolated point. That preserves the
712    // 8-bit edges of classic low-rate sampled effects during upsampling.
713    if step < (1u64 << 32) {
714        let sample_idx = (position >> 32) as usize;
715        return samples.get(sample_idx).copied();
716    }
717    interpolated_sample(samples, position)
718}
719
720fn interpolated_sample(samples: &[StereoSample], position: u64) -> Option<StereoSample> {
721    let sample_idx = (position >> 32) as usize;
722    let first = *samples.get(sample_idx)?;
723    let second = samples.get(sample_idx + 1).copied().unwrap_or(first);
724    let frac = (position & 0xFFFF_FFFF) as i64;
725    Some(StereoSample {
726        left: interpolate_u8(first.left, second.left, frac),
727        right: interpolate_u8(first.right, second.right, frac),
728    })
729}
730
731fn interpolate_u8(first: u8, second: u8, frac: i64) -> u8 {
732    let delta = second as i64 - first as i64;
733    let interpolated = first as i64 + (delta * frac) / (1i64 << 32);
734    interpolated.clamp(0, 255) as u8
735}
736
737#[cfg(test)]
738fn apply_volume(sample: u8, packed_volume: u32) -> u8 {
739    let left = (packed_volume & 0xFFFF) as i32;
740    let right = ((packed_volume >> 16) & 0xFFFF) as i32;
741    let average = (left + right) / 2;
742    apply_volume_channel(sample, average)
743}
744
745fn apply_volume_stereo(sample: StereoSample, packed_volume: u32) -> StereoSample {
746    let left_volume = (packed_volume & 0xFFFF) as i32;
747    let right_volume = ((packed_volume >> 16) & 0xFFFF) as i32;
748    StereoSample {
749        left: apply_volume_channel(sample.left, left_volume),
750        right: apply_volume_channel(sample.right, right_volume),
751    }
752}
753
754fn apply_volume_channel(sample: u8, volume: i32) -> u8 {
755    let centered = sample as i32 - 0x80;
756    let scaled = centered * volume / FULL_VOLUME as i32;
757    (scaled + 0x80).clamp(0, 255) as u8
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    /// Regression gate for `SoundManager::new()` default field values:
765    ///   - `channels` / `pending_callbacks` /
766    ///     `pending_sound_callbacks` start empty (SndManager starts
767    ///     with no allocated channels)
768    ///   - all debug counters start at 0
769    ///
770    /// A future refactor that reorders fields or changes defaults
771    /// would silently break callers that depend on a clean initial
772    /// state.
773    #[test]
774    fn sound_manager_new_zero_initialized() {
775        let sm = SoundManager::new();
776        assert!(sm.channels.is_empty(), "channels must start empty");
777        assert!(
778            sm.pending_callbacks.is_empty(),
779            "pending_callbacks must start empty"
780        );
781        assert!(
782            sm.pending_sound_callbacks.is_empty(),
783            "pending_sound_callbacks must start empty"
784        );
785        assert_eq!(sm.debug_cmd_count, 0, "debug_cmd_count must start at 0");
786        assert_eq!(
787            sm.debug_buffer_cmd_count, 0,
788            "debug_buffer_cmd_count must start at 0"
789        );
790        assert_eq!(
791            sm.debug_double_buffer_count, 0,
792            "debug_double_buffer_count must start at 0"
793        );
794        assert_eq!(
795            sm.debug_samples_mixed, 0,
796            "debug_samples_mixed must start at 0"
797        );
798        assert!(
799            sm.debug_unhandled_cmds.is_empty(),
800            "debug_unhandled_cmds must start empty"
801        );
802        assert!(
803            sm.debug_cmd_codes_seen.is_empty(),
804            "debug_cmd_codes_seen must start empty"
805        );
806        assert_eq!(
807            sm.debug_file_play_count, 0,
808            "debug_file_play_count must start at 0"
809        );
810        assert_eq!(
811            sm.sys_beep_volume(),
812            FULL_STEREO_VOLUME,
813            "system beep volume starts at full L+R"
814        );
815        assert_eq!(
816            sm.default_output_volume(),
817            FULL_STEREO_VOLUME,
818            "default output volume starts at full L+R"
819        );
820    }
821
822    /// Module-level constants encode Mac Sound Manager invariants
823    /// that the rest of the sound pipeline silently depends on:
824    ///   - OUTPUT_RATE = 22050 Hz (the mix-frame sample rate
825    ///     EV + M2 both produce at; changing this invalidates
826    ///     every `samples_mixed` floor in the ungated gates).
827    ///   - FULL_VOLUME = 0x0100 (8 bits of volume range per IM:
828    ///     Sound 2-9).
829    ///   - UNITY_RATE_FIXED = 0x0001_0000 (1.0 as Mac Fixed
830    ///     16.16 — 1:1 sample-rate playback).
831    ///   - STD_Q_LENGTH = 128 (per-channel command queue depth,
832    ///     default per IM:Sound 2-107).
833    #[test]
834    fn sound_module_constants_match_mac_sound_manager() {
835        assert_eq!(OUTPUT_RATE, 22050, "OUTPUT_RATE = 22050 Hz");
836        assert_eq!(
837            FULL_VOLUME, 0x0100,
838            "FULL_VOLUME = 256 (8-bit volume range)"
839        );
840        assert_eq!(
841            UNITY_RATE_FIXED, 0x0001_0000,
842            "UNITY_RATE_FIXED = 1.0 as 16.16 Fixed"
843        );
844        assert_eq!(STD_Q_LENGTH, 128, "STD_Q_LENGTH = 128 queue slots");
845    }
846
847    /// `sound::cmd::*` constants must match IM:Sound 1994's documented
848    /// command codes. A drift here would corrupt every
849    /// `bufferCmd` / `soundCmd` / `rateCmd` dispatch without any
850    /// downstream test noticing — `execute_sound_command`'s match
851    /// statement would silently route the wrong command to the wrong
852    /// arm.
853    ///
854    /// References:
855    ///   Sound 1994, 2-126 (SndCommand cmd field table)
856    ///   Executor sound.cpp cmd table
857    #[test]
858    fn sound_cmd_constants_match_ism_sound_1994() {
859        assert_eq!(cmd::NULL, 0, "nullCmd per IM:Sound 2-126");
860        assert_eq!(cmd::QUIET, 3, "quietCmd per IM:Sound 2-126");
861        assert_eq!(cmd::FLUSH, 4, "flushCmd per IM:Sound 2-126");
862        assert_eq!(cmd::CALLBACK, 13, "callBackCmd per IM:Sound 2-126");
863        assert_eq!(cmd::AVAILABLE, 24, "availableCmd per IM:Sound 2-92");
864        assert_eq!(cmd::VERSION, 25, "versionCmd per IM:Sound 2-92");
865        assert_eq!(cmd::TOTAL_LOAD, 26, "totalLoadCmd per IM:Sound 2-92");
866        assert_eq!(cmd::LOAD, 27, "loadCmd per IM:Sound 2-92");
867        assert_eq!(cmd::REST, 43, "restCmd per IM:Sound 2-95");
868        assert_eq!(cmd::VOLUME, 46, "volumeCmd per IM:Sound 2-126");
869        assert_eq!(cmd::SOUND, 80, "soundCmd per IM:Sound 2-126");
870        assert_eq!(cmd::BUFFER, 81, "bufferCmd per IM:Sound 2-126");
871        assert_eq!(cmd::RATE, 82, "rateCmd per IM:Sound 2-126");
872        assert_eq!(cmd::GET_RATE, 85, "getRateCmd per IM:Sound 2-126");
873    }
874
875    /// `mix_frame` leaves queued commands behind active playback.
876    /// `SndDoCommand` feeds a FIFO, so a queued `quietCmd` must not
877    /// preempt the current `bufferCmd`; `SndDoImmediate` is the
878    /// documented queue-bypass path.
879    #[test]
880    fn mix_frame_defers_queued_quiet_until_playback_finishes() {
881        let mut sm = SoundManager::new();
882        let mut chan = SndChannel::new(0x1234_0000, true);
883        chan.play_buffer(vec![0x80; 128], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
884        assert!(chan.is_playing(), "channel active pre-queue");
885
886        chan.enqueue(SndCommand {
887            cmd: cmd::QUIET,
888            param1: 0,
889            param2: 0,
890        });
891        sm.channels.push(chan);
892
893        let output = sm.mix_frame(64);
894        assert_eq!(
895            output.len(),
896            64,
897            "active buffer must mix before queued QUIET"
898        );
899        assert_eq!(sm.debug_samples_mixed, 64);
900        assert_eq!(sm.channels[0].queue.len(), 1);
901        assert!(sm.channels[0].is_playing());
902
903        sm.mix_frame(64);
904        assert_eq!(
905            sm.channels[0].queue.len(),
906            1,
907            "command remains queued until the next idle drain"
908        );
909        assert!(!sm.channels[0].is_playing());
910
911        let output = sm.mix_frame(64);
912        assert!(
913            output.is_empty(),
914            "idle queued QUIET drains with no playback"
915        );
916        assert!(sm.channels[0].queue.is_empty());
917    }
918
919    /// `SoundManager::mix_frame` positive case — an active playing
920    /// buffer must produce a `Vec` of `num_samples` bytes AND advance
921    /// `debug_samples_mixed` by that count. Sibling to the empty-case
922    /// test.
923    #[test]
924    fn mix_frame_advances_samples_mixed_for_active_channel() {
925        let mut sm = SoundManager::new();
926        let mut chan = SndChannel::new(0x1234_0000, true);
927        // Install an active buffer at native OUTPUT_RATE so step = 1.0.
928        chan.play_buffer(vec![0x80; 128], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
929        sm.channels.push(chan);
930
931        let pre = sm.debug_samples_mixed;
932        let output = sm.mix_frame(64);
933        assert_eq!(
934            output.len(),
935            64,
936            "mix_frame(64) with active channel must produce 64 bytes"
937        );
938        assert_eq!(
939            sm.debug_samples_mixed,
940            pre + 64,
941            "debug_samples_mixed must advance by output.len()"
942        );
943    }
944
945    /// `SoundManager::mix_frame` has two terminating behaviours:
946    ///   (a) No active channels → return empty `Vec`.
947    ///   (b) At least one active channel → return `Vec` of
948    ///       `num_samples` bytes, add `output.len()` to
949    ///       `debug_samples_mixed`.
950    /// This test locks in case (a): an empty `SoundManager` returns an
951    /// empty `Vec`, and `debug_samples_mixed` stays at 0.
952    #[test]
953    fn mix_frame_returns_empty_when_no_active_channels() {
954        let mut sm = SoundManager::new();
955        let output = sm.mix_frame(256);
956        assert!(
957            output.is_empty(),
958            "mix_frame with no channels must return empty Vec (got len {})",
959            output.len()
960        );
961        assert_eq!(sm.debug_samples_mixed, 0);
962
963        // With channels but none playing/double-buffered: still empty.
964        sm.channels.push(SndChannel::new(0x1234_0000, true));
965        let output = sm.mix_frame(256);
966        assert!(
967            output.is_empty(),
968            "mix_frame with idle channels must return empty Vec (got len {})",
969            output.len()
970        );
971        assert_eq!(sm.debug_samples_mixed, 0);
972    }
973
974    /// `SndChannel::play_buffer` installs a new `PlayingBuffer`,
975    /// resets `rate_fixed` to unity (caller must re-apply rate via
976    /// `set_rate` AFTER `play_buffer` if needed), sets `playback_kind`,
977    /// stores `file_completion_addr`, and clears `file_paused`. A
978    /// regression that forgets any of these initialisations would
979    /// corrupt subsequent `mix_frame` output.
980    #[test]
981    fn play_buffer_installs_playing_and_resets_state() {
982        let mut chan = SndChannel::new(0x1234_0000, true);
983        chan.rate_fixed = 0x0000_4000; // non-unity pre-state
984        chan.file_paused = true; // pre-state that should clear
985
986        let samples = vec![0x10, 0x20, 0x30, 0x40, 0x50, 0x60];
987        let sample_rate = 11025 << 16; // half OUTPUT_RATE for step = 0.5
988        chan.play_buffer(
989            samples.clone(),
990            sample_rate,
991            PlaybackKind::File,
992            0xABCD_1234,
993        );
994
995        assert_eq!(
996            chan.rate_fixed, UNITY_RATE_FIXED,
997            "rate_fixed reset to unity"
998        );
999        assert!(!chan.file_paused, "file_paused cleared");
1000        assert_eq!(chan.playback_kind, Some(PlaybackKind::File));
1001        assert_eq!(chan.file_completion_addr, 0xABCD_1234);
1002        let playing = chan.playing.as_ref().expect("playing installed");
1003        assert_eq!(
1004            playing.samples,
1005            samples
1006                .iter()
1007                .copied()
1008                .map(StereoSample::mono)
1009                .collect::<Vec<_>>()
1010        );
1011        assert_eq!(playing.sample_rate_fixed, sample_rate);
1012        assert_eq!(playing.position, 0, "position starts at 0");
1013        // Step at 11025 source / 22050 output = 0.5 → \$0_8000_0000.
1014        assert_eq!(
1015            playing.step, 0x8000_0000,
1016            "step must be fixed_div(sample_rate, OUTPUT_RATE<<16)"
1017        );
1018    }
1019
1020    /// `playback_step` computes how far (in 32.32 fixed-point) the
1021    /// sample index should advance each `OUTPUT_RATE` tick given the
1022    /// source sample rate and the user rate multiplier (both 16.16
1023    /// Fixed). The formula is:
1024    ///   base = fixed_div(sample_rate, OUTPUT_RATE << 16)
1025    ///   step = (base * rate_fixed) >> 16
1026    ///
1027    /// Critical invariants:
1028    ///   - Source rate == OUTPUT_RATE with UNITY rate → step
1029    ///     should be exactly 1 sample per output sample (1.0 in
1030    ///     32.32 = \$1_0000_0000).
1031    ///   - Source rate == 2 * OUTPUT_RATE → step = 2.0.
1032    ///   - Rate multiplier 0.5 → half the base step.
1033    #[test]
1034    fn playback_step_at_unity_matches_sample_rate_ratio() {
1035        // Source == OUTPUT_RATE (22050), unity rate multiplier:
1036        //   base = 22050 << 16 / 22050 << 16 = 1.0
1037        //   step = 1.0 * 1.0 = 1.0 = \$1_0000_0000
1038        assert_eq!(
1039            playback_step(OUTPUT_RATE << 16, UNITY_RATE_FIXED),
1040            0x1_0000_0000,
1041            "22050 Hz source + unity rate = step 1.0"
1042        );
1043
1044        // Source = 2 * OUTPUT_RATE (44100), unity rate:
1045        //   base = 2.0; step = 2.0 = \$2_0000_0000
1046        assert_eq!(
1047            playback_step((2 * OUTPUT_RATE) << 16, UNITY_RATE_FIXED),
1048            0x2_0000_0000,
1049            "44100 Hz source + unity rate = step 2.0"
1050        );
1051
1052        // Source = OUTPUT_RATE, rate multiplier = 0.5:
1053        //   base = 1.0; step = 1.0 * 0.5 = 0.5 = \$0_8000_0000
1054        let half_rate = UNITY_RATE_FIXED / 2;
1055        assert_eq!(
1056            playback_step(OUTPUT_RATE << 16, half_rate),
1057            0x8000_0000,
1058            "22050 Hz source + 0.5x rate = step 0.5"
1059        );
1060    }
1061
1062    /// `fixed_div(x, y)` returns `x / y` with 32 fractional
1063    /// bits. Used by `playback_step` to compute the resampling
1064    /// step. The result format is: upper 32 bits are the integer
1065    /// quotient, lower 32 are the fractional part.
1066    ///
1067    /// Divide-by-zero must return 0 (guard against malformed
1068    /// sound headers producing an infinite step).
1069    #[test]
1070    fn fixed_div_contract() {
1071        // 2 / 1 = 2.0 → upper = 2, lower = 0 → 0x2_0000_0000
1072        assert_eq!(fixed_div(2, 1), 0x2_0000_0000);
1073        // 1 / 2 = 0.5 → upper = 0, lower = 0x8000_0000
1074        assert_eq!(fixed_div(1, 2), 0x8000_0000);
1075        // 3 / 4 = 0.75 → upper = 0, lower = 0xC000_0000
1076        assert_eq!(fixed_div(3, 4), 0xC000_0000);
1077        // 5 / 2 = 2.5 → upper = 2, lower = 0x8000_0000
1078        assert_eq!(fixed_div(5, 2), 0x2_8000_0000);
1079        // Guard: divide-by-zero returns 0.
1080        assert_eq!(fixed_div(1, 0), 0);
1081        assert_eq!(fixed_div(0, 0), 0);
1082        // Zero numerator → zero result.
1083        assert_eq!(fixed_div(0, 5), 0);
1084    }
1085
1086    /// `apply_volume` scales an unsigned 8-bit sample by the packed
1087    /// L/R volume, centering around 0x80 before scaling and re-
1088    /// centering after. The math is:
1089    ///   average = (left + right) / 2
1090    ///   scaled  = ((sample - 0x80) * average / FULL_VOLUME) + 0x80
1091    /// clamped to 0..=255.
1092    ///
1093    /// Regression gate for the scaling math itself — a bug here
1094    /// would either silence everything (too-small average), clip
1095    /// loudly (too-large), or flip polarity (reverse signed-vs-
1096    /// unsigned conversion).
1097    #[test]
1098    fn apply_volume_scales_around_0x80_center() {
1099        // FULL volume (L=0x100, R=0x100) preserves input.
1100        let full_lr = ((FULL_VOLUME as u32) << 16) | FULL_VOLUME as u32;
1101        assert_eq!(apply_volume(0x80, full_lr), 0x80, "silence stays silent");
1102        assert_eq!(apply_volume(0xFF, full_lr), 0xFF, "max positive stays max");
1103        assert_eq!(apply_volume(0x00, full_lr), 0x00, "max negative stays max");
1104
1105        // Half volume (L=0x080, R=0x080) halves the excursion.
1106        let half = ((FULL_VOLUME as u32 / 2) << 16) | (FULL_VOLUME as u32 / 2);
1107        assert_eq!(
1108            apply_volume(0x80, half),
1109            0x80,
1110            "silence at any volume stays silent"
1111        );
1112        // 0xC0 = +0x40 from center → halved → +0x20 → 0xA0
1113        assert_eq!(apply_volume(0xC0, half), 0xA0);
1114        // 0x40 = -0x40 from center → halved → -0x20 → 0x60
1115        assert_eq!(apply_volume(0x40, half), 0x60);
1116
1117        // Zero volume silences everything.
1118        assert_eq!(apply_volume(0xFF, 0), 0x80);
1119        assert_eq!(apply_volume(0x00, 0), 0x80);
1120    }
1121
1122    /// `SndChannel::set_volume` stores the packed L/R volume directly
1123    /// into `chan.volume`. The `apply_volume` function consumes this
1124    /// packed value to scale each mixed sample; a regression that
1125    /// masked or shifted the stored value would change the effective
1126    /// playback loudness without any trap handler misbehaving.
1127    #[test]
1128    fn set_volume_stores_packed_lr() {
1129        let mut chan = SndChannel::new(0x1234_0000, true);
1130        // Initial value is FULL L+R packed.
1131        let full = ((FULL_VOLUME as u32) << 16) | FULL_VOLUME as u32;
1132        assert_eq!(chan.volume, full, "default volume must be FULL L+R");
1133
1134        // Test a non-uniform L=0x40 R=0xC0 pack.
1135        let packed = 0x00C0_0040u32;
1136        chan.set_volume(packed);
1137        assert_eq!(chan.volume, packed, "set_volume must store the exact u32");
1138
1139        // Overwriting replaces (no merging).
1140        chan.set_volume(0);
1141        assert_eq!(chan.volume, 0, "set_volume must replace, not merge");
1142    }
1143
1144    /// `SndChannel::queue_callback` appends;
1145    /// `take_pending_callback_cmds` drains. The pair is used by
1146    /// `execute_sound_command`'s `callBackCmd` path to defer user
1147    /// callback execution until the main thread services the channel.
1148    /// A regression that swaps push for replace (or take for clone)
1149    /// would break the defer-and-drain semantics.
1150    #[test]
1151    fn queue_callback_and_take_drain_semantics() {
1152        let mut chan = SndChannel::new(0x1234_0000, true);
1153        assert!(chan.pending_callback_cmds.is_empty());
1154
1155        chan.queue_callback(SndCommand {
1156            cmd: 11,
1157            param1: 1,
1158            param2: 0,
1159        });
1160        chan.queue_callback(SndCommand {
1161            cmd: 11,
1162            param1: 2,
1163            param2: 0,
1164        });
1165        assert_eq!(
1166            chan.pending_callback_cmds.len(),
1167            2,
1168            "queue_callback must append (not replace)"
1169        );
1170        assert_eq!(chan.pending_callback_cmds[0].param1, 1);
1171        assert_eq!(chan.pending_callback_cmds[1].param1, 2);
1172
1173        let drained = chan.take_pending_callback_cmds();
1174        assert_eq!(drained.len(), 2);
1175        assert_eq!(drained[0].param1, 1);
1176        assert_eq!(drained[1].param1, 2);
1177        assert!(
1178            chan.pending_callback_cmds.is_empty(),
1179            "take must drain the Vec (mem::take semantics)"
1180        );
1181
1182        // Double-take is empty, no panic.
1183        let second_drain = chan.take_pending_callback_cmds();
1184        assert!(second_drain.is_empty());
1185    }
1186
1187    /// `SndChannel::set_rate` stores the new rate in `rate_fixed` AND
1188    /// recomputes `playing.step` if a buffer is playing. The step
1189    /// recomputation is what makes pitch-change during active playback
1190    /// actually affect output; forgetting it would keep the channel
1191    /// playing at the previous rate until the next buffer replaces the
1192    /// `PlayingBuffer` entirely.
1193    #[test]
1194    fn set_rate_updates_step_on_active_playing() {
1195        let mut chan = SndChannel::new(0x1234_0000, true);
1196        // Set up active playback at 22050 Hz source.
1197        chan.playing = Some(PlayingBuffer {
1198            samples: vec![StereoSample::SILENCE; 64],
1199            sample_rate_fixed: 22050 << 16,
1200            position: 0,
1201            step: fixed_div(22050 << 16, (OUTPUT_RATE as u64) << 16),
1202        });
1203        let original_step = chan.playing.as_ref().unwrap().step;
1204
1205        // set_rate to unity → step should match original (source == output).
1206        chan.set_rate(UNITY_RATE_FIXED);
1207        assert_eq!(chan.rate_fixed, UNITY_RATE_FIXED);
1208        let new_step = chan.playing.as_ref().unwrap().step;
1209        assert_eq!(
1210            new_step, original_step,
1211            "set_rate(UNITY) must recompute step to original for unity rate"
1212        );
1213
1214        // Set rate to half — step should halve.
1215        let half_rate = UNITY_RATE_FIXED / 2;
1216        chan.set_rate(half_rate);
1217        assert_eq!(chan.rate_fixed, half_rate);
1218        let halved_step = chan.playing.as_ref().unwrap().step;
1219        assert!(
1220            halved_step < original_step,
1221            "set_rate(half) must reduce step; got {:#x} (orig {:#x})",
1222            halved_step,
1223            original_step
1224        );
1225    }
1226
1227    /// `SndChannel::set_rate` without an active playing buffer must
1228    /// still store `rate_fixed` (so the NEXT `play_buffer` call can
1229    /// honour the pre-configured rate). The absence of a `playing`
1230    /// value must not prevent the store — a regression that added
1231    /// `if let Some(ref mut playing) = self.playing` as the outer
1232    /// guard of the whole function would break this.
1233    #[test]
1234    fn set_rate_stores_rate_without_active_playing() {
1235        let mut chan = SndChannel::new(0x1234_0000, true);
1236        assert!(chan.playing.is_none());
1237
1238        let target_rate = 0x0000_C000;
1239        chan.set_rate(target_rate);
1240
1241        assert_eq!(
1242            chan.rate_fixed, target_rate,
1243            "set_rate must store rate_fixed even when no buffer is playing"
1244        );
1245        assert_eq!(
1246            chan.current_rate(),
1247            target_rate,
1248            "current_rate() must reflect the stored rate_fixed"
1249        );
1250    }
1251
1252    /// `SndChannel::pause_file_playback_toggle` toggles `file_paused`
1253    /// ONLY when `playback_kind == File`. Calls on a Buffer-playback
1254    /// channel (or a channel with no active playback) must be no-ops:
1255    /// pause-file semantics are per IM:Sound 2-139 file-playback-
1256    /// specific.
1257    #[test]
1258    fn pause_file_playback_toggle_gated_on_playback_kind() {
1259        let mut chan = SndChannel::new(0x1234_0000, true);
1260
1261        // No playback_kind set: toggle is no-op.
1262        assert!(!chan.file_paused);
1263        chan.pause_file_playback_toggle();
1264        assert!(
1265            !chan.file_paused,
1266            "toggle on non-file channel must not flip file_paused"
1267        );
1268
1269        // Buffer playback: toggle still no-op.
1270        chan.playback_kind = Some(PlaybackKind::Buffer);
1271        chan.pause_file_playback_toggle();
1272        assert!(
1273            !chan.file_paused,
1274            "toggle on Buffer-kind channel must not flip file_paused"
1275        );
1276
1277        // File playback: toggle flips.
1278        chan.playback_kind = Some(PlaybackKind::File);
1279        chan.pause_file_playback_toggle();
1280        assert!(
1281            chan.file_paused,
1282            "toggle on File-kind channel must flip file_paused (first call)"
1283        );
1284        chan.pause_file_playback_toggle();
1285        assert!(
1286            !chan.file_paused,
1287            "toggle on File-kind channel must flip file_paused (second call)"
1288        );
1289    }
1290
1291    /// `is_playing` and `has_active_playback` have a subtle
1292    /// distinction. A paused file-play channel is "active playback"
1293    /// (SndPauseFilePlay stored the file-play state, waiting for
1294    /// SndPauseFilePlay(FALSE) to resume) but NOT "playing" (not
1295    /// producing samples this frame). `mix_frame` uses
1296    /// `has_active_playback` as an "is this channel alive" indicator
1297    /// so it knows not to free resources under the channel's feet;
1298    /// `is_playing` is used to decide whether to emit samples on this
1299    /// specific frame. Mixing these up would cause paused channels to
1300    /// either get torn down prematurely or to keep emitting samples
1301    /// while paused.
1302    #[test]
1303    fn is_playing_vs_has_active_playback_distinction() {
1304        let mut chan = SndChannel::new(0x1234_0000, true);
1305        // Empty channel: neither.
1306        assert!(!chan.is_playing(), "fresh channel: is_playing = false");
1307        assert!(
1308            !chan.has_active_playback(),
1309            "fresh channel: has_active_playback = false"
1310        );
1311
1312        // Active playback buffer: both true.
1313        chan.playing = Some(PlayingBuffer {
1314            samples: vec![StereoSample::SILENCE; 8],
1315            sample_rate_fixed: 22050 << 16,
1316            position: 0,
1317            step: 0x0001_0000,
1318        });
1319        assert!(chan.is_playing(), "active buffer: is_playing = true");
1320        assert!(
1321            chan.has_active_playback(),
1322            "active buffer: has_active_playback = true"
1323        );
1324
1325        // Pause (file_paused = true, but playing still Some):
1326        // still playing AND still active.
1327        chan.file_paused = true;
1328        assert!(chan.is_playing(), "playing+paused: is_playing = true");
1329        assert!(
1330            chan.has_active_playback(),
1331            "playing+paused: has_active_playback = true"
1332        );
1333
1334        // Pause-only (no playing): only has_active_playback.
1335        chan.playing = None;
1336        assert!(
1337            !chan.is_playing(),
1338            "paused without buffer: is_playing = false"
1339        );
1340        assert!(
1341            chan.has_active_playback(),
1342            "paused without buffer: has_active_playback = true \
1343             (pause state alone keeps channel alive for resume)"
1344        );
1345    }
1346
1347    /// `SndChannel::flush` clears ONLY the command queue, `q_head`,
1348    /// and `q_tail`. It does NOT clear playback state (`playing`,
1349    /// `playback_kind`, `file_completion_addr`, `file_paused`,
1350    /// `rate_fixed`, `pending_callback_cmds`, `double_buffer`).
1351    /// Games issue QUIET + FLUSH pairs during channel init; a
1352    /// regression that made flush mistakenly stop playback mid-sample
1353    /// would cut active music short.
1354    #[test]
1355    fn flush_clears_queue_only_not_playback_state() {
1356        let mut chan = SndChannel::new(0x1234_0000, true);
1357
1358        // Pre-populate every piece of playback state flush should
1359        // leave alone.
1360        chan.enqueue(SndCommand {
1361            cmd: 81,
1362            param1: 0,
1363            param2: 0,
1364        });
1365        chan.q_head = 7;
1366        chan.q_tail = 11;
1367        chan.playing = Some(PlayingBuffer {
1368            samples: vec![StereoSample::SILENCE; 16],
1369            sample_rate_fixed: 22050 << 16,
1370            position: 0,
1371            step: 0x0001_0000,
1372        });
1373        chan.playback_kind = Some(PlaybackKind::Buffer);
1374        chan.file_completion_addr = 0xABCD_1234;
1375        chan.file_paused = true;
1376        chan.rate_fixed = 0x0000_8000;
1377        chan.pending_callback_cmds.push(SndCommand {
1378            cmd: 11,
1379            param1: 0,
1380            param2: 0,
1381        });
1382
1383        chan.flush();
1384
1385        assert!(chan.queue.is_empty(), "flush must clear the queue");
1386        assert_eq!(chan.q_head, 0, "flush must reset q_head");
1387        assert_eq!(chan.q_tail, 0, "flush must reset q_tail");
1388
1389        // Must NOT touch playback state.
1390        assert!(chan.playing.is_some(), "flush must NOT clear playing");
1391        assert!(
1392            chan.playback_kind.is_some(),
1393            "flush must NOT clear playback_kind"
1394        );
1395        assert_eq!(
1396            chan.file_completion_addr, 0xABCD_1234,
1397            "flush must NOT clear file_completion_addr"
1398        );
1399        assert!(chan.file_paused, "flush must NOT clear file_paused");
1400        assert_eq!(
1401            chan.rate_fixed, 0x0000_8000,
1402            "flush must NOT reset rate_fixed"
1403        );
1404        assert_eq!(
1405            chan.pending_callback_cmds.len(),
1406            1,
1407            "flush must NOT clear pending_callback_cmds"
1408        );
1409    }
1410
1411    /// `SndChannel::quiet` stops active playback without flushing the FIFO.
1412    /// Specifically quiet clears:
1413    ///   - `playing` (current playback buffer) → None
1414    ///   - `playback_kind` → None
1415    ///   - `pending_callback_cmds` → empty
1416    ///   - `file_completion_addr` → 0
1417    ///   - `file_paused` → false
1418    ///   - `rate_fixed` → UNITY_RATE_FIXED
1419    ///   - `double_buffer` → None
1420    /// A regression that makes quiet a no-op (or that makes it only
1421    /// call flush without the extra state clears) would silently corrupt
1422    /// channel-reset flows.
1423    #[test]
1424    fn quiet_clears_all_playback_state() {
1425        let mut chan = SndChannel::new(0x1234_0000, true);
1426
1427        // Simulate an active channel: pending cmd in queue, active playback,
1428        // file-play state, non-unity rate, and a double-buffer handle.
1429        chan.enqueue(SndCommand {
1430            cmd: 81,
1431            param1: 0,
1432            param2: 0,
1433        });
1434        chan.playing = Some(PlayingBuffer {
1435            samples: vec![StereoSample::SILENCE; 16],
1436            sample_rate_fixed: 22050 << 16,
1437            position: 0,
1438            step: 0x0001_0000,
1439        });
1440        chan.playback_kind = Some(PlaybackKind::File);
1441        chan.file_completion_addr = 0xABCD_1234;
1442        chan.file_paused = true;
1443        chan.rate_fixed = 0x0000_8000; // non-unity
1444        chan.pending_callback_cmds.push(SndCommand {
1445            cmd: 11, // CALLBACK
1446            param1: 0,
1447            param2: 0,
1448        });
1449        // Don't hand-construct DoubleBufferState (fields are private
1450        // implementation details and can drift). Test via a
1451        // `.is_some()` marker using `quiet`'s behaviour instead:
1452        // after quiet, `double_buffer` must end at None regardless
1453        // of what it was before. Skip the pre-state setup.
1454
1455        chan.quiet();
1456
1457        assert_eq!(chan.queue.len(), 1, "quiet must not flush queued commands");
1458        assert_eq!(chan.q_head, 0);
1459        assert_eq!(chan.q_tail, 0);
1460        assert!(chan.playing.is_none(), "quiet must clear playing");
1461        assert!(
1462            chan.playback_kind.is_none(),
1463            "quiet must clear playback_kind"
1464        );
1465        assert!(
1466            chan.pending_callback_cmds.is_empty(),
1467            "quiet must clear pending_callback_cmds"
1468        );
1469        assert_eq!(chan.file_completion_addr, 0);
1470        assert!(!chan.file_paused, "quiet must clear file_paused");
1471        assert_eq!(
1472            chan.rate_fixed, UNITY_RATE_FIXED,
1473            "quiet must reset rate_fixed to unity"
1474        );
1475        assert!(
1476            chan.double_buffer.is_none(),
1477            "quiet must clear double_buffer"
1478        );
1479    }
1480
1481    /// `SndChannel::enqueue` returns false when the queue is at
1482    /// `STD_Q_LENGTH` capacity. Under normal conditions the queue
1483    /// never fills. If `service_guest_sound_queues` had a regression
1484    /// that stopped draining the queue, this would silently cause
1485    /// enqueue rejections.
1486    #[test]
1487    fn enqueue_returns_false_when_queue_full() {
1488        let mut chan = SndChannel::new(0x1234_0000, true);
1489        // Fill the queue to STD_Q_LENGTH.
1490        for i in 0..STD_Q_LENGTH {
1491            assert!(
1492                chan.enqueue(SndCommand {
1493                    cmd: 3, // QUIET
1494                    param1: i as i16,
1495                    param2: 0,
1496                }),
1497                "enqueue at slot {} must succeed while queue has space",
1498                i
1499            );
1500        }
1501        // Queue is now at capacity — next enqueue must fail.
1502        assert!(
1503            !chan.enqueue(SndCommand {
1504                cmd: 3,
1505                param1: STD_Q_LENGTH as i16,
1506                param2: 0,
1507            }),
1508            "enqueue must return false when queue is full"
1509        );
1510        // And the queue state is unchanged.
1511        assert_eq!(chan.queue.len(), STD_Q_LENGTH);
1512    }
1513
1514    /// Locks in `SoundManager::find_channel_mut`'s contract: returns
1515    /// `Some(&mut SndChannel)` iff the `guest_ptr` matches an existing
1516    /// channel, `None` otherwise. Returning the wrong channel (or
1517    /// `None` when the ptr matches) would silently break
1518    /// `execute_sound_command` on per-channel cmds (QUIET, FLUSH,
1519    /// VOLUME, RATE) which rely on `find_channel_mut` to locate the
1520    /// target.
1521    #[test]
1522    fn find_channel_mut_matches_on_guest_ptr() {
1523        let mut sm = SoundManager::new();
1524        sm.channels.push(SndChannel::new(0xAAAA_0000, true));
1525        sm.channels.push(SndChannel::new(0xBBBB_0000, true));
1526
1527        // Hit: returns Some and matches the requested ptr.
1528        let found = sm.find_channel_mut(0xBBBB_0000);
1529        assert!(
1530            found.is_some(),
1531            "find_channel_mut must return Some for known ptr"
1532        );
1533        assert_eq!(found.unwrap().guest_ptr, 0xBBBB_0000);
1534
1535        // Miss: unknown ptr returns None.
1536        assert!(
1537            sm.find_channel_mut(0xCCCC_0000).is_none(),
1538            "find_channel_mut must return None for unknown ptr"
1539        );
1540
1541        // NIL ptr (zero) also misses by default.
1542        assert!(
1543            sm.find_channel_mut(0).is_none(),
1544            "find_channel_mut must return None for zero ptr"
1545        );
1546    }
1547
1548    /// Locks in `SoundManager::remove_channel`'s contract: returns
1549    /// `true` iff the `guest_ptr` matched an existing channel, and on
1550    /// a hit, shrinks the channel list by 1. A `false` return when
1551    /// the ptr matches (or `true` when it doesn't) would silently
1552    /// corrupt the channel-list invariant.
1553    #[test]
1554    fn remove_channel_returns_true_and_shrinks_on_hit() {
1555        let mut sm = SoundManager::new();
1556        sm.channels.push(SndChannel::new(0x1234_0000, true));
1557        sm.channels.push(SndChannel::new(0x1234_1000, true));
1558        assert_eq!(sm.channels.len(), 2);
1559
1560        // Miss: unknown ptr returns false, list unchanged.
1561        assert!(!sm.remove_channel(0xDEAD_0000));
1562        assert_eq!(sm.channels.len(), 2);
1563
1564        // Hit: known ptr returns true, list shrinks.
1565        assert!(sm.remove_channel(0x1234_0000));
1566        assert_eq!(sm.channels.len(), 1);
1567        assert_eq!(sm.channels[0].guest_ptr, 0x1234_1000);
1568
1569        // Double-remove of the same ptr: second call returns false.
1570        assert!(!sm.remove_channel(0x1234_0000));
1571        assert_eq!(sm.channels.len(), 1);
1572    }
1573
1574    /// Locks in `mix_frame`'s multi-call playback-continuity contract.
1575    /// Real games call `mix_frame` repeatedly with small `num_samples`
1576    /// (host audio callback window, typically 512-4096 samples per
1577    /// call); a single `snd` resource can span hundreds of calls. The
1578    /// position must accumulate across calls — a regression that
1579    /// reset position at frame entry would loop the first slice
1580    /// forever; one that reset step would repeat the first sample.
1581    #[test]
1582    fn mix_frame_continues_playback_position_across_calls() {
1583        let mut sm = SoundManager::new();
1584        let mut chan = SndChannel::new(0x1234_0000, true);
1585        chan.play_buffer(
1586            vec![0x90, 0xA0, 0xB0, 0xC0],
1587            OUTPUT_RATE << 16,
1588            PlaybackKind::Buffer,
1589            0,
1590        );
1591        sm.channels.push(chan);
1592
1593        // Call 1: consumes samples[0..2].
1594        let out = sm.mix_frame(2);
1595        assert_eq!(out, vec![0x90, 0xA0], "first mix_frame emits head half");
1596        assert!(sm.channels[0].is_playing(), "playback continues mid-buffer");
1597
1598        // Call 2: consumes samples[2..4], buffer exhausts at end.
1599        let out = sm.mix_frame(2);
1600        assert_eq!(out, vec![0xB0, 0xC0], "second mix_frame emits tail half");
1601        assert!(
1602            !sm.channels[0].is_playing(),
1603            "playback cleared on final_idx >= samples.len()"
1604        );
1605
1606        // Call 3: nothing playing → empty Vec sentinel.
1607        let out = sm.mix_frame(2);
1608        assert!(out.is_empty(), "post-exhaust mix_frame returns empty");
1609
1610        // debug_samples_mixed accumulates across the two active
1611        // frames (2 + 2 = 4), not the empty third.
1612        assert_eq!(sm.debug_samples_mixed, 4);
1613    }
1614
1615    #[test]
1616    fn samples_until_next_exhaustion_tracks_resampled_boundary() {
1617        let mut sm = SoundManager::new();
1618        let mut chan = SndChannel::new(0x1234_0000, true);
1619        chan.play_buffer(
1620            vec![0x90, 0xA0],
1621            (OUTPUT_RATE / 2) << 16,
1622            PlaybackKind::Buffer,
1623            0,
1624        );
1625        sm.channels.push(chan);
1626
1627        assert_eq!(
1628            sm.samples_until_next_exhaustion(),
1629            Some(4),
1630            "half-rate two-sample buffer emits four output samples"
1631        );
1632
1633        let output = sm.mix_frame(1);
1634        assert_eq!(output, vec![0x90]);
1635        assert_eq!(
1636            sm.samples_until_next_exhaustion(),
1637            Some(3),
1638            "boundary query must follow playback position across calls"
1639        );
1640    }
1641
1642    /// Locks in the integration between `mix_frame` and
1643    /// `apply_volume`. The mixer calls
1644    ///   `apply_volume(buf.samples[sample_idx], chan.volume)`
1645    /// before the additive-sum step. A half-volume channel
1646    /// (0x00800080 packed = 128 left/right, avg=128, half of
1647    /// FULL_VOLUME=256) should halve the centered sample amplitude
1648    /// before it hits the output. A regression that inlined the
1649    /// sample lookup bypassing `apply_volume` would silently break
1650    /// volumeCmd-driven volume fades per IM:Sound 2-96.
1651    #[test]
1652    fn mix_frame_applies_channel_volume_to_sample() {
1653        let mut sm = SoundManager::new();
1654        let mut chan = SndChannel::new(0x1234_0000, true);
1655
1656        chan.play_buffer(
1657            vec![0xA0; 8], // centered amplitude +0x20
1658            OUTPUT_RATE << 16,
1659            PlaybackKind::Buffer,
1660            0,
1661        );
1662        // Half volume: packed 0x00800080 (128 left/right → avg 128
1663        // = FULL_VOLUME/2). 0x20 centered → +0x10 scaled → 0x90.
1664        chan.set_volume(0x0080_0080);
1665        sm.channels.push(chan);
1666
1667        let output = sm.mix_frame(4);
1668
1669        assert!(
1670            output.iter().all(|&b| b == 0x90),
1671            "half-volume must halve centered amplitude (0xA0 → 0x90), got {:02X}",
1672            output[0]
1673        );
1674
1675        // Sanity: at full volume the same buffer produces 0xA0.
1676        let mut sm = SoundManager::new();
1677        let mut chan = SndChannel::new(0x1234_0000, true);
1678        chan.play_buffer(vec![0xA0; 8], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
1679        // Default volume from SndChannel::new is 0x0100_0100 (full).
1680        sm.channels.push(chan);
1681        let output = sm.mix_frame(4);
1682        assert!(
1683            output.iter().all(|&b| b == 0xA0),
1684            "full volume passes sample through unchanged, got {:02X}",
1685            output[0]
1686        );
1687
1688        // Zero volume collapses any source to silence (0x80).
1689        let mut sm = SoundManager::new();
1690        let mut chan = SndChannel::new(0x1234_0000, true);
1691        chan.play_buffer(vec![0xA0; 8], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
1692        chan.set_volume(0);
1693        sm.channels.push(chan);
1694        let output = sm.mix_frame(4);
1695        assert!(
1696            output.iter().all(|&b| b == 0x80),
1697            "zero volume collapses sample to silence (0x80), got {:02X}",
1698            output[0]
1699        );
1700    }
1701
1702    #[test]
1703    fn mix_frame_preserves_audio_when_default_output_volume_changes() {
1704        let mut sm = SoundManager::new();
1705        sm.set_default_output_volume(0x0080_0080);
1706        let mut chan = SndChannel::new(0x1234_0000, true);
1707        chan.play_buffer(vec![0xA0; 8], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
1708        sm.channels.push(chan);
1709
1710        let output = sm.mix_frame(4);
1711
1712        assert!(
1713            output.iter().all(|&b| b == 0xA0),
1714            "default output volume stores the device default and must not attenuate the current mixed stream, got {:02X}",
1715            output[0]
1716        );
1717
1718        let mut sm = SoundManager::new();
1719        sm.set_default_output_volume(0);
1720        let mut chan = SndChannel::new(0x1234_0000, true);
1721        chan.play_buffer(vec![0xA0; 8], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
1722        sm.channels.push(chan);
1723
1724        let output = sm.mix_frame(4);
1725
1726        assert!(
1727            output.iter().all(|&b| b == 0xA0),
1728            "zero default output volume must not silence active channel audio, got {:02X}",
1729            output[0]
1730        );
1731    }
1732
1733    /// Mirror of the resampling test for the upsampling direction. When a
1734    /// buffer's `sample_rate_fixed` is BELOW OUTPUT_RATE (e.g.
1735    /// 0.5× = 11025 Hz source at 22050 Hz output), the computed
1736    /// step is 0.5 in 32.32 fixed-point. Sample-and-hold preserves the
1737    /// original 8-bit sample edges instead of linearly smoothing them; that
1738    /// matters for classic low-rate sound effects where interpolation sounds
1739    /// muffled. A regression that truncated the fractional part of `step` to
1740    /// 0 would freeze playback forever on the first sample; a regression
1741    /// doubling the step would halve the pitch of every under-sample-rate snd
1742    /// resource.
1743    #[test]
1744    fn mix_frame_resamples_half_rate_with_sample_hold() {
1745        let mut sm = SoundManager::new();
1746        let mut chan = SndChannel::new(0x1234_0000, true);
1747
1748        // 2-sample source at 0.5× OUTPUT_RATE → step = 0.5.
1749        // With 4 output samples, source[0] plays at positions
1750        // 0.0 and 0.5, source[1] at positions 1.0 and 1.5, and
1751        // position 2.0 exhausts the buffer (break).
1752        chan.play_buffer(
1753            vec![0x90, 0xA0],
1754            (OUTPUT_RATE / 2) << 16,
1755            PlaybackKind::Buffer,
1756            0,
1757        );
1758        sm.channels.push(chan);
1759
1760        let output = sm.mix_frame(6);
1761
1762        // Expected:
1763        //   output[0] = source[0] = 0x90
1764        //   output[1] = source[0] held at fractional position 0.5
1765        //   output[2] = source[1] = 0xA0
1766        //   output[3] = source[1] held at tail = 0xA0
1767        //   output[4] = untouched silence (position 2.0 → idx 2, break)
1768        //   output[5] = untouched silence
1769        assert_eq!(output.len(), 6);
1770        assert_eq!(output[0], 0x90, "source[0] at step 0");
1771        assert_eq!(
1772            output[1], 0x90,
1773            "low-rate upsampling must hold the source sample, not smooth it"
1774        );
1775        assert_eq!(output[2], 0xA0, "source[1] at step 2 (position 1.0)");
1776        assert_eq!(output[3], 0xA0, "tail sample held at step 3 (position 1.5)");
1777        assert_eq!(output[4], 0x80, "break leaves default silence");
1778        assert_eq!(output[5], 0x80, "break leaves default silence");
1779
1780        // Playback cleared on overflow past the 2-sample buffer.
1781        assert!(!sm.channels[0].is_playing());
1782    }
1783
1784    #[test]
1785    fn mix_frame_stereo_preserves_channel_separation() {
1786        let stereo_samples = vec![
1787            StereoSample {
1788                left: 0x00,
1789                right: 0xFF,
1790            },
1791            StereoSample {
1792                left: 0x40,
1793                right: 0xC0,
1794            },
1795        ];
1796
1797        let mut stereo_sm = SoundManager::new();
1798        let mut stereo_chan = SndChannel::new(0x1234_0000, true);
1799        stereo_chan.play_stereo_buffer(
1800            stereo_samples.clone(),
1801            OUTPUT_RATE << 16,
1802            PlaybackKind::Buffer,
1803            0,
1804        );
1805        stereo_sm.channels.push(stereo_chan);
1806
1807        assert_eq!(stereo_sm.mix_frame_stereo(2), vec![0x00, 0xFF, 0x40, 0xC0]);
1808
1809        let mut mono_sm = SoundManager::new();
1810        let mut mono_chan = SndChannel::new(0x1234_0000, true);
1811        mono_chan.play_stereo_buffer(stereo_samples, OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
1812        mono_sm.channels.push(mono_chan);
1813
1814        assert_eq!(mono_sm.mix_frame(2), vec![0x80, 0x80]);
1815    }
1816
1817    #[test]
1818    fn mix_frame_resamples_classic_rate22khz_with_fractional_interpolation() {
1819        // EV/EVO 'snd ' resources commonly use Sound Manager's documented
1820        // rate22khz value: 22,254.54545 Hz. The mixer output contract is
1821        // 22,050 Hz, so playback advances by just over one source sample
1822        // per output sample. This must interpolate the fractional position
1823        // instead of periodically dropping source samples as nearest-neighbor
1824        // resampling would.
1825        const RATE_22KHZ_FIXED: u32 = 0x56EE_8BA3;
1826
1827        let mut sm = SoundManager::new();
1828        let mut chan = SndChannel::new(0x1234_0000, true);
1829        chan.play_buffer(
1830            vec![0x80, 0x00, 0xFF, 0x80],
1831            RATE_22KHZ_FIXED,
1832            PlaybackKind::Buffer,
1833            0,
1834        );
1835        sm.channels.push(chan);
1836
1837        let output = sm.mix_frame(3);
1838
1839        assert_eq!(output[0], 0x80, "position 0.0 reads source[0]");
1840        assert!(
1841            (0x01..=0x0F).contains(&output[1]),
1842            "position just after source[1] should interpolate toward source[2], got {:#04X}",
1843            output[1]
1844        );
1845        assert!(
1846            output[2] > 0x80,
1847            "next fractional sample stays on the rising edge"
1848        );
1849    }
1850
1851    /// Locks in `mix_frame`'s resampling step for non-unity source
1852    /// sample rates. `play_buffer` computes
1853    ///   step = fixed_div(sample_rate_fixed, OUTPUT_RATE << 16)
1854    /// in 32.32 fixed-point, so playback advances the source position
1855    /// by that step per output sample. For a buffer whose
1856    /// `sample_rate_fixed` is 2× `OUTPUT_RATE`, every other source
1857    /// sample should be selected because the step lands on integer
1858    /// source positions. A regression breaking the step calculation
1859    /// would pitch-shift every non-unity-rate snd resource.
1860    #[test]
1861    fn mix_frame_resamples_2x_source_via_step_advance() {
1862        let mut sm = SoundManager::new();
1863        let mut chan = SndChannel::new(0x1234_0000, true);
1864
1865        // 4-sample buffer at 2× OUTPUT_RATE → step = 2.0. Two
1866        // output samples pull source[0] and source[2]; next
1867        // iteration hits source[4] (out of bounds) and breaks.
1868        chan.play_buffer(
1869            vec![0x90, 0xA0, 0xB0, 0xC0],
1870            (OUTPUT_RATE * 2) << 16,
1871            PlaybackKind::Buffer,
1872            0,
1873        );
1874        sm.channels.push(chan);
1875
1876        // Request 3 samples so we can see:
1877        //   output[0] = source[0] = 0x90
1878        //   output[1] = source[2] = 0xB0
1879        //   output[2] = untouched silence 0x80 (break triggered)
1880        let output = sm.mix_frame(3);
1881
1882        assert_eq!(output.len(), 3);
1883        assert_eq!(output[0], 0x90, "source[0] at step 0");
1884        assert_eq!(output[1], 0xB0, "source[2] at step 1 (2.0 advance)");
1885        assert_eq!(output[2], 0x80, "break left default silence");
1886
1887        // Playback exhausted on overflow.
1888        assert!(
1889            !sm.channels[0].is_playing(),
1890            "playback cleared once source position >= samples.len()"
1891        );
1892        // samples_mixed counts OUTPUT bytes emitted (all 3,
1893        // including the silence-by-default slot).
1894        assert_eq!(sm.debug_samples_mixed, 3);
1895    }
1896
1897    /// Extends `apply_volume` coverage to the AMPLIFICATION case
1898    /// (volume > FULL_VOLUME). Some games boost above unity. The math:
1899    ///   centered * average / FULL_VOLUME
1900    /// permits avg > FULL_VOLUME, scaling above unity. The
1901    /// `clamp(0, 255)` on the result is the safety net that prevents
1902    /// wraparound when amplified samples exceed [0, 255]. A
1903    /// regression that integer-overflowed in the multiply or
1904    /// truncated the avg to FULL_VOLUME would silently break boosted-
1905    /// volume playback.
1906    #[test]
1907    fn apply_volume_amplifies_above_full_volume_and_clamps() {
1908        // 2× FULL_VOLUME: L=R=0x200 → avg=0x200.
1909        let two_x = ((FULL_VOLUME as u32 * 2) << 16) | (FULL_VOLUME as u32 * 2);
1910
1911        // sample=0xA0 (+0x20 centered) → 0x20 × 0x200 / 0x100 = 0x40
1912        // → result = 0x40 + 0x80 = 0xC0.
1913        assert_eq!(
1914            apply_volume(0xA0, two_x),
1915            0xC0,
1916            "+0x20 doubled = +0x40 → 0xC0"
1917        );
1918
1919        // sample=0x60 (-0x20 centered) → -0x20 × 0x200 / 0x100 = -0x40
1920        // → result = -0x40 + 0x80 = 0x40.
1921        assert_eq!(
1922            apply_volume(0x60, two_x),
1923            0x40,
1924            "-0x20 doubled = -0x40 → 0x40"
1925        );
1926
1927        // sample=0xFF (+0x7F centered) → 0x7F × 2 = 0xFE → +0x80 = 0x17E
1928        // → clamps to 0xFF (upper saturation).
1929        assert_eq!(
1930            apply_volume(0xFF, two_x),
1931            0xFF,
1932            "+0x7F doubled saturates at 0xFF"
1933        );
1934
1935        // sample=0x00 (-0x80 centered) → -0x80 × 2 = -0x100 → +0x80 = -0x80
1936        // → clamps to 0x00 (lower saturation).
1937        assert_eq!(
1938            apply_volume(0x00, two_x),
1939            0x00,
1940            "-0x80 doubled saturates at 0x00"
1941        );
1942
1943        // sample=0x80 (silence) at any volume → still silence.
1944        assert_eq!(
1945            apply_volume(0x80, two_x),
1946            0x80,
1947            "silence is silence regardless of gain"
1948        );
1949    }
1950
1951    /// Locks in the interaction between `pause_file_playback_toggle`
1952    /// and `quiet`. `quiet()` must clear `file_paused` along with all
1953    /// other playback state. A regression that omitted the
1954    /// `file_paused`-clear would leave the channel "paused" even
1955    /// after quiet, causing `mix_frame` to keep producing silence
1956    /// indefinitely.
1957    #[test]
1958    fn quiet_clears_file_paused_after_pause_toggle() {
1959        let mut chan = SndChannel::new(0x1234_0000, true);
1960        chan.play_buffer(vec![0x80; 16], OUTPUT_RATE << 16, PlaybackKind::File, 0);
1961        // Toggle paused on.
1962        chan.pause_file_playback_toggle();
1963        assert!(
1964            chan.has_active_playback(),
1965            "playing OR file_paused → active"
1966        );
1967
1968        // quiet must wipe everything, including file_paused.
1969        chan.quiet();
1970        assert!(!chan.is_playing(), "quiet clears playing");
1971        assert!(
1972            !chan.has_active_playback(),
1973            "quiet must clear file_paused too — has_active_playback = playing OR file_paused"
1974        );
1975    }
1976
1977    /// Locks in `mix_frame`'s waiting-for-refill silence contract.
1978    /// When a channel has `playing=None` but `double_buffer=Some`,
1979    /// `mix_frame` must set `any_active=true` without mixing anything,
1980    /// so the returned output is `num_samples` of silence (0x80)
1981    /// rather than empty. This is the "buffer exhausted, waiting for
1982    /// callback to refill" steady-state that occurs EVERY `mix_frame`
1983    /// between a double-buffer exhaustion and the guest's doubleback
1984    /// proc firing. Without this contract, the output stream would
1985    /// briefly go empty (underrunning the host audio callback) every
1986    /// time a DB buffer runs out. Matches IM:Sound 2-111 seamless-
1987    /// double-buffer semantics.
1988    #[test]
1989    fn mix_frame_channel_with_db_but_no_playing_outputs_silence() {
1990        let mut sm = SoundManager::new();
1991        let mut chan = SndChannel::new(0x1234_0000, true);
1992        // playing stays None; attach DB in waiting state.
1993        chan.double_buffer = Some(DoubleBufferState {
1994            header_ptr: 0x0070_0000,
1995            current_buffer: 1,
1996            callback_addr: 0xCAFE_0000,
1997            chan_ptr: 0x1234_0000,
1998            sample_rate: OUTPUT_RATE << 16,
1999            num_channels: 1,
2000            sample_size: 8,
2001            last_buffer_seen: false,
2002            waiting_for_callback: true,
2003            pending_callback_buffers: [false, true],
2004        });
2005        assert!(!chan.is_playing(), "playing stays None pre-mix");
2006        sm.channels.push(chan);
2007
2008        let output = sm.mix_frame(32);
2009
2010        assert_eq!(
2011            output.len(),
2012            32,
2013            "DB-waiting channel must still produce num_samples (non-empty)"
2014        );
2015        assert!(
2016            output.iter().all(|&b| b == 0x80),
2017            "no playing buffer → output is pure silence (0x80), got {:02X}",
2018            output[0]
2019        );
2020        // Channel state unchanged: DB still present, no callback
2021        // re-triggered (waiting_for_callback still true).
2022        let db = sm.channels[0]
2023            .double_buffer
2024            .as_ref()
2025            .expect("double_buffer must remain installed");
2026        assert!(
2027            db.waiting_for_callback,
2028            "waiting_for_callback must stay true across idle mix_frame"
2029        );
2030        assert_eq!(db.current_buffer, 1, "current_buffer must not flip on idle");
2031        assert!(
2032            sm.pending_callbacks.is_empty(),
2033            "no new callback pushed on idle-wait frame"
2034        );
2035    }
2036
2037    #[test]
2038    fn mix_frame_idle_double_buffer_requests_refill_once() {
2039        let mut sm = SoundManager::new();
2040        let mut chan = SndChannel::new(0x1234_0000, true);
2041        chan.double_buffer = Some(DoubleBufferState {
2042            header_ptr: 0x0070_0000,
2043            current_buffer: 1,
2044            callback_addr: 0xCAFE_0000,
2045            chan_ptr: 0x1234_0000,
2046            sample_rate: OUTPUT_RATE << 16,
2047            num_channels: 1,
2048            sample_size: 8,
2049            last_buffer_seen: false,
2050            waiting_for_callback: false,
2051            pending_callback_buffers: [false; 2],
2052        });
2053        sm.channels.push(chan);
2054
2055        let output = sm.mix_frame(32);
2056
2057        assert_eq!(
2058            output.len(),
2059            32,
2060            "idle DB channel stays active while requesting a refill"
2061        );
2062        assert!(
2063            output.iter().all(|&b| b == 0x80),
2064            "no ready buffer means the active output is silence"
2065        );
2066        assert_eq!(sm.pending_callbacks.len(), 1);
2067        let callback = &sm.pending_callbacks[0];
2068        assert_eq!(callback.callback_addr, 0xCAFE_0000);
2069        assert_eq!(callback.chan_ptr, 0x1234_0000);
2070        assert_eq!(callback.header_ptr, 0x0070_0000);
2071        assert_eq!(
2072            callback.exhausted_buffer_index, 1,
2073            "retry asks the guest to refill the current missing buffer"
2074        );
2075        let db = sm.channels[0].double_buffer.as_ref().unwrap();
2076        assert!(db.waiting_for_callback);
2077
2078        sm.mix_frame(32);
2079        assert_eq!(
2080            sm.pending_callbacks.len(),
2081            1,
2082            "waiting_for_callback prevents refill callback spam"
2083        );
2084    }
2085
2086    /// Locks in `mix_frame`'s multi-channel additive-mix contract.
2087    /// The mixer loops over each channel summing its volume-scaled
2088    /// sample into the output via:
2089    ///   mixed = output[i] + sample - 0x80; output[i] = clamp(mixed, 0, 255)
2090    /// A regression that dropped the `- 0x80` offset would double the
2091    /// silence baseline, and a regression that removed `.clamp(0, 255)`
2092    /// would wrap u8 on overflow, producing audible glitches.
2093    #[test]
2094    fn mix_frame_two_active_channels_sum_arithmetically_and_clamp() {
2095        // Pass-through test: A=0x90 (+0x10), B=0xA0 (+0x20)
2096        // → output[i] = 0x80 + 0x10 + 0x20 = 0xB0.
2097        let mut sm = SoundManager::new();
2098        let mut a = SndChannel::new(0x1000_0000, true);
2099        let mut b = SndChannel::new(0x2000_0000, true);
2100        a.play_buffer(vec![0x90; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2101        b.play_buffer(vec![0xA0; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2102        sm.channels.push(a);
2103        sm.channels.push(b);
2104
2105        let output = sm.mix_frame(16);
2106
2107        assert_eq!(output.len(), 16, "active-channel mix produces num_samples");
2108        assert!(
2109            output.iter().all(|&b| b == 0xB0),
2110            "two-channel sum: 0x80 + (0x90-0x80) + (0xA0-0x80) = 0xB0, got {:02X}",
2111            output[0]
2112        );
2113        // Both channels contributed to samples_mixed (one count,
2114        // not two — samples_mixed tracks output byte count).
2115        assert_eq!(sm.debug_samples_mixed, 16);
2116
2117        // Positive-clip case: two channels at 0xFF clamp to 0xFF.
2118        let mut sm = SoundManager::new();
2119        let mut a = SndChannel::new(0x1000_0000, true);
2120        let mut b = SndChannel::new(0x2000_0000, true);
2121        a.play_buffer(vec![0xFF; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2122        b.play_buffer(vec![0xFF; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2123        sm.channels.push(a);
2124        sm.channels.push(b);
2125        let output = sm.mix_frame(4);
2126        assert!(
2127            output.iter().all(|&v| v == 0xFF),
2128            "0xFF + 0xFF saturates to 0xFF (upper clamp), got {:02X}",
2129            output[0]
2130        );
2131
2132        // Negative-clip case: two channels at 0x00 clamp to 0x00.
2133        let mut sm = SoundManager::new();
2134        let mut a = SndChannel::new(0x1000_0000, true);
2135        let mut b = SndChannel::new(0x2000_0000, true);
2136        a.play_buffer(vec![0x00; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2137        b.play_buffer(vec![0x00; 32], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2138        sm.channels.push(a);
2139        sm.channels.push(b);
2140        let output = sm.mix_frame(4);
2141        assert!(
2142            output.iter().all(|&v| v == 0x00),
2143            "0x00 + 0x00 saturates to 0x00 (lower clamp), got {:02X}",
2144            output[0]
2145        );
2146    }
2147
2148    /// Locks in the guard flags that inhibit duplicate double-buffer
2149    /// callback queueing in `mix_frame`:
2150    ///   - `last_buffer_seen=true`: the guest already told us via
2151    ///     `dbLastBuffer` that no more data will come; don't ask
2152    ///     for a refill we'll never get.
2153    ///   - `pending_callback_buffers[n]=true`: we already asked for
2154    ///     that specific slot to be refilled; don't queue a duplicate
2155    ///     for the same slot.
2156    ///
2157    /// A regression removing either guard would cause callback spam or
2158    /// a callback request after the guest marked the stream complete.
2159    #[test]
2160    fn mix_frame_double_buffer_guards_inhibit_callback_push() {
2161        // Case A: last_buffer_seen=true → no callback.
2162        {
2163            let mut sm = SoundManager::new();
2164            let mut chan = SndChannel::new(0x1234_0000, true);
2165            chan.play_buffer(vec![0x80, 0x80], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2166            chan.double_buffer = Some(DoubleBufferState {
2167                header_ptr: 0x0070_0000,
2168                current_buffer: 0,
2169                callback_addr: 0xCAFE_0000,
2170                chan_ptr: 0x1234_0000,
2171                sample_rate: OUTPUT_RATE << 16,
2172                num_channels: 1,
2173                sample_size: 8,
2174                last_buffer_seen: true,
2175                waiting_for_callback: false,
2176                pending_callback_buffers: [false; 2],
2177            });
2178            sm.channels.push(chan);
2179
2180            sm.mix_frame(4);
2181
2182            assert!(
2183                sm.pending_callbacks.is_empty(),
2184                "last_buffer_seen=true must inhibit callback push"
2185            );
2186            // current_buffer must NOT flip since we skipped the
2187            // whole guarded block.
2188            assert_eq!(
2189                sm.channels[0]
2190                    .double_buffer
2191                    .as_ref()
2192                    .unwrap()
2193                    .current_buffer,
2194                0,
2195                "current_buffer must NOT flip when last_buffer_seen=true"
2196            );
2197        }
2198
2199        // Case B: this same buffer already has a pending callback → no duplicate.
2200        {
2201            let mut sm = SoundManager::new();
2202            let mut chan = SndChannel::new(0x1234_0000, true);
2203            chan.play_buffer(vec![0x80, 0x80], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2204            chan.double_buffer = Some(DoubleBufferState {
2205                header_ptr: 0x0070_0000,
2206                current_buffer: 0,
2207                callback_addr: 0xCAFE_0000,
2208                chan_ptr: 0x1234_0000,
2209                sample_rate: OUTPUT_RATE << 16,
2210                num_channels: 1,
2211                sample_size: 8,
2212                last_buffer_seen: false,
2213                waiting_for_callback: true,
2214                pending_callback_buffers: [true, false],
2215            });
2216            sm.channels.push(chan);
2217
2218            sm.mix_frame(4);
2219
2220            assert!(
2221                sm.pending_callbacks.is_empty(),
2222                "pending_callback_buffers[current]=true must inhibit duplicate callback push"
2223            );
2224            assert_eq!(
2225                sm.channels[0]
2226                    .double_buffer
2227                    .as_ref()
2228                    .unwrap()
2229                    .current_buffer,
2230                1,
2231                "current_buffer still advances to the paired slot"
2232            );
2233        }
2234    }
2235
2236    #[test]
2237    fn mix_frame_allows_other_double_buffer_callback_while_one_slot_is_pending() {
2238        let mut sm = SoundManager::new();
2239        let mut chan = SndChannel::new(0x1234_0000, true);
2240        chan.play_buffer(vec![0x80, 0x80], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2241        chan.double_buffer = Some(DoubleBufferState {
2242            header_ptr: 0x0070_0000,
2243            current_buffer: 1,
2244            callback_addr: 0xCAFE_0000,
2245            chan_ptr: 0x1234_0000,
2246            sample_rate: OUTPUT_RATE << 16,
2247            num_channels: 1,
2248            sample_size: 8,
2249            last_buffer_seen: false,
2250            waiting_for_callback: true,
2251            pending_callback_buffers: [true, false],
2252        });
2253        sm.channels.push(chan);
2254
2255        sm.mix_frame(4);
2256
2257        assert_eq!(
2258            sm.pending_callbacks.len(),
2259            1,
2260            "pending refill for buffer 0 must not suppress buffer 1's doubleback"
2261        );
2262        assert_eq!(sm.pending_callbacks[0].exhausted_buffer_index, 1);
2263        let db = sm.channels[0].double_buffer.as_ref().unwrap();
2264        assert_eq!(db.current_buffer, 0);
2265        assert_eq!(
2266            db.pending_callback_buffers,
2267            [true, true],
2268            "both slots can have outstanding refills independently"
2269        );
2270        assert!(db.waiting_for_callback);
2271    }
2272
2273    /// Locks in `mix_frame`'s double-buffer exhaustion contract. When
2274    /// a channel with an active `double_buffer`
2275    /// (`last_buffer_seen=false`, `waiting_for_callback=false`)
2276    /// exhausts its current playback, `mix_frame` must:
2277    ///   - flip `current_buffer` to the other slot (0↔1)
2278    ///   - set `waiting_for_callback = true` (so the exhausted slot
2279    ///     isn't re-triggered on the next frame before the refill
2280    ///     callback has had a chance to run)
2281    ///   - push a `PendingDoubleBackCallback` carrying
2282    ///     `callback_addr`, `chan_ptr`, `header_ptr`, and the
2283    ///     *just-exhausted* buffer index (not the newly-flipped one)
2284    ///     to `pending_callbacks`
2285    ///
2286    /// Matches IM:Sound 2-111..113: the doubleback proc receives the
2287    /// `DbhBufferPtr` for the buffer it should now refill (the one
2288    /// that just finished).
2289    #[test]
2290    fn mix_frame_double_buffer_exhaust_queues_callback_and_flips_slot() {
2291        let mut sm = SoundManager::new();
2292        let mut chan = SndChannel::new(0x1234_0000, true);
2293
2294        // Install a short Buffer playback so it exhausts in 2
2295        // samples; attach double_buffer state around it.
2296        chan.play_buffer(vec![0x80, 0x80], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2297        chan.double_buffer = Some(DoubleBufferState {
2298            header_ptr: 0x0070_0000,
2299            current_buffer: 0,
2300            callback_addr: 0xCAFE_0000,
2301            chan_ptr: 0x1234_0000,
2302            sample_rate: OUTPUT_RATE << 16,
2303            num_channels: 1,
2304            sample_size: 8,
2305            last_buffer_seen: false,
2306            waiting_for_callback: false,
2307            pending_callback_buffers: [false; 2],
2308        });
2309        sm.channels.push(chan);
2310
2311        sm.mix_frame(4); // overshoots — buffer exhausts
2312
2313        // Exactly one PendingDoubleBackCallback pushed.
2314        assert_eq!(
2315            sm.pending_callbacks.len(),
2316            1,
2317            "one double-back callback queued"
2318        );
2319        let p = &sm.pending_callbacks[0];
2320        assert_eq!(p.callback_addr, 0xCAFE_0000);
2321        assert_eq!(p.chan_ptr, 0x1234_0000);
2322        assert_eq!(p.header_ptr, 0x0070_0000);
2323        assert_eq!(
2324            p.exhausted_buffer_index, 0,
2325            "exhausted index is the OLD current_buffer, not the flipped one"
2326        );
2327
2328        // Channel's double_buffer state flipped and armed.
2329        let db = sm.channels[0]
2330            .double_buffer
2331            .as_ref()
2332            .expect("db still present");
2333        assert_eq!(db.current_buffer, 1, "current_buffer flipped 0 → 1");
2334        assert!(
2335            db.waiting_for_callback,
2336            "waiting_for_callback armed so next frame doesn't re-trigger"
2337        );
2338        assert_eq!(db.pending_callback_buffers, [true, false]);
2339    }
2340
2341    /// Locks in `mix_frame`'s file-playback completion contract. When
2342    /// a channel with `playback_kind == File` and a non-zero
2343    /// `file_completion_addr` exhausts, `mix_frame` must push one
2344    /// `PendingSoundCallback::FileCompletion` (carrying the
2345    /// `file_completion_addr` as `callback_addr` and the channel
2346    /// `guest_ptr`) to `pending_sound_callbacks`, AND clear
2347    /// `file_completion_addr` on the channel. Per IM:Sound 2-151,
2348    /// `MyFilePlayCompletionRoutine(chan: SndChannelPtr)` is the
2349    /// signature the trap layer dispatches.
2350    #[test]
2351    fn mix_frame_file_playback_exhaust_queues_file_completion_callback() {
2352        let mut sm = SoundManager::new();
2353        let mut chan = SndChannel::new(0x1234_0000, true);
2354
2355        chan.play_buffer(
2356            vec![0x80; 2],
2357            OUTPUT_RATE << 16,
2358            PlaybackKind::File,
2359            0xABCD_1234, // file_completion_addr
2360        );
2361        assert_eq!(chan.file_completion_addr, 0xABCD_1234);
2362        sm.channels.push(chan);
2363
2364        sm.mix_frame(4); // overshoots
2365
2366        // Exactly one FileCompletion queued, no Command variants.
2367        assert_eq!(sm.pending_sound_callbacks.len(), 1);
2368        match &sm.pending_sound_callbacks[0] {
2369            PendingSoundCallback::FileCompletion {
2370                callback_addr,
2371                chan_ptr,
2372            } => {
2373                assert_eq!(
2374                    *callback_addr, 0xABCD_1234,
2375                    "file_completion_addr propagates"
2376                );
2377                assert_eq!(*chan_ptr, 0x1234_0000, "chan guest_ptr propagates");
2378            }
2379            other => panic!("expected FileCompletion, got {:?}", other),
2380        }
2381        // Channel's file_completion_addr cleared so we don't
2382        // double-fire next frame.
2383        assert_eq!(
2384            sm.channels[0].file_completion_addr, 0,
2385            "file_completion_addr must be cleared after push"
2386        );
2387        // Playback state cleared.
2388        assert!(!sm.channels[0].is_playing());
2389        assert!(!sm.channels[0].has_active_playback());
2390    }
2391
2392    /// Locks in `mix_frame`'s buffer-exhaustion-callback contract.
2393    /// When a channel with a non-zero `callback_addr` and queued
2394    /// `pending_callback_cmds` finishes playback (position past end
2395    /// of samples), `mix_frame` must:
2396    ///   - drain `pending_callback_cmds` via `take_pending_callback_cmds`
2397    ///   - push one `PendingSoundCallback::Command` per drained cmd
2398    ///     to `SoundManager::pending_sound_callbacks`
2399    ///   - clear `chan.playing` / `chan.playback_kind`
2400    ///
2401    /// The trap layer then fires each queued guest callback per
2402    /// IM:Sound 2-152 (`callBackCmd` / `MyCallbackProcedure`).
2403    #[test]
2404    fn mix_frame_buffer_exhaust_queues_pending_sound_callback_per_cmd() {
2405        let mut sm = SoundManager::new();
2406        let mut chan = SndChannel::new(0x1234_0000, true);
2407
2408        // 2-sample playback at unity rate so 2 mix_frame samples
2409        // exhaust it fully.
2410        chan.play_buffer(vec![0x80, 0x80], OUTPUT_RATE << 16, PlaybackKind::Buffer, 0);
2411        chan.callback_addr = 0xBEEF_0000;
2412        chan.queue_callback(SndCommand {
2413            cmd: cmd::CALLBACK,
2414            param1: 7,
2415            param2: 0x1111,
2416        });
2417        chan.queue_callback(SndCommand {
2418            cmd: cmd::CALLBACK,
2419            param1: 9,
2420            param2: 0x2222,
2421        });
2422        sm.channels.push(chan);
2423
2424        sm.mix_frame(4); // overshoots; buffer exhausts
2425
2426        // Playback cleared.
2427        assert!(
2428            !sm.channels[0].is_playing(),
2429            "playback cleared on exhaustion"
2430        );
2431        // Both queued callback cmds pushed to pending list.
2432        assert_eq!(
2433            sm.pending_sound_callbacks.len(),
2434            2,
2435            "one PendingSoundCallback::Command per queued callback cmd"
2436        );
2437        for (i, pending) in sm.pending_sound_callbacks.iter().enumerate() {
2438            match pending {
2439                PendingSoundCallback::Command {
2440                    callback_addr,
2441                    chan_ptr,
2442                    cmd,
2443                } => {
2444                    assert_eq!(*callback_addr, 0xBEEF_0000, "callback_addr propagates");
2445                    assert_eq!(*chan_ptr, 0x1234_0000, "chan_ptr propagates");
2446                    let expected_param1 = if i == 0 { 7 } else { 9 };
2447                    assert_eq!(cmd.param1, expected_param1, "cmd ordering preserved");
2448                }
2449                _ => panic!("expected Command variant, got {:?}", pending),
2450            }
2451        }
2452        // pending_callback_cmds drained from the channel.
2453        assert!(
2454            sm.channels[0].take_pending_callback_cmds().is_empty(),
2455            "pending_callback_cmds drained on exhaustion"
2456        );
2457    }
2458
2459    /// Locks in `mix_frame`'s file-paused-channel contract. When a
2460    /// channel has `file_paused=true` (via
2461    /// `pause_file_playback_toggle`), `mix_frame` must skip mixing
2462    /// it BUT still treat the manager as `any_active=true`, so the
2463    /// returned `Vec` is `num_samples` of silence (0x80) rather than
2464    /// the empty-`Vec` "no channels active" sentinel. This mirrors
2465    /// Mac Sound Manager semantics per IM:Sound 2-139: a paused
2466    /// file-playback channel holds its slot in the output stream;
2467    /// output doesn't vanish from the user's perspective.
2468    #[test]
2469    fn mix_frame_paused_file_channel_outputs_silence_not_empty() {
2470        let mut sm = SoundManager::new();
2471        let mut chan = SndChannel::new(0x1234_0000, true);
2472
2473        // Install a File-kind playback, then toggle paused on.
2474        chan.play_buffer(vec![0x80; 128], OUTPUT_RATE << 16, PlaybackKind::File, 0);
2475        chan.pause_file_playback_toggle();
2476        assert!(chan.file_paused, "file_paused must be set after toggle");
2477        sm.channels.push(chan);
2478
2479        let output = sm.mix_frame(64);
2480
2481        assert_eq!(
2482            output.len(),
2483            64,
2484            "paused file channel must still produce num_samples of output"
2485        );
2486        assert!(
2487            output.iter().all(|&b| b == 0x80),
2488            "paused file channel output must be pure silence (0x80)"
2489        );
2490        // Paused channel contributes silence; debug_samples_mixed
2491        // still tracks that samples flowed through the mixer.
2492        assert_eq!(
2493            sm.debug_samples_mixed, 64,
2494            "debug_samples_mixed tracks samples even for paused channels"
2495        );
2496    }
2497
2498    /// Locks in `mix_frame`'s FLUSH-ordering semantics. When a FLUSH
2499    /// command is queued BEFORE other commands, running `mix_frame`
2500    /// must drain FLUSH (which calls `chan.flush()` clearing the
2501    /// queue) and any subsequent queued commands are discarded rather
2502    /// than executed. A regression that reordered the match arms
2503    /// (e.g. processed all cmds first, then flush-after) would
2504    /// silently corrupt the Sound Manager's FIFO-drop-after-flush
2505    /// contract per IM:Sound 2-93 (`flushCmd`).
2506    #[test]
2507    fn mix_frame_flush_cmd_discards_subsequent_queued_cmds() {
2508        let mut sm = SoundManager::new();
2509        let mut chan = SndChannel::new(0x1234_0000, true);
2510        chan.callback_addr = 0x00AB_CDEF;
2511
2512        // Queue [FLUSH, CALLBACK]. If CALLBACK runs, it will post a
2513        // pending sound callback. FLUSH must discard it instead.
2514        chan.enqueue(SndCommand {
2515            cmd: cmd::FLUSH,
2516            param1: 0,
2517            param2: 0,
2518        });
2519        chan.enqueue(SndCommand {
2520            cmd: cmd::CALLBACK,
2521            param1: 7,
2522            param2: 0x1111_2222,
2523        });
2524        assert_eq!(chan.queue.len(), 2, "two cmds queued pre mix_frame");
2525        sm.channels.push(chan);
2526
2527        let output = sm.mix_frame(64);
2528
2529        assert!(output.is_empty(), "idle command drain produces no audio");
2530        assert!(
2531            sm.channels[0].queue.is_empty(),
2532            "queue must be empty after mix_frame"
2533        );
2534        assert!(
2535            sm.pending_sound_callbacks.is_empty(),
2536            "callback after FLUSH must be discarded, not executed"
2537        );
2538    }
2539
2540    /// Locks in `SndChannel::new`'s observable initial-state contract.
2541    /// A refactor that flipped `allocated` semantics, changed the
2542    /// default rate off unity, or pre-populated callbacks would
2543    /// silently break the Sound Manager contract per IM:Sound 2-80
2544    /// (`SndNewChannel` initial state) and IM:Sound 2-97 (unity rate
2545    /// default).
2546    #[test]
2547    fn sndchannel_new_initial_state_matches_mac_defaults() {
2548        let mut chan = SndChannel::new(0x1234_0000, true);
2549
2550        // Constructor args pass through unchanged.
2551        assert_eq!(
2552            chan.guest_ptr, 0x1234_0000,
2553            "guest_ptr must match constructor arg"
2554        );
2555        assert!(chan.allocated, "allocated=true must propagate");
2556
2557        // Fields that start zero / None per IM:Sound 2-80.
2558        assert_eq!(
2559            chan.callback_addr, 0,
2560            "callback_addr starts 0 (no userRoutine yet)"
2561        );
2562        assert!(
2563            chan.double_buffer.is_none(),
2564            "double_buffer starts None (not in SndPlayDoubleBuffer)"
2565        );
2566
2567        // Playback accessors report no activity on a fresh channel.
2568        assert!(!chan.is_playing(), "fresh channel reports is_playing=false");
2569        assert!(
2570            !chan.has_active_playback(),
2571            "fresh channel reports has_active_playback=false"
2572        );
2573
2574        // Rate defaults to unity — a channel playing a buffer at the
2575        // buffer's sample_rate with no explicit rateCmd must play at
2576        // that rate (IM:Sound 2-97).
2577        assert_eq!(
2578            chan.current_rate(),
2579            0x0001_0000,
2580            "rate_fixed must default to UNITY_RATE_FIXED (0x0001_0000)"
2581        );
2582
2583        // No pending callbacks queued.
2584        assert!(
2585            chan.take_pending_callback_cmds().is_empty(),
2586            "pending_callback_cmds must start empty"
2587        );
2588
2589        // `allocated=false` path (game-provided channel record).
2590        let guest_alloc = SndChannel::new(0xDEAD_0000, false);
2591        assert_eq!(guest_alloc.guest_ptr, 0xDEAD_0000);
2592        assert!(!guest_alloc.allocated, "allocated=false must propagate");
2593    }
2594}