//! Sound Manager trap handlers.
//!
//! Handles SndPlay, SndNewChannel, SndDisposeChannel, SndDoCommand,
//! SndDoImmediate, SoundDispatch, and SysBeep.
//! Reference: Inside Macintosh: Sound 1994; references/executor/src/sound/sound.cpp
use crate::cpu::{CpuOps, Register};
use crate::memory::{MacMemoryBus, MemoryBus};
use crate::sound::{self, SndChannel, SndCommand};
use crate::Result;
/// Size of the guest SndChannel record we allocate.
/// Must be large enough for the game to read fields it expects.
/// Sound 1994, 2-93: nextChan(4) + firstMod(4) + callBack(4) + userInfo(4)
/// + wait(4) + cmdInProgress(8) + flags(2) + qLength(2) + qHead(2) + qTail(2)
/// + `queue[128*8]` = 1060 bytes. We allocate 1088 for alignment.
const GUEST_SND_CHANNEL_SIZE: u32 = 1088;
const GUEST_SND_CHANNEL_CMD_IN_PROGRESS_OFFSET: u32 = 20;
const GUEST_SND_CHANNEL_FLAGS_OFFSET: u32 = 28;
const GUEST_SND_CHANNEL_Q_LENGTH_OFFSET: u32 = 30;
const GUEST_SND_CHANNEL_Q_HEAD_OFFSET: u32 = 32;
const GUEST_SND_CHANNEL_Q_TAIL_OFFSET: u32 = 34;
const GUEST_SND_CHANNEL_QUEUE_OFFSET: u32 = 36;
const SQUARE_WAVE_SYNTH_ID: i16 = 1;
const WAVE_TABLE_SYNTH_ID: i16 = 3;
const SAMPLED_SYNTH_ID: i16 = 5;
use std::sync::OnceLock;
static TRACE_SOUND: OnceLock<bool> = OnceLock::new();
fn trace_sound_enabled() -> bool {
*TRACE_SOUND.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_SOUND").is_some())
}
impl super::TrapDispatcher {
fn clear_guest_sound_channel_state(&self, bus: &mut MacMemoryBus, chan_ptr: u32) {
if chan_ptr == 0 {
return;
}
// Scrub the guest-visible record before the channel block is reused
// or returned to the allocator.
bus.write_long(chan_ptr, 0); // nextChan
bus.write_long(chan_ptr + 4, 0); // firstMod
bus.write_long(chan_ptr + 8, 0); // callBack
bus.write_long(chan_ptr + 12, 0); // userInfo
bus.write_long(chan_ptr + 16, 0); // wait
bus.write_long(chan_ptr + 20, 0); // cmdInProgress.cmd/param1
bus.write_long(chan_ptr + 24, 0); // cmdInProgress.param2
bus.write_word(chan_ptr + 28, 0); // flags
bus.write_word(chan_ptr + 30, 0); // qLength
bus.write_word(chan_ptr + 32, 0); // qHead
bus.write_word(chan_ptr + 34, 0); // qTail
}
fn release_sound_channel(&mut self, bus: &mut MacMemoryBus, chan_ptr: u32) {
self.sound_manager
.pending_callbacks
.retain(|pending| pending.chan_ptr != chan_ptr);
self.sound_manager
.pending_sound_callbacks
.retain(|pending| match pending {
crate::sound::PendingSoundCallback::Command {
chan_ptr: pending_chan,
..
}
| crate::sound::PendingSoundCallback::FileCompletion {
chan_ptr: pending_chan,
..
} => *pending_chan != chan_ptr,
});
if let Some(chan) = self.sound_manager.take_channel(chan_ptr) {
self.clear_guest_sound_channel_state(bus, chan.guest_ptr);
if chan.allocated {
bus.free(chan.guest_ptr);
}
}
}
pub(crate) fn dispatch_sound<C: CpuOps>(
&mut self,
is_tool: bool,
trap_num: u16,
cpu: &mut C,
bus: &mut MacMemoryBus,
) -> Option<Result<()>> {
Some(match (is_tool, trap_num) {
// SndPlay ($A805)
// FUNCTION SndPlay(chan: SndChannelPtr; sndHdl: SndListHandle; async: BOOLEAN): OSErr;
// Stack: SP+0: async(2), SP+2: sndHdl(4), SP+6: chan(4), SP+10: result(2)
// Sound 1994, 2-121 to 2-123
// SndPlay ($A805): Parses snd format 1 & 2, handles bufferCmd with stdSH
// (encode=$00), returns resProblem (-204) for NIL/unloaded handles and
// badFormat (-206) for malformed resources per IM:Sound 1994 p. 2-122..2-123.
(true, 0x005) => {
let sp = cpu.read_reg(Register::A7);
let async_flag = bus.read_word(sp) as i16;
let snd_handle = bus.read_long(sp + 2);
let chan_ptr = bus.read_long(sp + 6);
let err: i16 = if snd_handle != 0 {
let snd_ptr = bus.read_long(snd_handle);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndPlay async={} chan=${:08X} handle=${:08X} ptr=${:08X}",
async_flag, chan_ptr, snd_handle, snd_ptr
);
}
if snd_ptr != 0 {
self.snd_play_resource(bus, chan_ptr, snd_ptr)
} else {
// IM:Sound 1994 p. 2-122: unloaded sound handle -> resProblem.
-204
}
} else {
// A NIL Handle is not a loadable sound resource.
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndPlay async={} chan=${:08X} handle=NULL -> resProblem",
async_flag, chan_ptr
);
}
-204
};
bus.write_word(sp + 10, err as u16);
cpu.write_reg(Register::A7, sp + 10);
Ok(())
}
// ============================================================
// Sound Manager Stub family (HLE-no-audio rationale)
// ============================================================
//
// Three Sound Manager traps below — $A9C8 SysBeep, $A802
// SndAddModifier, $A806 SndControl — collapse to no-op /
// noErr-returning stubs in Systemless because the underlying
// facilities are not modelled. The HLE compromise is the
// same across all three: the trap pops its documented
// Pascal frame, writes the IM-canonical "success" sentinel
// (no result for SysBeep PROCEDURE; noErr for SndAddModifier
// and SndControl FUNCTIONs), and otherwise leaves
// dispatcher state untouched. Apps that defensively check
// OSErr proceed cleanly.
//
// Per-trap HLE rationale:
//
// $A9C8 SysBeep — Systemless does not emit audio. Per
// IM:Sound 2-181: "When the system alert sound is
// disabled, the Sound Manager effectively ignores all
// calls to the SysBeep procedure. No sound is created
// and the menu bar does not flash. Also, no resources
// are loaded into memory." Our HLE behaves as if the
// user had explicitly disabled the system alert sound
// via the Sound control panel — a defensible no-op per
// the IM-documented semantic. The duration parameter is
// per IM:II II-385 / IM:Sound 1994 1557 "ignored except
// on a Macintosh Plus, Macintosh SE, or Macintosh
// Classic when the system alert sound is the Simple
// Beep" — System 7+ on every other model ignores it.
//
// $A802 SndAddModifier — per IM:Sound 6808: "for
// internal Sound Manager use only. You should not call
// it in your application." The trap loads a 'snth'
// resource and links a modifier proc to a sound
// channel — Systemless runs no synthesizer modifier procs
// (no guest-fn dispatch infrastructure for the modifier
// ProcPtr; same compromise documented for ModalDialog
// filterProc + Alert filterProc + Pack1 LSearch
// searchProc).
//
// $A806 SndControl — per IM:Sound 6232: "In Sound
// Manager version 3.0 and later, however, you virtually
// never need to call SndControl. The capabilities that
// SndControl provides are either provided by the
// Gestalt function or are no longer supported. The
// SndControl function is documented here for
// completeness only." Systemless reports Sound Manager
// 3.0 via SndSoundManagerVersion ($0C) so apps gated
// on the version check skip SndControl entirely.
//
// Per-trap status table:
// $A9C8 SysBeep — Stub (no-op): PROCEDURE, no
// return value, no other side
// effects.
// $A802 SndAddModifier — Stub: FUNCTION returning OSErr,
// writes 0 (noErr).
// $A806 SndControl — Stub: FUNCTION returning OSErr,
// writes 0 (noErr).
//
// SoundDispatch sub-routine SCStatus / SMStatus introspection
// ($0010 SndChannelStatus / $0014 SndManagerStatus) are
// promoted to Partial below — they fill the documented
// VAR-out status records with HLE-defensible defaults
// (zero-fill SCStatus; smNumChannels = active channel
// count, zero CPU load fields).
// SysBeep ($A9C8)
// PROCEDURE SysBeep(duration: INTEGER);
// Stack: SP+0: duration(2)
// Inside Macintosh Volume II, II-385
// Inside Macintosh: Sound 1994, 2-181 (line 1551..1572)
//
// HLE: Systemless emits no audio. Per IM:Sound 2-181 the
// documented behaviour when the system alert sound is
// disabled is "no sound is created and the menu bar does
// not flash. Also, no resources are loaded into memory" —
// our no-op is the IM-correct disabled-sound semantic.
// Pops 2 bytes (duration INTEGER), no return value, no
// other side effects (no SndChannel allocation, no command
// queue mutation, no menu-bar flash).
// SysBeep ($A9C8): Pops 2-byte duration per IM:II II-385 + IM:Sound 1994 2-181; HLE emits no audio (matches IM-documented "system alert sound disabled" semantic — no SndChannel allocation, no command-queue mutation, no menu-bar flash)
(true, 0x1C8) => {
let sp = cpu.read_reg(Register::A7);
cpu.write_reg(Register::A7, sp + 2);
Ok(())
}
// SoundDispatch ($A800)
// Multi-purpose Sound Manager dispatcher. Selector in D0.
// Bits 31-24: parameter bytes / 2
// Bits 23-16: routine selector
// Sound 1994, 2-256
//
// Caveat on the paramSize byte: IM:Sound 1994 page 8401's
// selector reference table lists routines like SndChannelStatus
// as `$00100008` — paramSize byte is documented as 0 even
// though the function's stack signature requires 10 bytes of
// params. Real apps built with MPW glue compute the selector
// at compile time including the correct paramSize byte
// (e.g. `$05100008` for SndChannelStatus, `$03140008` for
// SndManagerStatus). Apps that pass the IM table's literal
// value directly hit our routine handler with `param_bytes=0`
// and would corrupt the stack on routines that expect to pop
// params. None of the canonical games we exercise hit this
// code path, so the routines that don't read the stack
// (0x10/0x14/0x1C) currently stub-respond at sp+0 instead
// of sp+param_bytes. Re-evaluate per-routine if a future
// title trips the divergence.
// SoundDispatch ($A800): Routes by selector; SndPlayDoubleBuffer ($20) with doubleback callbacks, SndStartFilePlay ($00) plays 'snd ' resources by ID or AIFF file by refnum with async completion callbacks, SndStopFilePlay ($08) stops channel, SndPauseFilePlay ($04) toggles file pause, SndSoundManagerVersion ($0C) returns 3.0; SndChannelStatus ($10) zero-fills 24-byte SCStatus per IM:Sound 2-101 (no async playback so all fields are documented-correct at zero); SndManagerStatus ($14) fills 6-byte SMStatus with smNumChannels = active channel count + zero CPU load fields per IM:Sound 2-101 + 2-201; volume selectors ($24/$28/$2C/$30) stub
(true, 0x000) => {
let sp = cpu.read_reg(Register::A7);
let selector = cpu.read_reg(Register::D0);
let param_bytes = ((selector >> 24) & 0xFF) * 2;
let routine = (selector >> 16) & 0xFF;
if trace_sound_enabled() {
eprintln!(
"[SOUND] SoundDispatch selector=${:08X} routine=${:02X} param_bytes={}",
selector, routine, param_bytes
);
}
match routine {
// SndSoundManagerVersion (routine $0C, sel $000C0008)
// FUNCTION SndSoundManagerVersion: NumVersion;
// Sound 1994, 2-201
0x0C => {
// Executor reports 3.3.3.3 here; matching that value
// keeps apps on the Sound Manager 3.x path.
bus.write_long(sp, 0x03030303);
}
// SndPlayDoubleBuffer (routine $20, sel $04200008)
// FUNCTION SndPlayDoubleBuffer(chan: SndChannelPtr;
// params: SndDoubleBufferHeaderPtr): OSErr;
// Stack: SP+0: params(4), SP+4: chan(4), SP+8: result(2)
// Sound 1994, 2-145
0x20 => {
let params_ptr = bus.read_long(sp);
let chan_ptr = bus.read_long(sp + 4);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndPlayDoubleBuffer chan=${:08X} params=${:08X}",
chan_ptr, params_ptr
);
}
self.snd_play_double_buffer(bus, chan_ptr, params_ptr);
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// GetSysBeepVolume (routine $24, sel $02240024)
// FUNCTION GetSysBeepVolume(VAR level: LongInt): OSErr;
// Sound 1994, 2-203
0x24 => {
let level_ptr = bus.read_long(sp);
if level_ptr != 0 {
bus.write_long(level_ptr, 0x00500050); // half volume L+R
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SetSysBeepVolume (routine $28, sel $02280024)
// FUNCTION SetSysBeepVolume(level: LongInt): OSErr;
// Sound 1994, 2-204
0x28 => {
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// GetDefaultOutputVolume (routine $2C, sel $022C0024)
// FUNCTION GetDefaultOutputVolume(VAR level: LongInt): OSErr;
// Sound 1994, 2-205
0x2C => {
let level_ptr = bus.read_long(sp);
if level_ptr != 0 {
bus.write_long(level_ptr, 0x00500050); // half volume L+R
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SetDefaultOutputVolume (routine $30, sel $02300024)
// FUNCTION SetDefaultOutputVolume(level: LongInt): OSErr;
// Sound 1994, 2-206
0x30 => {
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndStartFilePlay (routine $00, sel $0D000008)
// FUNCTION SndStartFilePlay(chan: SndChannelPtr; fRefNum: Integer;
// resNum: Integer; bufferSize: LongInt; theBuffer: Ptr;
// theSelection: AudioSelectionPtr; theCompletion: ProcPtr;
// async: Boolean): OSErr;
// Sound 1994, 2-138
0x00 => {
let async_flag = bus.read_word(sp) as i16;
let completion = bus.read_long(sp + 2);
let selection = bus.read_long(sp + 6);
let the_buffer = bus.read_long(sp + 10);
let buffer_size = bus.read_long(sp + 14);
let res_num = bus.read_word(sp + 18) as i16;
let f_ref_num = bus.read_word(sp + 20) as i16;
let chan_ptr = bus.read_long(sp + 22);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndStartFilePlay chan=${:08X} fRefNum={} resNum={} buffer_size={} buffer=${:08X} selection=${:08X} completion=${:08X} async={}",
chan_ptr,
f_ref_num,
res_num,
buffer_size,
the_buffer,
selection,
completion,
async_flag
);
}
let err = self.snd_start_file_play(
bus, chan_ptr, f_ref_num, res_num, selection, completion, async_flag,
);
bus.write_word(sp + param_bytes, err as u16);
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndPauseFilePlay (routine $04, sel $02040008)
// Sound 1994, 2-139
0x04 => {
let chan_ptr = bus.read_long(sp);
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.pause_file_playback_toggle();
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndStopFilePlay (routine $08, sel $03080008)
// FUNCTION SndStopFilePlay(chan: SndChannelPtr;
// quietNow: Boolean): OSErr;
// Stack: SP+0: quietNow(2), SP+2: chan(4), SP+6: result(2)
// Sound 1994, 2-140
0x08 => {
let _quiet_now = bus.read_word(sp) as i16;
let chan_ptr = bus.read_long(sp + 2);
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.quiet();
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndChannelStatus (routine $10)
// FUNCTION SndChannelStatus(chan: SndChannelPtr;
// theLength: Integer;
// theStatus: SCStatusPtr): OSErr;
// Pascal stack frame (param_bytes = 10):
// SP+0 theStatus SCStatusPtr (4) — last-pushed
// SP+4 theLength INTEGER (2)
// SP+6 chan SndChannelPtr (4) — first-pushed
// SP+10 result OSErr (2)
// IM:Sound 6286 documents the canonical selector
// as $00100008 (paramSize byte = 0); MPW glue
// computes $05100008 (paramSize = 5 = 10 bytes).
// Inside Macintosh: Sound 1994, 2-101 + 2-200 (line 6267..6300)
//
// HLE: zero-fill the 24-byte SCStatus record per
// IM:Sound 7182..7194 layout. With no async audio
// playback in Systemless, all fields are documented-
// correct at zero: scStartTime/scEndTime/
// scCurrentTime = 0 (not playing from disk),
// scChannelBusy = FALSE (no commands processing),
// scChannelDisposed = FALSE (channel still
// valid), scChannelPaused = FALSE, scUnused = 0,
// scChannelAttributes = 0 (no init flags set),
// scCPULoad = 0 (per IM:Sound 3043: "this field
// is obsolete, and you should not rely on its
// value"). Apps using Listing 2-13's pattern
// (reading scChannelPaused) get the IM-correct
// FALSE; apps polling scChannelBusy (Listing 2-25)
// get FALSE — exactly what they'd see on real
// Mac after the channel has finished processing
// all commands.
0x10 => {
let the_status = bus.read_long(sp);
let _the_length = bus.read_word(sp + 4) as i16;
let _chan = bus.read_long(sp + 6);
if the_status != 0 {
// SCStatus is 24 bytes per IM:Sound 7182..7194:
// scStartTime Fixed(4) + scEndTime Fixed(4)
// + scCurrentTime Fixed(4) + scChannelBusy Bool(1)
// + scChannelDisposed Bool(1) + scChannelPaused Bool(1)
// + scUnused Bool(1) + scChannelAttributes LongInt(4)
// + scCPULoad LongInt(4) = 24 bytes total
for off in 0..24 {
bus.write_byte(the_status + off, 0);
}
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndManagerStatus (routine $14)
// FUNCTION SndManagerStatus(theLength: Integer;
// theStatus: SMStatusPtr): OSErr;
// Pascal stack frame (param_bytes = 6):
// SP+0 theStatus SMStatusPtr (4) — last-pushed
// SP+4 theLength INTEGER (2) — first-pushed
// SP+6 result OSErr (2)
// MPW glue computes $03140008 (paramSize = 3 = 6 bytes).
// Inside Macintosh: Sound 1994, 2-101 + 2-201 (line 6302..6330)
//
// HLE: fill the 6-byte PACKED SMStatus record per
// IM:Sound 7196..7204 layout. smNumChannels gets
// the live count from self.sound_manager.channels
// (so apps using Listing 2-14's MyGetNumChannels
// pattern see the correct allocated-channel
// count). smMaxCPULoad and smCurCPULoad are 0
// (per IM:Sound 3081 WARNING: "Your application
// should not reply on the values returned in the
// smMaxCPULoad and smCurCPULoad fields. To
// determine if it is safe to allocate a channel,
// simply try to allocate it with the SndNewChannel
// function" — Systemless's no-CPU-load semantic
// matches the documented unreliability and is
// the safest defensive default).
0x14 => {
let _the_length = bus.read_word(sp + 4) as i16;
let the_status = bus.read_long(sp);
if the_status != 0 {
// SMStatus is 6 bytes (PACKED RECORD per
// IM:Sound 7198..7203):
// smMaxCPULoad INTEGER (2)
// smNumChannels INTEGER (2)
// smCurCPULoad INTEGER (2)
bus.write_word(the_status, 0); // smMaxCPULoad
let num_channels = self.sound_manager.channels.len() as u16;
bus.write_word(the_status + 2, num_channels);
bus.write_word(the_status + 4, 0); // smCurCPULoad
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// SndGetSysBeepState (routine $18, sel $00180008)
// Sound 1994, 2-202
//
// Sound Input Manager also uses routine byte $18 under
// selector family $0014: SPBOpenDevice is $05180014.
// Disambiguate on the low selector word before treating
// this as the beep-state procedure.
0x18 => {
if selector & 0xFFFF == 0x0014 {
// SPBOpenDevice(deviceName, permission, VAR inRefNum): OSErr.
// Stack: SP+0 inRefNum ptr, SP+4 permission,
// SP+6 deviceName ptr, SP+10 result.
let in_ref_num_ptr = bus.read_long(sp);
let _permission = bus.read_word(sp + 4) as i16;
let _device_name = bus.read_long(sp + 6);
if in_ref_num_ptr != 0 {
bus.write_long(in_ref_num_ptr, 1);
}
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
} else {
// Write 1 (sysBeepEnable) to the VAR param.
let state_ptr = bus.read_long(sp);
if state_ptr != 0 {
bus.write_word(state_ptr, 1);
}
}
cpu.write_reg(Register::D0, 0);
}
// SndSetSysBeepState (routine $1C, sel $001C0008)
// Sound 1994, 2-202
0x1C => {
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
// GetSoundHeaderOffset (routine $0404>>8=04, sel $04040024)
// Actually routine byte is at bits 16-23, so this would be
// the same $04 as SndPauseFilePlay. The full selector
// distinguishes them via the low word. For now, $04 handles
// both — SndPauseFilePlay is a no-op anyway.
_ => {
// Unknown selector — pop params and return noErr.
if param_bytes > 0 {
bus.write_word(sp + param_bytes, 0); // noErr
cpu.write_reg(Register::A7, sp + param_bytes);
}
}
}
cpu.write_reg(Register::D0, 0);
Ok(())
}
// SndNewChannel ($A807)
// FUNCTION SndNewChannel(VAR chan: SndChannelPtr; synth: Integer;
// init: LongInt; userRoutine: ProcPtr): OSErr;
// Stack: SP+0: userRoutine(4), SP+4: init(4), SP+8: synth(2),
// SP+10: chan(4, VAR), SP+14: result(2)
// Sound 1994, 2-195
// SndNewChannel ($A807): Accepts synth 0 (no linked synth)
// or the documented built-in synth ids 1/3/5; other nonzero
// synth ids fail with resProblem (-204) before any channel
// allocation or caller memory write. For successful calls,
// allocates/initializes the guest channel record with callback
// procedure and tracks it in SoundManager.
(true, 0x007) => {
let sp = cpu.read_reg(Register::A7);
let user_routine = bus.read_long(sp);
let init = bus.read_long(sp + 4);
let synth = bus.read_word(sp + 8) as i16;
let chan_ptr_ptr = bus.read_long(sp + 10);
let supported_synth = matches!(
synth,
0 | SQUARE_WAVE_SYNTH_ID | WAVE_TABLE_SYNTH_ID | SAMPLED_SYNTH_ID
);
if !supported_synth {
bus.write_word(sp + 14, (-204i16) as u16); // resProblem
cpu.write_reg(Register::A7, sp + 14);
Ok(())
} else {
let existing_guest_ptr = if chan_ptr_ptr != 0 {
bus.read_long(chan_ptr_ptr)
} else {
0
};
let (guest_ptr, allocated) = if existing_guest_ptr != 0 {
(existing_guest_ptr, false)
} else {
(bus.alloc(GUEST_SND_CHANNEL_SIZE), true)
};
let preserved_user_info = if existing_guest_ptr != 0 {
bus.read_long(guest_ptr + 12)
} else {
0
};
let q_length = if existing_guest_ptr != 0 {
bus.read_word(guest_ptr + 30).max(1)
} else {
128
};
// Initialize key fields in guest memory so the game can inspect them.
// SndChannel layout: nextChan(4), firstMod(4), callBack(4), userInfo(4),
// wait(4), cmdInProgress(8), flags(2), qLength(2), qHead(2), qTail(2)
bus.write_long(guest_ptr, 0); // nextChan
bus.write_long(guest_ptr + 4, 0); // firstMod
bus.write_long(guest_ptr + 8, user_routine); // callBack
bus.write_long(guest_ptr + 12, preserved_user_info); // userInfo
bus.write_long(guest_ptr + 16, 0); // wait
bus.write_long(guest_ptr + 20, 0); // cmdInProgress.cmd/param1
bus.write_long(guest_ptr + 24, 0); // cmdInProgress.param2
bus.write_word(guest_ptr + 28, 0); // flags
bus.write_word(guest_ptr + 30, q_length); // qLength
bus.write_word(guest_ptr + 32, 0); // qHead
bus.write_word(guest_ptr + 34, 0); // qTail
if chan_ptr_ptr != 0 {
bus.write_long(chan_ptr_ptr, guest_ptr);
}
self.sound_manager.remove_channel(guest_ptr);
let mut chan = SndChannel::new(guest_ptr, allocated);
chan.callback_addr = user_routine;
self.sound_manager.channels.push(chan);
if trace_sound_enabled() {
// Log caller PC + surrounding bytes for back-referencing
// sound-init call sites.
let pc = cpu.read_reg(Register::PC);
eprintln!(
"[SOUND] SndNewChannel synth={} init=${:08X} userRoutine=${:08X} -> chan=${:08X} (PC=${:08X})",
synth, init, user_routine, guest_ptr, pc
);
// 8 words of instruction context around PC, starting
// 8 bytes before (typical post-trap PC points to the
// instruction AFTER the $A807 word; walking back
// gives us the call site).
let ctx_start = pc.saturating_sub(64);
eprint!("[SOUND] SndNewChannel ctx @${:08X}:", ctx_start);
for i in 0..40 {
eprint!(" {:04X}", bus.read_word(ctx_start + (i as u32) * 2));
}
eprintln!();
// Also read A5 so we can decode -$4052(A5) pushes.
let a5 = cpu.read_reg(Register::A5);
eprintln!(
"[SOUND] SndNewChannel A5=${:08X} -$4052(A5)=${:08X} -$4050(A5)=${:08X} -$4054(A5)=${:08X}",
a5,
bus.read_long(a5.wrapping_sub(0x4052)),
bus.read_long(a5.wrapping_sub(0x4050)),
bus.read_long(a5.wrapping_sub(0x4054))
);
}
bus.write_word(sp + 14, 0); // noErr
cpu.write_reg(Register::A7, sp + 14);
Ok(())
}
}
// SndDisposeChannel ($A801)
// FUNCTION SndDisposeChannel(chan: SndChannelPtr; quietNow: BOOLEAN): OSErr;
// Stack: SP+0: quietNow(2), SP+2: chan(4), SP+6: result(2)
// Sound 1994, 2-196
// SndDisposeChannel ($A801): Removes channel from SoundManager
(true, 0x001) => {
let sp = cpu.read_reg(Register::A7);
let quiet_now = bus.read_word(sp) as i16;
let chan_ptr = bus.read_long(sp + 2);
self.release_sound_channel(bus, chan_ptr);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndDisposeChannel chan=${:08X} quietNow={}",
chan_ptr, quiet_now
);
}
bus.write_word(sp + 6, 0); // noErr
cpu.write_reg(Register::A7, sp + 6);
Ok(())
}
// SndDoCommand ($A803)
// pascal OSErr SndDoCommand(SndChannelPtr chan, const SndCommand *cmd,
// Boolean noWait);
// Stack: SP+0: noWait(2), SP+2: cmd(4, ptr), SP+6: chan(4), SP+10: result(2)
// Sound 1994, 2-130
// SndDoCommand ($A803): Public entry point rejects NIL/unknown
// channels with badChannel (-205). On a busy channel, commands
// enter the channel FIFO; SndDoImmediate is the queue-bypass path.
// Idle-channel commands execute immediately in the HLE so query
// commands like getRateCmd still return observable results.
(true, 0x003) => {
let sp = cpu.read_reg(Register::A7);
let no_wait = bus.read_word(sp) as i16;
let cmd_ptr = bus.read_long(sp + 2);
let chan_ptr = bus.read_long(sp + 6);
let valid_channel = chan_ptr != 0
&& self
.sound_manager
.channels
.iter()
.any(|chan| chan.guest_ptr == chan_ptr);
let err = if valid_channel {
// Only touch the guest command record after the channel
// has been validated.
let cmd = self.read_snd_command(bus, cmd_ptr);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndDoCommand chan=${:08X} noWait={} cmd={} param1={} param2=${:08X}",
chan_ptr, no_wait, cmd.cmd, cmd.param1, cmd.param2
);
}
let channel_busy = self
.sound_manager
.find_channel_mut(chan_ptr)
.map(|chan| chan.has_active_playback() || chan.double_buffer.is_some())
.unwrap_or(false);
if channel_busy {
if self
.sound_manager
.find_channel_mut(chan_ptr)
.is_some_and(|chan| chan.enqueue(cmd))
{
0
} else {
-203 // queueFull
}
} else {
self.execute_sound_command(bus, chan_ptr, cmd);
0
}
} else {
-205 // badChannel
};
bus.write_word(sp + 10, err as u16);
cpu.write_reg(Register::A7, sp + 10);
Ok(())
}
// SndDoImmediate ($A804)
// pascal OSErr SndDoImmediate(SndChannelPtr chan, const SndCommand *cmd);
// Stack: SP+0: cmd(4, ptr), SP+4: chan(4), SP+8: result(2)
// Sound 1994, 2-131
// SndDoImmediate ($A804): Same command support as SndDoCommand,
// including badChannel (-205) for NIL/unknown channels.
(true, 0x004) => {
let sp = cpu.read_reg(Register::A7);
let cmd_ptr = bus.read_long(sp);
let chan_ptr = bus.read_long(sp + 4);
let valid_channel = chan_ptr != 0
&& self
.sound_manager
.channels
.iter()
.any(|chan| chan.guest_ptr == chan_ptr);
let err = if valid_channel {
// Only touch the guest command record after the channel
// has been validated.
let cmd = self.read_snd_command(bus, cmd_ptr);
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndDoImmediate chan=${:08X} cmd={} param1={} param2=${:08X}",
chan_ptr, cmd.cmd, cmd.param1, cmd.param2
);
}
self.execute_sound_command(bus, chan_ptr, cmd);
0
} else {
-205 // badChannel
};
bus.write_word(sp + 8, err as u16);
cpu.write_reg(Register::A7, sp + 8);
Ok(())
}
// SndAddModifier ($A802)
// FUNCTION SndAddModifier(chan: SndChannelPtr; modifier: ProcPtr;
// id: INTEGER; init: LongInt): OSErr;
// Stack: SP+0 init(4), SP+4 id(2), SP+6 modifier(4), SP+10 chan(4), SP+14 result(2)
// Inside Macintosh: Sound 1994, 2-197 (line 6790..6820)
//
// HLE: per IM:Sound 6808 "for internal Sound Manager use
// only. You should not call it in your application."
// On stock BasiliskII System 7.5.3, a direct application
// call with a NIL `chan` still pops the documented
// 14-byte frame and returns badChannel (-205). Systemless matches that modern
// runtime envelope instead of the older nominal-success
// prose, because we do not model Sound Manager internal
// modifier linkage or 68K guest-fn dispatch (same
// compromise as ModalDialog filterProc, Alert filterProc,
// Pack1 LSearch searchProc).
//
// Regression coverage:
// sound::tests::sndaddmodifier_nil_channel_returns_badchannel_and_consumes_pascal_frame
// SndAddModifier ($A802): Pops 14-byte Pascal frame (init LongInt + id INTEGER + modifier ProcPtr + chan SndChannelPtr) per IM:Sound 1994 2-197 and writes badChannel (-205) at SP+14 to match BasiliskII's observed NIL-channel behavior for this obsolete internal-only trap.
(true, 0x002) => {
let sp = cpu.read_reg(Register::A7);
bus.write_word(sp + 14, (-205i16) as u16); // badChannel
cpu.write_reg(Register::A7, sp + 14);
Ok(())
}
// SndControl ($A806)
// FUNCTION SndControl(id: INTEGER; VAR cmd: SndCommand): OSErr;
// Stack: SP+0 cmd(4, ptr), SP+4 id(2), SP+6 result(2)
// Inside Macintosh: Sound 1994, 2-134 to 2-135
//
// HLE: Systemless reports Sound Manager 3.0 via
// SndSoundManagerVersion ($A800 selector $0C), so the
// legacy control-query surface is narrow. Still, callers
// that do use SndControl expect the documented query
// commands to round-trip through the SndCommand record.
//
// We therefore model the 3.x-compatible subset:
// - availableCmd: report zero-init support for the
// documented built-in synth ids (square/wavetable/
// sampled) by rewriting param1 to 1; otherwise 0.
// - totalLoadCmd/loadCmd: obsolete in 3.x, normalize
// the documented load-factor output word to 0.
// - versionCmd: obsolete; normalize param1/param2 to 0.
//
// Unknown commands still succeed as noErr and preserve the
// caller's record contents, which keeps the HLE tolerant of
// stray information probes while making the documented
// query subset observable to fixture code.
//
// Regression coverage:
// sound::tests::sndcontrol_consumes_cmdptr_and_id_and_returns_noerr
// sound::tests::sndcontrol_availablecmd_zero_init_known_synth_sets_param1_true
// SndControl ($A806): Pops 6-byte Pascal frame (cmd Ptr + id INTEGER) per IM:Sound 1994 2-134..2-135; writes noErr at SP+6 and normalizes the documented control-query outputs for availableCmd/versionCmd/totalLoadCmd/loadCmd.
(true, 0x006) => {
let sp = cpu.read_reg(Register::A7);
let cmd_ptr = bus.read_long(sp);
let synth_id = bus.read_word(sp + 4);
if cmd_ptr != 0 {
let mut cmd = self.read_snd_command(bus, cmd_ptr);
match cmd.cmd {
sound::cmd::AVAILABLE => {
let supported_synth = matches!(synth_id, 1 | 3 | 5);
cmd.param1 = if supported_synth && cmd.param2 == 0 {
1
} else {
0
};
}
sound::cmd::VERSION => {
cmd.param1 = 0;
cmd.param2 = 0;
}
sound::cmd::TOTAL_LOAD | sound::cmd::LOAD => {
cmd.param1 = 0;
}
_ => {}
}
self.write_guest_snd_command(bus, cmd_ptr, Some(cmd));
}
bus.write_word(sp + 6, 0); // noErr
cpu.write_reg(Register::A7, sp + 6);
Ok(())
}
_ => return None,
})
}
/// Read a SndCommand record from guest memory.
/// SndCommand: cmd(2) + param1(2) + param2(4) = 8 bytes.
/// Sound 1994, 2-99
fn read_snd_command(&self, bus: &MacMemoryBus, addr: u32) -> SndCommand {
SndCommand {
cmd: bus.read_word(addr),
param1: bus.read_word(addr + 2) as i16,
param2: bus.read_long(addr + 4),
}
}
fn write_guest_snd_command(&self, bus: &mut MacMemoryBus, addr: u32, cmd: Option<SndCommand>) {
let cmd = cmd.unwrap_or(SndCommand {
cmd: 0,
param1: 0,
param2: 0,
});
bus.write_word(addr, cmd.cmd);
bus.write_word(addr + 2, cmd.param1 as u16);
bus.write_long(addr + 4, cmd.param2);
}
pub(crate) fn sync_guest_sound_channel_state(&self, bus: &mut MacMemoryBus) {
for chan in &self.sound_manager.channels {
if chan.guest_ptr == 0 {
continue;
}
let active = chan.has_active_playback() || chan.double_buffer.is_some();
bus.write_word(
chan.guest_ptr + GUEST_SND_CHANNEL_FLAGS_OFFSET,
if active { 1 } else { 0 },
);
if !active {
self.write_guest_snd_command(
bus,
chan.guest_ptr + GUEST_SND_CHANNEL_CMD_IN_PROGRESS_OFFSET,
None,
);
}
}
}
/// Drain queue entries written directly into the guest SndChannel record.
///
/// Sound 1994 documents the queue embedded in SndChannel, and Executor's
/// interrupt-driven sound callback consumes commands from that queue.
/// Some games update it directly instead of calling SndDoCommand for every
/// command. Keep the guest-visible queue/head/tail authoritative here.
pub(crate) fn service_guest_sound_queues(&mut self, bus: &mut MacMemoryBus) {
let channel_ptrs: Vec<u32> = self
.sound_manager
.channels
.iter()
.map(|chan| chan.guest_ptr)
.filter(|ptr| *ptr != 0)
.collect();
for chan_ptr in channel_ptrs {
let mut serviced = 0usize;
loop {
serviced += 1;
if serviced > 16 {
break;
}
let active = self
.sound_manager
.find_channel_mut(chan_ptr)
.map(|chan| chan.has_active_playback() || chan.double_buffer.is_some())
.unwrap_or(false);
if active {
break;
}
let q_length = bus
.read_word(chan_ptr + GUEST_SND_CHANNEL_Q_LENGTH_OFFSET)
.max(1) as usize;
let q_head = bus.read_word(chan_ptr + GUEST_SND_CHANNEL_Q_HEAD_OFFSET) as usize;
let q_tail = bus.read_word(chan_ptr + GUEST_SND_CHANNEL_Q_TAIL_OFFSET) as usize;
if q_head == q_tail {
let cmd = self
.read_snd_command(bus, chan_ptr + GUEST_SND_CHANNEL_CMD_IN_PROGRESS_OFFSET);
if cmd.cmd == 0 {
break;
}
if trace_sound_enabled() {
eprintln!(
"[SOUND] guest cmdInProgress chan=${:08X} cmd={} param1={} param2=${:08X}",
chan_ptr, cmd.cmd, cmd.param1, cmd.param2
);
}
self.execute_sound_command(bus, chan_ptr, cmd);
let still_active = self
.sound_manager
.find_channel_mut(chan_ptr)
.map(|chan| chan.has_active_playback() || chan.double_buffer.is_some())
.unwrap_or(false);
if !still_active {
self.write_guest_snd_command(
bus,
chan_ptr + GUEST_SND_CHANNEL_CMD_IN_PROGRESS_OFFSET,
None,
);
}
continue;
}
let slot = q_head % q_length;
let cmd_addr = chan_ptr + GUEST_SND_CHANNEL_QUEUE_OFFSET + (slot as u32) * 8;
let cmd = self.read_snd_command(bus, cmd_addr);
if trace_sound_enabled() {
eprintln!(
"[SOUND] guest queue chan=${:08X} head={} tail={} cmd={} param1={} param2=${:08X}",
chan_ptr, q_head, q_tail, cmd.cmd, cmd.param1, cmd.param2
);
}
bus.write_word(
chan_ptr + GUEST_SND_CHANNEL_Q_HEAD_OFFSET,
((slot + 1) % q_length) as u16,
);
self.write_guest_snd_command(
bus,
chan_ptr + GUEST_SND_CHANNEL_CMD_IN_PROGRESS_OFFSET,
Some(cmd.clone()),
);
self.execute_sound_command(bus, chan_ptr, cmd);
}
}
self.sync_guest_sound_channel_state(bus);
}
/// Execute a sound command on a channel, handling bufferCmd to load samples.
fn execute_sound_command(&mut self, bus: &mut MacMemoryBus, chan_ptr: u32, cmd: SndCommand) {
self.sound_manager.debug_cmd_count += 1;
// Dedup-record this cmd code so runtime diagnostics can surface
// the cmd-mix distribution per-game.
if !self.sound_manager.debug_cmd_codes_seen.contains(&cmd.cmd) {
self.sound_manager.debug_cmd_codes_seen.push(cmd.cmd);
}
match cmd.cmd {
sound::cmd::NULL => {}
sound::cmd::QUIET => {
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.quiet();
}
}
sound::cmd::FLUSH => {
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.flush();
}
}
sound::cmd::REST => {
// Sound 1994, 2-95: restCmd inserts a rest of `param1`
// half-frames in a sequence-channel (note synth path).
// For a sample-mixing channel rests don't translate to
// anything observable in the PCM stream; the channel
// either has buffered samples to play or doesn't. Accept
// as a recognised no-op.
}
sound::cmd::CALLBACK => {
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
if chan.has_active_playback() || chan.double_buffer.is_some() {
chan.queue_callback(cmd);
} else {
let callback_addr = chan.callback_addr;
let chan_ptr = chan.guest_ptr;
if callback_addr != 0 {
self.sound_manager.pending_sound_callbacks.push(
crate::sound::PendingSoundCallback::Command {
callback_addr,
chan_ptr,
cmd,
},
);
}
}
}
}
sound::cmd::VOLUME => {
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.set_volume(cmd.param2);
}
}
sound::cmd::BUFFER | sound::cmd::SOUND => {
self.execute_buffer_cmd(bus, chan_ptr, &cmd);
}
sound::cmd::RATE => {
if let Some(chan) = self.sound_manager.find_channel_mut(chan_ptr) {
chan.set_rate(cmd.param2);
}
}
sound::cmd::GET_RATE => {
if cmd.param2 != 0 {
let rate = self
.sound_manager
.find_channel_mut(chan_ptr)
.map(|c| c.current_rate())
.unwrap_or(0x0001_0000);
bus.write_long(cmd.param2, rate);
}
}
_ => {
if !self.sound_manager.debug_unhandled_cmds.contains(&cmd.cmd) {
self.sound_manager.debug_unhandled_cmds.push(cmd.cmd);
}
if trace_sound_enabled() {
eprintln!(
"[SOUND] unhandled cmd={} param1={} param2=${:08X} chan=${:08X}",
cmd.cmd, cmd.param1, cmd.param2, chan_ptr
);
}
}
}
}
/// Handle bufferCmd: read SoundHeader from guest memory and start playback.
/// Supports stdSH (encode=$00), extSH (encode=$FF), and MACE cmpSH (encode=$FE).
///
/// stdSH layout (Sound 1994, 2-104):
/// +0: samplePtr (long), +4: length (long), +8: sampleRate (Fixed),
/// +12: loopStart, +16: loopEnd, +20: encode ($00), +21: baseFreq,
/// +22: sampleArea — unsigned 8-bit mono PCM
///
/// extSH layout (Sound 1994, 2-106):
/// +0: samplePtr (long), +4: numChannels (long), +8: sampleRate (Fixed),
/// +12: loopStart, +16: loopEnd, +20: encode ($FF), +21: baseFreq,
/// +22: numFrames (long), +26: AIFFSampleRate (10 bytes),
/// +36: markerChunk (4), +40: instrumentChunks (4), +44: AESRecording (4),
/// +48: sampleSize (word), +50: futureUse (14 bytes),
/// +64: sampleArea — interleaved, 8 or 16 bit
///
/// cmpSH layout (Sound 1994, 2-109):
/// +0: samplePtr, +4: numChannels, +8: sampleRate, +20: encode ($FE),
/// +22: numFrames, +40: format, +56: compressionID, +58: packetSize,
/// +62: sampleSize, +64: compressed sampleArea.
fn execute_buffer_cmd(&mut self, bus: &mut MacMemoryBus, chan_ptr: u32, cmd: &SndCommand) {
self.sound_manager.debug_buffer_cmd_count += 1;
let header_addr = cmd.param2;
if header_addr == 0 {
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd with null header on chan=${:08X}",
chan_ptr
);
}
return;
}
let sample_ptr = bus.read_long(header_addr);
let sample_rate = bus.read_long(header_addr + 8);
let encode = bus.read_byte(header_addr + 20);
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd chan=${:08X} header=${:08X} sample_ptr=${:08X} sample_rate=${:08X} encode=${:02X}",
chan_ptr, header_addr, sample_ptr, sample_rate, encode
);
}
let samples = match encode {
0x00 => {
// stdSH: unsigned 8-bit mono
let length = bus.read_long(header_addr + 4) as usize;
if length == 0 {
return;
}
let data_addr = if sample_ptr != 0 {
sample_ptr
} else {
header_addr + 22
};
let mut buf = vec![0u8; length];
for (i, byte) in buf.iter_mut().enumerate() {
*byte = bus.read_byte(data_addr + i as u32);
}
buf
}
0xFF => {
// extSH: multi-channel, 8 or 16 bit
let num_channels = bus.read_long(header_addr + 4) as usize;
let num_frames = bus.read_long(header_addr + 22) as usize;
let sample_size = bus.read_word(header_addr + 48) as usize;
if num_frames == 0 || num_channels == 0 {
return;
}
let data_addr = if sample_ptr != 0 {
sample_ptr
} else {
header_addr + 64
};
let bytes_per_sample = sample_size / 8;
let total_bytes = num_channels * num_frames * bytes_per_sample;
// Read raw interleaved data
let mut raw = vec![0u8; total_bytes];
for (i, byte) in raw.iter_mut().enumerate() {
*byte = bus.read_byte(data_addr + i as u32);
}
// Convert to unsigned 8-bit mono
let mut buf = vec![0u8; num_frames];
for (frame, dst) in buf.iter_mut().enumerate() {
let mut accum: i32 = 0;
for ch in 0..num_channels {
let offset = (frame * num_channels + ch) * bytes_per_sample;
if sample_size == 16 {
// Big-endian signed 16-bit
let s16 = i16::from_be_bytes([raw[offset], raw[offset + 1]]);
accum += (s16 >> 8) as i32; // scale to -128..127
} else {
// 8-bit unsigned (same as stdSH)
accum += raw[offset] as i32 - 128;
}
}
// Mix channels and convert back to unsigned 8-bit
let mixed = accum / num_channels as i32;
*dst = (mixed + 128).clamp(0, 255) as u8;
}
buf
}
0xFE => {
// cmpSH: compressed sampled sound. Playroom stores most
// effects as mono MACE 3:1 packets (`compressionID` 3).
let num_channels = bus.read_long(header_addr + 4) as usize;
let num_frames = bus.read_long(header_addr + 22) as usize;
let format = bus.read_long(header_addr + 40);
let compression_id = bus.read_word(header_addr + 56) as i16;
let packet_size = bus.read_word(header_addr + 58) as usize;
let sample_size = bus.read_word(header_addr + 62) as usize;
if num_frames == 0 || num_channels == 0 {
return;
}
let data_addr = if sample_ptr != 0 {
sample_ptr
} else {
header_addr + 64
};
let mace_kind = match (compression_id, format) {
(3, _) | (-1, MACE3_FORMAT) => Some(MaceCompression::ThreeToOne),
(4, _) | (-1, MACE6_FORMAT) => Some(MaceCompression::SixToOne),
_ => None,
};
let Some(mace_kind) = mace_kind else {
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd: unsupported cmpSH compression={} format=${:08X} packet={} bits={} channels={}",
compression_id, format, packet_size, sample_size, num_channels
);
}
return;
};
if num_channels != 1 || sample_size != 8 {
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd: unsupported cmpSH layout compression={:?} channels={} bits={}",
mace_kind, num_channels, sample_size
);
}
return;
}
let expected_packet_bits = mace_kind.packet_size_bits();
let packet_size_bits = if packet_size == 0 {
expected_packet_bits
} else {
packet_size
};
if packet_size_bits != expected_packet_bits {
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd: unsupported cmpSH packet size compression={:?} packet={} expected={}",
mace_kind, packet_size_bits, expected_packet_bits
);
}
return;
}
let bytes_per_packet = packet_size_bits / 8;
let Some(total_bytes) = num_frames
.checked_mul(num_channels)
.and_then(|frames| frames.checked_mul(bytes_per_packet))
else {
return;
};
let mut compressed = vec![0u8; total_bytes];
for (i, byte) in compressed.iter_mut().enumerate() {
*byte = bus.read_byte(data_addr + i as u32);
}
match mace_kind {
MaceCompression::ThreeToOne => decode_mace3_mono_to_u8(&compressed),
MaceCompression::SixToOne => decode_mace6_mono_to_u8(&compressed),
}
}
_ => {
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd: unsupported encode=${:02X} at header=${:08X}",
encode, header_addr
);
}
return;
}
};
let sample_count = samples.len();
// If no channel specified (chan_ptr == 0), use or create a default channel.
let chan = if chan_ptr == 0 {
if self.sound_manager.channels.is_empty() {
self.sound_manager.channels.push(SndChannel::new(0, true));
}
self.sound_manager.channels.last_mut().unwrap()
} else {
if let Some(c) = self.sound_manager.find_channel_mut(chan_ptr) {
c
} else {
// Channel not found — create one implicitly.
self.sound_manager
.channels
.push(SndChannel::new(chan_ptr, false));
self.sound_manager.channels.last_mut().unwrap()
}
};
chan.play_buffer(samples, sample_rate, sound::PlaybackKind::Buffer, 0);
if trace_sound_enabled() {
eprintln!(
"[SOUND] bufferCmd started playback on chan=${:08X} samples={} rate=${:08X}",
chan.guest_ptr, sample_count, sample_rate
);
}
}
/// Parse a snd resource and play it.
/// Handles format 1 and format 2.
/// Reference: executor sound.cpp ROMlib_get_snd_cmds / C_SndPlay
fn snd_play_resource(&mut self, bus: &mut MacMemoryBus, chan_ptr: u32, snd_ptr: u32) -> i16 {
let format = bus.read_word(snd_ptr) as i16;
let alloc_size = bus.get_alloc_size(snd_ptr);
let mut offset: u32;
match format {
1 => {
if let Some(size) = alloc_size {
if size < 6 {
return -206;
}
}
// Format 1: +0=format(2), +2=num_data_types(2),
// then [data_type_id(2) + init_option(4)] × num_data_types,
// then num_commands(2), then SndCommand[...]
let num_data_types = bus.read_word(snd_ptr + 2) as u32;
let Some(header_end) = 4u32.checked_add(num_data_types.saturating_mul(6)) else {
return -206;
};
if let Some(size) = alloc_size {
if header_end
.checked_add(2)
.is_none_or(|required| required > size)
{
return -206;
}
}
offset = header_end;
}
2 => {
if let Some(size) = alloc_size {
if size < 6 {
return -206;
}
}
// Format 2: +0=format(2), +2=ref_count(2),
// then num_commands(2), then SndCommand[...]
offset = 4;
}
// IM:Sound 1994 p. 2-123 result codes: malformed resource -> badFormat.
_ => return -206,
}
let num_commands = bus.read_word(snd_ptr + offset) as usize;
offset += 2;
if let Some(size) = alloc_size {
let command_bytes = match (num_commands as u32).checked_mul(8) {
Some(bytes) => bytes,
None => return -206,
};
if offset
.checked_add(command_bytes)
.is_none_or(|required| required > size)
{
return -206;
}
}
if trace_sound_enabled() {
eprintln!(
"[SOUND] snd resource ptr=${:08X} format={} num_commands={} requested_chan=${:08X}",
snd_ptr, format, num_commands, chan_ptr
);
}
// Ensure we have a channel to play on.
let (effective_chan, temporary_channel) = if chan_ptr != 0 {
(chan_ptr, false)
} else {
// IM:Sound 1994 2-122: when chan is NIL, SndPlay allocates an
// internal channel and releases it after synchronous playback.
let guest_ptr = bus.alloc(GUEST_SND_CHANNEL_SIZE);
self.sound_manager
.channels
.push(SndChannel::new(guest_ptr, true));
(guest_ptr, true)
};
for i in 0..num_commands {
let cmd_offset = offset + (i as u32) * 8;
let mut cmd_word = bus.read_word(snd_ptr + cmd_offset);
let param1 = bus.read_word(snd_ptr + cmd_offset + 2) as i16;
let mut param2 = bus.read_long(snd_ptr + cmd_offset + 4);
// dataOffsetFlag: if bit 15 of cmd is set, param2 is an offset
// from the start of the resource data, not an absolute pointer.
// Reference: executor sound.cpp lines 368-372
if cmd_word & 0x8000 != 0 {
param2 += snd_ptr;
cmd_word &= !0x8000;
}
// Format 2 with first command being soundCmd: convert to bufferCmd.
// Reference: executor sound.cpp lines 375-378
if format == 2 && i == 0 && cmd_word == sound::cmd::SOUND {
cmd_word = sound::cmd::BUFFER;
}
let cmd = SndCommand {
cmd: cmd_word,
param1,
param2,
};
if trace_sound_enabled() {
eprintln!(
"[SOUND] snd cmd[{}] chan=${:08X} cmd={} param1={} param2=${:08X}",
i, effective_chan, cmd.cmd, cmd.param1, cmd.param2
);
}
self.execute_sound_command(bus, effective_chan, cmd);
}
if temporary_channel {
self.release_sound_channel(bus, effective_chan);
}
0
}
/// SndPlayDoubleBuffer: set up double-buffer playback on a channel.
/// The game pre-fills two buffers and provides a callback to refill them.
///
/// SndDoubleBufferHeader layout (Sound 1994, 2-111):
/// +0: dbhNumChannels (2)
/// +2: dbhSampleSize (2)
/// +4: dbhCompressionID (2)
/// +6: dbhPacketSize (2)
/// +8: dbhSampleRate (4, Fixed 16.16)
/// +12: `dbhBufferPtr[0]` (4)
/// +16: `dbhBufferPtr[1]` (4)
/// +20: dbhDoubleBack (4, ProcPtr)
///
/// SndDoubleBuffer layout (Sound 1994, 2-112):
/// +0: dbNumFrames (4)
/// +4: dbFlags (4)
/// +8: `dbUserInfo[0]` (4)
/// +12: `dbUserInfo[1]` (4)
/// +16: dbSoundData[...]
fn snd_play_double_buffer(&mut self, bus: &MacMemoryBus, chan_ptr: u32, header_ptr: u32) {
if header_ptr == 0 {
return;
}
// Track double-buffer submissions separately from bufferCmd-style
// single-buffer ones. Both feed mix_frame but through different
// code paths.
self.sound_manager.debug_double_buffer_count += 1;
let num_channels = bus.read_word(header_ptr) as usize;
let sample_size = bus.read_word(header_ptr + 2) as usize;
let sample_rate = bus.read_long(header_ptr + 8);
let buf0_ptr = bus.read_long(header_ptr + 12);
let buf1_ptr = bus.read_long(header_ptr + 16);
let callback_addr = bus.read_long(header_ptr + 20);
if trace_sound_enabled() {
eprintln!(
"[SOUND] snd_play_double_buffer chan=${:08X} header=${:08X} channels={} bits={} rate=${:08X} buf0=${:08X} buf1=${:08X} callback=${:08X}",
chan_ptr, header_ptr, num_channels, sample_size, sample_rate, buf0_ptr, buf1_ptr, callback_addr
);
}
// Find or create the channel.
let chan = if let Some(c) = self.sound_manager.find_channel_mut(chan_ptr) {
c
} else {
self.sound_manager
.channels
.push(SndChannel::new(chan_ptr, false));
self.sound_manager.channels.last_mut().unwrap()
};
// Set up double-buffer state.
chan.double_buffer = Some(sound::DoubleBufferState {
header_ptr,
current_buffer: 0,
callback_addr,
chan_ptr,
sample_rate,
num_channels,
sample_size,
last_buffer_seen: false,
waiting_for_callback: false,
});
// Read samples from buffer 0 and start playing.
Self::load_double_buffer_samples(
bus,
chan,
buf0_ptr,
sample_rate,
num_channels,
sample_size,
);
}
/// Read samples from a SndDoubleBuffer record into the channel's PlayingBuffer.
/// Called when starting double-buffer playback and after a callback refills a buffer.
pub fn load_double_buffer_samples(
bus: &MacMemoryBus,
chan: &mut SndChannel,
buf_ptr: u32,
sample_rate: u32,
num_channels: usize,
sample_size: usize,
) {
if buf_ptr == 0 {
return;
}
let num_frames = bus.read_long(buf_ptr) as usize;
let flags = bus.read_long(buf_ptr + 4);
let data_addr = buf_ptr + 16; // dbSoundData starts at offset 16
if trace_sound_enabled() {
eprintln!(
"[SOUND] load_double_buffer_samples chan=${:08X} buf=${:08X} frames={} flags=${:08X} rate=${:08X} channels={} bits={}",
chan.guest_ptr, buf_ptr, num_frames, flags, sample_rate, num_channels, sample_size
);
}
// dbBufferReady = $00000001, dbLastBuffer = $00000004
// Sound 1994, 2-113
if flags & 0x01 == 0 {
return;
}
if num_frames == 0 {
return;
}
let Some(samples) =
decode_double_buffer_samples(bus, data_addr, num_frames, num_channels, sample_size)
else {
if trace_sound_enabled() {
eprintln!(
"[SOUND] load_double_buffer_samples unsupported format chan=${:08X} buf=${:08X} channels={} bits={}",
chan.guest_ptr, buf_ptr, num_channels, sample_size
);
}
return;
};
chan.play_buffer(samples, sample_rate, sound::PlaybackKind::Buffer, 0);
// Check for last buffer flag.
if flags & 0x04 != 0 {
if let Some(ref mut db) = chan.double_buffer {
db.last_buffer_seen = true;
}
}
}
fn snd_start_file_play(
&mut self,
bus: &mut MacMemoryBus,
chan_ptr: u32,
f_ref_num: i16,
res_num: i16,
_selection_ptr: u32,
completion: u32,
async_flag: i16,
) -> i16 {
if f_ref_num == 0 && res_num != 0 {
if let Some((_, snd_ptr)) = self.find_resource_any(*b"snd ", res_num) {
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndStartFilePlay using 'snd ' resource id={} ptr=${:08X}",
res_num, snd_ptr
);
}
return self.snd_play_resource(bus, chan_ptr, snd_ptr);
}
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndStartFilePlay missing 'snd ' resource id={}",
res_num
);
}
return -192; // resNotFound
}
if f_ref_num == 0 {
return 0;
}
let Some(filename) = self.open_files.get(&(f_ref_num as u16)).cloned() else {
return -51; // rfNumErr
};
let Some(file_data) = self.vfs.get(&filename).cloned() else {
return -43; // fnfErr
};
let Some((samples, sample_rate_fixed)) = parse_aiff_samples(&file_data) else {
if trace_sound_enabled() {
eprintln!(
"[SOUND] SndStartFilePlay unsupported file format refnum={} file=\"{}\"",
f_ref_num, filename
);
}
return -200; // paramErr
};
let effective_chan = if chan_ptr != 0 {
chan_ptr
} else if async_flag != 0 {
return -205; // badChannel
} else {
let guest_ptr = bus.alloc(GUEST_SND_CHANNEL_SIZE);
self.sound_manager
.channels
.push(SndChannel::new(guest_ptr, true));
guest_ptr
};
let chan = if let Some(chan) = self.sound_manager.find_channel_mut(effective_chan) {
chan
} else {
self.sound_manager
.channels
.push(SndChannel::new(effective_chan, false));
self.sound_manager.channels.last_mut().unwrap()
};
chan.play_buffer(
samples,
sample_rate_fixed,
sound::PlaybackKind::File,
if async_flag != 0 { completion } else { 0 },
);
// Bump the SndStartFilePlay submission counter AFTER play_buffer
// succeeds (i.e. AIFF parsed + playback installed).
self.sound_manager.debug_file_play_count += 1;
0
}
}
const MACE3_FORMAT: u32 = 0x4D41_4333; // 'MAC3'
const MACE6_FORMAT: u32 = 0x4D41_4336; // 'MAC6'
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MaceCompression {
ThreeToOne,
SixToOne,
}
impl MaceCompression {
fn packet_size_bits(self) -> usize {
match self {
Self::ThreeToOne => 16,
Self::SixToOne => 8,
}
}
}
#[derive(Default)]
struct MaceChannelState {
index: i32,
factor: i32,
prev2: i32,
previous: i32,
level: i32,
}
// MACE predictor tables, ported from FFmpeg's LGPL MACE decoder.
const MACE_TAB1: [i32; 8] = [-13, 8, 76, 222, 222, 76, 8, -13];
const MACE_TAB3: [i32; 4] = [-18, 140, 140, -18];
const MACE_TAB2: &[[i32; 4]] = &[
[37, 116, 206, 330],
[39, 121, 216, 346],
[41, 127, 225, 361],
[42, 132, 235, 377],
[44, 137, 245, 392],
[46, 144, 256, 410],
[48, 150, 267, 428],
[51, 157, 280, 449],
[53, 165, 293, 470],
[55, 172, 306, 490],
[58, 179, 319, 511],
[60, 187, 333, 534],
[63, 195, 348, 557],
[66, 205, 364, 583],
[69, 214, 380, 609],
[72, 223, 396, 635],
[75, 233, 414, 663],
[79, 244, 433, 694],
[82, 254, 453, 725],
[86, 265, 472, 756],
[90, 278, 495, 792],
[94, 290, 516, 826],
[98, 303, 538, 862],
[102, 316, 562, 901],
[107, 331, 588, 942],
[112, 345, 614, 983],
[117, 361, 641, 1027],
[122, 377, 670, 1074],
[127, 394, 701, 1123],
[133, 411, 732, 1172],
[139, 430, 764, 1224],
[145, 449, 799, 1280],
[152, 469, 835, 1337],
[159, 490, 872, 1397],
[166, 512, 911, 1459],
[173, 535, 951, 1523],
[181, 558, 993, 1590],
[189, 584, 1038, 1663],
[197, 610, 1085, 1738],
[206, 637, 1133, 1815],
[215, 665, 1183, 1895],
[225, 695, 1237, 1980],
[235, 726, 1291, 2068],
[246, 759, 1349, 2161],
[257, 792, 1409, 2257],
[268, 828, 1472, 2357],
[280, 865, 1538, 2463],
[293, 903, 1606, 2572],
[306, 944, 1678, 2688],
[319, 986, 1753, 2807],
[334, 1030, 1832, 2933],
[349, 1076, 1914, 3065],
[364, 1124, 1999, 3202],
[380, 1174, 2088, 3344],
[398, 1227, 2182, 3494],
[415, 1281, 2278, 3649],
[434, 1339, 2380, 3811],
[453, 1398, 2486, 3982],
[473, 1461, 2598, 4160],
[495, 1526, 2714, 4346],
[517, 1594, 2835, 4540],
[540, 1665, 2961, 4741],
[564, 1740, 3093, 4953],
[589, 1818, 3232, 5175],
[615, 1898, 3375, 5405],
[643, 1984, 3527, 5647],
[671, 2072, 3683, 5898],
[701, 2164, 3848, 6161],
[733, 2261, 4020, 6438],
[766, 2362, 4199, 6724],
[800, 2467, 4386, 7024],
[836, 2578, 4583, 7339],
[873, 2692, 4786, 7664],
[912, 2813, 5001, 8008],
[952, 2938, 5223, 8364],
[995, 3070, 5457, 8739],
[1039, 3207, 5701, 9129],
[1086, 3350, 5956, 9537],
[1134, 3499, 6220, 9960],
[1185, 3655, 6497, 10404],
[1238, 3818, 6788, 10869],
[1293, 3989, 7091, 11355],
[1351, 4166, 7407, 11861],
[1411, 4352, 7738, 12390],
[1474, 4547, 8084, 12946],
[1540, 4750, 8444, 13522],
[1609, 4962, 8821, 14126],
[1680, 5183, 9215, 14756],
[1756, 5415, 9626, 15415],
[1834, 5657, 10057, 16104],
[1916, 5909, 10505, 16822],
[2001, 6173, 10975, 17574],
[2091, 6448, 11463, 18356],
[2184, 6736, 11974, 19175],
[2282, 7037, 12510, 20032],
[2383, 7351, 13068, 20926],
[2490, 7679, 13652, 21861],
[2601, 8021, 14260, 22834],
[2717, 8380, 14897, 23854],
[2838, 8753, 15561, 24918],
[2965, 9144, 16256, 26031],
[3097, 9553, 16982, 27193],
[3236, 9979, 17740, 28407],
[3380, 10424, 18532, 29675],
[3531, 10890, 19359, 31000],
[3688, 11375, 20222, 32382],
[3853, 11883, 21125, 32767],
[4025, 12414, 22069, 32767],
[4205, 12967, 23053, 32767],
[4392, 13546, 24082, 32767],
[4589, 14151, 25157, 32767],
[4793, 14783, 26280, 32767],
[5007, 15442, 27452, 32767],
[5231, 16132, 28678, 32767],
[5464, 16851, 29957, 32767],
[5708, 17603, 31294, 32767],
[5963, 18389, 32691, 32767],
[6229, 19210, 32767, 32767],
[6507, 20067, 32767, 32767],
[6797, 20963, 32767, 32767],
[7101, 21899, 32767, 32767],
[7418, 22876, 32767, 32767],
[7749, 23897, 32767, 32767],
[8095, 24964, 32767, 32767],
[8456, 26078, 32767, 32767],
[8833, 27242, 32767, 32767],
[9228, 28457, 32767, 32767],
[9639, 29727, 32767, 32767],
];
const MACE_TAB4: &[[i32; 2]] = &[
[64, 216],
[67, 226],
[70, 236],
[74, 246],
[77, 257],
[80, 268],
[84, 280],
[88, 294],
[92, 307],
[96, 321],
[100, 334],
[104, 350],
[109, 365],
[114, 382],
[119, 399],
[124, 416],
[130, 434],
[136, 454],
[142, 475],
[148, 495],
[155, 519],
[162, 541],
[169, 564],
[176, 590],
[185, 617],
[193, 644],
[201, 673],
[210, 703],
[220, 735],
[230, 767],
[240, 801],
[251, 838],
[262, 876],
[274, 914],
[286, 955],
[299, 997],
[312, 1041],
[326, 1089],
[341, 1138],
[356, 1188],
[372, 1241],
[388, 1297],
[406, 1354],
[424, 1415],
[443, 1478],
[462, 1544],
[483, 1613],
[505, 1684],
[527, 1760],
[551, 1838],
[576, 1921],
[601, 2007],
[628, 2097],
[656, 2190],
[686, 2288],
[716, 2389],
[748, 2496],
[781, 2607],
[816, 2724],
[853, 2846],
[891, 2973],
[930, 3104],
[972, 3243],
[1016, 3389],
[1061, 3539],
[1108, 3698],
[1158, 3862],
[1209, 4035],
[1264, 4216],
[1320, 4403],
[1379, 4599],
[1441, 4806],
[1505, 5019],
[1572, 5244],
[1642, 5477],
[1715, 5722],
[1792, 5978],
[1872, 6245],
[1955, 6522],
[2043, 6813],
[2134, 7118],
[2229, 7436],
[2329, 7767],
[2432, 8114],
[2541, 8477],
[2655, 8854],
[2773, 9250],
[2897, 9663],
[3026, 10094],
[3162, 10546],
[3303, 11016],
[3450, 11508],
[3604, 12020],
[3765, 12556],
[3933, 13118],
[4108, 13703],
[4292, 14315],
[4483, 14953],
[4683, 15621],
[4892, 16318],
[5111, 17046],
[5339, 17807],
[5577, 18602],
[5826, 19433],
[6086, 20300],
[6358, 21205],
[6642, 22152],
[6938, 23141],
[7248, 24173],
[7571, 25252],
[7909, 26380],
[8262, 27557],
[8631, 28786],
[9016, 30072],
[9419, 31413],
[9839, 32767],
[10278, 32767],
[10737, 32767],
[11216, 32767],
[11717, 32767],
[12240, 32767],
[12786, 32767],
[13356, 32767],
[13953, 32767],
[14576, 32767],
[15226, 32767],
[15906, 32767],
[16615, 32767],
];
fn parse_aiff_samples(file_data: &[u8]) -> Option<(Vec<u8>, u32)> {
if file_data.len() < 12 || &file_data[0..4] != b"FORM" {
return None;
}
let form_kind = &file_data[8..12];
if form_kind != b"AIFF" && form_kind != b"AIFC" {
return None;
}
let mut offset = 12usize;
let mut channels: Option<usize> = None;
let mut sample_size: Option<usize> = None;
let mut sample_rate_hz: Option<f64> = None;
let mut ssnd_data: Option<&[u8]> = None;
while offset + 8 <= file_data.len() {
let chunk_id = &file_data[offset..offset + 4];
let chunk_size = u32::from_be_bytes([
file_data[offset + 4],
file_data[offset + 5],
file_data[offset + 6],
file_data[offset + 7],
]) as usize;
offset += 8;
if offset + chunk_size > file_data.len() {
return None;
}
let chunk = &file_data[offset..offset + chunk_size];
match chunk_id {
b"COMM" => {
if chunk.len() < 18 {
return None;
}
channels = Some(u16::from_be_bytes([chunk[0], chunk[1]]) as usize);
sample_size = Some(u16::from_be_bytes([chunk[6], chunk[7]]) as usize);
sample_rate_hz = Some(extended80_to_f64(&chunk[8..18])?);
if form_kind == b"AIFC" {
if chunk.len() < 22 {
return None;
}
let compression = &chunk[18..22];
if compression != b"NONE" && compression != b"raw " {
return None;
}
}
}
b"SSND" => {
if chunk.len() < 8 {
return None;
}
let data_offset =
u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) as usize;
if 8 + data_offset > chunk.len() {
return None;
}
ssnd_data = Some(&chunk[8 + data_offset..]);
}
_ => {}
}
offset += chunk_size + (chunk_size & 1);
}
let channels = channels?;
let sample_size = sample_size?;
let sample_rate_hz = sample_rate_hz?;
let ssnd_data = ssnd_data?;
if channels == 0 || sample_size == 0 {
return None;
}
let bytes_per_sample = sample_size / 8;
if bytes_per_sample == 0 {
return None;
}
let frame_bytes = channels.checked_mul(bytes_per_sample)?;
if frame_bytes == 0 {
return None;
}
let frame_count = ssnd_data.len() / frame_bytes;
let mut samples = Vec::with_capacity(frame_count);
for frame in 0..frame_count {
let mut accum = 0i32;
for channel in 0..channels {
let offset = frame * frame_bytes + channel * bytes_per_sample;
let sample = match sample_size {
8 => ssnd_data[offset] as i8 as i32,
16 => i16::from_be_bytes([ssnd_data[offset], ssnd_data[offset + 1]]) as i32 >> 8,
_ => return None,
};
accum += sample;
}
let mono = (accum / channels as i32 + 0x80).clamp(0, 255) as u8;
samples.push(mono);
}
let sample_rate_fixed = (sample_rate_hz * 65536.0).round() as u32;
Some((samples, sample_rate_fixed))
}
fn extended80_to_f64(bytes: &[u8]) -> Option<f64> {
if bytes.len() < 10 {
return None;
}
let sign = (bytes[0] & 0x80) != 0;
let exponent = (((bytes[0] & 0x7F) as i32) << 8 | bytes[1] as i32) - 16383;
let mantissa = u64::from_be_bytes([
bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9],
]);
if exponent == -16383 && mantissa == 0 {
return Some(0.0);
}
let value = (mantissa as f64 / (1u64 << 63) as f64) * 2f64.powi(exponent);
Some(if sign { -value } else { value })
}
fn decode_double_buffer_samples(
bus: &MacMemoryBus,
data_addr: u32,
num_frames: usize,
num_channels: usize,
sample_size: usize,
) -> Option<Vec<u8>> {
if num_frames == 0 || num_channels == 0 {
return Some(Vec::new());
}
match sample_size {
8 => {
let frame_bytes = num_channels;
let total_bytes = num_frames.checked_mul(frame_bytes)?;
let mut raw = vec![0u8; total_bytes];
for (offset, byte) in raw.iter_mut().enumerate() {
*byte = bus.read_byte(data_addr + offset as u32);
}
let mut out = Vec::with_capacity(num_frames);
for frame in 0..num_frames {
let mut accum = 0i32;
let base = frame * frame_bytes;
for channel in 0..num_channels {
accum += raw[base + channel] as i32 - 128;
}
out.push((accum / num_channels as i32 + 128).clamp(0, 255) as u8);
}
Some(out)
}
16 => {
let frame_bytes = num_channels.checked_mul(2)?;
let total_bytes = num_frames.checked_mul(frame_bytes)?;
let mut raw = vec![0u8; total_bytes];
for (offset, byte) in raw.iter_mut().enumerate() {
*byte = bus.read_byte(data_addr + offset as u32);
}
let mut out = Vec::with_capacity(num_frames);
for frame in 0..num_frames {
let mut accum = 0i32;
let base = frame * frame_bytes;
for channel in 0..num_channels {
let offset = base + channel * 2;
let sample = i16::from_be_bytes([raw[offset], raw[offset + 1]]) as i32 >> 8;
accum += sample;
}
out.push((accum / num_channels as i32 + 128).clamp(0, 255) as u8);
}
Some(out)
}
_ => None,
}
}
fn decode_mace3_mono_to_u8(compressed: &[u8]) -> Vec<u8> {
let usable_len = compressed.len() & !1;
let mut state = MaceChannelState::default();
let mut out = Vec::with_capacity(usable_len * 3);
for &packet in &compressed[..usable_len] {
let values = [packet & 0x07, (packet >> 3) & 0x03, packet >> 5];
for (tab_idx, value) in values.into_iter().enumerate() {
out.push(mace3_chomp(&mut state, value, tab_idx));
}
}
out
}
fn decode_mace6_mono_to_u8(compressed: &[u8]) -> Vec<u8> {
let mut state = MaceChannelState::default();
let mut out = Vec::with_capacity(compressed.len() * 6);
for &packet in compressed {
let values = [packet >> 5, (packet >> 3) & 0x03, packet & 0x07];
for (tab_idx, value) in values.into_iter().enumerate() {
out.extend_from_slice(&mace6_chomp(&mut state, value, tab_idx));
}
}
out
}
fn mace3_chomp(state: &mut MaceChannelState, value: u8, tab_idx: usize) -> u8 {
let current = mace_clip_i16(mace_read_table(state, value, tab_idx) + state.level);
state.level = current - (current >> 3);
mace_i16_to_u8(current)
}
fn mace6_chomp(state: &mut MaceChannelState, value: u8, tab_idx: usize) -> [u8; 2] {
let mut current = mace_read_table(state, value, tab_idx);
if (state.previous ^ current) >= 0 {
state.factor = (state.factor + 506).min(32767);
} else {
state.factor = (state.factor - 314).max(-32767);
}
current = mace_clip_i16(current + state.level);
state.level = (current * state.factor) >> 15;
current >>= 1;
let out0 = mace_clip_i16(state.previous + state.prev2 - ((state.prev2 - current) >> 2));
let out1 = mace_clip_i16(state.previous + current + ((state.prev2 - current) >> 2));
state.prev2 = state.previous;
state.previous = current;
[mace_i16_to_u8(out0), mace_i16_to_u8(out1)]
}
fn mace_read_table(state: &mut MaceChannelState, value: u8, tab_idx: usize) -> i32 {
let row = ((state.index & 0x7F0) >> 4) as usize;
let (stride, delta_table, current) = match tab_idx {
1 => {
let value = value as usize;
let current = if value < 2 {
MACE_TAB4[row][value]
} else {
-1 - MACE_TAB4[row][4 - value - 1]
};
(2, &MACE_TAB3[..], current)
}
_ => {
let value = value as usize;
let current = if value < 4 {
MACE_TAB2[row][value]
} else {
-1 - MACE_TAB2[row][8 - value - 1]
};
(4, &MACE_TAB1[..], current)
}
};
debug_assert!((value as usize) < stride * 2);
state.index += delta_table[value as usize] - (state.index >> 5);
if state.index < 0 {
state.index = 0;
}
current
}
fn mace_clip_i16(value: i32) -> i32 {
if value > 32767 {
32767
} else if value < -32768 {
-32767
} else {
value
}
}
fn mace_i16_to_u8(value: i32) -> u8 {
((value >> 8) + 128).clamp(0, 255) as u8
}
#[cfg(test)]
mod tests {
use super::{
decode_double_buffer_samples, decode_mace3_mono_to_u8, decode_mace6_mono_to_u8,
extended80_to_f64, parse_aiff_samples, GUEST_SND_CHANNEL_SIZE,
};
use crate::cpu::{CpuOps, Register};
use crate::memory::{MacMemoryBus, MemoryBus};
use crate::sound::{
cmd, PendingDoubleBackCallback, PendingSoundCallback, PlaybackKind, SndChannel, SndCommand,
};
use crate::trap::test_helpers::{setup, TEST_SP};
/// Assemble a minimal AIFF/AIFC file in memory.
fn build_aiff(
form_kind: &[u8; 4],
channels: u16,
sample_size_bits: u16,
sample_rate_extended80: [u8; 10],
ssnd_samples: &[u8],
comm_compression: Option<&[u8; 4]>,
) -> Vec<u8> {
// COMM chunk payload:
// channels(2) numSampleFrames(4) sampleSize(2) sampleRate(10) [+ comp(4) for AIFC]
let mut comm = Vec::new();
comm.extend_from_slice(&channels.to_be_bytes());
let num_frames =
(ssnd_samples.len() / (channels as usize * (sample_size_bits as usize / 8))) as u32;
comm.extend_from_slice(&num_frames.to_be_bytes());
comm.extend_from_slice(&sample_size_bits.to_be_bytes());
comm.extend_from_slice(&sample_rate_extended80);
if let Some(cc) = comm_compression {
comm.extend_from_slice(cc);
}
// SSND chunk payload: offset(4) blockSize(4) data
let mut ssnd = Vec::new();
ssnd.extend_from_slice(&0u32.to_be_bytes());
ssnd.extend_from_slice(&0u32.to_be_bytes());
ssnd.extend_from_slice(ssnd_samples);
// Assemble FORM.
let mut out = Vec::new();
out.extend_from_slice(b"FORM");
let form_size = 4 + (8 + comm.len()) + (8 + ssnd.len());
out.extend_from_slice(&(form_size as u32).to_be_bytes());
out.extend_from_slice(form_kind);
out.extend_from_slice(b"COMM");
out.extend_from_slice(&(comm.len() as u32).to_be_bytes());
out.extend_from_slice(&comm);
out.extend_from_slice(b"SSND");
out.extend_from_slice(&(ssnd.len() as u32).to_be_bytes());
out.extend_from_slice(&ssnd);
out
}
fn write_minimal_format2_snd_handle(
bus: &mut MacMemoryBus,
snd_handle: u32,
snd_ptr: u32,
format_word: u16,
) {
bus.write_long(snd_handle, snd_ptr);
bus.write_word(snd_ptr, format_word); // format
bus.write_word(snd_ptr + 2, 0); // refCount
bus.write_word(snd_ptr + 4, 0); // numCommands
}
fn alloc_minimal_format2_snd_handle(
bus: &mut MacMemoryBus,
format_word: u16,
data_size: u32,
) -> (u32, u32) {
let snd_handle = bus.alloc(4);
let snd_ptr = bus.alloc(data_size);
assert_ne!(snd_handle, 0, "sound handle allocation must succeed");
assert_ne!(snd_ptr, 0, "sound data allocation must succeed");
write_minimal_format2_snd_handle(bus, snd_handle, snd_ptr, format_word);
(snd_handle, snd_ptr)
}
/// Locks in `parse_aiff_samples` end-to-end decoding — the entry
/// point for SndStartFilePlay AIFF decode. Covers 1-channel 8-bit,
/// 2-channel 16-bit downmix, AIFC "NONE" compression, and rejection
/// of non-AIFF magic / unsupported AIFC compression.
#[test]
fn parse_aiff_samples_decodes_canonical_files() {
// 1.0 Hz extended80 encoding used for deterministic
// sample_rate_fixed = 0x00010000.
let one_hz = [0x3F, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
// Case 1: 1-channel 8-bit, two samples (+64, -64).
// i8 signed +64 / -64 → u8 output (+0x80) = 0xC0 / 0x40.
let aiff = build_aiff(b"AIFF", 1, 8, one_hz, &[0x40, 0xC0], None);
let (samples, rate) = parse_aiff_samples(&aiff).expect("valid AIFF");
assert_eq!(samples, vec![0xC0, 0x40]);
assert_eq!(rate, 0x0001_0000, "1.0 Hz → unity 16.16 fixed");
// Case 2: 2-channel 16-bit, one stereo frame. Both
// channels at +0x4000 (i16) → per-channel i32>>8 = +0x40
// → accum=+0x80 → downmix(+0x80/2)+0x80 = 0xC0.
let frame_16bit_stereo: [u8; 4] = [0x40, 0x00, 0x40, 0x00];
let aiff = build_aiff(b"AIFF", 2, 16, one_hz, &frame_16bit_stereo, None);
let (samples, rate) = parse_aiff_samples(&aiff).expect("valid stereo AIFF");
assert_eq!(samples, vec![0xC0]);
assert_eq!(rate, 0x0001_0000);
// Case 3: AIFC with "NONE" compression must be accepted.
let aiff = build_aiff(b"AIFC", 1, 8, one_hz, &[0x40, 0xC0], Some(b"NONE"));
let (samples, _) = parse_aiff_samples(&aiff).expect("AIFC-NONE accepted");
assert_eq!(samples, vec![0xC0, 0x40]);
// Case 4: AIFC with unsupported compression rejected.
let aiff = build_aiff(b"AIFC", 1, 8, one_hz, &[0x40, 0xC0], Some(b"ima4"));
assert!(
parse_aiff_samples(&aiff).is_none(),
"AIFC with unsupported compression must be rejected"
);
// Case 5: non-AIFF FORM type rejected (e.g. AIFL).
let aiff = build_aiff(b"AIFL", 1, 8, one_hz, &[0x40], None);
assert!(
parse_aiff_samples(&aiff).is_none(),
"non-AIFF/AIFC FORM kind must be rejected"
);
// Case 6: too short for FORM header → None.
assert!(parse_aiff_samples(b"FOR").is_none());
}
/// Locks in the Apple-SANE extended-80 → f64 conversion used by
/// `parse_aiff_samples` to decode the AIFF COMM chunk's `sampleRate`
/// field (IEEE 80-bit extended format stored big-endian).
///
/// Encoded bytes (big-endian):
/// byte 0 MSB = sign bit
/// byte 0 low 7 + byte 1 = biased exponent (15 bits, bias 16383)
/// bytes 2..10 = mantissa (integer bit in MSB of byte 2,
/// then 63 fraction bits)
#[test]
fn extended80_to_f64_parses_canonical_values() {
// Zero: sign=0, exp=0, mantissa=0.
let zero = [0u8; 10];
assert_eq!(extended80_to_f64(&zero), Some(0.0));
// 1.0: sign=0, biased-exp=16383 (0x3FFF), mantissa=integer bit only.
let one = [0x3F, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(extended80_to_f64(&one), Some(1.0));
// 2.0: biased-exp=16384 (0x4000), mantissa=integer bit only.
let two = [0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(extended80_to_f64(&two), Some(2.0));
// -1.0: sign bit set.
let neg_one = [0xBF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(extended80_to_f64(&neg_one), Some(-1.0));
// 0.5: biased-exp=16382 (0x3FFE).
let half = [0x3F, 0xFE, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(extended80_to_f64(&half), Some(0.5));
// Too-short input rejected.
let short = [0u8; 9];
assert_eq!(extended80_to_f64(&short), None);
}
#[test]
fn sndplay_loaded_format2_resource_returns_noerr() {
// Inside Macintosh: Sound (1994), pp. 2-121 to 2-123:
// SndPlay returns noErr for a valid sound resource and
// leaves the active Sound Manager channel count unchanged
// when chan is NIL.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let snd_handle = 0x210000;
let snd_ptr = 0x210100;
write_minimal_format2_snd_handle(&mut bus, snd_handle, snd_ptr, 2);
let channel_count_before = disp.sound_manager.channels.len();
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0); // chan = NIL
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
assert_eq!(disp.sound_manager.channels.len(), channel_count_before);
}
#[test]
fn sndplay_nil_chan_async_true_is_accepted() {
// Inside Macintosh: Sound (1994), p. 2-122:
// If chan is NIL, async is ignored.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let snd_handle = 0x210200;
let snd_ptr = 0x210300;
write_minimal_format2_snd_handle(&mut bus, snd_handle, snd_ptr, 2);
bus.write_word(sp, 1); // async = TRUE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0); // chan = NIL
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
}
#[test]
fn sndplay_nil_chan_sync_path_reclaims_internal_channel_before_return() {
// Inside Macintosh: Sound (1994), p. 2-122:
// NIL-chan SndPlay allocates an internal channel and releases it
// after the synchronous play completes.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let snd_handle = 0x210350;
let snd_ptr = 0x210450;
let channel_count_before = disp.sound_manager.channels.len();
write_minimal_format2_snd_handle(&mut bus, snd_handle, snd_ptr, 2);
bus.write_word(sp, 1); // async = TRUE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0); // chan = NIL
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
assert_eq!(disp.sound_manager.channels.len(), channel_count_before);
}
#[test]
fn sndplay_nil_chan_sync_path_returns_internal_guest_channel_allocation_to_free_list() {
// The internal NIL-chan SndPlay path should release the temporary
// Sound Manager-owned guest SndChannel block once the synchronous
// play completes.
let (mut disp, mut cpu, mut bus) = setup();
let recycled = bus.alloc(GUEST_SND_CHANNEL_SIZE);
assert_ne!(recycled, 0);
bus.write_long(recycled, 0x1111_0001);
bus.write_long(recycled + 4, 0x1111_0002);
bus.write_long(recycled + 8, 0x1111_0003);
bus.write_long(recycled + 12, 0x1111_0004);
bus.write_long(recycled + 16, 0x1111_0005);
bus.write_long(recycled + 20, 0x1111_0006);
bus.write_long(recycled + 24, 0x1111_0007);
bus.write_word(recycled + 28, 0x1111);
bus.write_word(recycled + 30, 0x2222);
bus.write_word(recycled + 32, 0x3333);
bus.write_word(recycled + 34, 0x4444);
bus.free(recycled);
let sp = TEST_SP;
let snd_handle = 0x210700;
let snd_ptr = 0x210800;
write_minimal_format2_snd_handle(&mut bus, snd_handle, snd_ptr, 2);
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0); // chan = NIL
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(bus.read_word(sp + 10), 0);
let reallocated = bus.alloc(GUEST_SND_CHANNEL_SIZE);
assert_eq!(
reallocated, recycled,
"internal NIL-chan SndPlay must return its guest channel block to the allocator"
);
assert_eq!(bus.read_long(reallocated), 0, "nextChan cleared");
assert_eq!(bus.read_long(reallocated + 4), 0, "firstMod cleared");
assert_eq!(bus.read_long(reallocated + 8), 0, "callBack cleared");
assert_eq!(bus.read_long(reallocated + 12), 0, "userInfo cleared");
assert_eq!(bus.read_long(reallocated + 16), 0, "wait cleared");
assert_eq!(
bus.read_long(reallocated + 20),
0,
"cmdInProgress cmd/param1 cleared"
);
assert_eq!(
bus.read_long(reallocated + 24),
0,
"cmdInProgress param2 cleared"
);
assert_eq!(bus.read_word(reallocated + 28), 0, "flags cleared");
assert_eq!(bus.read_word(reallocated + 30), 0, "qLength cleared");
assert_eq!(bus.read_word(reallocated + 32), 0, "qHead cleared");
assert_eq!(bus.read_word(reallocated + 34), 0, "qTail cleared");
bus.free(reallocated);
}
#[test]
fn sndplay_unloaded_handle_returns_resproblem() {
// Inside Macintosh: Sound (1994), p. 2-122:
// unloaded handle -> resProblem (-204).
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let snd_handle = 0x210400;
bus.write_long(snd_handle, 0); // NIL master pointer (unloaded)
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10) as i16, -204);
}
#[test]
fn sndplay_nil_handle_returns_resproblem() {
// Inside Macintosh: Sound (1994), p. 2-122:
// missing sound resource input returns resProblem (-204).
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, 0); // sndHdl = NIL
bus.write_long(sp + 6, 0);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10) as i16, -204);
}
#[test]
fn sndplay_bad_format_returns_badformat() {
// Inside Macintosh: Sound (1994), pp. 2-122 to 2-123:
// malformed/unsupported format -> badFormat (-206).
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let snd_handle = 0x210500;
let snd_ptr = 0x210600;
write_minimal_format2_snd_handle(&mut bus, snd_handle, snd_ptr, 3);
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0x220000);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10) as i16, -206);
}
#[test]
fn sndplay_truncated_format2_handle_returns_badformat() {
// Inside Macintosh: Sound (1994), p. 2-123:
// corrupt/unusable sound resources return badFormat (-206).
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let (snd_handle, _snd_ptr) = alloc_minimal_format2_snd_handle(&mut bus, 2, 4);
bus.write_word(sp, 0); // async = FALSE
bus.write_long(sp + 2, snd_handle);
bus.write_long(sp + 6, 0);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x005, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10) as i16, -206);
}
#[test]
fn sysbeep_consumes_duration_word_and_leaves_sound_state_unchanged() {
// Inside Macintosh Volume II (1985), p. II-385 and
// Inside Macintosh: Sound (1991), pp. 22-80 and 22-95:
// SysBeep is a procedure with one Integer argument; in the
// disabled-alert-sound state, no sound output/resources are created.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 30); // duration ticks
bus.write_word(sp + 2, 0xBEEF); // sentinel: no function-result slot
let channel_count_before = disp.sound_manager.channels.len();
let callback_count_before = disp.sound_manager.pending_sound_callbacks.len();
let result = disp.dispatch_sound(true, 0x1C8, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 2);
assert_eq!(bus.read_word(sp + 2), 0xBEEF);
assert_eq!(disp.sound_manager.channels.len(), channel_count_before);
assert_eq!(
disp.sound_manager.pending_sound_callbacks.len(),
callback_count_before
);
}
#[test]
fn sndaddmodifier_nil_channel_returns_badchannel_and_consumes_pascal_frame() {
// Inside Macintosh: Sound (1991), p. 22-82:
// SndAddModifier(chan, modifier, id, init): OSErr.
// BasiliskII System 7.5.3 returns badChannel for a direct
// application call with a NIL channel pointer.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, 0); // init
bus.write_word(sp + 4, 5); // id
bus.write_long(sp + 6, 0); // modifier
bus.write_long(sp + 10, 0); // chan = NIL
bus.write_word(sp + 14, 0xFFFF); // result slot sentinel
let result = disp.dispatch_sound(true, 0x002, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 14);
assert_eq!(bus.read_word(sp + 14), (-205i16) as u16);
assert!(disp.sound_manager.channels.is_empty());
}
#[test]
fn sndcontrol_consumes_cmdptr_and_id_and_returns_noerr() {
// Inside Macintosh: Sound (1994), pp. 2-134 to 2-135:
// SndControl(id, VAR cmd): OSErr.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let cmd_ptr = 0x230600;
bus.write_word(cmd_ptr, 0x7777);
bus.write_word(cmd_ptr + 2, 0x8888);
bus.write_long(cmd_ptr + 4, 0x99AA_BBCC);
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, cmd_ptr); // cmd
bus.write_word(sp + 4, 5); // id
bus.write_word(sp + 6, 0xFFFF); // result slot sentinel
let result = disp.dispatch_sound(true, 0x006, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 6);
assert_eq!(bus.read_word(sp + 6), 0);
assert_eq!(bus.read_word(cmd_ptr), 0x7777);
assert_eq!(bus.read_word(cmd_ptr + 2), 0x8888);
assert_eq!(bus.read_long(cmd_ptr + 4), 0x99AA_BBCC);
}
#[test]
fn sndcontrol_availablecmd_zero_init_known_synth_sets_param1_true() {
// IM:Sound 1994, 2-92 + 2-134..2-135: availableCmd returns 1 in
// param1 when the Sound Manager supports the initialization
// options specified in param2. A zero-init query against a
// documented built-in synth id should therefore rewrite param1
// to 1 while still returning noErr.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let cmd_ptr = 0x230700;
bus.write_word(cmd_ptr, cmd::AVAILABLE);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, 0);
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, cmd_ptr);
bus.write_word(sp + 4, 5); // sampledSynth
bus.write_word(sp + 6, 0xFFFF);
let result = disp.dispatch_sound(true, 0x006, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 6);
assert_eq!(bus.read_word(sp + 6), 0);
assert_eq!(bus.read_word(cmd_ptr), cmd::AVAILABLE);
assert_eq!(bus.read_word(cmd_ptr + 2), 1);
assert_eq!(bus.read_long(cmd_ptr + 4), 0);
}
#[test]
fn sounddispatch_spb_open_device_pops_frame_and_returns_refnum() {
// Sound Input Manager selector $05180014 shares routine byte $18
// with SndGetSysBeepState. It must still consume its 10-byte
// Pascal frame and write an input device refnum.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP + 0x80;
let refnum_ptr = 0x240000;
cpu.write_reg(Register::A7, sp);
cpu.write_reg(Register::D0, 0x0518_0014);
bus.write_long(sp, refnum_ptr);
bus.write_word(sp + 4, 0); // permission
bus.write_long(sp + 6, 0); // default device name
bus.write_word(sp + 10, 0xFFFF);
bus.write_long(refnum_ptr, 0xDEAD_BEEF);
let result = disp.dispatch_sound(true, 0x000, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
assert_eq!(bus.read_long(refnum_ptr), 1);
assert_eq!(cpu.read_reg(Register::D0), 0);
}
#[test]
fn sndnewchannel_returns_noerr_and_writes_nonnull_channel_pointer() {
// Inside Macintosh: Sound (1994), p. 2-195:
// SndNewChannel stores a newly allocated channel pointer through
// the VAR chan argument and returns noErr.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let chan_ptr_ptr = 0x220000;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(sp, 0); // userRoutine
bus.write_long(sp + 4, 0); // init
bus.write_word(sp + 8, 5); // sampledSynth
bus.write_long(sp + 10, chan_ptr_ptr);
bus.write_word(sp + 14, 0xFFFF);
let result = disp.dispatch_sound(true, 0x007, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 14);
assert_eq!(bus.read_word(sp + 14), 0);
let chan_ptr = bus.read_long(chan_ptr_ptr);
assert_ne!(chan_ptr, 0);
assert!(disp.sound_manager.find_channel_mut(chan_ptr).is_some());
}
#[test]
fn sndnewchannel_sets_channel_callback_to_userroutine() {
// Inside Macintosh: Sound (1994), p. 2-195:
// SndNewChannel associates userRoutine with the created channel.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let chan_ptr_ptr = 0x220100;
let user_routine = 0x00C0_FFEE;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(sp, user_routine);
bus.write_long(sp + 4, 0);
bus.write_word(sp + 8, 5);
bus.write_long(sp + 10, chan_ptr_ptr);
let result = disp.dispatch_sound(true, 0x007, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
assert_ne!(chan_ptr, 0);
assert_eq!(bus.read_long(chan_ptr + 8), user_routine);
let chan = disp
.sound_manager
.find_channel_mut(chan_ptr)
.expect("channel tracked");
assert_eq!(chan.callback_addr, user_routine);
}
#[test]
fn sndnewchannel_unsupported_synth_returns_resproblem_and_does_not_allocate() {
// Inside Macintosh: Sound (1994), p. 2-195:
// a nonzero synth id requests loading/linking a 'snth' resource,
// and resProblem reports failure loading that resource.
let (mut disp, mut cpu, mut bus) = setup();
let sp = TEST_SP;
let chan_ptr_ptr = 0x220180;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(sp, 0); // userRoutine
bus.write_long(sp + 4, 0); // init
bus.write_word(sp + 8, 0x7FFF); // unsupported synth id
bus.write_long(sp + 10, chan_ptr_ptr);
bus.write_word(sp + 14, 0xFFFF);
let result = disp.dispatch_sound(true, 0x007, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 14);
assert_eq!(bus.read_word(sp + 14), (-204i16) as u16);
assert_eq!(bus.read_long(chan_ptr_ptr), 0, "chan VAR remains NIL");
assert!(
disp.sound_manager.channels.is_empty(),
"unsupported synth must not create a tracked channel"
);
}
#[test]
fn snddisposechannel_returns_noerr_and_removes_channel_from_manager_state() {
// Inside Macintosh: Sound (1994), p. 2-196:
// SndDisposeChannel disposes a channel returned by SndNewChannel.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220200;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
assert!(disp.sound_manager.find_channel_mut(chan_ptr).is_some());
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 1); // quietNow = TRUE
bus.write_long(sp + 2, chan_ptr);
bus.write_word(sp + 6, 0xFFFF);
let result = disp.dispatch_sound(true, 0x001, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 6);
assert_eq!(bus.read_word(sp + 6), 0);
assert!(disp.sound_manager.find_channel_mut(chan_ptr).is_none());
assert!(
bus.get_alloc_size(chan_ptr).is_none(),
"Sound Manager-owned channel storage must be released on dispose"
);
assert_eq!(
bus.alloc(GUEST_SND_CHANNEL_SIZE),
chan_ptr,
"disposed Sound Manager-owned channel block must return to allocator"
);
}
#[test]
fn snddisposechannel_preserves_caller_owned_channel_storage() {
// Inside Macintosh: Sound (1994), p. 2-196:
// if the application created its own SndChannel record, the Sound
// Manager does not dispose that memory.
let (mut disp, mut cpu, mut bus) = setup();
let chan_ptr = bus.alloc(GUEST_SND_CHANNEL_SIZE);
assert_ne!(chan_ptr, 0);
let chan_ptr_ptr = 0x220280;
let create_sp = TEST_SP;
bus.write_long(chan_ptr_ptr, chan_ptr);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
assert!(disp.sound_manager.find_channel_mut(chan_ptr).is_some());
bus.write_long(chan_ptr, 0x1111_0001);
bus.write_long(chan_ptr + 4, 0x1111_0002);
bus.write_long(chan_ptr + 8, 0x1111_0003);
bus.write_long(chan_ptr + 12, 0x1111_0004);
bus.write_long(chan_ptr + 16, 0x1111_2222);
bus.write_long(chan_ptr + 20, 0x3333_4444);
bus.write_long(chan_ptr + 24, 0x5555_6666);
bus.write_word(chan_ptr + 28, 0x7777);
bus.write_word(chan_ptr + 30, 0xAAAA);
bus.write_word(chan_ptr + 32, 0x8888);
bus.write_word(chan_ptr + 34, 0x9999);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 0); // quietNow = FALSE
bus.write_long(sp + 2, chan_ptr);
bus.write_word(sp + 6, 0xFFFF);
let result = disp.dispatch_sound(true, 0x001, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 6);
assert_eq!(bus.read_word(sp + 6), 0);
assert!(disp.sound_manager.find_channel_mut(chan_ptr).is_none());
assert_eq!(
bus.get_alloc_size(chan_ptr),
Some(GUEST_SND_CHANNEL_SIZE),
"caller-owned channel storage must stay allocated after dispose"
);
assert_eq!(bus.read_long(chan_ptr), 0, "nextChan cleared");
assert_eq!(bus.read_long(chan_ptr + 4), 0, "firstMod cleared");
assert_eq!(bus.read_long(chan_ptr + 8), 0, "callBack cleared");
assert_eq!(bus.read_long(chan_ptr + 12), 0, "userInfo cleared");
assert_eq!(bus.read_long(chan_ptr + 16), 0, "wait field cleared");
assert_eq!(
bus.read_long(chan_ptr + 20),
0,
"cmdInProgress cmd/param1 cleared"
);
assert_eq!(
bus.read_long(chan_ptr + 24),
0,
"cmdInProgress param2 cleared"
);
assert_eq!(bus.read_word(chan_ptr + 28), 0, "flags cleared");
assert_eq!(bus.read_word(chan_ptr + 30), 0, "qLength cleared");
assert_eq!(bus.read_word(chan_ptr + 32), 0, "qHead cleared");
assert_eq!(bus.read_word(chan_ptr + 34), 0, "qTail cleared");
}
#[test]
fn snddisposechannel_discards_pending_callbacks_for_disposed_channel() {
// Inside Macintosh: Sound (1994), p. 2-196:
// disposing a channel removes it from Sound Manager bookkeeping, so
// completion/callback queues should no longer retain work for it.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x2202C0;
let other_chan_ptr = 0x220340;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
disp.sound_manager
.pending_sound_callbacks
.push(PendingSoundCallback::Command {
callback_addr: 0x00AB_CDEF,
chan_ptr,
cmd: crate::sound::SndCommand {
cmd: cmd::CALLBACK,
param1: 7,
param2: 0x1111_2222,
},
});
disp.sound_manager
.pending_sound_callbacks
.push(PendingSoundCallback::FileCompletion {
callback_addr: 0x00FE_DCBA,
chan_ptr,
});
disp.sound_manager
.pending_sound_callbacks
.push(PendingSoundCallback::FileCompletion {
callback_addr: 0x0000_2222,
chan_ptr: other_chan_ptr,
});
disp.sound_manager
.pending_callbacks
.push(PendingDoubleBackCallback {
callback_addr: 0x00CA_FE00,
chan_ptr,
header_ptr: 0x0022_4400,
exhausted_buffer_index: 1,
});
disp.sound_manager
.pending_callbacks
.push(PendingDoubleBackCallback {
callback_addr: 0x0000_3333,
chan_ptr: other_chan_ptr,
header_ptr: 0x0022_5500,
exhausted_buffer_index: 0,
});
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 1); // quietNow = TRUE
bus.write_long(sp + 2, chan_ptr);
bus.write_word(sp + 6, 0xFFFF);
let result = disp.dispatch_sound(true, 0x001, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(bus.read_word(sp + 6), 0);
assert_eq!(disp.sound_manager.pending_sound_callbacks.len(), 1);
assert!(matches!(
&disp.sound_manager.pending_sound_callbacks[0],
PendingSoundCallback::FileCompletion {
callback_addr: 0x0000_2222,
chan_ptr: ptr
} if *ptr == other_chan_ptr
));
assert_eq!(disp.sound_manager.pending_callbacks.len(), 1);
assert_eq!(
disp.sound_manager.pending_callbacks[0].chan_ptr, other_chan_ptr,
"double-back callbacks for disposed channel must be removed"
);
}
#[test]
fn snddocommand_nullcmd_returns_noerr() {
// Inside Macintosh: Sound (1994), p. 2-130:
// SndDoCommand processes a SndCommand for the given channel.
// A nullCmd command should return noErr.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220300;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
let cmd_ptr = 0x230000;
bus.write_word(cmd_ptr, cmd::NULL);
bus.write_word(cmd_ptr + 2, 0x1234);
bus.write_long(cmd_ptr + 4, 0x89AB_CDEF);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 1); // noWait = TRUE
bus.write_long(sp + 2, cmd_ptr);
bus.write_long(sp + 6, chan_ptr);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x003, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
}
#[test]
fn snddocommand_queues_quiet_behind_active_buffer() {
// Inside Macintosh: Sound (1994), pp. 2-13 and 2-130:
// SndDoCommand enters the channel FIFO. It must not bypass an
// active bufferCmd; SndDoImmediate is the bypass path.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220380;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
disp.sound_manager
.find_channel_mut(chan_ptr)
.expect("channel exists")
.play_buffer(
vec![0x80; 128],
crate::sound::OUTPUT_RATE << 16,
PlaybackKind::Buffer,
0,
);
let cmd_ptr = 0x230040;
bus.write_word(cmd_ptr, cmd::QUIET);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, 0);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 1); // noWait = TRUE
bus.write_long(sp + 2, cmd_ptr);
bus.write_long(sp + 6, chan_ptr);
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x003, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), 0);
assert!(disp
.sound_manager
.find_channel_mut(chan_ptr)
.expect("channel exists")
.is_playing());
assert_eq!(disp.sound_manager.mix_frame(64).len(), 64);
}
#[test]
fn snddocommand_nil_channel_returns_badchannel() {
// Inside Macintosh: Sound (1994), p. 2-130:
// SndDoCommand requires a valid sound channel and returns
// badChannel when chan is corrupt or unusable.
let (mut disp, mut cpu, mut bus) = setup();
let cmd_ptr = 0x230080;
bus.write_word(cmd_ptr, cmd::NULL);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, 0);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 1); // noWait = TRUE
bus.write_long(sp + 2, cmd_ptr);
bus.write_long(sp + 6, 0); // chan = NIL
bus.write_word(sp + 10, 0xFFFF);
let result = disp.dispatch_sound(true, 0x003, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 10);
assert_eq!(bus.read_word(sp + 10), (-205i16) as u16);
}
#[test]
fn snddocommand_callbackcmd_uses_channel_callback_proc() {
// Inside Macintosh: Sound (1994), pp. 2-126 and 2-130:
// callBackCmd executes via the channel callback procedure set by
// SndNewChannel.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220400;
let user_routine = 0x00AB_CDEF;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, user_routine);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
disp.sound_manager.pending_sound_callbacks.clear();
let cmd_ptr = 0x230100;
bus.write_word(cmd_ptr, cmd::CALLBACK);
bus.write_word(cmd_ptr + 2, 7);
bus.write_long(cmd_ptr + 4, 0x1122_3344);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_word(sp, 0); // noWait = FALSE
bus.write_long(sp + 2, cmd_ptr);
bus.write_long(sp + 6, chan_ptr);
let result = disp.dispatch_sound(true, 0x003, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(disp.sound_manager.pending_sound_callbacks.len(), 1);
match &disp.sound_manager.pending_sound_callbacks[0] {
PendingSoundCallback::Command {
callback_addr,
chan_ptr: callback_chan,
cmd,
} => {
assert_eq!(*callback_addr, user_routine);
assert_eq!(*callback_chan, chan_ptr);
assert_eq!(cmd.cmd, cmd::CALLBACK);
assert_eq!(cmd.param1, 7);
assert_eq!(cmd.param2, 0x1122_3344);
}
other => panic!("unexpected callback variant: {other:?}"),
}
}
#[test]
fn snddoimmediate_nullcmd_returns_noerr() {
// Inside Macintosh: Sound (1994), p. 2-131:
// SndDoImmediate executes the supplied command immediately.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220500;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
let cmd_ptr = 0x230200;
bus.write_word(cmd_ptr, cmd::NULL);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, 0);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, cmd_ptr);
bus.write_long(sp + 4, chan_ptr);
bus.write_word(sp + 8, 0xFFFF);
let result = disp.dispatch_sound(true, 0x004, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 8);
assert_eq!(bus.read_word(sp + 8), 0);
}
#[test]
fn snddoimmediate_nil_channel_returns_badchannel() {
// Inside Macintosh: Sound (1994), p. 2-131:
// SndDoImmediate returns badChannel when chan is corrupt or unusable.
let (mut disp, mut cpu, mut bus) = setup();
let cmd_ptr = 0x230180;
bus.write_word(cmd_ptr, cmd::NULL);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, 0);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, cmd_ptr);
bus.write_long(sp + 4, 0); // chan = NIL
bus.write_word(sp + 8, 0xFFFF);
let result = disp.dispatch_sound(true, 0x004, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 8);
assert_eq!(bus.read_word(sp + 8), (-205i16) as u16);
}
#[test]
fn snddoimmediate_getratecmd_writes_fixed_rate_to_param2() {
// Inside Macintosh: Sound (1994), pp. 2-97 and 2-131:
// getRateCmd writes the channel's current Fixed rate to param2.
let (mut disp, mut cpu, mut bus) = setup();
let create_sp = TEST_SP;
let chan_ptr_ptr = 0x220600;
bus.write_long(chan_ptr_ptr, 0);
bus.write_long(create_sp, 0);
bus.write_long(create_sp + 4, 0);
bus.write_word(create_sp + 8, 5);
bus.write_long(create_sp + 10, chan_ptr_ptr);
assert!(disp
.dispatch_sound(true, 0x007, &mut cpu, &mut bus)
.unwrap()
.is_ok());
let chan_ptr = bus.read_long(chan_ptr_ptr);
disp.sound_manager
.find_channel_mut(chan_ptr)
.expect("channel exists")
.set_rate(0x0001_8000);
let rate_out_ptr = 0x230300;
bus.write_long(rate_out_ptr, 0xDEAD_BEEF);
let cmd_ptr = 0x230308;
bus.write_word(cmd_ptr, cmd::GET_RATE);
bus.write_word(cmd_ptr + 2, 0);
bus.write_long(cmd_ptr + 4, rate_out_ptr);
let sp = TEST_SP + 0x40;
cpu.write_reg(Register::A7, sp);
bus.write_long(sp, cmd_ptr);
bus.write_long(sp + 4, chan_ptr);
bus.write_word(sp + 8, 0xFFFF);
let result = disp.dispatch_sound(true, 0x004, &mut cpu, &mut bus);
assert!(result.unwrap().is_ok());
assert_eq!(cpu.read_reg(Register::A7), sp + 8);
assert_eq!(bus.read_word(sp + 8), 0);
assert_eq!(bus.read_long(rate_out_ptr), 0x0001_8000);
}
#[test]
fn decode_double_buffer_samples_downmixes_stereo_8bit() {
let mut bus = MacMemoryBus::new(1024 * 1024);
let data_addr = 0x1000;
let bytes = [0x00, 0xFF, 0x40, 0xC0];
for (idx, byte) in bytes.into_iter().enumerate() {
bus.write_byte(data_addr + idx as u32, byte);
}
let samples =
decode_double_buffer_samples(&bus, data_addr, 2, 2, 8).expect("stereo 8-bit audio");
assert_eq!(samples, vec![128, 128]);
}
#[test]
fn decode_mace3_mono_expands_packet_frame_to_unsigned_samples() {
let samples = decode_mace3_mono_to_u8(&[0x00, 0x00]);
assert_eq!(samples, vec![0x80; 6]);
}
#[test]
fn decode_mace6_mono_expands_packet_to_unsigned_samples() {
let samples = decode_mace6_mono_to_u8(&[0x00]);
assert_eq!(samples, vec![0x80; 6]);
}
#[test]
fn buffercmd_cmpsh_mace3_starts_buffer_playback() {
let (mut disp, _cpu, mut bus) = setup();
let chan_ptr = 0x250000;
disp.sound_manager
.channels
.push(SndChannel::new(chan_ptr, false));
let header = 0x260000;
bus.write_long(header, 0); // samplePtr = NIL, data follows header
bus.write_long(header + 4, 1); // mono
bus.write_long(header + 8, crate::sound::OUTPUT_RATE << 16);
bus.write_long(header + 12, 0); // loopStart
bus.write_long(header + 16, 0); // loopEnd
bus.write_byte(header + 20, 0xFE); // cmpSH
bus.write_byte(header + 21, 60); // baseFrequency
bus.write_long(header + 22, 1); // one MACE3 packet frame = 2 bytes
bus.write_long(header + 40, super::MACE3_FORMAT);
bus.write_word(header + 56, 3); // threeToOne
bus.write_word(header + 58, 16); // threeToOnePacketSize
bus.write_word(header + 62, 8); // 8-bit samples after expansion
bus.write_byte(header + 64, 0x00);
bus.write_byte(header + 65, 0x00);
disp.execute_buffer_cmd(
&mut bus,
chan_ptr,
&SndCommand {
cmd: cmd::BUFFER,
param1: 0,
param2: header,
},
);
assert!(disp
.sound_manager
.find_channel_mut(chan_ptr)
.expect("channel exists")
.is_playing());
assert_eq!(disp.sound_manager.mix_frame(6), vec![0x80; 6]);
}
/// Edge-case coverage for `decode_double_buffer_samples` — the
/// SndPlayDoubleBuffer decode path. Catches regressions dropping an
/// `if num_frames == 0` short-circuit (infinite loop), omitting the
/// sample_size match-arm fallthrough (None for unsupported widths),
/// or mishandling the mono-channel accum/1 divide.
#[test]
fn decode_double_buffer_samples_edge_cases() {
let mut bus = MacMemoryBus::new(1024 * 1024);
let data_addr = 0x3000;
bus.write_bytes(data_addr, &[0xAA; 64]);
// num_frames=0 → Some(empty) short-circuit.
let out = decode_double_buffer_samples(&bus, data_addr, 0, 2, 16)
.expect("empty frames → Some(empty)");
assert!(out.is_empty(), "num_frames=0 produces empty Vec");
// num_channels=0 → Some(empty) short-circuit.
let out = decode_double_buffer_samples(&bus, data_addr, 4, 0, 8)
.expect("zero channels → Some(empty)");
assert!(out.is_empty(), "num_channels=0 produces empty Vec");
// sample_size=24 (not 8 or 16) → None.
assert!(
decode_double_buffer_samples(&bus, data_addr, 4, 1, 24).is_none(),
"unsupported sample_size=24 must be rejected"
);
assert!(
decode_double_buffer_samples(&bus, data_addr, 4, 1, 32).is_none(),
"unsupported sample_size=32 must be rejected"
);
// Mono 8-bit pass-through: each byte is already unsigned
// PCM in [0, 255]. accum=(byte-128) / 1 + 128 = byte.
bus.write_byte(data_addr, 0x40);
bus.write_byte(data_addr + 1, 0x80);
bus.write_byte(data_addr + 2, 0xC0);
let out =
decode_double_buffer_samples(&bus, data_addr, 3, 1, 8).expect("mono 8-bit frames");
assert_eq!(out, vec![0x40, 0x80, 0xC0], "mono 8-bit pass-through");
// Mono 16-bit: sample i16 >> 8 gives the upper byte.
// +0x4000 big-endian = [0x40, 0x00] → i16 = 0x4000 → >>8 = 0x40
// → +128 = 0xC0.
// -0x4000 big-endian = [0xC0, 0x00] → i16 = -0x4000 → >>8 = -0x40
// → +128 = 0x40.
bus.write_byte(data_addr, 0x40);
bus.write_byte(data_addr + 1, 0x00);
bus.write_byte(data_addr + 2, 0xC0);
bus.write_byte(data_addr + 3, 0x00);
let out =
decode_double_buffer_samples(&bus, data_addr, 2, 1, 16).expect("mono 16-bit frames");
assert_eq!(out, vec![0xC0, 0x40], "mono 16-bit upper-byte + center");
}
#[test]
fn decode_double_buffer_samples_downmixes_stereo_16bit() {
let mut bus = MacMemoryBus::new(1024 * 1024);
let data_addr = 0x2000;
let bytes = [
0x80, 0x00, 0x7F, 0xFF, // frame 0: hard left + right
0x20, 0x00, 0x60, 0x00, // frame 1: medium amplitudes
];
for (idx, byte) in bytes.into_iter().enumerate() {
bus.write_byte(data_addr + idx as u32, byte);
}
let samples =
decode_double_buffer_samples(&bus, data_addr, 2, 2, 16).expect("stereo 16-bit audio");
assert_eq!(samples, vec![128, 192]);
}
}