//! 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
0x18 => {
// 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); valid channels execute
// nullCmd, quietCmd, flushCmd, callBackCmd, volumeCmd,
// bufferCmd/soundCmd, rateCmd, getRateCmd; others logged.
(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
);
}
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) and extSH (encode=$FF).
///
/// 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
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
}
_ => {
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
}
}
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,
}
}
#[cfg(test)]
mod tests {
use super::{
decode_double_buffer_samples, 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};
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 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_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]);
}
/// 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]);
}
}