[Scummvm-git-logs] scummvm master -> c725b3ac2afcd801b81dc380ecf2b6c440ba152e

dreammaster noreply at scummvm.org
Mon Jul 27 00:32:55 UTC 2026


This automated email contains information about 1 new commit which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
c725b3ac2a MADS: PHANTOM: Added RSound Roland MT32 base class and RSound1


Commit: c725b3ac2afcd801b81dc380ecf2b6c440ba152e
    https://github.com/scummvm/scummvm/commit/c725b3ac2afcd801b81dc380ecf2b6c440ba152e
Author: Paul Gilbert (dreammaster at scummvm.org)
Date: 2026-07-27T10:32:39+10:00

Commit Message:
MADS: PHANTOM: Added RSound Roland MT32 base class and RSound1

Assisted-by: Claude Code:claude-opus-4.8

Changed paths:
  A engines/mads/phantom/rsound.cpp
  A engines/mads/phantom/rsound.h
  A engines/mads/phantom/rsound_phantom.cpp
  A engines/mads/phantom/rsound_phantom.h
    engines/mads/module.mk
    engines/mads/phantom/sound.cpp


diff --git a/engines/mads/module.mk b/engines/mads/module.mk
index eccf8ce6084..00c9320f5a9 100644
--- a/engines/mads/module.mk
+++ b/engines/mads/module.mk
@@ -261,6 +261,8 @@ MODULE_OBJS := \
 	phantom/phantom.o \
 	phantom/asound.o \
 	phantom/asound_phantom.o \
+	phantom/rsound.o \
+	phantom/rsound_phantom.o \
 	phantom/catacombs.o \
 	phantom/global.o \
 	phantom/main_menu.o \
diff --git a/engines/mads/phantom/rsound.cpp b/engines/mads/phantom/rsound.cpp
new file mode 100644
index 00000000000..9c3a3c60b20
--- /dev/null
+++ b/engines/mads/phantom/rsound.cpp
@@ -0,0 +1,1091 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/util.h"
+#include "mads/phantom/rsound.h"
+
+namespace MADS {
+namespace Phantom {
+
+/*-----------------------------------------------------------------------*/
+
+void Channel::reset(byte *startPtr) {
+	_activeCount = 0;
+	_pitchBendFadeStep = 0;
+	_volumeFadeStep = 0;
+	_panFadeStep = 0;
+	_note = 0;
+	_program = 0;
+	_velocity = 0;
+	_noteOffset = 0;
+	_keyOnDelayOverride = 0;
+	_keyOnDelay = 0;
+	_volumeFadeCounter = 0;
+	_volumeFadeReload = 0;
+	_pitchBendFadeCounter = 0;
+	_panFadeCounter = 0;
+	_panFadeReload = 0;
+	_pitchBendFadeReload = 0;
+	_pitchBendFadeCount = 0;
+	_innerLoopCount = 0;
+	_outerLoopCount = 0;
+	_branchTarget = nullptr;
+	_transpose = 0;
+	_pendingStop = 0;
+	_volume = 0;
+
+	_loopStartPtr = startPtr;
+	_pSrc = startPtr;
+	_innerLoopPtr = startPtr;
+	_outerLoopPtr = startPtr;
+	_soundData = startPtr;
+	_pan = 0x40;
+	_pitchBend = 0x40;
+}
+
+void Channel::enable(int flag) {
+	if (_activeCount) {
+		_pendingStop = flag;
+		// Matches Channel_enable's "mov [bx+20h], ax" - invalidates the
+		// identity pointer so isSoundActive() no longer matches this
+		// channel. The original writes register garbage (AH was never
+		// explicitly cleared); nullptr is the correct semantic
+		// equivalent without inheriting undefined register state.
+		_soundData = nullptr;
+	}
+}
+
+void Channel::load(byte *pData) {
+	reset(pData);
+	_activeCount = 1;
+	_owner->resetPitchBend(_midiChannel);
+}
+
+/*-----------------------------------------------------------------------*/
+
+RSound::RSound(Audio::Mixer *mixer, const Common::Path &filename,
+		int dataOffset, int dataSize, int sysExOffset) : SoundDriver(mixer, filename, dataOffset, dataSize) {
+	_commandParam = 0;
+	_frameCounter = 0;
+	_isDisabled = false;
+	_randomSeed = 1234;
+	_lastMidiStatus = 0;
+	_sysexChecksum = 0;
+	_stateChangedFlag = 0;
+	_pollResult = 0;
+	_sysExOffset = sysExOffset;
+	_fadeCheckCounter = 0;
+	_fadeCheckPeriod = 0;
+
+	_tickCounter = 0;
+	_clockMedTarget = 0;
+	_clockCoarseTarget = 0;
+	_clockUnknown = 0;
+	_clockCoarse = 112;
+	_clockMed = 28;
+	_clockFine = 7;
+	_clockEnabled1 = 0;
+	_clockEnabled2 = 0;
+	_randomAmbianceTriggerFlag = 0;
+
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT; ++i) {
+		_channels[i]._owner = this;
+		_channels[i]._midiChannel = i + 1;
+	}
+
+	for (int i = 0; i <= RSOUND_CHANNEL_COUNT; ++i)
+		for (int j = 0; j < 4; ++j)
+			_heldNotes[i][j] = 0xFF;
+
+	for (int i = 0; i < ARRAYSIZE(_scriptVariables); ++i)
+		_scriptVariables[i] = 0;
+
+	command0();
+}
+
+int RSound::stop() {
+	command0();
+	int result = _pollResult;
+	_pollResult = 0;
+	return result;
+}
+
+int RSound::poll() {
+	update();
+	int result = _pollResult;
+	_pollResult = 0;
+	return result;
+}
+
+void RSound::setVolume(int volume) {
+	// TODO: no confirmed handler for this in the disassembly seen so far.
+}
+
+void RSound::resultCheck() {
+	// Matches the "cmp word_12BB8, 0xFFFF" latch right after rsound_update.
+	if (_stateChangedFlag != 0xFFFF) {
+		_stateChangedFlag = 0xFFFF;
+		_pollResult = 0xFFFF;
+	}
+}
+
+/*-----------------------------------------------------------------------*/
+
+int RSound::getRandomNumber() {
+	uint16 val = _randomSeed + 0x9249;
+	val = (val >> 3) | (val << 13); // matches three "ror ax,1" in a row
+	_randomSeed = val;
+	return val;
+}
+
+Channel *RSound::playSoundData(byte *pData, int startingChannel, int freeScanEnd, int fallbackScanEnd) {
+	for (int i = startingChannel; i <= freeScanEnd; ++i) {
+		if (!_channels[i]._activeCount) {
+			_channels[i].load(pData);
+			return &_channels[i];
+		}
+	}
+
+	for (int i = fallbackScanEnd; i >= startingChannel; --i) {
+		if (_channels[i]._pendingStop == 0xFF) {
+			_channels[i].load(pData);
+			return &_channels[i];
+		}
+	}
+
+	return nullptr;
+}
+
+Channel *RSound::playSound(int offset) {
+	// Channels 6-8 (0-based 5-7), symmetric free/fallback scan.
+	return playSoundData(loadData(offset), 5, 7, 7);
+}
+
+Channel *RSound::playSoundAny(int offset) {
+	// Channels 1-5 (0-based 0-4) for the free scan, but the pending-stop
+	// fallback only reaches down to channel 4 (0-based 3) - channel 5
+	// can never be pre-empted here, confirmed from the disassembly.
+	return playSoundData(loadData(offset), 0, 4, 3);
+}
+
+Channel *RSound::playSoundChannels1To8(int offset) {
+	// Channels 1-8 (0-based 0-7), symmetric free/fallback scan.
+	return playSoundData(loadData(offset), 0, 7, 7);
+}
+
+bool RSound::isSoundActive(byte *pData) {
+	// Matches the disassembly exactly: only channels 1-8 are checked,
+	// channel 9 is never included.
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT - 1; ++i) {
+		if (_channels[i]._activeCount && _channels[i]._soundData == pData)
+			return true;
+	}
+	return false;
+}
+
+/*-----------------------------------------------------------------------*/
+// Low-level MIDI transmission. sendMidiByte() is the single point that
+// needs to change once the real MT-32/MIDI output interface is wired up;
+// everything else funnels through it.
+
+void RSound::sendMidiByte(byte value) {
+	warning("RSound: MIDI byte %02X", value);
+}
+
+void RSound::sendStatus(int midiChannel, byte statusNibble) {
+	byte status = statusNibble | midiChannel;
+	if (_lastMidiStatus != status) {
+		_lastMidiStatus = status;
+		sendMidiByte(status);
+	}
+}
+
+void RSound::sendNoteOn(int midiChannel, int note, int velocity) {
+	sendStatus(midiChannel, 0x90);
+	sendMidiByte(note);
+	sendMidiByte(velocity);
+}
+
+void RSound::sendProgramChange(int midiChannel, int program) {
+	sendStatus(midiChannel, 0xC0);
+	sendMidiByte(program);
+}
+
+void RSound::sendVolume(int midiChannel, int volume) {
+	sendStatus(midiChannel, 0xB0);
+	sendMidiByte(7);
+	sendMidiByte(volume);
+}
+
+void RSound::sendVolumeCC(int midiChannel, int volume) {
+	// Matches sub_10423: unlike sendVolume()/sendStatus(), this sends the
+	// status byte UNCONDITIONALLY (no _lastMidiStatus dedup check) -
+	// used by command7 when restoring all 9 channels' volumes in a row.
+	byte status = 0xB0 | midiChannel;
+	_lastMidiStatus = status;
+	sendMidiByte(status);
+	sendMidiByte(7);
+	sendMidiByte(volume);
+}
+
+void RSound::sendPitchBend(int midiChannel, int value) {
+	sendStatus(midiChannel, 0xE0);
+	sendMidiByte(0); // LSB always 0 - only coarse (MSB) control is used
+	sendMidiByte(value);
+}
+
+void RSound::resetPitchBend(int midiChannel) {
+	sendPitchBend(midiChannel, 0x40);
+}
+
+void RSound::sendPan(int midiChannel, int value) {
+	sendStatus(midiChannel, 0xB0);
+	sendMidiByte(0x0A); // CC#10: Pan
+	sendMidiByte(value);
+}
+
+void RSound::muteChannel(int midiChannel) {
+	// Matches muteChannel: unconditional status send, like sendVolumeCC().
+	byte status = 0xB0 | midiChannel;
+	_lastMidiStatus = status;
+	sendMidiByte(status);
+	sendMidiByte(7);
+	sendMidiByte(0);
+}
+
+void RSound::sendGmReset(int count) {
+	// Matches sub_1068A: counts DOWN from count to 1, using the counter
+	// itself as the MIDI channel number each iteration.
+	for (int midiChannel = count; midiChannel >= 1; --midiChannel) {
+		_fadeCheckPeriod = 0; // matches "mov cs:byte_107BE, 0" at the top of every iteration
+
+		byte status = 0xB0 | midiChannel;
+		_lastMidiStatus = status;
+		sendMidiByte(status);
+		sendMidiByte(0x7B); // All Notes Off
+		sendMidiByte(0);
+		sendMidiByte(0x79); // Reset All Controllers
+		sendMidiByte(0);
+		sendMidiByte(7);    // Channel Volume
+		sendMidiByte(100);
+		sendMidiByte(0x0A); // Pan
+		sendMidiByte(0x40);
+	}
+}
+
+void RSound::sendSysExData(const byte *pData) {
+	static const byte header[] = { 0xF0, 0x41, 0x10, 0x16, 0x12 };
+	for (int i = 0; i < ARRAYSIZE(header); ++i)
+		sendMidiByte(header[i]);
+
+	_sysexChecksum = 0;
+	for (int i = 0; pData[i] != 0xFF; ++i) {
+		sendMidiByte(pData[i]);
+		_sysexChecksum += pData[i];
+	}
+
+	sendMidiByte((~_sysexChecksum + 1) & 0x7F);
+	sendMidiByte(0xF7);
+}
+
+void RSound::sendSysEx(int offset) {
+	sendSysExData(loadData(offset));
+}
+
+void RSound::sendPatchInitSequence() {
+	// TENTATIVE - see header comment for sendPatchInitSequence(). Matches
+	// sub_102BE exactly: 4 outer iterations, each sending one SysEx
+	// message built from the fixed header at loadData(0xA3) plus a
+	// computed payload; byte_12BD2 (here: base) persists and accumulates
+	// across outer iterations.
+	byte base = 0;
+	for (int outer = 0; outer < 4; ++outer) {
+		byte *header = loadData(0xA3);
+		for (int i = 0; header[i] != 0xFF; ++i)
+			sendMidiByte(header[i]);
+
+		_sysexChecksum = 0;
+		byte b1 = 5;
+		_sysexChecksum += b1; sendMidiByte(b1);
+		byte b2 = (byte)(outer << 1);
+		_sysexChecksum += b2; sendMidiByte(b2);
+		byte b3 = 0;
+		_sysexChecksum += b3; sendMidiByte(b3);
+
+		for (int inner = 0; inner < 0x20; ++inner) {
+			byte v1 = (byte)(outer >> 1);
+			_sysexChecksum += v1; sendMidiByte(v1);
+			byte v2 = (byte)(inner + base);
+			_sysexChecksum += v2; sendMidiByte(v2);
+			static const byte tail[] = { 0x18, 0x32, 0x0C, 0, 1, 0 };
+			for (int t = 0; t < ARRAYSIZE(tail); ++t) {
+				_sysexChecksum += tail[t];
+				sendMidiByte(tail[t]);
+			}
+		}
+
+		sendMidiByte((~_sysexChecksum + 1) & 0x7F);
+		sendMidiByte(0xF7);
+
+		base = (byte)((base + 0x20) & 0x3F);
+	}
+}
+
+void RSound::sendReverbSysEx(int mode, int time, int level) {
+	// CONFIRMED: unk_12BC9 (RSound1's dseg offset 0xA9) holds the fixed
+	// 3-byte Roland address 10 00 01h - the real MT-32 System Area Reverb
+	// parameter address, a hardware protocol constant rather than
+	// driver-specific sound data - immediately followed by the 3 mutable
+	// payload bytes (byte_12BCC/CD/CE) that this function fills in
+	// before sending. Hardcoded (not read via loadData()) since there's
+	// no reason to expect this address to live at the same offset in
+	// every driver's own resource file.
+	byte buffer[7] = { 0x10, 0x00, 0x01, (byte)(mode & 3), (byte)(time & 7), (byte)(level & 7), 0xFF };
+	sendSysExData(buffer);
+}
+
+/*-----------------------------------------------------------------------*/
+
+void RSound::Channel_flushHeldNotes(Channel *channel) {
+	byte *slots = _heldNotes[channel->_midiChannel];
+	for (int i = 0; i < 4; ++i) {
+		if (slots[i] != 0xFF) {
+			sendStatus(channel->_midiChannel, 0x90);
+			sendMidiByte(slots[i]);
+			sendMidiByte(0); // velocity 0 = note off
+			slots[i] = 0xFF;
+		}
+	}
+}
+
+void RSound::Channel_checkFade(Channel *channel, int midiChannel) {
+	if (!channel->_activeCount)
+		return;
+	if (!channel->_pendingStop)
+		return;
+
+	if (channel->_volume == 0) {
+		// unk_172E5 (RSound1's dseg offset 0x47C5) is 3 zero bytes - a
+		// generic silence/no-op stream, not driver-specific sound data.
+		// Hardcoded (not read via loadData()) for the same reason as
+		// sendReverbSysEx()'s fixed address - no reason to expect the
+		// same offset holds the same bytes in every driver's own
+		// resource file.
+		static byte silenceStream[3] = { 0, 0, 0 };
+		channel->_pSrc = silenceStream;
+		channel->_pendingStop = 0;
+		return;
+	}
+
+	channel->_volume -= 1;
+	sendVolume(midiChannel, channel->_volume);
+}
+
+void RSound::checkFadingChannels() {
+	if (!_fadeCheckPeriod)
+		return;
+	if (--_fadeCheckCounter > 0)
+		return;
+
+	_fadeCheckCounter = _fadeCheckPeriod;
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT; ++i)
+		Channel_checkFade(&_channels[i], i + 1);
+}
+
+/*-----------------------------------------------------------------------*/
+
+void RSound::resetChannelRange(int first, int last) {
+	for (int i = first; i <= last; ++i) {
+		_channels[i]._activeCount = 0;
+		_channels[i]._pitchBendFadeStep = 0; // matches "mov [bx],ax; mov [bx+2],ax" zeroing the first 4 bytes
+	}
+}
+
+void RSound::resetAllChannels() {
+	bool wasDisabled = _isDisabled;
+	_isDisabled = true;
+	resetChannelRange(0, RSOUND_CHANNEL_COUNT - 1);
+	for (int i = 0; i <= RSOUND_CHANNEL_COUNT; ++i)
+		for (int j = 0; j < 4; ++j)
+			_heldNotes[i][j] = 0xFF;
+	_isDisabled = wasDisabled;
+}
+
+void RSound::resetChannels1to5() {
+	_isDisabled = true;
+	resetChannelRange(0, 4);
+	for (int i = 0; i <= RSOUND_CHANNEL_COUNT; ++i)
+		for (int j = 0; j < 4; ++j)
+			_heldNotes[i][j] = 0xFF;
+	_isDisabled = false;
+}
+
+void RSound::resetChannels6to9() {
+	_isDisabled = true;
+	resetChannelRange(5, 8);
+	_isDisabled = false;
+}
+
+/*-----------------------------------------------------------------------*/
+
+int RSound::command0() {
+	bool wasDisabled = _isDisabled;
+	_isDisabled = true;
+
+	resetAllChannels();
+	sendGmReset(RSOUND_CHANNEL_COUNT);
+	sendSysEx(_sysExOffset);
+
+	_isDisabled = wasDisabled;
+	return 0;
+}
+
+int RSound::command1() {
+	command3();
+	command5(); // shares command1/command5's tail (enables channels 5-8) - see command5()
+	return 0;
+}
+
+int RSound::command2() {
+	resetChannels1to5();
+	sendGmReset(4);
+	return 0;
+}
+
+int RSound::command3() {
+	// Matches rsound_command3: enables channels 1,2,3,4 AND 9 (not 5-8) -
+	// a genuine, confirmed asymmetry (channel 9 falls through into the
+	// tail-shared Channel_enable call with the others).
+	_fadeCheckPeriod = 1; // armFadeCheck
+	_channels[0].enable(0xFF);
+	_channels[1].enable(0xFF);
+	_channels[2].enable(0xFF);
+	_channels[3].enable(0xFF);
+	_channels[8].enable(0xFF);
+	return 0;
+}
+
+int RSound::command4() {
+	// Matches rsound_command4's chunk at loc_106DB: reset channels 6-9,
+	// then a full sendGmReset(9) (all 9 channels) - the "reset 6-9" and
+	// "GM-reset all 9" are both really executed, matching the original
+	// exactly despite the apparent redundancy.
+	resetChannels6to9();
+	sendGmReset(RSOUND_CHANNEL_COUNT);
+	return 0;
+}
+
+int RSound::command5() {
+	// Matches rsound_command5/loc_108A9: enables channels 5,6,7,8.
+	_fadeCheckPeriod = 1;
+	_channels[4].enable(0xFF);
+	_channels[5].enable(0xFF);
+	_channels[6].enable(0xFF);
+	_channels[7].enable(0xFF);
+	return 0;
+}
+
+int RSound::command6() {
+	_isDisabled = true;
+	for (int i = 1; i <= RSOUND_CHANNEL_COUNT; ++i)
+		muteChannel(i);
+	return 0;
+}
+
+int RSound::command7() {
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT; ++i)
+		sendVolumeCC(i + 1, _channels[i]._volume);
+	_isDisabled = false;
+	return 0;
+}
+
+int RSound::command8() {
+	int result = 0;
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT; ++i)
+		result |= _channels[i]._activeCount;
+	return result;
+}
+
+/*-----------------------------------------------------------------------*/
+
+int RSound::readScriptByte(byte *&pSrc) {
+	++pSrc;
+	return (int8)*pSrc;
+}
+
+uint16 RSound::readScriptWord(byte *&pSrc) {
+	byte lo = *++pSrc;
+	byte hi = *++pSrc;
+	return lo | (hi << 8);
+}
+
+void RSound::update() {
+	getRandomNumber();
+	if (_isDisabled)
+		return;
+
+	++_frameCounter;
+	++_tickCounter; // matches "inc word_12BE5" alongside _frameCounter
+	checkRandomAmbianceTrigger();
+	pollAllChannels();
+	checkFadingChannels();
+}
+
+void RSound::pollAllChannels() {
+	for (int i = 0; i < RSOUND_CHANNEL_COUNT; ++i)
+		pollActiveChannel(&_channels[i]);
+}
+
+/*-----------------------------------------------------------------------*/
+// Per-channel opcode interpreter. Bytes with the high bit clear are
+// two-byte [note][duration] events (or, if > 0xBD, an opcode 0xBE-0xFF).
+// Matches the sibling ASound driver's pollActiveChannel() structure: a
+// goto-based re-entrant dispatch, re-looping after every opcode that
+// doesn't consume a duration tick.
+
+void RSound::pollActiveChannel(Channel *ch) {
+	int midiChannel = ch->_midiChannel;
+
+	if (!ch->_activeCount)
+		return;
+
+	if (ch->_keyOnDelay) {
+		if (--ch->_keyOnDelay == 0)
+			Channel_flushHeldNotes(ch);
+	}
+
+	if (--ch->_activeCount != 0)
+		goto post_keyon;
+
+dispatch:
+	{
+		// NOTE: ch->_pSrc is left untouched by each case below until its
+		// own final "ch->_pSrc = ..." / "ch->_pSrc += N" assignment, so
+		// any earlier use of ch->_pSrc within the same case (e.g. to
+		// compute a resume/branch target) still refers to this opcode's
+		// start position - only the local pSrc advances as operand bytes
+		// are read via readScriptByte()/readScriptWord().
+		byte *pSrc = ch->_pSrc;
+		byte b = *pSrc;
+
+		if (!(b & 0x80)) {
+			// ---- Simple note event: [note][duration] (matches loc_11FCA;
+			// distinct from - and simpler than - the explicit chord opcode
+			// 0xED below, which is count-prefixed and can hold up to 4
+			// simultaneous notes) ----
+			int note = (int8)pSrc[0] + ch->_transpose;
+			int duration = pSrc[1];
+			ch->_activeCount = duration;
+			ch->_pSrc += 2;
+
+			if (note != ch->_note)
+				Channel_flushHeldNotes(ch);
+
+			if (note != 0 && duration != 0) {
+				if (ch->_keyOnDelayOverride)
+					ch->_keyOnDelay = ch->_keyOnDelayOverride;
+				else
+					ch->_keyOnDelay = ch->_activeCount - ch->_noteOffset;
+
+				bool skip = false;
+				if (ch->_noteOffset < 0 && _heldNotes[midiChannel][0] == (byte)note)
+					skip = true;
+
+				if (!skip) {
+					ch->_note = note;
+					_heldNotes[midiChannel][0] = (byte)note;
+					sendNoteOn(midiChannel, note, ch->_velocity);
+				}
+			}
+			goto post_keyon;
+		}
+
+		if (b <= 0xBD)
+			goto post_keyon; // matches the disassembly's fallthrough for 0x80-0xBD
+
+		switch (b) {
+		case 0xBE: {
+			// TODO: purpose unconfirmed - no reader found for
+			// _clockUnknown anywhere in the disassembly seen so far.
+			_clockUnknown = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			goto dispatch;
+		}
+		case 0xBF: {
+			// TODO: purpose unconfirmed - no reader found for
+			// _clockCoarse/_clockEnabled1/_clockEnabled2 anywhere in the
+			// disassembly seen so far. Only takes effect (via the
+			// _tickCounter==0 gate) if executed before the very first
+			// update() tick.
+			_clockCoarseTarget = readScriptWord(pSrc);
+			if (_tickCounter == 0)
+				_clockCoarse = _clockCoarseTarget;
+			_clockEnabled1 = 1;
+			_clockEnabled2 = 1;
+			ch->_pSrc += 3;
+			goto dispatch;
+		}
+		case 0xC0: {
+			// TODO: purpose unconfirmed - no reader found for _clockMed
+			// anywhere in the disassembly seen so far. Same one-time-only
+			// gate as 0xBF above.
+			_clockMedTarget = readScriptByte(pSrc);
+			if (_tickCounter == 0)
+				_clockMed = _clockMedTarget;
+			ch->_pSrc += 2;
+			goto dispatch;
+		}
+		case 0xC1: {
+			// TODO: purpose unconfirmed - no reader found for _clockFine
+			// anywhere in the disassembly seen so far. Unlike 0xBE/0xBF/
+			// 0xC0, this sets the value directly and unconditionally
+			// (no _tickCounter gate).
+			_clockFine = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			goto dispatch;
+		}
+		case 0xC2: {
+			int v1 = readScriptByte(pSrc);
+			int v2 = readScriptByte(pSrc);
+			int v3 = readScriptByte(pSrc);
+			sendReverbSysEx(v1, v2, v3);
+			ch->_pSrc += 4;
+			goto dispatch;
+		}
+		case 0xC3: {
+			int v = readScriptByte(pSrc);
+			noOpHandler(v);
+			ch->_pSrc += 2;
+			goto dispatch;
+		}
+		case 0xC4: {
+			// TODO: NOT PORTABLE AS-IS - the disassembly calls the word
+			// operand as a raw code-address function pointer
+			// ("mov bx,ax; call bx"). There's no equivalent in a C++
+			// port without knowing what specific handful of sub_
+			// routines this is meant to invoke. error() (not warning())
+			// so this is impossible to miss if real game data ever
+			// actually triggers it, rather than silently no-opping.
+			readScriptWord(pSrc);
+			error("RSound::pollActiveChannel: opcode 0xC4 (function-pointer call) not portable as-is");
+			ch->_pSrc += 3;
+			goto dispatch;
+		}
+
+		// ---- Conditional branches, WITH resume-point bookkeeping ----
+		// (field_22/ _branchTarget), matching the call/return pair below.
+		case 0xC5: case 0xC6: case 0xC7: case 0xC8:
+		case 0xC9: case 0xCA: case 0xCB: case 0xCC:
+		// ---- Same 8 conditions, WITHOUT bookkeeping (plain goto-if) ----
+		case 0xCD: case 0xCE: case 0xCF: case 0xD0:
+		case 0xD1: case 0xD2: case 0xD3: case 0xD4: {
+			bool withSave = (b <= 0xCC);
+			bool varMode = (b <= 0xC8) || (b >= 0xCD && b <= 0xD0); // 0xC5-C8, 0xCD-D0: var vs var; 0xC9-CC, 0xD1-D4: var vs immediate
+			int idx1 = readScriptByte(pSrc);
+			int idx2 = readScriptByte(pSrc);
+			int lhs = _scriptVariables[idx1 & 0xFF];
+			int rhs = varMode ? _scriptVariables[idx2 & 0xFF] : idx2;
+
+			int cond = (b - (withSave ? 0xC5 : 0xCD)) % 4; // 0:'>' 1:'<' 2:'!=' 3:'=='
+			bool take = false;
+			switch (cond) {
+			case 0: take = (lhs > rhs); break;
+			case 1: take = (lhs < rhs); break;
+			case 2: take = (lhs != rhs); break;
+			case 3: take = (lhs == rhs); break;
+			}
+
+			if (take) {
+				if (withSave)
+					ch->_branchTarget = ch->_pSrc + 5;
+				ch->_pSrc = loadData(readScriptWord(pSrc));
+			} else {
+				ch->_pSrc += 5;
+			}
+			goto dispatch;
+		}
+
+		// ---- Bitwise/arithmetic ops on script variables ----
+		case 0xD5: { // XOR, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] ^= _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xD6: { // XOR, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] ^= (byte)imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xD7: { // OR, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] |= _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xD8: { // OR, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] |= (byte)imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xD9: { // AND, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] &= _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDA: { // AND, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] &= (byte)imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDB: { // MOD, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			byte divisor = _scriptVariables[idx2 & 0xFF];
+			if (divisor)
+				_scriptVariables[idx1 & 0xFF] = _scriptVariables[idx1 & 0xFF] % divisor;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDC: {
+			// SUSPECTED BUG in the original (MOD, "immediate" form): the
+			// disassembly reads a second operand byte but never uses it,
+			// instead computing scriptVar[idx1] % scriptVar[idx1] (always
+			// 0 when nonzero) - unlike every other immediate-mode
+			// arithmetic opcode (0xD6/D8/DA/E0/E2/E4), which all use the
+			// operand directly. Preserved exactly rather than "fixed" to
+			// use the operand as a real divisor, since that would be
+			// guessing at intended behavior.
+			int idx1 = readScriptByte(pSrc);
+			readScriptByte(pSrc); // operand read but unused, matching the disassembly
+			byte self = _scriptVariables[idx1 & 0xFF];
+			if (self)
+				_scriptVariables[idx1 & 0xFF] = self % self;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDD: { // DIV, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			byte divisor = _scriptVariables[idx2 & 0xFF];
+			if (divisor)
+				_scriptVariables[idx1 & 0xFF] = _scriptVariables[idx1 & 0xFF] / divisor;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDE: {
+			// Same suspected bug shape as 0xDC (DIV "immediate" form:
+			// self-divide, operand read but unused - always yields 1
+			// when nonzero) - preserved exactly.
+			int idx1 = readScriptByte(pSrc);
+			readScriptByte(pSrc); // operand read but unused, matching the disassembly
+			byte self = _scriptVariables[idx1 & 0xFF];
+			if (self)
+				_scriptVariables[idx1 & 0xFF] = self / self;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xDF: { // MUL, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] = (byte)(_scriptVariables[idx1 & 0xFF] * _scriptVariables[idx2 & 0xFF]);
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE0: { // MUL, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] = (byte)(_scriptVariables[idx1 & 0xFF] * imm);
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE1: { // SUB, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] -= _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE2: { // SUB, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] -= (byte)imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE3: { // ADD, variable
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] += _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE4: { // ADD, immediate
+			int idx1 = readScriptByte(pSrc), imm = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] += (byte)imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE5: { // DEC
+			int idx1 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF]--;
+			ch->_pSrc += 2; goto dispatch;
+		}
+		case 0xE6: { // INC
+			int idx1 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF]++;
+			ch->_pSrc += 2; goto dispatch;
+		}
+		case 0xE7: {
+			// Pokes scriptVar[idx1] into the sound-data stream itself at
+			// offset (currentPos + 1 + idx2) - a self-modifying-script op.
+			int idx1 = readScriptByte(pSrc);
+			int idx2 = readScriptByte(pSrc);
+			byte v = _scriptVariables[idx1 & 0xFF];
+			(pSrc + 1 + idx2)[0] = v;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE8: { // copy: scriptVar[idx1] = scriptVar[idx2]
+			int idx1 = readScriptByte(pSrc), idx2 = readScriptByte(pSrc);
+			_scriptVariables[idx1 & 0xFF] = _scriptVariables[idx2 & 0xFF];
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xE9: { // set: scriptVar[idx1] = immediate (raw, unsigned)
+			int idx1 = readScriptByte(pSrc);
+			byte imm = *++pSrc;
+			_scriptVariables[idx1 & 0xFF] = imm;
+			ch->_pSrc += 3; goto dispatch;
+		}
+		case 0xEA: {
+			// TODO: low confidence - a self-modifying op that reads
+			// table1[scriptVar[idx1]], then writes it into table2 at an
+			// offset determined by table2's own leading "size" byte.
+			// Translated as literally as possible; purpose unconfirmed.
+			int idx1 = readScriptByte(pSrc);
+			int len2 = readScriptByte(pSrc);
+			byte *table1Base = pSrc + 1;
+			byte v1 = table1Base[_scriptVariables[idx1 & 0xFF]];
+			byte *table2 = pSrc + 1 + len2 + 1;
+			int8 sizeByte = (int8)*(pSrc + 1 + len2);
+			table2[sizeByte] = v1;
+			ch->_pSrc += len2 + 4;
+			goto dispatch;
+		}
+		case 0xEB: {
+			// Picks a uniform random value in [rangeLow, rangeHigh], reads
+			// a "tableByte" positioned right after the two range operands,
+			// and writes the random value at offset (pSrc+2+tableByte)
+			// relative to pSrc's position after reading both operands
+			// (an extra "inc word_174A0" in the disassembly, beyond the
+			// two operand reads, is what puts tableByte one byte further
+			// out than the 0xEC case below).
+			int rangeLow = readScriptByte(pSrc);
+			int rangeHigh = readScriptByte(pSrc);
+			int range = rangeHigh - rangeLow + 1;
+			int r = range ? (getRandomNumber() % range) : 0;
+			byte value = (byte)(rangeLow + r);
+			int tableByte = (int8)*(pSrc + 1);
+			(pSrc + 2 + tableByte)[0] = value;
+			ch->_pSrc += 4;
+			goto dispatch;
+		}
+		case 0xEC: {
+			// Same general shape as 0xEA: random-pick from a table1 of
+			// length idx1, then poke it into table2 at an offset given
+			// by table2's own leading "size" byte.
+			int len1 = readScriptByte(pSrc);
+			int r = len1 ? (getRandomNumber() % len1) : 0;
+			byte *table1 = pSrc + 1;
+			byte v1 = table1[r];
+			byte *table2 = pSrc + 1 + len1 + 1;
+			int8 sizeByte = (int8)*(pSrc + 1 + len1);
+			table2[sizeByte] = v1;
+			ch->_pSrc += len1 + 3;
+			goto dispatch;
+		}
+
+		// ---- Loop / restart-pointer opcodes ----
+		case 0xFD: {
+			if (ch->_soundData == nullptr) {
+				ch->_pSrc = ch->_loopStartPtr;
+			} else {
+				ch->_loopStartPtr = ch->_soundData;
+				ch->_pSrc = ch->_soundData;
+				ch->_innerLoopPtr = ch->_soundData;
+				ch->_outerLoopPtr = ch->_soundData;
+			}
+			goto post_keyon; // matches "jmp loc_11FAB" tail used by this cluster
+		}
+		case 0xFC: {
+			byte *ptr = loadData(readScriptWord(pSrc));
+			ch->_loopStartPtr = ptr;
+			ch->_pSrc = ptr;
+			ch->_innerLoopPtr = ptr;
+			ch->_outerLoopPtr = ptr;
+			ch->_soundData = ptr;
+			goto post_keyon;
+		}
+		case 0xFB: { // unconditional jump, no bookkeeping
+			ch->_pSrc = loadData(readScriptWord(pSrc));
+			goto post_keyon;
+		}
+		case 0xFA: { // "call": jump, saving a resume point
+			byte *target = loadData(readScriptWord(pSrc));
+			ch->_branchTarget = ch->_pSrc + 3;
+			ch->_pSrc = target;
+			goto post_keyon;
+		}
+		case 0xF9: { // "return"
+			if (ch->_branchTarget) {
+				ch->_pSrc = ch->_branchTarget;
+				ch->_branchTarget = nullptr;
+			} else {
+				ch->_pSrc++;
+			}
+			goto post_keyon;
+		}
+		case 0xF8: {
+			ch->_program = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			sendProgramChange(midiChannel, ch->_program);
+			goto post_keyon;
+		}
+		case 0xF7: {
+			ch->_noteOffset = readScriptByte(pSrc);
+			ch->_keyOnDelayOverride = 0;
+			ch->_pSrc += 2;
+			goto post_keyon;
+		}
+		case 0xF6: {
+			ch->_keyOnDelayOverride = readScriptByte(pSrc);
+			ch->_noteOffset = 0;
+			ch->_pSrc += 2;
+			goto post_keyon;
+		}
+		case 0xF5: {
+			ch->_pitchBendFadeReload = readScriptByte(pSrc);
+			ch->_pitchBendFadeStep = readScriptByte(pSrc);
+			ch->_pitchBendFadeCount = readScriptByte(pSrc);
+			ch->_pitchBendFadeCounter = 1;
+			ch->_pSrc += 4;
+			goto post_keyon;
+		}
+		case 0xF4: {
+			ch->_volume = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			sendVolume(midiChannel, ch->_volume);
+			goto post_keyon;
+		}
+		case 0xF3: {
+			ch->_velocity = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			goto post_keyon;
+		}
+		case 0xF2: {
+			ch->_volumeFadeReload = readScriptByte(pSrc);
+			ch->_volumeFadeStep = readScriptByte(pSrc);
+			ch->_volumeFadeCounter = 1;
+			ch->_pSrc += 3;
+			goto post_keyon;
+		}
+		case 0xF1: {
+			ch->_pitchBend = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			sendPitchBend(midiChannel, ch->_pitchBend);
+			goto post_keyon;
+		}
+		case 0xF0: {
+			ch->_pan = readScriptByte(pSrc);
+			sendPan(midiChannel, ch->_pan);
+			ch->_pSrc += 2;
+			goto post_keyon;
+		}
+		case 0xEF: {
+			ch->_panFadeReload = readScriptByte(pSrc);
+			ch->_panFadeStep = readScriptByte(pSrc);
+			ch->_panFadeCounter = 1;
+			ch->_pSrc += 3;
+			goto post_keyon;
+		}
+		case 0xEE: {
+			ch->_transpose = readScriptByte(pSrc);
+			ch->_pSrc += 2;
+			goto post_keyon;
+		}
+		case 0xED: {
+			// ---- Chord event: [count][note1..noteN][duration] ----
+			// Matches loc_11102 - distinct from (and richer than) the
+			// simple single-note "high bit clear" format at the top of
+			// dispatch: this one is count-prefixed and can hold up to
+			// 4 simultaneous notes (matching _heldNotes' 4 slots).
+			int count = readScriptByte(pSrc);
+			int firstNote = (int8)*(pSrc + 1) + ch->_transpose;
+			if (firstNote != _heldNotes[midiChannel][0])
+				Channel_flushHeldNotes(ch);
+
+			int i;
+			for (i = 0; i < count; ++i) {
+				int note = (int8)*(pSrc + 1 + i) + ch->_transpose;
+				bool skip = false;
+				if (ch->_noteOffset < 0 && _heldNotes[midiChannel][i] == (byte)note)
+					skip = true;
+				if (!skip) {
+					_heldNotes[midiChannel][i] = (byte)note;
+					sendNoteOn(midiChannel, note, ch->_velocity);
+				}
+			}
+			for (; i < 4; ++i)
+				_heldNotes[midiChannel][i] = 0xFF;
+
+			ch->_activeCount = *(pSrc + 1 + count);
+			if (ch->_keyOnDelayOverride)
+				ch->_keyOnDelay = ch->_keyOnDelayOverride;
+			else
+				ch->_keyOnDelay = ch->_activeCount - ch->_noteOffset;
+
+			ch->_pSrc += count + 3;
+			goto post_keyon;
+		}
+		default:
+			// Unknown/unhandled opcode - matches the disassembly falling
+			// through to re-dispatch on an unrecognised value.
+			goto dispatch;
+		}
+	}
+
+post_keyon:
+	// ---- Volume fade tail ----
+	if (ch->_volumeFadeStep) {
+		if (--ch->_volumeFadeCounter == 0) {
+			ch->_volumeFadeCounter = ch->_volumeFadeReload;
+			ch->_volume += ch->_volumeFadeStep;
+			if ((byte)ch->_volume > 0x7F) {
+				ch->_volumeFadeStep = 0;
+				ch->_volume = ((byte)ch->_volume > 0xAF) ? 0 : 0x7F;
+			}
+			sendVolume(midiChannel, ch->_volume);
+		}
+	}
+
+	// ---- Pitch bend fade tail ----
+	if (ch->_pitchBendFadeStep) {
+		if (--ch->_pitchBendFadeCounter == 0) {
+			ch->_pitchBendFadeCounter = ch->_pitchBendFadeReload;
+			ch->_pitchBend += ch->_pitchBendFadeStep;
+			sendPitchBend(midiChannel, ch->_pitchBend);
+		}
+		if (--ch->_pitchBendFadeCount == 0)
+			ch->_pitchBendFadeStep = 0;
+	}
+
+	// ---- Pan fade tail ----
+	if (ch->_panFadeStep) {
+		if (--ch->_panFadeCounter == 0) {
+			ch->_panFadeCounter = ch->_panFadeReload;
+			ch->_pan += ch->_panFadeStep;
+			sendPan(midiChannel, ch->_pan);
+		}
+	}
+}
+
+} // namespace Phantom
+} // namespace MADS
diff --git a/engines/mads/phantom/rsound.h b/engines/mads/phantom/rsound.h
new file mode 100644
index 00000000000..7241791c36a
--- /dev/null
+++ b/engines/mads/phantom/rsound.h
@@ -0,0 +1,439 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MADS_PHANTOM_RSOUND_H
+#define MADS_PHANTOM_RSOUND_H
+
+#include "mads/core/sound_manager.h"
+
+namespace MADS {
+namespace Phantom {
+
+class RSound;
+
+#define RSOUND_CHANNEL_COUNT 9
+
+/**
+ * Represents the data for a channel on the Return of the Phantom MT-32 /
+ * MPU-401 driver. Ported from the Channel struct identified in rsound.ph1's
+ * disassembly (sizeof 0x27, five bytes larger than the equivalent Rex
+ * Nebular RSound Channel struct).
+ *
+ * The word-sized fields at 0x14-0x20 (_loopStartPtr through _soundData) are
+ * at the IDENTICAL offsets/order/roles as Rex Nebular's Channel struct -
+ * that part of the layout is unchanged between games. The byte fields at
+ * 0x00-0x13 are the same overall SET as Rex Nebular (confirmed by matching
+ * each field against the exact MIDI status byte/controller number sent by
+ * its dedicated helper, e.g. sendProgramChange sends status 0xC0 using
+ * _program), just reordered, with _pendingStop moved down to the very last
+ * byte (0x26) to make room for a new field (_keyOnDelayOverride, 0x08) with
+ * no Rex Nebular equivalent. Two more new fields exist past the Rex Nebular
+ * struct's end: _branchTarget (0x22) and _transpose (0x25).
+ */
+class Channel {
+public:
+	RSound *_owner = nullptr;
+	int _midiChannel = 0;         // 1-9: the MIDI channel this struct drives (C++ convenience, not a real field)
+
+	int _activeCount = 0;         // gate/duration countdown; also freshly loaded from the duration byte of a note event
+	int _pitchBendFadeStep = 0;   // per-step delta added to _pitchBend each ramp tick
+	int _volumeFadeStep = 0;      // per-step delta added to _volume each ramp tick
+	int _panFadeStep = 0;         // per-step delta added to _pan each ramp tick
+	int _note = 0;                // last-played note value (note + _transpose), used to detect a note change
+	int _program = 0;             // patch/instrument number, sent as a Program Change
+	int _velocity = 0;            // note velocity, used by RSound::sendNoteOn()
+	int _noteOffset = 0;          // subtracted from _activeCount to derive _keyOnDelay (relative mode)
+	int _keyOnDelayOverride = 0;  // when non-zero, used directly as _keyOnDelay instead of the relative computation (absolute mode; new to Phantom)
+	int _keyOnDelay = 0;          // countdown to RSound::Channel_flushHeldNotes()
+	int _volumeFadeCounter = 0;   // counts down to 0 before applying _volumeFadeStep
+	int _volumeFadeReload = 0;    // reload value for _volumeFadeCounter
+	int _pitchBendFadeCounter = 0; // counts down to 0 before applying _pitchBendFadeStep
+	int _panFadeCounter = 0;      // counts down to 0 before applying _panFadeStep
+	int _panFadeReload = 0;       // reload value for _panFadeCounter
+	int _pan = 0;                 // current pan value (MIDI CC#10); 64 = center
+	int _volume = 0;              // current channel volume (MIDI CC#7)
+	int _pitchBend = 0;           // current pitch bend value (status 0xEn, coarse/MSB only)
+	int _pitchBendFadeReload = 0; // reload value for _pitchBendFadeCounter
+	int _pitchBendFadeCount = 0;  // total ramp steps remaining before the pitch-bend ramp halts
+	byte *_loopStartPtr = nullptr; // fixed restart anchor used by the full-reset/loop-restart opcode
+	byte *_pSrc = nullptr;         // current read pointer into the sound-data stream
+	byte *_innerLoopPtr = nullptr; // inner-loop restart address
+	byte *_outerLoopPtr = nullptr; // outer-loop restart address
+	int _innerLoopCount = 0;
+	int _outerLoopCount = 0;
+	byte *_soundData = nullptr;    // identity pointer used by RSound::isSoundActive()
+	byte *_branchTarget = nullptr; // resume-after-branch pointer, used by the call/return opcode pair (new to Phantom)
+	int _transpose = 0;            // added to note bytes before comparison/storage (new to Phantom)
+	int _pendingStop = 0;          // non-zero while the channel is fading out to silence
+
+public:
+	Channel() {}
+
+	/**
+	 * Zeroes the entire 40-byte channel struct, then re-initialises the
+	 * loop/source/sound-data pointers to startPtr and centers pan and
+	 * pitch bend (0x40). More aggressive than Rex Nebular's equivalent,
+	 * which leaves volume/program/velocity/key-on state untouched.
+	 */
+	void reset(byte *startPtr);
+
+	/**
+	 * Marks the channel as pending-stop (fading toward silence) and
+	 * invalidates _soundData so isSoundActive() no longer matches it.
+	 */
+	void enable(int flag);
+
+	/**
+	 * Loads a brand new sound into the channel: resets it, marks it
+	 * active, and resets pitch bend to center on the real device.
+	 */
+	void load(byte *pData);
+};
+
+/**
+ * Base class for the Return of the Phantom MT-32 / MPU-401 sound player
+ * resource files (rsound.ph1-.ph9). Mirrors the structure of the Rex
+ * Nebular RSound family (see nebular/rsound.h), adapted for this game's
+ * substantially richer Channel_pollActive script VM (~65 opcodes here vs.
+ * ~15 in Rex Nebular): general arithmetic on a script-variable table,
+ * conditional branches, and a call/return pair, in addition to the shared
+ * note/fade/loop mechanics.
+ *
+ * NOTE: The actual MIDI transmission (sendMidiByte()) currently just logs
+ * via warning() - it isn't hooked up to a real ScummVM MIDI/MT-32 output
+ * yet, matching the Rex Nebular RSound family. Every other MIDI-sending
+ * helper funnels through sendMidiByte().
+ *
+ * NOTE: DOS-specific driver ceremony from the original (timer IRQ hooking,
+ * MPU-401 hardware detection/reset, PIT-based SysEx delay calibration, the
+ * system-clock save/restore around it) has no ScummVM equivalent and is not
+ * ported - matching how Rex Nebular's RSound skips the same kind of
+ * hardware-detection dance.
+ */
+class RSound : public SoundDriver {
+	friend class Channel;
+private:
+	uint16 _randomSeed;
+	byte _lastMidiStatus;         // running-status cache, avoids resending an unchanged status byte
+	byte _sysexChecksum;
+	int _stateChangedFlag;        // word_12BB8 in the disassembly; latches _pollResult=0xFFFF once per state change
+
+	/**
+	 * Per-MIDI-channel held-note slots (index 0 unused; channels are
+	 * 1-9; 4 = max chord polyphony). TODO/unconfirmed: the disassembly
+	 * shows TWO seemingly-parallel tables using the identical
+	 * "channel*4+slot" indexing and 0xFF-empty-slot convention -
+	 * byte_1760C (read/written directly by the chord-note-storing logic
+	 * in Channel_pollActive) and an unnamed array at dseg offset 0x4AEC
+	 * (used by resetAllChannels's initialization and by the
+	 * flush-held-notes helper). IDA never resolves 0x4AEC to a named
+	 * symbol anywhere, so it's not confirmed whether these are the same
+	 * underlying memory (most likely, and what's implemented here) or
+	 * two genuinely separate tables - worth double-checking.
+	 */
+	byte _heldNotes[RSOUND_CHANNEL_COUNT + 1][4];
+
+	/**
+	 * Data-segment offset of this driver's own "command0_array" (the
+	 * table sent by command0() via sendSysEx). Each driver has its own
+	 * copy of this table at its own offset within its own resource file -
+	 * matches the Rex Nebular RSound family's identical need. Not yet
+	 * confirmed for rsound.ph1 - the disassembly shows only the symbolic
+	 * "command0_array" label, not its numeric offset.
+	 */
+	int _sysExOffset;
+
+	/**
+	 * General-purpose script variable table (byte_17480 in the
+	 * disassembly). Confirmed 32 bytes via the gap to the next declared
+	 * global (_scriptReadPtr) - also matches the sibling ASound driver's
+	 * analogous _scriptVars[32].
+	 */
+	byte _scriptVariables[32];
+
+	/**
+	 * Half-rate fade-check timer (byte_107BD/byte_107BE in the
+	 * disassembly) - same counter/period/reload shape as the Rex Nebular
+	 * RSound4/RSound6 callback mechanism, but drives checkFadingChannels()
+	 * directly rather than an arbitrary function pointer.
+	 */
+	int _fadeCheckCounter;
+	int _fadeCheckPeriod;
+
+	/**
+	 * Cluster of globals written by opcodes 0xBE-0xC1 but with no
+	 * confirmed reader anywhere in the disassembly seen so far (all
+	 * TENTATIVE names - see individual comments). _clockFine/_clockMed/
+	 * _clockCoarse default to 7/28/112, a clean 4x progression,
+	 * suggesting a coarse/medium/fine clock-division hierarchy (a common
+	 * shape for a MIDI-clock-like timing subdivision) rather than three
+	 * unrelated parameters. _tickCounter gates a one-time-only override:
+	 * opcodes 0xBF/0xC0 only take effect if executed before the first
+	 * update() tick ever runs, since _tickCounter increments
+	 * unconditionally every tick thereafter and the gate checks "== 0".
+	 */
+	int _tickCounter;             // word_12BE5
+	int _clockMedTarget;          // word_12BE7 - pending value for _clockMed, set by opcode 0xC0
+	int _clockCoarseTarget;       // word_12BE9 - pending value for _clockCoarse, set by opcode 0xBF
+	int _clockUnknown;            // word_12BEB - default 0; doesn't fit the 4x pattern, standalone (opcode 0xBE)
+	int _clockCoarse;             // word_12BEE - default 112 (=28*4)
+	int _clockMed;                // word_12BF0 - default 28 (=7*4)
+	int _clockFine;               // word_12BF2 - default 7
+	int _clockEnabled1;           // word_12BD3 - set to 1 by opcode 0xBF
+	int _clockEnabled2;           // word_12BE3 - set to 1 by opcode 0xBF
+
+	void update();
+	void pollAllChannels();
+
+	/**
+	 * Per-channel opcode interpreter (Channel_pollActive in the
+	 * disassembly). Implements a bytecode VM: bytes with the high bit
+	 * clear are two-byte [note][duration] events; bytes > 0xBD are
+	 * commands (0xBE-0xFF), dispatched via the switch in the .cpp.
+	 * Re-enters its own dispatch point (via goto) after every opcode that
+	 * doesn't consume a duration tick, matching the sibling ASound
+	 * driver's pollActiveChannel() structure.
+	 */
+	void pollActiveChannel(Channel *channel);
+
+	/**
+	 * Reads one byte from the channel's current script position and
+	 * sign-extends it, advancing pSrc by 1.
+	 */
+	int readScriptByte(byte *&pSrc);
+
+	/**
+	 * Reads two bytes (little-endian) from the channel's current script
+	 * position as a raw offset into _soundData, advancing pSrc by 2.
+	 * Distinct from readScriptByte() - used only for absolute
+	 * jump/restart targets embedded in the script.
+	 */
+	uint16 readScriptWord(byte *&pSrc);
+
+	void resetChannelRange(int first, int last);
+	void resetAllChannels();
+	void resetChannels1to5();
+	void resetChannels6to9();
+
+	void checkFadingChannels();
+	void Channel_checkFade(Channel *channel, int midiChannel);
+	void Channel_flushHeldNotes(Channel *channel);
+
+protected:
+	int _commandParam;
+
+	byte *loadData(int offset) {
+		return &_soundData[offset];
+	}
+
+	/**
+	 * byte_1303E in the disassembly. Cleared to 0 by RSound1's command37
+	 * (a "cancel any pending random-ambiance trigger" side effect of
+	 * playing that specific sound). CONFIRMED: the only code that ever
+	 * sets it to 0xFF (arming checkRandomAmbianceTrigger()) is itself
+	 * unreachable/dead code - so in the real game this mechanism never
+	 * actually fires. Implemented faithfully anyway (matching sub_1222E's
+	 * shape exactly) in case that changes for a different driver.
+	 * Protected (not private) so driver subclasses with their own
+	 * commands touching it (like RSound1's command37) can reach it
+	 * directly.
+	 */
+	int _randomAmbianceTriggerFlag;
+
+	/**
+	 * Hook called once per update() frame after the disabled check.
+	 * Only drivers with a random-ambiance/music picker (e.g. RSound1's
+	 * command16) override this; every other driver leaves it a no-op.
+	 * Matches sub_1222E's confirmed shape: if _randomAmbianceTriggerFlag
+	 * == 0xFF, clear it and fire the driver-specific picker.
+	 */
+	virtual void checkRandomAmbianceTrigger() {
+	}
+
+	void resultCheck();
+
+	/**
+	 * Plays the specified sound, using any free channel from 6 to 8.
+	 * Matches the disassembly's playSound exactly (rsound_channel6-8).
+	 */
+	Channel *playSound(int offset);
+
+	/**
+	 * Plays the specified sound, using any free channel from 1 to 5.
+	 * NOTE: unlike Rex Nebular's playSoundAny() (which reaches all 9
+	 * channels), THIS driver's playSoundAny only scans channels 1-5 for a
+	 * free slot - confirmed directly from the disassembly. Also confirmed:
+	 * the pending-stop fallback scan only reaches down to channel 4, NOT
+	 * channel 5 - a genuine asymmetry preserved exactly (channel 5 can
+	 * never be pre-empted by this call, only picked while free).
+	 */
+	Channel *playSoundAny(int offset);
+
+	/**
+	 * Plays the specified sound, using any free channel from 1 to 8
+	 * (everything except channel 9). Matches sub_104FF in the
+	 * disassembly - a third, distinct scan range from playSound() and
+	 * playSoundAny() above.
+	 */
+	Channel *playSoundChannels1To8(int offset);
+
+	/**
+	 * Scans [startingChannel, freeScanEnd] for a free channel; if none
+	 * found, scans [startingChannel, fallbackScanEnd] in reverse for a
+	 * pending-stop channel to pre-empt. The two end bounds are usually
+	 * the same, but playSoundAny() is a confirmed exception (see above).
+	 */
+	Channel *playSoundData(byte *pData, int startingChannel, int freeScanEnd, int fallbackScanEnd);
+
+	/**
+	 * Checks whether the given block of data is already loaded into a channel.
+	 */
+	bool isSoundActive(byte *pData);
+
+	int getRandomNumber();
+
+	// ---- Low-level MIDI send helpers -------------------------------
+	// All funnel through sendMidiByte(), the single hook point for
+	// wiring up real MT-32/MIDI output.
+	void sendMidiByte(byte value);
+	void sendStatus(int midiChannel, byte statusNibble);
+	void sendNoteOn(int midiChannel, int note, int velocity);
+	void sendProgramChange(int midiChannel, int program);
+	void sendVolume(int midiChannel, int volume);
+	void sendVolumeCC(int midiChannel, int volume);
+	void resetPitchBend(int midiChannel);
+	void sendPitchBend(int midiChannel, int value);
+	void sendPan(int midiChannel, int value);
+	void muteChannel(int midiChannel);
+
+	/**
+	 * Sends the GM-reset Control Change sequence (all notes off, reset all
+	 * controllers, volume=100, pan=center) to `count` MIDI channels,
+	 * counting down from `count` to 1. Matches sub_1068A.
+	 */
+	void sendGmReset(int count);
+
+	/**
+	 * Sends a single Roland DT1-style SysEx message from a raw buffer:
+	 * the fixed header, then bytes from pData up to (not including) a
+	 * 0xFF terminator - each byte sent and folded into a running
+	 * checksum - then the checksum byte and a closing F7. The shared
+	 * core of sendSysEx()/sendReverbSysEx() below - split out so
+	 * hardcoded protocol buffers (not driver-specific loaded sound data)
+	 * can be sent without going through loadData().
+	 */
+	void sendSysExData(const byte *pData);
+
+	/**
+	 * sendSysExData() for a block already in this driver's own loaded
+	 * sound data. Matches sendSysEx exactly (same algorithm as the
+	 * confirmed Rex Nebular RSound::sendSysEx()).
+	 */
+	void sendSysEx(int offset);
+
+	/**
+	 * TENTATIVE: matches sub_102BE - a nested loop (4 outer x 32 inner
+	 * iterations) building and sending a SysEx message each inner pass.
+	 * The overall shape (loop counters, accumulating base value, fixed
+	 * bytes 0x18/0x32/0x0C) is clear from the disassembly, but the exact
+	 * purpose (a bulk patch/rhythm-setup initialization sequence is the
+	 * working hypothesis) is not confirmed.
+	 */
+	void sendPatchInitSequence();
+
+	/**
+	 * CONFIRMED: matches sub_108C7 - masks the 3 caller-supplied values
+	 * to 2/3/3 bits (mode 0-3, time 0-7, level 0-7) and sends them via
+	 * the real Roland MT-32 System Area Reverb SysEx address (10 00 01h -
+	 * originally found at RSound1's dseg offset 0xA9, but hardcoded here
+	 * rather than read via loadData(), since it's a fixed hardware
+	 * protocol address, not driver-specific sound data).
+	 */
+	void sendReverbSysEx(int mode, int time, int level);
+
+	/**
+	 * Matches sub_108F9 - a confirmed no-op (reads one operand, does
+	 * nothing with it).
+	 */
+	void noOpHandler(int param) {
+	}
+
+	virtual int command0();
+	int command1();
+	int command2();
+	int command3();
+
+	/**
+	 * NOTE: virtual, unlike command1-3/6-8. Confirmed (from RSound1) that
+	 * this driver's own command4/command5 are gated by isSoundActive() on
+	 * a driver-specific data offset (0x3D98 for RSound1) before doing
+	 * anything else - a per-driver detail that doesn't belong hardcoded
+	 * in the shared base. This base implementation is an UNGATED
+	 * fallback for drivers whose own command4/5 haven't been confirmed
+	 * yet; override once their disassembly is available.
+	 */
+	virtual int command4();
+	virtual int command5();
+	int command6();
+	int command7();
+	int command8();
+
+	int nullCommand() {
+		return 0;
+	}
+
+public:
+	Channel _channels[RSOUND_CHANNEL_COUNT];
+	int _frameCounter;
+	bool _isDisabled;
+	int _pollResult;
+
+public:
+	/**
+	 * Constructor
+	 * @param mixer			Mixer
+	 * @param filename		Specifies the Roland sound player file to use
+	 * @param dataOffset	Offset in the file of the data segment
+	 * @param dataSize		Size of the data segment
+	 * @param sysExOffset	Offset of this driver's own command0_array
+	 */
+	RSound(Audio::Mixer *mixer, const Common::Path &filename,
+		int dataOffset, int dataSize, int sysExOffset);
+
+	~RSound() override {
+	}
+
+	int stop() override;
+	int poll() override;
+	void noise() override {
+		// No equivalent in the Roland driver - noise() is an Adlib/OPL-only concept
+	}
+	void setVolume(int volume) override;
+
+	int getFrameCounter() {
+		return _frameCounter;
+	}
+};
+
+} // namespace Phantom
+} // namespace MADS
+
+#endif
diff --git a/engines/mads/phantom/rsound_phantom.cpp b/engines/mads/phantom/rsound_phantom.cpp
new file mode 100644
index 00000000000..563f4052b76
--- /dev/null
+++ b/engines/mads/phantom/rsound_phantom.cpp
@@ -0,0 +1,403 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "mads/phantom/rsound_phantom.h"
+
+namespace MADS {
+namespace Phantom {
+
+RSound1::RSound1(Audio::Mixer *mixer) : RSound(mixer, "rsound.ph1", 0x2D20, 0x4B30, 0xDC) {
+}
+
+int RSound1::command(int commandId, int param) {
+	_commandParam = param;
+	_frameCounter = 0;
+
+	switch (commandId) {
+	case 0: return command0();
+	case 1: return command1();
+	case 2: return command2();
+	case 3: return command3();
+	case 4: return command4();
+	case 5: return command5();
+	case 6: return command6();
+	case 7: return command7();
+	case 8: return command8();
+	case 16: return command16();
+	case 24: return command24();
+	case 25: return command25();
+	case 26: return command26();
+	case 27: return command27();
+	case 32: return command32();
+	case 33: return command33();
+	case 34: return command34();
+	case 35: return command35();
+	case 36: return command36();
+	case 38: return command38();
+	case 39: return command39();
+	case 64: return command64();
+	case 65: return command65();
+	case 66: return command66();
+	case 67: return command67();
+	case 68: return command68();
+	case 69: return command69();
+	case 70: return command70();
+	case 71: return command71();
+	case 72: return command72();
+	case 73: return command73();
+	case 74: return command74();
+	case 75: return command75();
+	case 76: return command76();
+	default:
+		// TODO: command 37 not yet implemented - disassembly not yet
+		// provided. There is no command 17 (see the class comment).
+		return 0;
+	}
+}
+
+void RSound1::checkRandomAmbianceTrigger() {
+	// Matches sub_1222E exactly. CONFIRMED: the only code that ever arms
+	// this (setting _randomAmbianceTriggerFlag to 0xFF) is itself
+	// unreachable/dead code, so in practice this check always fails and
+	// command16() never fires via this path - only command37's clear
+	// (setting it to 0) is live. Kept faithful to the original regardless.
+	if (_randomAmbianceTriggerFlag != 0xFF)
+		return;
+	_randomAmbianceTriggerFlag = 0;
+	command16();
+}
+
+int RSound1::command16() {
+	// Matches rsound_command16. CONFIRMED: "ds:4982h" is
+	// rsound_channel1._activeCount, not a loadData()-relative data byte -
+	// i.e. this gate is "only bother checking whether one of the five
+	// known pieces is already playing if channel 1 is actually busy;
+	// if it's idle, nothing could conflict, so skip straight to picking."
+	if (_channels[0]._activeCount) {
+		if (isSoundActive(loadData(0x2172))) return 0;
+		if (isSoundActive(loadData(0x2448))) return 0;
+		if (isSoundActive(loadData(0x3826))) return 0;
+		if (isSoundActive(loadData(0x2780))) return 0;
+		if (isSoundActive(loadData(0x2E26))) return 0;
+	}
+
+	int idx;
+	do {
+		idx = getRandomNumber() & 7;
+	} while (idx > 4 || idx == _lastRandomAmbianceIndex);
+	_lastRandomAmbianceIndex = idx;
+
+	typedef int (RSound1:: *AmbiancePtr)();
+	static const AmbiancePtr targets[5] = {
+		&RSound1::sound1, &RSound1::sound2, &RSound1::sound3,
+		&RSound1::sound4, &RSound1::sound5
+	};
+	return (this->*targets[idx])();
+}
+
+int RSound1::command4() {
+	// Confirmed: rsound_command4 is gated by isSoundActive(0x3D98) -
+	// which is also command39's own target sound - before falling
+	// through to the base class's reset-channels-6-9 + full GM-reset.
+	if (isSoundActive(loadData(0x3D98)))
+		return 0;
+	return RSound::command4();
+}
+
+int RSound1::command5() {
+	// Confirmed: same isSoundActive(0x3D98) gate as command4() above.
+	if (isSoundActive(loadData(0x3D98)))
+		return 0;
+	return RSound::command5();
+}
+
+int RSound1::command24() {
+	playSound(0x4596);
+	playSound(0x45AA);
+	return 0;
+}
+
+int RSound1::command25() {
+	playSound(0x45BC);
+	playSound(0x45D0);
+	return 0;
+}
+
+int RSound1::command26() {
+	playSound(0x45E2);
+	return 0;
+}
+
+int RSound1::command27() {
+	playSound(0x45EC);
+	return 0;
+}
+
+int RSound1::command32() {
+	byte *pData = loadData(0x2044);
+	if (!isSoundActive(pData)) {
+		command1();
+		playSoundAny(0x2044);
+		playSoundAny(0x206B);
+		playSoundAny(0x208B);
+		playSoundAny(0x20AB);
+		playSoundAny(0x20C9);
+		playSoundAny(0x20E3);
+	}
+	return 0;
+}
+
+int RSound1::command33() {
+	byte *pData = loadData(0x530);
+	if (!isSoundActive(pData)) {
+		command1();
+		playSoundAny(0x530);
+		playSoundAny(0x766);
+		playSoundAny(0x929);
+		playSoundAny(0xB37);
+		playSoundAny(0xCBA);
+		_channels[5].load(loadData(0xE20));
+	}
+	return 0;
+}
+
+int RSound1::command34() {
+	byte *pData = loadData(0x1014);
+	if (!isSoundActive(pData)) {
+		command1();
+		playSoundAny(0x1014);
+		playSoundAny(0x126B);
+		playSoundAny(0x137C);
+		playSoundAny(0x149B);
+		playSoundAny(0x158C);
+		playSoundAny(0x164E);
+	}
+	return 0;
+}
+
+int RSound1::command35() {
+	// isSoundActive(0x1BD4) is checked twice in a row in the disassembly
+	// (a genuine, harmless duplicate - both checks are on the same data,
+	// so the second is always redundant with the first) - preserved
+	// exactly rather than collapsed to a single check.
+	byte *pData = loadData(0x1BD4);
+	if (!isSoundActive(pData) && !isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x1D46));
+		_channels[2].load(loadData(0x1DDA));
+		_channels[3].load(loadData(0x1E91));
+		_channels[4].load(loadData(0x1F1B));
+	}
+	return 0;
+}
+
+int RSound1::command36() {
+	byte *pData = loadData(0x1734);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x1882));
+		_channels[2].load(loadData(0x1949));
+		_channels[3].load(loadData(0x1A08));
+		_channels[4].load(loadData(0x1A9E));
+	}
+	return 0;
+}
+
+int RSound1::command38() {
+	// Also independently reachable via command16's random picker.
+	byte *pData = loadData(0x2172);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x2268));
+		_channels[2].load(loadData(0x22E1));
+		_channels[3].load(loadData(0x239A));
+	}
+	return 0;
+}
+
+int RSound1::command39() {
+	byte *pData = loadData(0x3D98);
+	if (!isSoundActive(pData)) {
+		command1();
+		playSoundChannels1To8(0x3D98);
+		playSoundChannels1To8(0x3F39);
+		playSoundChannels1To8(0x404D);
+		playSoundChannels1To8(0x413F);
+		playSoundChannels1To8(0x4247);
+		playSoundChannels1To8(0x43FD);
+	}
+	return 0;
+}
+
+int RSound1::sound1() {
+	// Not a real disassembly function - index 0 of funcs_122AD dispatches
+	// straight to command38 (see the class comment).
+	return command38();
+}
+
+int RSound1::sound2() {
+	byte *pData = loadData(0x2448);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x25B8));
+		_channels[2].load(loadData(0x26C9));
+		_channels[3].load(loadData(0x2772));
+	}
+	return 0;
+}
+
+int RSound1::sound3() {
+	byte *pData = loadData(0x3826);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x3AAA));
+		_channels[2].load(loadData(0x3CE6));
+		_channels[3].load(loadData(0x3CD7));
+	}
+	return 0;
+}
+
+int RSound1::sound4() {
+	byte *pData = loadData(0x2780);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x2972));
+		_channels[2].load(loadData(0x2AF5));
+		_channels[3].load(loadData(0x2CBA));
+	}
+	return 0;
+}
+
+int RSound1::sound5() {
+	byte *pData = loadData(0x35D8);
+	if (!isSoundActive(pData)) {
+		command1();
+		_channels[0].load(pData);
+		_channels[1].load(loadData(0x36A8));
+		_channels[2].load(loadData(0x3757));
+		_channels[3].load(loadData(0x3814));
+	}
+	return 0;
+}
+
+int RSound1::command37() {
+	// The disassembly's first "call isSoundActive" has no preceding
+	// "lea cx" - unlike every other command, which always reloads cx
+	// immediately before an isSoundActive check. The master dispatcher
+	// (rsound_command) never touches cx before invoking any command, so
+	// cx here holds whatever the game engine's own call into command()
+	// left it as - genuinely external state with no reproducible value,
+	// not something this port can derive from the disassembly. Treating
+	// this check as inert (unpredictable external data will essentially
+	// never match a real loaded Channel._soundData) rather than
+	// inventing a target offset for it, so command1() and the loads
+	// below run unconditionally.
+	_randomAmbianceTriggerFlag = 0;
+	command1();
+	_randomAmbianceTriggerFlag = 0;
+	_channels[2].load(loadData(0x3CF6));
+	_channels[3].load(loadData(0x3D3E));
+	_channels[4].load(loadData(0x3D75));
+	return 0;
+}
+
+int RSound1::command64() {
+	playSound(0x460B);
+	return 0;
+}
+
+int RSound1::command65() {
+	playSound(0x461D);
+	playSound(0x4631);
+	return 0;
+}
+
+int RSound1::command66() {
+	playSound(0x4647);
+	return 0;
+}
+
+int RSound1::command67() {
+	playSound(0x465D);
+	_channels[8].load(loadData(0x466E));
+	return 0;
+}
+
+int RSound1::command68() {
+	playSound(0x4725);
+	return 0;
+}
+
+int RSound1::command69() {
+	playSound(0x4676);
+	playSound(0x4693);
+	return 0;
+}
+
+int RSound1::command70() {
+	playSound(0x46AC);
+	playSound(0x46B8);
+	playSound(0x46C4);
+	playSound(0x47B7);
+	return 0;
+}
+
+int RSound1::command71() {
+	playSound(0x470B);
+	return 0;
+}
+
+int RSound1::command72() {
+	playSound(0x4747);
+	return 0;
+}
+
+int RSound1::command73() {
+	playSound(0x4761);
+	return 0;
+}
+
+int RSound1::command74() {
+	playSound(0x47A7);
+	playSound(0x47B7);
+	return 0;
+}
+
+int RSound1::command75() {
+	// Plays the same offset twice - a genuine quirk, preserved exactly.
+	playSound(0x4783);
+	playSound(0x4783);
+	return 0;
+}
+
+int RSound1::command76() {
+	playSound(0x46D0);
+	return 0;
+}
+
+} // namespace Phantom
+} // namespace MADS
diff --git a/engines/mads/phantom/rsound_phantom.h b/engines/mads/phantom/rsound_phantom.h
new file mode 100644
index 00000000000..b8df21a3980
--- /dev/null
+++ b/engines/mads/phantom/rsound_phantom.h
@@ -0,0 +1,114 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MADS_PHANTOM_RSOUND_PHANTOM_H
+#define MADS_PHANTOM_RSOUND_PHANTOM_H
+
+#include "mads/phantom/rsound.h"
+
+namespace MADS {
+namespace Phantom {
+
+/**
+ * RSound1 (rsound.ph1)
+ *
+ * Dispatch table layout (funcs_10960/10976/1098C/109A2/109B8 in the
+ * disassembly - a 5-bucket sparse dispatch, mirroring the sibling ASound1's
+ * identical structure). There is no command 17 - bucket 2's dispatch
+ * bounds check only ever admits index 16 (confirmed: word_13047 = 16, an
+ * inclusive upper bound equal to the bucket's own lower bound).
+ *   commands  0-8   (base class, except command4/5 - see RSound::command4/5)
+ *   command   16    (random-ambiance picker, this class)
+ *   commands 24-27  (this class)
+ *   commands 32-39  (this class)
+ *   commands 64-76  (this class - unlike ASound1's equivalent range, this
+ *                    one WAS reconstructable from the disassembly)
+ *
+ * command16 picks one of five alternatives at random (avoiding immediate
+ * repeats via _lastRandomAmbianceIndex), after first checking whether one
+ * of five known pieces is already playing via isSoundActive() gates.
+ * sound1() doesn't exist as a separate function in the disassembly -
+ * index 0 of funcs_122AD dispatches directly to command38 (also
+ * independently reachable as a direct command) - but is declared here as
+ * a thin wrapper for consistency with sound2()-sound5().
+ */
+class RSound1 : public RSound {
+private:
+	typedef int (RSound1:: *CommandPtr)();
+
+	// Mirrors word_1225D: avoids picking the same alternative twice in a row.
+	int _lastRandomAmbianceIndex = -1;
+
+	void checkRandomAmbianceTrigger() override;
+
+	int command16();
+
+	// Overrides confirming the isSoundActive(0x3D98) gate the base
+	// class's ungated command4()/command5() lack - see RSound::command4/5.
+	int command4() override;
+	int command5() override;
+
+	int command24();
+	int command25();
+	int command26();
+	int command27();
+
+	int command32();
+	// Targets of command16's random picker (funcs_122AD in the
+	// disassembly). sound1() is not a real disassembly function - see
+	// the class comment.
+	int command38();
+	int sound1();
+	int sound2();
+	int sound3();
+	int sound4();
+	int sound5();
+	int command33();
+	int command34();
+	int command35();
+	int command36();
+	int command37();
+	int command39();
+
+	int command64();
+	int command65();
+	int command66();
+	int command67();
+	int command68();
+	int command69();
+	int command70();
+	int command71();
+	int command72();
+	int command73();
+	int command74();
+	int command75();
+	int command76();
+
+public:
+	RSound1(Audio::Mixer *mixer);
+
+	int command(int commandId, int param) override;
+};
+
+} // namespace Phantom
+} // namespace MADS
+
+#endif
diff --git a/engines/mads/phantom/sound.cpp b/engines/mads/phantom/sound.cpp
index 88555f62e23..b40b0083af8 100644
--- a/engines/mads/phantom/sound.cpp
+++ b/engines/mads/phantom/sound.cpp
@@ -21,6 +21,7 @@
 
 #include "mads/phantom/sound.h"
 #include "mads/phantom/asound_phantom.h"
+#include "mads/phantom/rsound_phantom.h"
 
 namespace MADS {
 namespace Phantom {
@@ -38,7 +39,8 @@ void PhantomSoundManager::loadDriver(int sectionNumber) {
 
 	if (_isMT32) {
 		// Roland MT32 drivers
-		assert(0 == 1);
+		assert(sectionNumber == 1);
+		_driver = new RSound1(_mixer);
 
 	} else {
 		// Adlib drivers




More information about the Scummvm-git-logs mailing list