[Scummvm-git-logs] scummvm master -> 69daec4dd08f0d7bd401a9c6e8155c70d0163614

neuromancer noreply at scummvm.org
Wed Aug 5 12:06:01 UTC 2026


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

Summary:
d3a7f9df00 FREESCAPE: fixed tempo in the eclipse music for amiga/atari
6295cdfc86 FREESCAPE: improved adlib rendition for eclipse music
ebbdd6cc7f FREESCAPE: fixed notes in the dark music for amiga
3fab7f6a20 FREESCAPE: implemented dark music for atari
448320e7a2 FREESCAPE: implemented missing dark UI for atari
cede836959 FREESCAPE: implemented dark opl music for DOS
1533504287 FREESCAPE: refine driller opl music for DOS
ccebc18288 FREESCAPE: initial support for crypt atari
ff32415ac6 FREESCAPE: initial support for crypt amiga
2567e00261 FREESCAPE: fixed text messages in castle amiga and atari
628bb33e2e FREESCAPE: fixed incorrect palette in castle amiga and atari
aa4b9e764b FREESCAPE: removed invalid sounds from castle amiga demo
97e8e907fe FREESCAPE: implemented sounds from castle atari
5b0cb36c41 FREESCAPE: implemented key rendering from castle amiga/atari
69daec4dd0 FREESCAPE: changed unstable by testing in some castle/crypt releases


Commit: d3a7f9df00ba2107bb338deea1c1077988ceb751
    https://github.com/scummvm/scummvm/commit/d3a7f9df00ba2107bb338deea1c1077988ceb751
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: fixed tempo in the eclipse music for amiga/atari

Changed paths:
    engines/freescape/games/eclipse/atari.music.cpp
    engines/freescape/wb.cpp


diff --git a/engines/freescape/games/eclipse/atari.music.cpp b/engines/freescape/games/eclipse/atari.music.cpp
index 2efab460e70..2c85880c6d5 100644
--- a/engines/freescape/games/eclipse/atari.music.cpp
+++ b/engines/freescape/games/eclipse/atari.music.cpp
@@ -128,7 +128,6 @@ private:
 		byte envelopeFlags; // Instrument byte 4
 		byte envelopeToggle; // Bit7 envelope direction toggle
 		bool envelopeDone; // Mirrors original per-note envelope completion flag
-		bool useHardwareEnvelope;
 
 		// Effects
 		byte effectMode;   // 0=none, 1=pattern FX ($7D), 2=instrument FX ($7C)
@@ -180,9 +179,6 @@ private:
 	bool _musicActive;
 	byte _tickSpeed;
 	byte _tickCounter;
-	bool _hwEnvelopeDirty;
-	uint16 _hwEnvelopePeriod;
-	byte _hwEnvelopeShape;
 	int _songNum;
 
 	// --- Methods ---
@@ -232,7 +228,6 @@ EclipseAtariMusicPlayer::EclipseAtariMusicPlayer(const byte *data, uint32 dataSi
                                                    int songNum)
 	: _data(data), _dataSize(dataSize),
 	  _musicActive(false), _tickSpeed(6), _tickCounter(0),
-	  _hwEnvelopeDirty(false), _hwEnvelopePeriod(0), _hwEnvelopeShape(0),
 	  _numPatterns(0), _songNum(songNum) {
 
 	memset(_periods, 0, sizeof(_periods));
@@ -359,9 +354,6 @@ void EclipseAtariMusicPlayer::startSong(int songNum) {
 	int songIdx = songNum - 1;
 	_tickSpeed = 6;
 	_tickCounter = 0;
-	_hwEnvelopeDirty = false;
-	_hwEnvelopePeriod = 0;
-	_hwEnvelopeShape = 0;
 
 	// Silence all YM channels
 	for (int r = 0; r < 14; r++)
@@ -394,7 +386,6 @@ void EclipseAtariMusicPlayer::initChannel(int ch) {
 	c.decayTarget = 0x36;
 	c.toneEnabled = true;
 	c.envelopeDone = true;
-	c.useHardwareEnvelope = false;
 }
 
 // ---------------------------------------------------------------------------
@@ -585,8 +576,16 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		if (cmd == 0x7C) {
-			// Pattern effect 2: switch mode only (no parameter byte consumed).
+			// Pattern effect 2: identical to $7D except that the mode is 2,
+			// which survives the step boundary. Asm ref: TEXT+$031A, which
+			// loads mode 2 and jumps into the middle of the $7D handler, so
+			// the parameter byte is consumed here as well.
+			byte param = readDataByte(c.patternOffset + c.patternPos);
+			c.patternPos++;
 			c.effectMode = 2;
+			c.arpeggioMask = param;
+			c.arpeggioPos = 0;
+			buildArpeggioTable(c, param);
 			continue;
 		}
 
@@ -658,7 +657,6 @@ void EclipseAtariMusicPlayer::triggerNote(int ch) {
 
 	c.basePeriod = isRest ? 0 : getPeriod(note);
 	c.outputPeriod = c.basePeriod;
-	c.useHardwareEnvelope = false;
 
 	if (!isRest && c.basePeriod == 0) {
 		warning("TE-Atari: ch%d note %d has period 0", ch, note);
@@ -704,18 +702,6 @@ void EclipseAtariMusicPlayer::triggerNote(int ch) {
 		c.modStep = periodDelta;
 	}
 
-	// TEMUSIC instrument byte 4 bit7 selects YM hardware envelope mode.
-	if (!isRest && (c.envelopeFlags & 0x80)) {
-		c.useHardwareEnvelope = true;
-		c.envelopeDone = true;
-
-		// Envelope style comes from low nibble; period follows note pitch scale.
-		_hwEnvelopeShape = c.envelopeFlags & 0x0F;
-		uint16 envPeriod = (c.basePeriod > 0) ? (uint16)MAX(1, c.basePeriod >> 4) : 1;
-		_hwEnvelopePeriod = envPeriod;
-		_hwEnvelopeDirty = true;
-	}
-
 	debugC(3, kFreescapeDebugParser, "TE-Atari: ch%d NOTE note=%d(+%d) period=%d inst=%d vol=%d",
 		ch, c.note, c.transpose, c.basePeriod, c.instrumentIdx, c.volume);
 
@@ -864,12 +850,12 @@ void EclipseAtariMusicPlayer::processEnvelope(int ch) {
 	// Noise-only instruments may validly run with zero tone period.
 	if (c.outputPeriod == 0 && !c.noiseEnabled)
 		return;
-	if (c.useHardwareEnvelope)
-		return;
 
 	byte env = c.envelopeFlags;
 
 	// Instrument env byte bit7: oscillating level between attackLevel and target.
+	// The YM hardware envelope is never used: the register write loop at
+	// TEXT+$0AD2 only ever pushes registers 0-10 to the chip.
 	if (env & 0x80) {
 		byte step = env & 0x0F;
 		if (c.envelopeToggle == 0) {
@@ -937,8 +923,16 @@ void EclipseAtariMusicPlayer::processEnvelope(int ch) {
 // ---------------------------------------------------------------------------
 
 void EclipseAtariMusicPlayer::buildArpeggioTable(ChannelState &c, byte mask) {
-	c.arpeggioTableLen = WBCommon::buildArpeggioTable(_arpeggioIntervals, mask, c.arpeggioTable, 16, false);
+	// Asm ref: TEXT+$0846 — the selected intervals are written starting at
+	// slot 1 of the channel's region in the shared buffer at $CAA and are
+	// terminated by $FF; slot 0 is never written and holds 0. Playback starts
+	// at slot 1 and wraps back to slot 0, so the base note closes the cycle.
+	byte len = WBCommon::buildArpeggioTable(_arpeggioIntervals, mask, c.arpeggioTable, 15, false);
+	if (len > 0)
+		c.arpeggioTable[len++] = 0;
+	c.arpeggioTableLen = len;
 	c.arpeggioPos = 0;
+	c.effect7BActive = false; // Asm ref: TEXT+$08A2 clears the $7B state here
 }
 
 // ---------------------------------------------------------------------------
@@ -947,12 +941,6 @@ void EclipseAtariMusicPlayer::buildArpeggioTable(ChannelState &c, byte mask) {
 
 void EclipseAtariMusicPlayer::writeYMRegisters() {
 	byte mixer = 0x3F; // Start with all disabled (bits 0-2=tone, bits 3-5=noise)
-	if (_hwEnvelopeDirty) {
-		setReg(11, _hwEnvelopePeriod & 0xFF);
-		setReg(12, (_hwEnvelopePeriod >> 8) & 0xFF);
-		setReg(13, _hwEnvelopeShape & 0x0F);
-		_hwEnvelopeDirty = false;
-	}
 
 	// TEMUSIC channel loop runs 2 -> 0; keep that order so global noise register ownership matches.
 	for (int ch = kTENumChannels - 1; ch >= 0; ch--) {
@@ -960,8 +948,7 @@ void EclipseAtariMusicPlayer::writeYMRegisters() {
 
 		bool hasTone = c.toneEnabled && (c.outputPeriod > 0);
 		bool hasNoise = c.noiseEnabled;
-		bool usesHwEnvelope = c.useHardwareEnvelope;
-		if (!c.active || (!usesHwEnvelope && c.volume == 0) || (!hasTone && !hasNoise)) {
+		if (!c.active || c.volume == 0 || (!hasTone && !hasNoise)) {
 			// Channel silent
 			setReg(8 + ch, 0); // Volume = 0
 			continue;
@@ -989,14 +976,10 @@ void EclipseAtariMusicPlayer::writeYMRegisters() {
 			setReg(ch * 2 + 1, (period >> 8) & 0x0F); // Coarse tune
 		}
 
-		// Set volume (internal 0-63 → YM 0-15)
-		if (usesHwEnvelope) {
-			setReg(8 + ch, 0x10); // Enable YM hardware envelope on this channel
-		} else {
-			byte ymVol = c.volume >> 2;
-			if (ymVol > 15) ymVol = 15;
-			setReg(8 + ch, ymVol);
-		}
+		// Set volume (internal 0-63 → YM 0-15). Asm ref: TEXT+$0748.
+		byte ymVol = c.volume >> 2;
+		if (ymVol > 15) ymVol = 15;
+		setReg(8 + ch, ymVol);
 	}
 
 	setReg(7, mixer);
@@ -1010,8 +993,9 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 	if (!_musicActive)
 		return;
 
-	// Sequencer step occurs when tick counter is zero, then the counter advances.
-	// This matches the original TEMUSIC update loop and wb.cpp behavior.
+	// Sequencer step occurs when the tick counter is zero, then it advances.
+	// Asm ref: TEXT+$015C tests the speed counter before touching the per
+	// channel duration counter.
 	bool sequencerTick = (_tickCounter == 0);
 
 	if (sequencerTick) {
@@ -1037,7 +1021,6 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 				c.noiseEnabled = false;
 				c.freqSweep = false;
 				c.envelopeDone = false;
-				c.useHardwareEnvelope = false;
 				readPatternCommands(ch);
 			}
 		}
@@ -1052,8 +1035,11 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 		processEnvelope(ch);
 	}
 
+	// Asm ref: TEXT+$0808 — the speed counter is decremented every tick and
+	// only reloaded from the speed value ($C54) once it goes negative, so a
+	// speed of N leaves N + 1 ticks between sequencer steps.
 	_tickCounter++;
-	if (_tickCounter >= _tickSpeed)
+	if (_tickCounter > _tickSpeed)
 		_tickCounter = 0;
 
 	writeYMRegisters();
diff --git a/engines/freescape/wb.cpp b/engines/freescape/wb.cpp
index c3816cb5252..3b31e627f8a 100644
--- a/engines/freescape/wb.cpp
+++ b/engines/freescape/wb.cpp
@@ -60,14 +60,17 @@ int8 decodeOrderTranspose(byte cmd) {
 	return (int8)((cmd + 0x20) & 0xFF);
 }
 
+// Both engines mask the command byte and store it verbatim; zero is a legal
+// value because the counters they feed are reloaded with N and expire one tick
+// (respectively one sequencer step) after reaching zero, so N always means
+// N + 1 units. Asm ref: HDSMUSIC.AM $0256, TEMUSIC.ST $0266 (speed) and
+// HDSMUSIC.AM $02B4, TEMUSIC.ST $02A2 (duration).
 byte decodeTickSpeed(byte cmd) {
-	byte speed = cmd & 0x0F;
-	return speed == 0 ? 1 : speed;
+	return cmd & 0x0F;
 }
 
 byte decodeDuration(byte cmd) {
-	byte duration = cmd & 0x3F;
-	return duration == 0 ? 1 : duration;
+	return cmd & 0x3F;
 }
 
 byte buildArpeggioTable(const byte intervals[8], byte mask, byte *outTable, byte maxLen, bool includeBase) {
@@ -982,15 +985,17 @@ void WallyBebenStream::interrupt() {
 			if (_channels[ch].pendingNoteOn)
 				continue;
 
-			if (_channels[ch].durationCounter > 0) {
-				_channels[ch].durationCounter--;
-			}
+			// Asm ref: TEXT+$0152 — the counter is decremented unconditionally,
+			// the note is gated off on the step it reaches 0, and the next
+			// commands are only parsed on the following step, when it goes
+			// negative. A note of duration N therefore lasts N + 1 steps.
+			_channels[ch].durationCounter--;
 
 			if (_channels[ch].durationCounter == 0) {
 				// Note-off: enter release phase
 				if (_channels[ch].envelopePhase < 3)
 					_channels[ch].envelopePhase = 3;
-
+			} else if (_channels[ch].durationCounter < 0) {
 				// Read next commands
 				readPatternCommands(ch);
 			}
@@ -1042,9 +1047,11 @@ void WallyBebenStream::interrupt() {
 		setChannelVolume(ch, _channels[ch].volume);
 	}
 
-	// Advance tick counter
+	// Advance tick counter. Asm ref: TEXT+$0794 — the counter is decremented
+	// every tick and only reloaded with the speed value once it goes negative,
+	// so a speed of N puts N + 1 ticks between sequencer steps.
 	_tickCounter++;
-	if (_tickCounter >= _tickSpeed) {
+	if (_tickCounter > _tickSpeed) {
 		_tickCounter = 0;
 	}
 }


Commit: 6295cdfc86f12ed0790b79e1322b7f4e0b365358
    https://github.com/scummvm/scummvm/commit/6295cdfc86f12ed0790b79e1322b7f4e0b365358
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: improved adlib rendition for eclipse music

Changed paths:
    engines/freescape/games/eclipse/opl.music.cpp
    engines/freescape/games/eclipse/opl.music.h


diff --git a/engines/freescape/games/eclipse/opl.music.cpp b/engines/freescape/games/eclipse/opl.music.cpp
index 4e76d0957c8..4bb5280be85 100644
--- a/engines/freescape/games/eclipse/opl.music.cpp
+++ b/engines/freescape/games/eclipse/opl.music.cpp
@@ -21,8 +21,10 @@
 
 #include "engines/freescape/games/eclipse/opl.music.h"
 
+#include "common/debug.h"
 #include "common/textconsole.h"
 #include "common/util.h"
+#include "freescape/freescape.h"
 #include "freescape/wb.h"
 #include "freescape/games/eclipse/eclipse.musicdata.h"
 
@@ -31,13 +33,13 @@ using namespace Freescape::EclipseMusicData;
 namespace Freescape {
 
 struct EclipseOPLBasePatch {
-	byte modChar;
+	byte modChar;            // reg 0x20: AM | VIB | EGT | KSR | MULT
 	byte carChar;
-	byte modLevel;
-	byte carLevel;
-	byte modWave;
+	byte modLevel;           // reg 0x40: modulator total level == FM index
+	byte carLevel;           // reg 0x40: carrier total level == output level
+	byte modWave;            // reg 0xE0: 0 sine, 1 half-sine, 2 abs-sine, 3 pulse-sine
 	byte carWave;
-	byte feedbackConnection;
+	byte feedbackConnection; // reg 0xC0: feedback | connection
 };
 
 // ============================================================================
@@ -83,34 +85,56 @@ const uint16 kOPLFreqs[] = {
 const byte kOPLModOffset[] = { 0x00, 0x01, 0x02 };
 const byte kOPLCarOffset[] = { 0x03, 0x04, 0x05 };
 
+// Every operator sets EGT (0x20) so the envelope holds at the sustain level
+// until key-off, like the SID gate bit.
 const EclipseOPLBasePatch kOPLBasePatches[] = {
 	// 0: silent
-	{ 0x00, 0x00, 0x3F, 0x3F, 0x00, 0x00, 0x00 },
-	// 1: triangle - soft additive sine
-	{ 0x01, 0x01, 0x28, 0x00, 0x00, 0x00, 0x01 },
-	// 2: sawtooth - brighter feedback voice
+	{ 0x20, 0x20, 0x3F, 0x3F, 0x00, 0x00, 0x00 },
+	// 1: triangle - 2:1 FM at a low index, the weak odd harmonics of a SID triangle
+	{ 0x22, 0x21, 0x22, 0x00, 0x00, 0x00, 0x00 },
+	// 2: sawtooth - abs-sine modulator plus feedback folds towards a ramp
 	{ 0x21, 0x21, 0x18, 0x00, 0x02, 0x00, 0x0C },
-	// 3: pulse - compact square-like FM voice
-	{ 0x02, 0x01, 0x18, 0x00, 0x00, 0x01, 0x06 },
-	// 4: noise - metallic inharmonic approximation
-	{ 0x11, 0x0C, 0x08, 0x00, 0x00, 0x00, 0x05 },
+	// 3: pulse - half-sine carrier at 2:1, index swept by the SID pulse width
+	{ 0x22, 0x21, 0x18, 0x00, 0x00, 0x01, 0x06 },
+	// 4: noise - single operator, played on the rhythm-mode hi-hat
+	{ 0x2E, 0x20, 0x08, 0x3F, 0x00, 0x00, 0x00 },
 };
 
-// Software ADSR rate tables (8.8 fixed point, per-frame at 50 Hz)
-const uint16 kAttackRate[16] = {
-	0x0F00, 0x0F00, 0x0F00, 0x0C80,
-	0x07E5, 0x055B, 0x0469, 0x03C0,
-	0x0300, 0x0133, 0x009A, 0x0060,
-	0x004D, 0x001A, 0x000F, 0x000A
+const byte kNoiseWaveformFamily = 4;
+
+// OPL2 rhythm mode: bit 5 of register 0xBD turns channels 6-8 into percussion
+// voices, the only source of real noise on an OPL2. Melodic playback only ever
+// uses channels 0-2, so they are free.
+const byte kRhythmEnable   = 0x20;
+const byte kRhythmHiHatBit = 0x01;
+const byte kRhythmHiHatOp  = 0x11; // channel 7 modulator operator offset
+const byte kRhythmChannel  = 7;
+
+// SID envelope nibbles to OPL2 envelope rates, matched in log space against
+// 0.22 * 2^(14-AR) ms for attack and 1.27 * 2^(15-rate) ms for decay/release.
+// SID's slowest rates are beyond what the OPL can reach and clamp to 1.
+const byte kSIDAttackToOPL[16] = {
+	11, 9, 8, 7, 7, 6, 6, 5, 5, 4, 3, 2, 2, 1, 1, 1
 };
 
-const uint16 kDecayReleaseRate[16] = {
-	0x0F00, 0x0C80, 0x0640, 0x042B,
-	0x02A2, 0x01C9, 0x0178, 0x0140,
-	0x0100, 0x0066, 0x0033, 0x0020,
-	0x001A, 0x0009, 0x0005, 0x0003
+const byte kSIDDecayToOPL[16] = {
+	13, 11, 10, 9, 9, 8, 8, 7, 7, 6, 5, 4, 4, 2, 1, 1
 };
 
+// SID sustain is a linear fraction S/15, OPL sustain level is attenuation in
+// 3 dB steps. Nibble 0 means silence.
+const byte kSIDSustainToOPL[16] = {
+	15, 8, 6, 5, 4, 3, 3, 2, 2, 1, 1, 1, 1, 0, 0, 0
+};
+
+const char *const kWaveformFamilyName[] = {
+	"silent", "triangle", "sawtooth", "pulse", "noise"
+};
+
+int oplFrequencyHz(uint16 fnum, byte block) {
+	return (int)(((uint32)fnum * 49716) >> (20 - block));
+}
+
 byte getWaveformFamily(byte ctrl) {
 	if ((ctrl & 0x80) != 0)
 		return 4;
@@ -167,12 +191,7 @@ void EclipseOPLMusicPlayer::ChannelState::reset() {
 	carBaseLevel = 0x3F;
 	modLevel = 0x3F;
 	carLevel = 0x3F;
-	adsrPhase = kPhaseOff;
-	adsrVolume = 0;
-	attackRate = 0;
-	decayRate = 0;
-	sustainLevel = 0;
-	releaseRate = 0;
+	rhythmVoice = false;
 }
 
 // ============================================================================
@@ -183,7 +202,8 @@ EclipseOPLMusicPlayer::EclipseOPLMusicPlayer()
 	: _opl(nullptr),
 	  _musicActive(false),
 	  _speedDivider(1),
-	  _speedCounter(0) {
+	  _speedCounter(0),
+	  _rhythmReg(kRhythmEnable) {
 	memcpy(_arpeggioIntervals, kArpeggioIntervals, 8);
 
 	_opl = OPL::Config::create();
@@ -245,6 +265,15 @@ void EclipseOPLMusicPlayer::setFrequency(int channel, uint16 fnum, byte block) {
 void EclipseOPLMusicPlayer::writeFrequency(int channel, uint16 fnum, byte block) {
 	if (!_opl)
 		return;
+
+	// A rhythm voice is pitched from the percussion channel and must never
+	// have the key-on bit set.
+	if (_channels[channel].rhythmVoice) {
+		_opl->writeReg(0xA0 + kRhythmChannel, fnum & 0xFF);
+		_opl->writeReg(0xB0 + kRhythmChannel, ((fnum >> 8) & 0x03) | (block << 2));
+		return;
+	}
+
 	_opl->writeReg(0xA0 + channel, fnum & 0xFF);
 	// Preserve key-on bit in 0xB0
 	byte b0 = ((fnum >> 8) & 0x03) | (block << 2);
@@ -253,6 +282,11 @@ void EclipseOPLMusicPlayer::writeFrequency(int channel, uint16 fnum, byte block)
 	_opl->writeReg(0xB0 + channel, b0);
 }
 
+void EclipseOPLMusicPlayer::programEnvelope(byte op, byte attack, byte decay, byte sustain, byte release) {
+	_opl->writeReg(0x60 + op, (attack << 4) | decay);
+	_opl->writeReg(0x80 + op, (sustain << 4) | release);
+}
+
 void EclipseOPLMusicPlayer::setOPLInstrument(int channel, byte instrumentOffset) {
 	if (!_opl)
 		return;
@@ -261,9 +295,26 @@ void EclipseOPLMusicPlayer::setOPLInstrument(int channel, byte instrumentOffset)
 		patchIdx = 0;
 
 	byte ctrl = kInstruments[instrumentOffset + 0];
-	const EclipseOPLBasePatch &patch = kOPLBasePatches[getWaveformFamily(ctrl)];
-	byte mod = kOPLModOffset[channel];
-	byte car = kOPLCarOffset[channel];
+	byte attackDecay = kInstruments[instrumentOffset + 1];
+	byte sustainRelease = kInstruments[instrumentOffset + 2];
+	byte family = getWaveformFamily(ctrl);
+	const EclipseOPLBasePatch &patch = kOPLBasePatches[family];
+
+	byte attack = kSIDAttackToOPL[attackDecay >> 4];
+	byte decay = kSIDDecayToOPL[attackDecay & 0x0F];
+	byte sustain = kSIDSustainToOPL[sustainRelease >> 4];
+	byte release = kSIDDecayToOPL[sustainRelease & 0x0F];
+
+	bool wasRhythm = _channels[channel].rhythmVoice;
+	_channels[channel].rhythmVoice = (family == kNoiseWaveformFamily);
+
+	debugC(2, kFreescapeDebugMedia,
+		"TE-AdLib: ch%d patch inst=%-2d %-8s SID ad=$%02X sr=$%02X -> OPL ar=%2d dr=%2d sl=%2d rr=%2d"
+		" | %s fb=%d modTL=%d carTL=%d",
+		channel, patchIdx, kWaveformFamilyName[family], attackDecay, sustainRelease,
+		attack, decay, sustain, release,
+		(patch.feedbackConnection & 0x01) ? "additive" : "FM",
+		(patch.feedbackConnection >> 1) & 0x07, patch.modLevel, patch.carLevel);
 
 	_channels[channel].pulseWidth = decodePulseWidth(kPulseWidthInit[patchIdx]);
 	_channels[channel].pulseWidthMod = kPulseWidthMod[patchIdx];
@@ -273,12 +324,43 @@ void EclipseOPLMusicPlayer::setOPLInstrument(int channel, byte instrumentOffset)
 	_channels[channel].modLevel = patch.modLevel;
 	_channels[channel].carLevel = patch.carLevel;
 
+	if (_channels[channel].rhythmVoice) {
+		// Silence the melodic channel this voice would otherwise have used.
+		_channels[channel].keyOn = false;
+		_opl->writeReg(0xB0 + channel, 0x00);
+		_opl->writeReg(0x40 + kOPLModOffset[channel], 0x3F);
+		_opl->writeReg(0x40 + kOPLCarOffset[channel], 0x3F);
+
+		_opl->writeReg(0x20 + kRhythmHiHatOp, patch.modChar);
+		_opl->writeReg(0xE0 + kRhythmHiHatOp, patch.modWave);
+		programEnvelope(kRhythmHiHatOp, attack, decay, sustain, release);
+		updatePulseWidth(channel, false);
+		applyOperatorLevels(channel);
+		return;
+	}
+
+	if (wasRhythm) {
+		_rhythmReg &= ~kRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+	}
+
+	byte mod = kOPLModOffset[channel];
+	byte car = kOPLCarOffset[channel];
+
 	_opl->writeReg(0x20 + mod, patch.modChar);
 	_opl->writeReg(0x20 + car, patch.carChar);
-	_opl->writeReg(0x60 + mod, 0xF0);
-	_opl->writeReg(0x60 + car, 0xF0);
-	_opl->writeReg(0x80 + mod, 0x00);
-	_opl->writeReg(0x80 + car, 0x00);
+
+	// The FM index follows the modulator's absolute output, and a SID
+	// oscillator keeps its waveform as the note decays, so hold the modulator
+	// flat and let only the carrier follow the ADSR. Enveloping it too
+	// collapses the index within milliseconds, leaving a bass note as a
+	// near-pure sine that is inaudible at 70 Hz. Additive patches are
+	// different: both operators are heard, so both take the real envelope.
+	if ((patch.feedbackConnection & 0x01) != 0)
+		programEnvelope(mod, attack, decay, sustain, release);
+	else
+		programEnvelope(mod, 15, 0, 0, 0);
+	programEnvelope(car, attack, decay, sustain, release);
 	_opl->writeReg(0xE0 + mod, patch.modWave);
 	_opl->writeReg(0xE0 + car, patch.carWave);
 	_opl->writeReg(0xC0 + channel, patch.feedbackConnection);
@@ -291,6 +373,15 @@ void EclipseOPLMusicPlayer::noteOn(int channel) {
 	if (!_opl)
 		return;
 	_channels[channel].keyOn = true;
+
+	if (_channels[channel].rhythmVoice) {
+		// Percussion voices are keyed from register 0xBD, not from 0xB0.
+		_opl->writeReg(0xBD, _rhythmReg & ~kRhythmHiHatBit);
+		_rhythmReg |= kRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+		return;
+	}
+
 	_opl->writeReg(0xA0 + channel, _channels[channel].frequencyFnum & 0xFF);
 	_opl->writeReg(0xB0 + channel, 0x20 | (_channels[channel].frequencyBlock << 2) |
 	                                 ((_channels[channel].frequencyFnum >> 8) & 0x03));
@@ -300,6 +391,13 @@ void EclipseOPLMusicPlayer::noteOff(int channel) {
 	if (!_opl)
 		return;
 	_channels[channel].keyOn = false;
+
+	if (_channels[channel].rhythmVoice) {
+		_rhythmReg &= ~kRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+		return;
+	}
+
 	byte b0 = ((_channels[channel].frequencyFnum >> 8) & 0x03) |
 	          (_channels[channel].frequencyBlock << 2);
 	_opl->writeReg(0xB0 + channel, b0);
@@ -363,14 +461,18 @@ void EclipseOPLMusicPlayer::processChannel(int channel, bool newBeat) {
 }
 
 void EclipseOPLMusicPlayer::finalizeChannel(int channel) {
+	// Mirrors the SID engine clearing the gate bit halfway through the note.
 	if (_channels[channel].durationReload != 0 &&
 	    !_channels[channel].gateOffDisabled &&
-	    ((_channels[channel].durationReload >> 1) == _channels[channel].durationCounter)) {
-		releaseADSR(channel);
+	    ((_channels[channel].durationReload >> 1) == _channels[channel].durationCounter) &&
+	    _channels[channel].keyOn) {
+		noteOff(channel);
+		debugC(3, kFreescapeDebugMedia, "TE-AdLib: ch%d gate off (note %d releasing)",
+			channel, _channels[channel].currentNote);
 	}
 
 	updatePulseWidth(channel, true);
-	updateADSR(channel);
+	applyOperatorLevels(channel);
 }
 
 // ============================================================================
@@ -407,6 +509,11 @@ void EclipseOPLMusicPlayer::silenceAll() {
 		_opl->writeReg(0x40 + kOPLModOffset[ch], 0x3F); // silence mod
 		_opl->writeReg(0x40 + kOPLCarOffset[ch], 0x3F); // silence car
 	}
+
+	// Leave rhythm mode armed with every trigger released.
+	_rhythmReg = kRhythmEnable;
+	_opl->writeReg(0xBD, _rhythmReg);
+	_opl->writeReg(0x40 + kRhythmHiHatOp, 0x3F);
 }
 
 // ============================================================================
@@ -432,6 +539,8 @@ void EclipseOPLMusicPlayer::loadNextPattern(int channel) {
 		if (value < ARRAYSIZE(kPatternOffsets)) {
 			_channels[channel].patternDataOffset = kPatternOffsets[value];
 			_channels[channel].patternOffset = 0;
+			debugC(3, kFreescapeDebugMedia, "TE-AdLib: ch%d order %d -> pattern %d (transpose %d)",
+				channel, _channels[channel].orderPos - 1, value, (int8)_channels[channel].transpose);
 		}
 		break;
 	}
@@ -487,6 +596,8 @@ void EclipseOPLMusicPlayer::parseCommands(int channel) {
 
 		if (cmd >= 0xF0) {
 			_speedDivider = cmd & 0x0F;
+			debugC(2, kFreescapeDebugMedia, "TE-AdLib: ch%d speed $%02X -> %d ticks per beat",
+				channel, cmd, _speedDivider + 1);
 			continue;
 		}
 
@@ -509,6 +620,10 @@ void EclipseOPLMusicPlayer::parseCommands(int channel) {
 		}
 
 		if (cmd == 0x7E) {
+			// Portamento down. noteStepCommand stands in for the INC/DEC
+			// opcode the C64 engine self-modifies; without it the direction is
+			// set but the note never steps.
+			_channels[channel].noteStepCommand = 0xFE;
 			_channels[channel].effectMode = 0xFE;
 			continue;
 		}
@@ -552,7 +667,6 @@ void EclipseOPLMusicPlayer::parseCommands(int channel) {
 void EclipseOPLMusicPlayer::applyNote(int channel, byte note) {
 	byte instrumentOffset = _channels[channel].instrumentOffset;
 	byte ctrl = kInstruments[instrumentOffset + 0];
-	byte attackDecay = kInstruments[instrumentOffset + 1];
 	byte sustainRelease = kInstruments[instrumentOffset + 2];
 	byte autoEffect = kInstruments[instrumentOffset + 4];
 	byte flags = kInstruments[instrumentOffset + 5];
@@ -577,7 +691,6 @@ void EclipseOPLMusicPlayer::applyNote(int channel, byte note) {
 		_channels[channel].currentNote = clampNote(_channels[channel].currentNote + 2);
 	}
 
-	// Set the OPL FM patch for this instrument
 	setOPLInstrument(channel, instrumentOffset);
 
 	_channels[channel].gateOffDisabled = (sustainRelease & 0x0F) == 0x0F;
@@ -585,16 +698,26 @@ void EclipseOPLMusicPlayer::applyNote(int channel, byte note) {
 	if (actualNote != 0)
 		loadCurrentFrequency(channel);
 
+	byte instrument = instrumentOffset / kInstrumentSize;
+	byte family = getWaveformFamily(ctrl);
+
 	if (actualNote == 0 || !gateEnabled) {
-		_channels[channel].adsrPhase = kPhaseOff;
-		_channels[channel].adsrVolume = 0;
-		applyOperatorLevels(channel);
 		noteOff(channel);
+		debugC(1, kFreescapeDebugMedia, "TE-AdLib: ch%d rest  inst=%-2d %-8s dur=%d",
+			channel, instrument, kWaveformFamilyName[family],
+			_channels[channel].durationReload);
 	} else {
-		triggerADSR(channel, attackDecay, sustainRelease);
-		applyOperatorLevels(channel);
+		// Key-off then key-on restarts the envelope from its current level,
+		// like re-gating a held SID voice.
 		noteOff(channel);
 		noteOn(channel);
+		debugC(1, kFreescapeDebugMedia,
+			"TE-AdLib: ch%d NOTE %3d (%d%+d) %5dHz inst=%-2d %-8s dur=%d%s%s",
+			channel, _channels[channel].currentNote, note, (int8)_channels[channel].transpose,
+			oplFrequencyHz(_channels[channel].frequencyFnum, _channels[channel].frequencyBlock),
+			instrument, kWaveformFamilyName[family], _channels[channel].durationReload,
+			_channels[channel].rhythmVoice ? " [rhythm hi-hat]" : "",
+			_channels[channel].effectParam ? " [arpeggio]" : "");
 	}
 
 	_channels[channel].durationCounter = _channels[channel].durationReload;
@@ -766,64 +889,6 @@ void EclipseOPLMusicPlayer::applyTimedSlide(int channel) {
 	setFrequency(channel, curFreq & 0x3FF, block);
 }
 
-void EclipseOPLMusicPlayer::triggerADSR(int channel, byte ad, byte sr) {
-	_channels[channel].adsrPhase = kPhaseAttack;
-	// Match the SID re-gate behavior: keep the current level when a new note
-	// starts so ornaments stay smooth instead of re-attacking from silence.
-	_channels[channel].attackRate = kAttackRate[ad >> 4];
-	_channels[channel].decayRate = kDecayReleaseRate[ad & 0x0F];
-	_channels[channel].sustainLevel = sr >> 4;
-	_channels[channel].releaseRate = kDecayReleaseRate[sr & 0x0F];
-}
-
-void EclipseOPLMusicPlayer::releaseADSR(int channel) {
-	if (_channels[channel].adsrPhase != kPhaseRelease &&
-	    _channels[channel].adsrPhase != kPhaseOff) {
-		_channels[channel].adsrPhase = kPhaseRelease;
-	}
-}
-
-void EclipseOPLMusicPlayer::updateADSR(int channel) {
-	switch (_channels[channel].adsrPhase) {
-	case kPhaseAttack:
-		_channels[channel].adsrVolume += _channels[channel].attackRate;
-		if (_channels[channel].adsrVolume >= 0x0F00) {
-			_channels[channel].adsrVolume = 0x0F00;
-			_channels[channel].adsrPhase = kPhaseDecay;
-		}
-		break;
-
-	case kPhaseDecay: {
-		uint16 sustainTarget = (uint16)_channels[channel].sustainLevel << 8;
-		if (_channels[channel].adsrVolume > _channels[channel].decayRate + sustainTarget) {
-			_channels[channel].adsrVolume -= _channels[channel].decayRate;
-		} else {
-			_channels[channel].adsrVolume = sustainTarget;
-			_channels[channel].adsrPhase = kPhaseSustain;
-		}
-		break;
-	}
-
-	case kPhaseSustain:
-		break;
-
-	case kPhaseRelease:
-		if (_channels[channel].adsrVolume > _channels[channel].releaseRate) {
-			_channels[channel].adsrVolume -= _channels[channel].releaseRate;
-		} else {
-			_channels[channel].adsrVolume = 0;
-			_channels[channel].adsrPhase = kPhaseOff;
-		}
-		break;
-
-	case kPhaseOff:
-		_channels[channel].adsrVolume = 0;
-		break;
-	}
-
-	applyOperatorLevels(channel);
-}
-
 void EclipseOPLMusicPlayer::updatePulseWidth(int channel, bool advance) {
 	if ((_channels[channel].waveform & 0x40) == 0) {
 		_channels[channel].modLevel = _channels[channel].modBaseLevel;
@@ -858,19 +923,19 @@ void EclipseOPLMusicPlayer::updatePulseWidth(int channel, bool advance) {
 	_channels[channel].carLevel = _channels[channel].carBaseLevel;
 }
 
+// The envelope lives in the chip, so the total-level registers only carry the
+// patch levels plus the pulse-width brightness motion.
 void EclipseOPLMusicPlayer::applyOperatorLevels(int channel) {
 	if (!_opl)
 		return;
 
-	byte mod = kOPLModOffset[channel];
-	byte car = kOPLCarOffset[channel];
-	uint16 inverseVolume = 0x0F00 - _channels[channel].adsrVolume;
-	byte attenuation = (inverseVolume * 63 + 0x0780) / 0x0F00;
-	byte modLevel = MIN<byte>(_channels[channel].modLevel + attenuation, 0x3F);
-	byte carLevel = MIN<byte>(_channels[channel].carLevel + attenuation, 0x3F);
+	if (_channels[channel].rhythmVoice) {
+		_opl->writeReg(0x40 + kRhythmHiHatOp, _channels[channel].modLevel & 0x3F);
+		return;
+	}
 
-	_opl->writeReg(0x40 + mod, modLevel);
-	_opl->writeReg(0x40 + car, carLevel);
+	_opl->writeReg(0x40 + kOPLModOffset[channel], _channels[channel].modLevel & 0x3F);
+	_opl->writeReg(0x40 + kOPLCarOffset[channel], _channels[channel].carLevel & 0x3F);
 }
 
 } // namespace Freescape
diff --git a/engines/freescape/games/eclipse/opl.music.h b/engines/freescape/games/eclipse/opl.music.h
index 90265ef8578..c0fe637b6d1 100644
--- a/engines/freescape/games/eclipse/opl.music.h
+++ b/engines/freescape/games/eclipse/opl.music.h
@@ -33,8 +33,8 @@ namespace Freescape {
  * Ports the Wally Beben C64 SID music to the OPL2 FM chip by:
  * - Reusing the same sequencer (order lists, patterns, instruments)
  * - Converting SID note numbers to OPL F-number/block pairs
- * - Mapping SID waveforms to OPL FM instrument patches
- * - Rebuilding the SID envelope and pulse-width motion on top of AdLib timbres
+ * - Mapping SID waveforms to OPL FM patches, and noise to rhythm mode
+ * - Driving the OPL envelope generator from the SID ADSR
  */
 class EclipseOPLMusicPlayer : public MusicPlayer {
 public:
@@ -51,14 +51,6 @@ private:
 		kMaxNote = 94
 	};
 
-	enum ADSRPhase {
-		kPhaseOff,
-		kPhaseAttack,
-		kPhaseDecay,
-		kPhaseSustain,
-		kPhaseRelease
-	};
-
 	struct ChannelState {
 		const byte *orderList;
 		byte orderPos;
@@ -103,12 +95,7 @@ private:
 		byte carBaseLevel;
 		byte modLevel;
 		byte carLevel;
-		ADSRPhase adsrPhase;
-		uint16 adsrVolume;
-		uint16 attackRate;
-		uint16 decayRate;
-		byte sustainLevel;
-		uint16 releaseRate;
+		bool rhythmVoice; // routed to the rhythm-mode hi-hat instead of an FM channel
 
 		void reset();
 	};
@@ -117,6 +104,7 @@ private:
 	bool _musicActive;
 	byte _speedDivider;
 	byte _speedCounter;
+	byte _rhythmReg; // shadow of register 0xBD
 	ChannelState _channels[kChannelCount];
 	byte _arpeggioIntervals[8];
 
@@ -134,9 +122,7 @@ private:
 	bool applyInstrumentVibrato(int channel);
 	void applyEffectArpeggio(int channel);
 	void applyTimedSlide(int channel);
-	void triggerADSR(int channel, byte ad, byte sr);
-	void releaseADSR(int channel);
-	void updateADSR(int channel);
+	void programEnvelope(byte op, byte attack, byte decay, byte sustain, byte release);
 	void updatePulseWidth(int channel, bool advance);
 	void applyOperatorLevels(int channel);
 


Commit: ebbdd6cc7fe28b5a09e3ca1ba5df697a87d9c7ec
    https://github.com/scummvm/scummvm/commit/ebbdd6cc7fe28b5a09e3ca1ba5df697a87d9c7ec
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: fixed notes in the dark music for amiga

Changed paths:
    engines/freescape/wb.cpp


diff --git a/engines/freescape/wb.cpp b/engines/freescape/wb.cpp
index 3b31e627f8a..141f60bcb04 100644
--- a/engines/freescape/wb.cpp
+++ b/engines/freescape/wb.cpp
@@ -97,10 +97,10 @@ const WBTableOffsets kDarkSideOffsets = {
 	0x0C42, // samplePtrTable: 16 x uint32 BE
 	0x0C82, // instrumentTable: 16 x 8 bytes
 	0x0D02, // arpeggioIntervals: 8 bytes
-	0x0D0A, // envelopeTable: 10 x 8 bytes
+	0x0D0A, // envelopeTable: 22 x 8 bytes
 	0x0DBA, // songTable: 2 songs x 4 channels
 	0x0DCA, // patternPtrTable: up to 128 x uint32 BE
-	16, 16, 10 // numSamples, numInstruments, numEnvelopes
+	16, 16, 22 // numSamples, numInstruments, numEnvelopes
 };
 
 const uint32 kMaxPatternEntries = 128;
@@ -124,7 +124,7 @@ private:
 
 	static const int kMaxSamples = 16;
 	static const int kMaxInstruments = 16;
-	static const int kMaxEnvelopes = 16;
+	static const int kMaxEnvelopes = 32; // the $C0 command carries a 5-bit index
 
 	struct InstrumentDesc {
 		byte sampleIndex;
@@ -135,15 +135,16 @@ private:
 	};
 	InstrumentDesc _instruments[kMaxInstruments];
 
+	// Asm ref: TEXT+$0900, which copies these into the channel at note-on.
 	struct EnvelopeDesc {
-		byte attackLevel;
-		byte decayTarget;
-		byte sustainLevel;
+		byte initialVolume;
+		byte targetVolume;
+		byte attackRate;  // added per tick until the volume reaches the target
 		byte releaseRate;
-		byte modDepth;
-		byte vibratoWave;
+		byte flags;       // 0 = release at once, $FF = never, else at duration/flags
+		byte effectParam;
 		byte arpeggioMask;
-		byte flags;
+		byte extraFlags;
 	};
 	EnvelopeDesc _envelopes[kMaxEnvelopes];
 
@@ -181,16 +182,15 @@ private:
 
 		// Instrument / envelope selection
 		byte instrumentIdx;     // 0-15
-		byte envelopeIdx;       // 0-9
+		byte envelopeIdx;       // 0-31
 
 		// Volume envelope
 		byte volume;            // Current output volume (0-64)
-		byte attackLevel;
-		byte decayTarget;
-		byte sustainLevel;
+		byte targetVolume;
+		byte attackRate;
 		byte releaseRate;
-		byte envelopePhase;     // 0=attack, 1=decay, 2=sustain, 3=release
-		byte modDepth;
+		byte envelopeFlags;
+		bool envelopeDone;
 
 		// Effects
 		byte effectMode;        // 0=none, 1=porta/arpeggio, 2=envelope vibrato
@@ -322,16 +322,16 @@ void WallyBebenStream::loadTables() {
 	}
 
 	// Envelope table
-	for (int i = 0; i < _offsets.numEnvelopes; i++) {
+	for (int i = 0; i < _offsets.numEnvelopes && i < kMaxEnvelopes; i++) {
 		uint32 off = _offsets.envelopeTable + i * 8;
-		_envelopes[i].attackLevel  = readDataByte(off + 0);
-		_envelopes[i].decayTarget  = readDataByte(off + 1);
-		_envelopes[i].sustainLevel = readDataByte(off + 2);
-		_envelopes[i].releaseRate  = readDataByte(off + 3);
-		_envelopes[i].modDepth     = readDataByte(off + 4);
-		_envelopes[i].vibratoWave  = readDataByte(off + 5);
-		_envelopes[i].arpeggioMask = readDataByte(off + 6);
-		_envelopes[i].flags        = readDataByte(off + 7);
+		_envelopes[i].initialVolume = readDataByte(off + 0);
+		_envelopes[i].targetVolume  = readDataByte(off + 1);
+		_envelopes[i].attackRate    = readDataByte(off + 2);
+		_envelopes[i].releaseRate   = readDataByte(off + 3);
+		_envelopes[i].flags         = readDataByte(off + 4);
+		_envelopes[i].effectParam   = readDataByte(off + 5);
+		_envelopes[i].arpeggioMask  = readDataByte(off + 6);
+		_envelopes[i].extraFlags    = readDataByte(off + 7);
 	}
 
 	// Song table: 2 songs x 4 channels x uint32 BE
@@ -384,9 +384,9 @@ void WallyBebenStream::loadTables() {
 
 	for (int i = 0; i < _offsets.numEnvelopes; i++) {
 		const EnvelopeDesc &env = _envelopes[i];
-		debug(3, "WB: Env %d: atk=%d dec=%d sus=%d rel=%d mod=%d arp=$%02X",
-			i, env.attackLevel, env.decayTarget, env.sustainLevel,
-			env.releaseRate, env.modDepth, env.arpeggioMask);
+		debug(3, "WB: Env %d: vol=%d target=%d atk=%d rel=%d flags=$%02X arp=$%02X",
+			i, env.initialVolume, env.targetVolume, env.attackRate,
+			env.releaseRate, env.flags, env.arpeggioMask);
 	}
 
 	debug(3, "WB: Song 1 order ptrs: $%X $%X $%X $%X",
@@ -449,14 +449,14 @@ void WallyBebenStream::initChannel(int ch) {
 	memset(&c, 0, sizeof(ChannelState));
 	c.duration = 1;
 	c.durationCounter = 0; // Will trigger readPatternCommands on first tick
-	c.envelopePhase = 3;   // Start in release (silent) until note-on
+	c.envelopeDone = true;
 
-	// Default envelope params: full volume sustain, so notes before any
-	// $C0 envelope command still produce sound (Env 0 has all zeros = silence)
-	c.attackLevel = 64;
-	c.decayTarget = 64;
-	c.sustainLevel = 64;
+	// Hold at full volume, so notes before any $C0 command still sound.
+	c.volume = 64;
+	c.targetVolume = 64;
+	c.attackRate = 0;
 	c.releaseRate = 0;
+	c.envelopeFlags = 0xFF;
 }
 
 // ---------------------------------------------------------------------------
@@ -518,6 +518,19 @@ void WallyBebenStream::readOrderList(int ch) {
 void WallyBebenStream::readPatternCommands(int ch) {
 	ChannelState &c = _channels[ch];
 
+	// Asm ref: TEXT+$01BC — transient per-note state is cleared before the next
+	// command stream is parsed, except that mode 2 survives a step boundary.
+	if (c.effectMode != 2) {
+		c.effectMode = 0;
+		c.arpeggioMask = 0;
+		c.arpeggioPos = 0;
+		c.arpeggioTableLen = 0;
+	}
+	c.portaUp = false;
+	c.portaDown = false;
+	c.effect7BActive = false;
+	c.envelopeDone = false;
+
 	for (int safety = 0; safety < 256; safety++) {
 		if (c.patternOffset + c.patternPos >= _dataSize)
 			break;
@@ -558,33 +571,9 @@ void WallyBebenStream::readPatternCommands(int ch) {
 		}
 
 		if (cmd >= 0xC0) {
-			// Set envelope: low 5 bits (0-31)
-			// Asm ref: TEXT+$2C8 — envelope command handler
-			// $C0 (index 0) is a no-op: Env 0 is all zeros (sentinel entry).
-			// The original engine treats index 0 as "no envelope change".
-			byte envIdx = cmd & 0x1F;
-			if (envIdx == 0 || envIdx >= _offsets.numEnvelopes) {
-				// Index 0 or out-of-range: skip, keep current envelope params
-				continue;
-			}
-
-			c.envelopeIdx = envIdx;
-
-			// Copy envelope parameters into channel state immediately
-			// (original engine loads params on $C0 command, not on note-on)
-			const EnvelopeDesc &env = _envelopes[c.envelopeIdx];
-			c.attackLevel  = MIN(env.attackLevel,  (byte)64);
-			c.decayTarget  = MIN(env.decayTarget,  (byte)64);
-			c.sustainLevel = MIN(env.sustainLevel, (byte)64);
-			c.releaseRate  = env.releaseRate;
-			c.modDepth     = env.modDepth;
-
-			// Envelope-triggered arpeggio/vibrato from envelope table
-			if (env.arpeggioMask != 0) {
-				c.effectMode = 2;
-				c.arpeggioMask = env.arpeggioMask;
-				buildArpeggioTable(ch, env.arpeggioMask);
-			}
+			// Asm ref: TEXT+$0290 only records the index; the parameters are
+			// copied at note-on, and index 0 is an ordinary entry.
+			c.envelopeIdx = cmd & 0x1F;
 			continue;
 		}
 
@@ -704,7 +693,7 @@ void WallyBebenStream::triggerNote(int ch) {
 		// Rest — silence channel
 		c.outputPeriod = 0;
 		c.volume = 0;
-		c.envelopePhase = 3;
+		c.envelopeDone = true;
 		c.effect7BActive = false;
 		setChannelVolume(ch, 0);
 		return;
@@ -780,11 +769,22 @@ void WallyBebenStream::triggerNote(int ch) {
 
 	setChannelPeriod(ch, c.outputPeriod);
 
-	// Reset envelope phase — params were already loaded by $C0 command
-	// (or default to full volume from initChannel)
-	// Asm ref: TEXT+$30C — note-on envelope reset
-	c.envelopePhase = 0; // Start at attack
-	c.volume = c.attackLevel;
+	// Asm ref: TEXT+$0900, reached from note-on rather than from $C0.
+	if (c.envelopeIdx < _offsets.numEnvelopes && c.envelopeIdx < kMaxEnvelopes) {
+		const EnvelopeDesc &env = _envelopes[c.envelopeIdx];
+		c.volume        = MIN(env.initialVolume, (byte)64);
+		c.targetVolume  = MIN(env.targetVolume, (byte)64);
+		c.attackRate    = env.attackRate;
+		c.releaseRate   = env.releaseRate;
+		c.envelopeFlags = env.flags;
+
+		// A non-zero interval mask forces the channel into effect mode.
+		if (env.arpeggioMask != 0) {
+			c.effectMode = 2;
+			c.arpeggioMask = env.arpeggioMask;
+			buildArpeggioTable(ch, env.arpeggioMask);
+		}
+	}
 
 	setChannelVolume(ch, c.volume);
 
@@ -885,67 +885,44 @@ void WallyBebenStream::processEffects(int ch) {
 
 // ---------------------------------------------------------------------------
 // Volume envelope — runs every frame (50Hz)
-// Asm ref: TEXT+$068C (envelope processing)
-//
-// Envelope table bytes (per entry, 8 bytes at TEXT+$D0A):
-//   byte 0 (attackLevel):  initial volume on note-on
-//   byte 1 (decayTarget):  target volume to fade toward and hold
-//   byte 2 (sustainLevel): sustain volume while note is active
-//   byte 3 (releaseRate):  volume decrease per tick on note-off
-//   byte 4 (modDepth):     modulation depth
-//   byte 5 (vibratoWave):  vibrato waveform selector
-//   byte 6 (arpeggioMask): bitmask into arpeggio interval table at TEXT+$D02
-//   byte 7 (flags):        misc flags
+// Asm ref: TEXT+$068C. Not an ADSR: the volume ramps from the envelope's
+// initial value towards its target by attackRate, releases once the duration
+// counter reaches duration/flags, then falls by releaseRate to zero.
 // ---------------------------------------------------------------------------
 
 void WallyBebenStream::processEnvelope(int ch) {
 	ChannelState &c = _channels[ch];
 
-	switch (c.envelopePhase) {
-	case 0: // Attack — start from attack level, then enter decay.
-		c.volume = MIN(c.attackLevel, (byte)64);
-		c.envelopePhase = 1;
-		break;
-
-	case 1: // Decay — decrease toward decay target.
-		// The 68K code decreases volume each tick; when it reaches exactly
-		// decayTarget it enters sustain. If attack == decay, the volume
-		// never changes and the engine stays in this phase (holds forever).
-		if (c.volume > c.decayTarget) {
-			c.volume--;
-			if (c.volume <= c.decayTarget) {
-				c.volume = c.decayTarget;
-				c.envelopePhase = 2;
-			}
-		}
-		break;
-
-	case 2: // Sustain — hold at decay target; sustainLevel is the fade rate
-		// (0 = hold forever, >0 = fade by sustainLevel per tick toward 0).
-		if (c.sustainLevel > 0) {
-			if (c.volume > c.sustainLevel)
-				c.volume -= c.sustainLevel;
-			else
-				c.volume = 0;
+	// A finished note holds its last level until the next note-on.
+	if (c.durationCounter <= 0)
+		return;
+
+	if (!c.envelopeDone) {
+		byte flags = c.envelopeFlags;
+
+		if (flags == 0) {
+			c.envelopeDone = true;
+			c.volume = c.targetVolume;
+			return;
 		}
-		break;
-
-	case 3: // Release — decrease volume on note-off
-		if (c.releaseRate > 0) {
-			if (c.volume > c.releaseRate) {
-				c.volume -= c.releaseRate;
-			} else {
-				c.volume = 0;
-			}
+
+		if (flags == 0xFF)
+			return; // Hold for the whole note
+
+		if (c.durationCounter == c.duration / flags) {
+			c.envelopeDone = true;
 		} else {
-			c.volume = 0; // Rate 0 = instant off
+			if (c.volume != c.targetVolume)
+				c.volume = (byte)(c.volume + c.attackRate);
+			if (c.volume > 64)
+				c.volume = 64;
+			return;
 		}
-		break;
 	}
 
-	// Clamp to Paula range
-	if (c.volume > 64)
-		c.volume = 64;
+	// Release
+	int next = (int)c.volume - (int)c.releaseRate;
+	c.volume = (next < 0) ? 0 : (byte)next;
 }
 
 // ---------------------------------------------------------------------------
@@ -954,7 +931,12 @@ void WallyBebenStream::processEnvelope(int ch) {
 
 void WallyBebenStream::buildArpeggioTable(int ch, byte mask) {
 	ChannelState &c = _channels[ch];
-	c.arpeggioTableLen = WBCommon::buildArpeggioTable(_arpeggioIntervals, mask, c.arpeggioTable, 16, true);
+	// Asm ref: TEXT+$07F4 — intervals are written from slot 1 and playback
+	// wraps back to slot 0, so the base note closes the cycle.
+	byte len = WBCommon::buildArpeggioTable(_arpeggioIntervals, mask, c.arpeggioTable, 15, false);
+	if (len > 0)
+		c.arpeggioTable[len++] = 0;
+	c.arpeggioTableLen = len;
 	c.arpeggioPos = 0;
 }
 
@@ -992,9 +974,11 @@ void WallyBebenStream::interrupt() {
 			_channels[ch].durationCounter--;
 
 			if (_channels[ch].durationCounter == 0) {
-				// Note-off: enter release phase
-				if (_channels[ch].envelopePhase < 3)
-					_channels[ch].envelopePhase = 3;
+				// Note-off. Asm ref: TEXT+$0156 tests the flags byte first, so
+				// only flags-0 envelopes gate the channel; the rest hold their
+				// last level, the envelope routine having stopped running.
+				if (_channels[ch].envelopeFlags == 0)
+					_channels[ch].volume = 0;
 			} else if (_channels[ch].durationCounter < 0) {
 				// Read next commands
 				readPatternCommands(ch);


Commit: 3fab7f6a201775c3662fdd14223cc7517780ee6a
    https://github.com/scummvm/scummvm/commit/3fab7f6a201775c3662fdd14223cc7517780ee6a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: implemented dark music for atari

Changed paths:
  A engines/freescape/wb_atari.cpp
  R engines/freescape/games/eclipse/atari.music.cpp
    engines/freescape/games/dark/atari.cpp
    engines/freescape/games/dark/dark.cpp
    engines/freescape/games/dark/dark.h
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/module.mk
    engines/freescape/wb.h


diff --git a/engines/freescape/games/dark/atari.cpp b/engines/freescape/games/dark/atari.cpp
index 88a0f2ff136..c9be75d5429 100644
--- a/engines/freescape/games/dark/atari.cpp
+++ b/engines/freescape/games/dark/atari.cpp
@@ -25,6 +25,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
 #include "freescape/language/8bitDetokeniser.h"
+#include "freescape/wb.h"
 
 namespace Freescape {
 
@@ -221,6 +222,22 @@ void DarkEngine::loadAssetsAtariFullGame() {
 	loadGlobalObjects(stream, 0x32f6, 24);
 	_sound = loadSoundsFx(stream, 0x266e8, 11);
 
+	// DSMUSIC2.ST is an embedded GEMDOS executable, listed in the file table
+	// at stream offset $60.
+	{
+		const uint32 kDsMusicOffset = 0xBCA2;
+		const uint32 kGemdosHeaderSize = 0x1C;
+		const uint32 kDsMusicTextSize = 0x1246;
+
+		stream->seek(kDsMusicOffset + kGemdosHeaderSize);
+		_musicData.resize(kDsMusicTextSize);
+		stream->read(_musicData.data(), kDsMusicTextSize);
+
+		delete _playerMusic;
+		_playerMusic = makeWallyBebenAtariPlayer(_musicData.data(), _musicData.size(),
+			kDarkSideAtariOffsets);
+	}
+
 	for (auto &area : _areaMap) {
 		// Center and pad each area name so we do not have to do it at each frame
 		area._value->_name = centerAndPadString(area._value->_name, 26);
diff --git a/engines/freescape/games/dark/dark.cpp b/engines/freescape/games/dark/dark.cpp
index 40cedf50c63..ab186fd3c8e 100644
--- a/engines/freescape/games/dark/dark.cpp
+++ b/engines/freescape/games/dark/dark.cpp
@@ -353,7 +353,7 @@ void DarkEngine::initGameState() {
 		}
 	}
 
-	if (isC64() && _playerMusic)
+	if ((isC64() || isAtariST()) && _playerMusic)
 		_playerMusic->startMusic();
 }
 
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index a39ef191f60..38575e68cf1 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -158,7 +158,7 @@ public:
 
 	void toggleC64Sound();
 
-	Common::Array<byte> _musicData; // HDSMUSIC.AM TEXT segment (Amiga)
+	Common::Array<byte> _musicData; // DSMUSIC.AM (Amiga) or DSMUSIC2.ST (Atari ST)
 
 	void drawString(const DarkFontSize size, const Common::String &str, int x, int y, uint32 primaryColor, uint32 secondaryColor, uint32 backColor, Graphics::Surface *surface);
 	void drawInfoMenu() override;
diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 1c98539713d..ec9f23cf361 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -25,15 +25,12 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/eclipse.h"
+#include "freescape/wb.h"
 #include "freescape/language/8bitDetokeniser.h"
 
 namespace Freescape {
 
 
-// Forward declaration (defined in atari.music.cpp)
-MusicPlayer *makeEclipseAtariMusicPlayer(const byte *data, uint32 dataSize,
-                                                  int songNum = 1);
-
 extern const int kAtariCompassPhaseCount = 72;
 extern const int kAtariCompassBaseFrames = 19;
 extern const int kAtariCompassTotalFrames = 37;
@@ -855,7 +852,8 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	stream->seek(kTEMusicOffset + kGemdosHeaderSize);
 	_musicData.resize(kTEMusicTextSize);
 	stream->read(_musicData.data(), kTEMusicTextSize);
-	_playerMusic = makeEclipseAtariMusicPlayer(_musicData.data(), _musicData.size());
+	_playerMusic = makeWallyBebenAtariPlayer(_musicData.data(), _musicData.size(),
+		kEclipseAtariOffsets);
 	debug(3, "TE-Atari: Loaded TEMUSIC.ST TEXT segment (%d bytes)", kTEMusicTextSize);
 
 	// UI font (Font A): 4-plane 16-color bordered font at prog $24C3E (file offset $24C5A)
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index 2ad965f3155..a4586ce866e 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -42,7 +42,6 @@ MODULE_OBJS := \
 	games/driller/zx.o \
 	games/eclipse/amiga.o \
 	games/eclipse/atari.o \
-	games/eclipse/atari.music.o \
 	games/eclipse/c64.o \
 	games/eclipse/c64.music.o \
 	games/eclipse/ay.music.o \
@@ -75,6 +74,7 @@ MODULE_OBJS := \
 	ui.o \
 	unpack.o \
 	wb.o \
+	wb_atari.o \
 	zx_tape.o
 
 ifdef USE_TINYGL
diff --git a/engines/freescape/wb.h b/engines/freescape/wb.h
index 8dfc10ffa02..fb553070b06 100644
--- a/engines/freescape/wb.h
+++ b/engines/freescape/wb.h
@@ -25,24 +25,19 @@
 #include "audio/audiostream.h"
 #include "common/types.h"
 
+#include "freescape/music.h"
+
 namespace Freescape {
 
 namespace WBCommon {
 
-/**
- * Decode order-list transpose command ($C1-$FE).
- * Formula used by Wally Beben engines: (cmd + $20) & $FF.
- */
+/** Decode order-list transpose command ($C1-$FE): (cmd + $20) & $FF. */
 int8 decodeOrderTranspose(byte cmd);
 
-/**
- * Decode speed command ($F0-$FD): low nibble, with 0 coerced to 1.
- */
+/** Decode speed command ($F0-$FD): low nibble. Zero is legal, see wb.cpp. */
 byte decodeTickSpeed(byte cmd);
 
-/**
- * Decode duration command ($80-$BF): low 6 bits, with 0 coerced to 1.
- */
+/** Decode duration command ($80-$BF): low 6 bits. Zero is legal. */
 byte decodeDuration(byte cmd);
 
 /**
@@ -90,6 +85,41 @@ Audio::AudioStream *makeWallyBebenStream(const byte *data, uint32 dataSize,
                                          bool stereo = true,
                                          const WBTableOffsets *offsets = nullptr);
 
+/** Table offsets and per-release constants for the Atari ST flavour, which
+ *  drives the YM2149 over three channels instead of Paula. */
+struct WBAtariTableOffsets {
+	uint32 periodTable;       // 96 x uint16 BE
+	uint32 arpeggioIntervals; // 8 bytes
+	uint32 instrumentTable;   // numInstruments x 8 bytes
+	uint32 songTable;         // 2 songs x 3 channels x uint32 BE
+	uint32 patternPtrTable;   // up to 32 x uint32 BE
+	int numInstruments;
+
+	byte noiseSeed;           // noise period loaded by instrument flag bit 0
+	bool noiseFollowsInstrument; // flag bit 1 takes the noise period from byte 5
+	byte noiseDrift;          // subtracted while the noise counter runs
+
+	// Frequency sweep, instrument flag bit 2
+	int16 sweepStep;
+	uint16 sweepMask;         // period wrap, 0 for none
+	int8 sweepNoiseDelta;
+};
+
+extern const WBAtariTableOffsets kEclipseAtariOffsets;
+extern const WBAtariTableOffsets kDarkSideAtariOffsets;
+
+/**
+ * Create a player for the Atari ST music engine used by Total Eclipse
+ * (TEMUSIC.ST) and Dark Side (DSMUSIC2.ST).
+ *
+ * @param data     Raw TEXT segment data, after the 0x1C GEMDOS header
+ * @param offsets  Table offsets for this release
+ * @param songNum  Song number to play (1 or 2)
+ */
+MusicPlayer *makeWallyBebenAtariPlayer(const byte *data, uint32 dataSize,
+                                       const WBAtariTableOffsets &offsets,
+                                       int songNum = 1);
+
 } // End of namespace Freescape
 
 #endif
diff --git a/engines/freescape/games/eclipse/atari.music.cpp b/engines/freescape/wb_atari.cpp
similarity index 61%
rename from engines/freescape/games/eclipse/atari.music.cpp
rename to engines/freescape/wb_atari.cpp
index 2c85880c6d5..ac2ab1795e0 100644
--- a/engines/freescape/games/eclipse/atari.music.cpp
+++ b/engines/freescape/wb_atari.cpp
@@ -20,19 +20,12 @@
  */
 
 /**
- * Total Eclipse Atari ST music player (YM2149 PSG).
+ * Atari ST player for the Wally Beben music engine (YM2149 PSG).
  *
- * Plays background music from the TEMUSIC.ST embedded GEMDOS executable.
- * Uses the same Wally Beben byte-stream pattern format as the Amiga
- * Dark Side engine (wb.cpp), but outputs to the YM2149/AY-3-8912 PSG
- * instead of Amiga Paula.
- *
- * TEMUSIC.ST data table offsets (TEXT-relative):
- *   $0B24  Period table (96 x uint16 BE)
- *   $0CC8  Arpeggio interval lookup (8 bytes)
- *   $0D60  Instrument table (12 x 8 bytes)
- *   $0DC0  Song table (2 songs x 3 channels x uint32 BE order-list pointers)
- *   $0DCC  Pattern pointer table (up to 31 x uint32 BE)
+ * Drives TEMUSIC.ST (Total Eclipse) and DSMUSIC2.ST (Dark Side), which are the
+ * same engine at the same code addresses with their data tables at different
+ * offsets. It shares the byte-stream pattern format with the Amiga flavour in
+ * wb.cpp, but writes YM2149 registers instead of feeding Paula.
  */
 
 #include "audio/ym2149.h"
@@ -47,22 +40,45 @@
 
 namespace Freescape {
 
-// TEXT-relative offsets for data tables within TEMUSIC.ST
-const uint32 kTEPeriodTableOffset      = 0x0B24; // 96 x uint16 BE
-const uint32 kTEArpeggioIntervalsOffset = 0x0CC8; // 8 bytes
-const uint32 kTEInstrumentTableOffset   = 0x0D60; // 12 x 8 bytes
-const uint32 kTESongTableOffset         = 0x0DC0; // 2 songs x 3 ch x uint32 BE
-const uint32 kTEPatternPtrTableOffset   = 0x0DCC; // up to 31 x uint32 BE
+const WBAtariTableOffsets kEclipseAtariOffsets = {
+	0x0B24, // periodTable
+	0x0CC8, // arpeggioIntervals
+	0x0D60, // instrumentTable
+	0x0DC0, // songTable
+	0x0DCC, // patternPtrTable
+	12,     // numInstruments
+	0x20,   // noiseSeed, asm ref TEMUSIC.ST $09CC
+	true,   // noiseFollowsInstrument, asm ref TEMUSIC.ST $0A02
+	0,      // noiseDrift: the value lives in a corrupt word of the only dump
+	0x64, 0x0FFF, 1 // sweep, asm ref TEMUSIC.ST $042A
+};
+
+const WBAtariTableOffsets kDarkSideAtariOffsets = {
+	0x0AFA, // periodTable
+	0x0C9E, // arpeggioIntervals
+	0x0CA6, // instrumentTable
+	0x0D46, // songTable
+	0x0D52, // patternPtrTable
+	20,     // numInstruments
+	0x3C,   // noiseSeed, asm ref DSMUSIC2.ST $09B4
+	false,  // noiseFollowsInstrument, asm ref DSMUSIC2.ST $09D8
+	6,      // noiseDrift, asm ref DSMUSIC2.ST $0462
+	0x96, 0, -8 // sweep, asm ref DSMUSIC2.ST $042A
+};
 
 const int kTENumChannels    = 3;
 const int kTENumPeriods     = 96;
-const int kTENumInstruments = 12;
-const int kTEMaxPatterns    = 31;
+const int kTEMaxInstruments = 32; // the instrument command carries a 5-bit index
+// Total Eclipse has 31 pattern pointers and Dark Side 32; both tables end where
+// the first order list begins, and loadTables() drops entries that do not point
+// into the module.
+const int kTEMaxPatterns    = 32;
 
-class EclipseAtariMusicPlayer : public MusicPlayer {
+class WallyBebenAtariPlayer : public MusicPlayer {
 public:
-	EclipseAtariMusicPlayer(const byte *data, uint32 dataSize, int songNum);
-	~EclipseAtariMusicPlayer();
+	WallyBebenAtariPlayer(const byte *data, uint32 dataSize,
+	                      const WBAtariTableOffsets &offsets, int songNum);
+	~WallyBebenAtariPlayer();
 
 	void startMusic() override;
 	void stopMusic() override;
@@ -74,20 +90,21 @@ private:
 	// --- Data tables ---
 	const byte *_data;
 	uint32 _dataSize;
+	WBAtariTableOffsets _offsets;
 
 	uint16 _periods[kTENumPeriods];
 
 	struct InstrumentDesc {
-		byte volume;       // Initial volume (0-$3F)
-		byte targetVol;    // Sustain/target volume
-		byte attackRate;   // Volume increment per tick
-		byte releaseRate;  // Volume decrement per tick
-		byte envFlags;     // Bit 7: hardware envelope
-		byte effectType;   // Effect configuration
-		byte arpeggioData; // Arpeggio bit pattern
-		byte flags;        // Additional flags
+		byte volume;
+		byte targetVol;
+		byte attackRate;
+		byte releaseRate;
+		byte envFlags;     // Bit 7 selects the oscillating volume mode
+		byte effectType;
+		byte arpeggioData;
+		byte flags;
 	};
-	InstrumentDesc _instruments[kTENumInstruments];
+	InstrumentDesc _instruments[kTEMaxInstruments];
 
 	// Song order list pointers (TEXT-relative)
 	uint32 _songOrderPtrs[2][kTENumChannels];
@@ -112,7 +129,6 @@ private:
 
 		// Note state
 		byte note;
-		byte prevNote;
 		byte duration;
 		int durationCounter;
 
@@ -120,21 +136,20 @@ private:
 		byte instrumentIdx;
 
 		// Volume envelope
-		byte volume;       // Current volume (0-63 internal scale)
-		byte attackLevel;  // Initial volume on note-on
-		byte decayTarget;  // Target volume to hold
-		byte attackRate;   // Increment per tick
-		byte releaseRate;  // Decrement per tick
-		byte envelopeFlags; // Instrument byte 4
-		byte envelopeToggle; // Bit7 envelope direction toggle
-		bool envelopeDone; // Mirrors original per-note envelope completion flag
+		byte volume;       // 0-63 internal, written to the YM as >>2
+		byte attackLevel;
+		byte decayTarget;
+		byte attackRate;
+		byte releaseRate;
+		byte envelopeFlags;
+		byte envelopeToggle;
+		bool envelopeDone;
 
 		// Effects
 		byte effectMode;   // 0=none, 1=pattern FX ($7D), 2=instrument FX ($7C)
 		bool portaUp;
 		bool portaDown;
-		int16 portaStep;
-		int16 portaTarget;
+		bool skipEffects;   // porta steps bypass the rest of the effects
 		byte arpeggioMask;
 		byte arpeggioPos;
 		byte arpeggioTable[16];
@@ -146,26 +161,20 @@ private:
 		byte delay;
 		byte delayCounter;
 
-		// Vibrato
-		byte vibratoSpeed;  // Phase increment per tick
-		byte vibratoDepth;  // Amplitude in period units
-		int8 vibratoPos;    // Current phase position (oscillates)
-		int8 vibratoDir;    // +1 or -1
-
 		// Noise
-		bool noiseEnabled;  // Instrument flags bit 0/1: noise mode
-		bool toneEnabled;   // If false, channel uses noise-only mode
-		bool skipTranspose; // Noise-only mode bypasses order-list transpose
-		bool freqSweep;     // Instrument flags bit 2: frequency sweep
-		byte noisePeriod;   // YM noise period source from instrument byte 5
-		byte noiseCounter;  // Instrument flags high nibble countdown ($54)
-
-		// Instrument byte-5 period modulation ($04A2..$05C8 path)
-		byte modParam;      // Raw instrument byte 5
-		byte modSpan;       // High nibble
-		byte modPos;        // Running position (mirrors +$30)
-		int8 modDir;        // -1/1 (mirrors sign of +$2D)
-		int16 modStep;      // Derived note-step delta (mirrors $C9A)
+		bool noiseEnabled;
+		bool toneEnabled;
+		bool skipTranspose; // Noise-only mode bypasses the order-list transpose
+		bool freqSweep;
+		byte noisePeriod;
+		byte noiseCounter;  // Countdown from the instrument flags high nibble
+
+		// Instrument byte-5 period modulation. Asm ref: $048A
+		byte modParam;
+		byte modSpan;
+		byte modPos;
+		int8 modDir;
+		int16 modStep;
 
 		// Period
 		int16 basePeriod;
@@ -187,6 +196,7 @@ private:
 	void initChannel(int ch);
 	void readOrderList(int ch);
 	void readPatternCommands(int ch);
+	void loadInstrument(int ch);
 	void triggerNote(int ch);
 	void processEffects(int ch);
 	void processEnvelope(int ch);
@@ -224,9 +234,10 @@ private:
 // Construction / data loading
 // ---------------------------------------------------------------------------
 
-EclipseAtariMusicPlayer::EclipseAtariMusicPlayer(const byte *data, uint32 dataSize,
-                                                   int songNum)
-	: _data(data), _dataSize(dataSize),
+WallyBebenAtariPlayer::WallyBebenAtariPlayer(const byte *data, uint32 dataSize,
+                                             const WBAtariTableOffsets &offsets,
+                                             int songNum)
+	: _data(data), _dataSize(dataSize), _offsets(offsets),
 	  _musicActive(false), _tickSpeed(6), _tickCounter(0),
 	  _numPatterns(0), _songNum(songNum) {
 
@@ -239,7 +250,7 @@ EclipseAtariMusicPlayer::EclipseAtariMusicPlayer(const byte *data, uint32 dataSi
 
 	_ym2149 = YM2149::Config::create();
 	if (!_ym2149 || !_ym2149->init()) {
-		warning("EclipseAtariMusicPlayer: Failed to create YM2149 emulator");
+		warning("WallyBebenAtariPlayer: Failed to create YM2149 emulator");
 		delete _ym2149;
 		_ym2149 = nullptr;
 	}
@@ -247,7 +258,7 @@ EclipseAtariMusicPlayer::EclipseAtariMusicPlayer(const byte *data, uint32 dataSi
 	loadTables();
 }
 
-EclipseAtariMusicPlayer::~EclipseAtariMusicPlayer() {
+WallyBebenAtariPlayer::~WallyBebenAtariPlayer() {
 	stopMusic();
 	delete _ym2149;
 }
@@ -257,47 +268,37 @@ EclipseAtariMusicPlayer::~EclipseAtariMusicPlayer() {
 // Public interface
 // ============================================================================
 
-void EclipseAtariMusicPlayer::startMusic() {
+void WallyBebenAtariPlayer::startMusic() {
 	if (!_ym2149)
 		return;
 	stopMusic();
-	_ym2149->start(new Common::Functor0Mem<void, EclipseAtariMusicPlayer>(
-		this, &EclipseAtariMusicPlayer::tickUpdate), 50);
+	_ym2149->start(new Common::Functor0Mem<void, WallyBebenAtariPlayer>(
+		this, &WallyBebenAtariPlayer::tickUpdate), 50);
 	startSong(_songNum);
 }
 
-void EclipseAtariMusicPlayer::stopMusic() {
+void WallyBebenAtariPlayer::stopMusic() {
 	_musicActive = false;
 	if (_ym2149) {
 		_ym2149->stop();
 	}
 }
 
-bool EclipseAtariMusicPlayer::isPlaying() const {
+bool WallyBebenAtariPlayer::isPlaying() const {
 	return _musicActive;
 }
 
-void EclipseAtariMusicPlayer::loadTables() {
-	// Period table: 96 x uint16 BE at TEXT+$0B24
+void WallyBebenAtariPlayer::loadTables() {
 	for (int i = 0; i < kTENumPeriods; i++) {
-		_periods[i] = readDataWord(kTEPeriodTableOffset + i * 2);
+		_periods[i] = readDataWord(_offsets.periodTable + i * 2);
 	}
 
-	// Fix note 46: corrupted by data artifact ($3095 instead of $010D).
-	// Correct value interpolated from surrounding notes (45=$011D, 47=$00FE).
-	if (_periods[46] == 0x3095) {
-		_periods[46] = 0x010D;
-		debug(3, "TE-Atari: Fixed corrupted period for note 46 ($3095 -> $010D)");
-	}
-
-	// Arpeggio interval table: 8 bytes at TEXT+$0CC8
 	for (int i = 0; i < 8; i++) {
-		_arpeggioIntervals[i] = readDataByte(kTEArpeggioIntervalsOffset + i);
+		_arpeggioIntervals[i] = readDataByte(_offsets.arpeggioIntervals + i);
 	}
 
-	// Instrument table: 12 x 8 bytes at TEXT+$0D60
-	for (int i = 0; i < kTENumInstruments; i++) {
-		uint32 off = kTEInstrumentTableOffset + i * 8;
+	for (int i = 0; i < _offsets.numInstruments && i < kTEMaxInstruments; i++) {
+		uint32 off = _offsets.instrumentTable + i * 8;
 		_instruments[i].volume      = readDataByte(off + 0);
 		_instruments[i].targetVol   = readDataByte(off + 1);
 		_instruments[i].attackRate  = readDataByte(off + 2);
@@ -311,31 +312,31 @@ void EclipseAtariMusicPlayer::loadTables() {
 	// Song table: 2 songs x 3 channels x uint32 BE at TEXT+$0DC0
 	for (int s = 0; s < 2; s++) {
 		for (int ch = 0; ch < kTENumChannels; ch++) {
-			_songOrderPtrs[s][ch] = readDataLong(kTESongTableOffset + s * 12 + ch * 4);
+			_songOrderPtrs[s][ch] = readDataLong(_offsets.songTable + s * 12 + ch * 4);
 		}
 	}
 
 	// Pattern pointer table at TEXT+$0DCC
 	_numPatterns = 0;
 	for (uint32 i = 0; i < kTEMaxPatterns; i++) {
-		uint32 ptr = readDataLong(kTEPatternPtrTableOffset + i * 4);
+		uint32 ptr = readDataLong(_offsets.patternPtrTable + i * 4);
 		_patternPtrs[i] = ptr;
 		if (ptr > 0 && ptr < _dataSize)
 			_numPatterns = i + 1;
 	}
 
-	debug(3, "TE-Atari: Loaded music data (%u bytes)", _dataSize);
-	debug(3, "TE-Atari: %d valid patterns", _numPatterns);
+	debug(3, "WB-Atari: Loaded music data (%u bytes)", _dataSize);
+	debug(3, "WB-Atari: %d valid patterns", _numPatterns);
 
 	for (int s = 0; s < 2; s++) {
-		debug(3, "TE-Atari: Song %d order ptrs: $%X $%X $%X",
+		debug(3, "WB-Atari: Song %d order ptrs: $%X $%X $%X",
 			s + 1, _songOrderPtrs[s][0], _songOrderPtrs[s][1], _songOrderPtrs[s][2]);
 	}
 
-	for (int i = 0; i < kTENumInstruments; i++) {
+	for (int i = 0; i < _offsets.numInstruments; i++) {
 		const InstrumentDesc &inst = _instruments[i];
 		if (inst.volume > 0 || inst.targetVol > 0)
-			debug(3, "TE-Atari: Inst %d: vol=%d target=%d atk=%d rel=%d envFlags=$%02X effect=$%02X arp=$%02X flags=$%02X",
+			debug(3, "WB-Atari: Inst %d: vol=%d target=%d atk=%d rel=%d envFlags=$%02X effect=$%02X arp=$%02X flags=$%02X",
 				i, inst.volume, inst.targetVol, inst.attackRate, inst.releaseRate,
 				inst.envFlags, inst.effectType, inst.arpeggioData, inst.flags);
 	}
@@ -345,7 +346,7 @@ void EclipseAtariMusicPlayer::loadTables() {
 // Song init
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::startSong(int songNum) {
+void WallyBebenAtariPlayer::startSong(int songNum) {
 	_musicActive = false;
 
 	if (songNum < 1 || songNum > 2)
@@ -370,30 +371,27 @@ void EclipseAtariMusicPlayer::startSong(int songNum) {
 
 	_musicActive = true;
 
-	debug(3, "TE-Atari: Song %d started, tickSpeed=%d", songNum, _tickSpeed);
+	debug(3, "WB-Atari: Song %d started, tickSpeed=%d", songNum, _tickSpeed);
 	for (int ch = 0; ch < kTENumChannels; ch++) {
-		debug(3, "TE-Atari: ch%d orderList=$%X pattern=$%X",
+		debug(3, "WB-Atari: ch%d orderList=$%X pattern=$%X",
 			ch, _channels[ch].orderListOffset, _channels[ch].patternOffset);
 	}
 }
 
-void EclipseAtariMusicPlayer::initChannel(int ch) {
+void WallyBebenAtariPlayer::initChannel(int ch) {
 	ChannelState &c = _channels[ch];
 	memset(&c, 0, sizeof(ChannelState));
 	c.duration = 1;
 	c.durationCounter = 0;
-	c.attackLevel = 0x36; // Default from instrument 1
-	c.decayTarget = 0x36;
 	c.toneEnabled = true;
 	c.envelopeDone = true;
 }
 
 // ---------------------------------------------------------------------------
-// Order list reader
-// Same format as wb.cpp: $00-$C0=pattern#, $C1-$FE=transpose, $FF=loop
+// Order list reader: $00-$C0 pattern, $C1-$FE transpose, $FF loop
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::readOrderList(int ch) {
+void WallyBebenAtariPlayer::readOrderList(int ch) {
 	ChannelState &c = _channels[ch];
 
 	for (int safety = 0; safety < 256; safety++) {
@@ -416,16 +414,16 @@ void EclipseAtariMusicPlayer::readOrderList(int ch) {
 		if (cmd < _numPatterns && _patternPtrs[cmd] > 0 && _patternPtrs[cmd] < _dataSize) {
 			c.patternOffset = _patternPtrs[cmd];
 			c.patternPos = 0;
-			debugC(3, kFreescapeDebugParser, "TE-Atari: ch%d order -> pattern %d (offset $%04X)", ch, cmd, c.patternOffset);
+			debugC(3, kFreescapeDebugMedia, "WB-Atari: ch%d order -> pattern %d (offset $%04X)", ch, cmd, c.patternOffset);
 		} else {
 			// Invalid pattern index — skip it and try next order entry
-			debugC(3, kFreescapeDebugParser, "TE-Atari: ch%d skipping invalid pattern index %d", ch, cmd);
+			debugC(3, kFreescapeDebugMedia, "WB-Atari: ch%d skipping invalid pattern index %d", ch, cmd);
 			continue;
 		}
 		return;
 	}
 
-	warning("TE-Atari: ch%d order list safety limit hit", ch);
+	warning("WB-Atari: ch%d order list safety limit hit", ch);
 }
 
 // ---------------------------------------------------------------------------
@@ -435,7 +433,7 @@ void EclipseAtariMusicPlayer::readOrderList(int ch) {
 //   $7D/$7C=vibrato/arpeggio, $00-$5F=note
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
+void WallyBebenAtariPlayer::readPatternCommands(int ch) {
 	ChannelState &c = _channels[ch];
 
 	for (int safety = 0; safety < 256; safety++) {
@@ -456,7 +454,7 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		if (cmd == 0xFC) {
-			// Song jump command: mirror TEMUSIC mailbox semantics.
+			// Song jump, via the mailbox.
 			byte command = readDataByte(c.patternOffset + c.patternPos);
 			c.patternPos++;
 			if (command == 0) {
@@ -476,56 +474,11 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		if (cmd >= 0xC0) {
-			// Instrument select: (cmd & $1F) = instrument index
+			// Asm ref: $027C only records the table offset. The parameters are
+			// copied into the channel at note-on, by loadInstrument().
 			byte instIdx = cmd & 0x1F;
-			if (instIdx < kTENumInstruments) {
+			if (instIdx < _offsets.numInstruments)
 				c.instrumentIdx = instIdx;
-				const InstrumentDesc &inst = _instruments[instIdx];
-				c.attackLevel = inst.volume;
-				c.decayTarget = inst.targetVol;
-				c.attackRate = inst.attackRate;
-				c.releaseRate = inst.releaseRate;
-				c.envelopeFlags = inst.envFlags;
-
-				// Instrument byte 5 is used as a PSG/noise control parameter.
-				c.modParam = inst.effectType;
-				c.modSpan = (inst.effectType >> 4) & 0x0F;
-				c.modPos = 0;
-				c.modDir = 1;
-				c.noisePeriod = inst.effectType & 0x3F;
-				c.toneEnabled = true;
-				c.noiseEnabled = false;
-				c.skipTranspose = false;
-				c.freqSweep = false;
-				c.noiseCounter = inst.flags >> 4;
-
-				// Instrument byte 6 preloads the channel interval table and forces mode $7C.
-				if (inst.arpeggioData != 0) {
-					c.effectMode = 2;
-					c.arpeggioMask = inst.arpeggioData;
-					c.arpeggioPos = 0;
-					buildArpeggioTable(c, inst.arpeggioData);
-				}
-
-				// Instrument byte 7 flags:
-				// bit0/1 noise modes, bit2 frequency sweep, bit3 retrigger.
-				if (inst.flags & 0x01) {
-					// Bit 0: tone + noise with fixed noise seed period.
-					c.noiseEnabled = true;
-					c.noisePeriod = 0x20;
-				} else if (inst.flags & 0x02) {
-					// Bit 1: note-relative noise-only mode.
-					c.noiseEnabled = true;
-					c.toneEnabled = false;
-					c.skipTranspose = true;
-					if (c.noisePeriod == 0)
-						c.noisePeriod = 1;
-				} else if (inst.flags & 0x04) {
-					// Bit 2: tone + noise mode with frequency sweep.
-					c.noiseEnabled = true;
-					c.freqSweep = true;
-				}
-			}
 			continue;
 		}
 
@@ -538,10 +491,8 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 			c.portaUp = true;
 			c.portaDown = false;
 			c.effectMode = 1;
-			// Original parser consumes the next byte and uses it as the immediate note.
 			if (c.patternOffset + c.patternPos >= _dataSize)
 				return;
-			c.prevNote = c.note;
 			c.note = readDataByte(c.patternOffset + c.patternPos);
 			c.patternPos++;
 			c.durationCounter = c.duration;
@@ -553,10 +504,8 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 			c.portaDown = true;
 			c.portaUp = false;
 			c.effectMode = 1;
-			// Original parser consumes the next byte and uses it as the immediate note.
 			if (c.patternOffset + c.patternPos >= _dataSize)
 				return;
-			c.prevNote = c.note;
 			c.note = readDataByte(c.patternOffset + c.patternPos);
 			c.patternPos++;
 			c.durationCounter = c.duration;
@@ -576,10 +525,8 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		if (cmd == 0x7C) {
-			// Pattern effect 2: identical to $7D except that the mode is 2,
-			// which survives the step boundary. Asm ref: TEXT+$031A, which
-			// loads mode 2 and jumps into the middle of the $7D handler, so
-			// the parameter byte is consumed here as well.
+			// Asm ref: $031A loads mode 2 and jumps into the $7D handler, so
+			// this consumes a parameter too. Mode 2 survives a step boundary.
 			byte param = readDataByte(c.patternOffset + c.patternPos);
 			c.patternPos++;
 			c.effectMode = 2;
@@ -590,8 +537,8 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		if (cmd == 0x7B) {
-			// TEMUSIC delayed slide command:
-			//   byte1 = base note (+transpose), byte2 low nibble = delay window.
+			// Slide: byte 1 is the base note, byte 2 packs the start offset
+			// and window length. Asm ref: $061A
 			c.arpeggioTableLen = 0;
 			c.arpeggioPos = 0;
 			c.effectMode = 1;
@@ -626,23 +573,76 @@ void EclipseAtariMusicPlayer::readPatternCommands(int ch) {
 		}
 
 		// Note value ($00-$5F)
-		c.prevNote = c.note;
 		c.note = cmd;
 		c.durationCounter = c.duration;
 		triggerNote(ch);
 		return;
 	}
 
-	warning("TE-Atari: ch%d pattern read safety limit hit", ch);
+	warning("WB-Atari: ch%d pattern read safety limit hit", ch);
 }
 
 // ---------------------------------------------------------------------------
-// Note trigger — set YM period, reset envelope
+// Note trigger
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::triggerNote(int ch) {
+// Asm ref: $0938, which note-on calls before it decides whether to transpose.
+void WallyBebenAtariPlayer::loadInstrument(int ch) {
+	ChannelState &c = _channels[ch];
+	const InstrumentDesc &inst = _instruments[c.instrumentIdx];
+
+	c.volume = inst.volume;
+	c.attackLevel = inst.volume;
+	c.decayTarget = inst.targetVol;
+	c.attackRate = inst.attackRate;
+	c.releaseRate = inst.releaseRate;
+	c.envelopeFlags = inst.envFlags;
+
+	c.modParam = inst.effectType;
+	c.modSpan = (inst.effectType >> 4) & 0x0F;
+	c.noisePeriod = inst.effectType & 0x3F;
+	c.toneEnabled = true;
+	c.noiseEnabled = false;
+	c.skipTranspose = false;
+	c.freqSweep = false;
+	c.noiseCounter = inst.flags >> 4;
+
+	// Byte 6 preloads the interval table. Its mode is $7C, not 2, so unlike a
+	// pattern $7C it dies at the step boundary and is reapplied by each note.
+	if (inst.arpeggioData != 0) {
+		c.effectMode = 0x7C;
+		c.arpeggioMask = inst.arpeggioData;
+		buildArpeggioTable(c, inst.arpeggioData);
+	}
+
+	debugC(2, kFreescapeDebugMedia,
+		"WB-Atari: ch%d inst %-2d vol=%d target=%d atk=%d rel=%d env=$%02X mod=$%02X arp=$%02X flags=$%02X",
+		ch, c.instrumentIdx, inst.volume, inst.targetVol, inst.attackRate,
+		inst.releaseRate, inst.envFlags, inst.effectType, inst.arpeggioData, inst.flags);
+
+	// Byte 7: bit0/1 noise modes, bit2 frequency sweep, bit3 retrigger.
+	if (inst.flags & 0x01) {
+		c.noiseEnabled = true;
+		c.noisePeriod = _offsets.noiseSeed;
+	} else if (inst.flags & 0x02) {
+		c.noiseEnabled = true;
+		c.toneEnabled = false;
+		c.skipTranspose = true;
+		if (!_offsets.noiseFollowsInstrument)
+			c.noisePeriod = _offsets.noiseSeed;
+		if (c.noisePeriod == 0)
+			c.noisePeriod = 1;
+	} else if (inst.flags & 0x04) {
+		c.noiseEnabled = true;
+		c.freqSweep = true;
+	}
+}
+
+void WallyBebenAtariPlayer::triggerNote(int ch) {
 	ChannelState &c = _channels[ch];
 
+	loadInstrument(ch);
+
 	// Apply transpose and clamp
 	int note = c.note;
 	if (!c.skipTranspose)
@@ -659,38 +659,18 @@ void EclipseAtariMusicPlayer::triggerNote(int ch) {
 	c.outputPeriod = c.basePeriod;
 
 	if (!isRest && c.basePeriod == 0) {
-		warning("TE-Atari: ch%d note %d has period 0", ch, note);
+		warning("WB-Atari: ch%d note %d has period 0", ch, note);
 		return;
 	}
 
 	// Reset envelope
 	c.envelopeToggle = 0;
 	c.envelopeDone = false;
-	c.volume = c.attackLevel;
 	c.delayCounter = c.delay;
 	c.arpeggioPos = 0;
+	c.modPos = 0;
+	c.modDir = 1;
 	c.modStep = 0;
-	const InstrumentDesc &inst = _instruments[c.instrumentIdx];
-	c.noiseCounter = inst.flags >> 4;
-
-	// Reapply byte-7 mixer mode on every note trigger.
-	c.toneEnabled = true;
-	c.noiseEnabled = false;
-	c.skipTranspose = false;
-	c.freqSweep = false;
-	if (inst.flags & 0x01) {
-		c.noiseEnabled = true;
-		c.noisePeriod = 0x20;
-	} else if (inst.flags & 0x02) {
-		c.noiseEnabled = true;
-		c.toneEnabled = false;
-		c.skipTranspose = true;
-		if (c.noisePeriod == 0)
-			c.noisePeriod = 1;
-	} else if (inst.flags & 0x04) {
-		c.noiseEnabled = true;
-		c.freqSweep = true;
-	}
 
 	if (!isRest && c.modParam != 0 && note + 1 < kTENumPeriods) {
 		int16 periodDelta = ABS((int16)getPeriod(note) - (int16)getPeriod(note + 1));
@@ -702,74 +682,50 @@ void EclipseAtariMusicPlayer::triggerNote(int ch) {
 		c.modStep = periodDelta;
 	}
 
-	debugC(3, kFreescapeDebugParser, "TE-Atari: ch%d NOTE note=%d(+%d) period=%d inst=%d vol=%d",
-		ch, c.note, c.transpose, c.basePeriod, c.instrumentIdx, c.volume);
+	debugC(1, kFreescapeDebugMedia,
+		"WB-Atari: ch%d %s %3d (%d%+d) %5dHz inst=%-2d dur=%d vol=%d%s%s%s",
+		ch, isRest ? "rest" : "NOTE", note, c.note, c.transpose,
+		c.basePeriod > 0 ? 125000 / c.basePeriod : 0,
+		c.instrumentIdx, c.duration, c.volume,
+		c.noiseEnabled ? (c.toneEnabled ? " [tone+noise]" : " [noise]") : "",
+		c.arpeggioTableLen > 0 ? " [arpeggio]" : "",
+		c.effect7BActive ? " [slide]" : "");
 
-	// Set up portamento if active
-	if (!isRest && (c.portaUp || c.portaDown)) {
-		int prevNote = c.prevNote;
-		if (!c.skipTranspose)
-			prevNote += c.transpose;
-		if (prevNote < 1) prevNote = 1;
-		if (prevNote >= kTENumPeriods) prevNote = kTENumPeriods - 1;
-
-		int16 prevPeriod = (c.prevNote > 0) ? getPeriod(prevNote) : c.basePeriod;
-		int16 delta = ABS(c.basePeriod - prevPeriod);
-		int steps = (_tickSpeed > 0) ? _tickSpeed : 1;
-		c.portaStep = delta / steps;
-		if (c.portaStep == 0)
-			c.portaStep = 1;
-		c.portaTarget = c.basePeriod;
-		c.basePeriod = prevPeriod;
-		c.outputPeriod = prevPeriod;
-	}
 }
 
 // ---------------------------------------------------------------------------
-// Effects processing — runs every tick (50 Hz)
+// Effects — run every tick
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::processEffects(int ch) {
+void WallyBebenAtariPlayer::processEffects(int ch) {
 	ChannelState &c = _channels[ch];
 
-	// Noise gate: in TEMUSIC, instrument high nibble is a countdown that
-	// can auto-disable channel noise after N ticks.
+	// The instrument flags high nibble counts down to a noise cut-off.
 	if (c.noiseEnabled) {
 		if (c.noiseCounter > 0) {
 			c.noiseCounter--;
+			// Asm ref: $0436 — the period walks down while the counter runs.
 			if (c.noiseCounter == 0)
 				c.noiseEnabled = false;
+			else if (_offsets.noiseDrift != 0)
+				c.noisePeriod = (byte)((c.noisePeriod - _offsets.noiseDrift) & 0x3F);
 		}
 	}
 
 	int16 period = c.basePeriod;
 
-	// Instrument flag bit2 enables the original per-tick sweep path (+$64, 12-bit wrap),
-	// and advances the shared noise period source.
+	// Flag bit 2 walks the period and drags the noise period with it.
 	if (c.freqSweep) {
-		c.basePeriod = (c.basePeriod + 0x64) & 0x0FFF;
+		c.basePeriod += _offsets.sweepStep;
+		if (_offsets.sweepMask != 0)
+			c.basePeriod &= _offsets.sweepMask;
 		if (c.basePeriod == 0)
 			c.basePeriod = 1;
-		c.noisePeriod = (c.noisePeriod + 1) & 0x1F;
+		c.noisePeriod = (byte)((c.noisePeriod + _offsets.sweepNoiseDelta) & 0x3F);
 		period = c.basePeriod;
 	}
 
-	// Portamento takes priority (active during porta regardless of effectMode)
-	if (c.portaUp) {
-		c.basePeriod -= c.portaStep;
-		if (c.basePeriod <= c.portaTarget) {
-			c.basePeriod = c.portaTarget;
-			c.portaUp = false;
-		}
-		period = c.basePeriod;
-	} else if (c.portaDown) {
-		c.basePeriod += c.portaStep;
-		if (c.basePeriod >= c.portaTarget) {
-			c.basePeriod = c.portaTarget;
-			c.portaDown = false;
-		}
-		period = c.basePeriod;
-	} else if (c.effectMode != 0 && c.arpeggioTableLen > 0) {
+	if (c.effectMode != 0 && c.arpeggioTableLen > 0) {
 		// Channel-local interval cycling (used by $7D/$7C paths).
 		int note = c.note;
 		if (!c.skipTranspose)
@@ -783,29 +739,31 @@ void EclipseAtariMusicPlayer::processEffects(int ch) {
 		if (c.arpeggioPos >= c.arpeggioTableLen)
 			c.arpeggioPos = 0;
 	} else if (c.effect7BActive) {
-		// $7B path: delayed slide from a base note period toward current note period.
-		byte window = c.effect7BParam & 0x0F;
-		if ((int)c.durationCounter + (int)window <= (int)c.duration) {
-			int16 target = c.basePeriod;
-			int steps = (_tickSpeed > 0) ? _tickSpeed : 1;
-			int16 delta = ABS(target - c.effect7BPeriod) / steps;
-			if (delta == 0)
-				delta = 1;
-
-			if (c.effect7BPeriod < target) {
-				c.effect7BPeriod += delta;
-				if (c.effect7BPeriod > target)
-					c.effect7BPeriod = target;
-			} else if (c.effect7BPeriod > target) {
-				c.effect7BPeriod -= delta;
+		// Asm ref: $061A — hold for `start` steps, slide to the base note over
+		// `window` steps, then settle on it.
+		int start = c.effect7BParam >> 4;
+		int window = c.effect7BParam & 0x0F;
+		int elapsed = (int)c.durationCounter + start;
+
+		if (elapsed <= (int)c.duration) {
+			int16 target = getPeriod(c.effect7BBaseNote);
+			if (elapsed + window <= (int)c.duration) {
+				c.effect7BPeriod = target;
+			} else if (window > 0) {
+				int divisor = window * ((int)_tickSpeed + 1);
+				int16 step = (int16)(ABS(target - c.basePeriod) / divisor);
+				if (step == 0)
+					step = 1;
 				if (c.effect7BPeriod < target)
-					c.effect7BPeriod = target;
+					c.effect7BPeriod = MIN<int16>(c.effect7BPeriod + step, target);
+				else
+					c.effect7BPeriod = MAX<int16>(c.effect7BPeriod - step, target);
 			}
 			period = c.effect7BPeriod;
 		}
 	}
 
-	// TEMUSIC enters byte-5 modulation only while mode is clear.
+	// Byte-5 modulation only runs while the mode is clear.
 	if (c.effectMode == 0 && c.modParam != 0 && c.modStep > 0 && c.modSpan > 0) {
 		// $7A delay applies to this modulation path, not to all effects.
 		if (c.delayCounter > 0) {
@@ -841,11 +799,10 @@ void EclipseAtariMusicPlayer::processEffects(int ch) {
 }
 
 // ---------------------------------------------------------------------------
-// Volume envelope — runs every tick (50 Hz)
-// Volume range: 0-63 internal, written to YM as >>2 (0-15)
+// Volume envelope — runs every tick
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::processEnvelope(int ch) {
+void WallyBebenAtariPlayer::processEnvelope(int ch) {
 	ChannelState &c = _channels[ch];
 	// Noise-only instruments may validly run with zero tone period.
 	if (c.outputPeriod == 0 && !c.noiseEnabled)
@@ -854,8 +811,8 @@ void EclipseAtariMusicPlayer::processEnvelope(int ch) {
 	byte env = c.envelopeFlags;
 
 	// Instrument env byte bit7: oscillating level between attackLevel and target.
-	// The YM hardware envelope is never used: the register write loop at
-	// TEXT+$0AD2 only ever pushes registers 0-10 to the chip.
+	// The YM hardware envelope is never used: the write loop at $0AD2 only
+	// ever pushes registers 0-10.
 	if (env & 0x80) {
 		byte step = env & 0x0F;
 		if (c.envelopeToggle == 0) {
@@ -879,7 +836,7 @@ void EclipseAtariMusicPlayer::processEnvelope(int ch) {
 		}
 	} else {
 		c.envelopeToggle = 0;
-		// Original routine skips non-bit7 envelope work only when duration counter is exactly zero.
+		// The original only skips this when the counter is exactly zero.
 		if (c.durationCounter == 0)
 			return;
 
@@ -922,11 +879,9 @@ void EclipseAtariMusicPlayer::processEnvelope(int ch) {
 // Arpeggio table builder
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::buildArpeggioTable(ChannelState &c, byte mask) {
-	// Asm ref: TEXT+$0846 — the selected intervals are written starting at
-	// slot 1 of the channel's region in the shared buffer at $CAA and are
-	// terminated by $FF; slot 0 is never written and holds 0. Playback starts
-	// at slot 1 and wraps back to slot 0, so the base note closes the cycle.
+void WallyBebenAtariPlayer::buildArpeggioTable(ChannelState &c, byte mask) {
+	// Asm ref: $0846 — intervals are written from slot 1 and playback wraps
+	// back to slot 0, so the base note closes the cycle.
 	byte len = WBCommon::buildArpeggioTable(_arpeggioIntervals, mask, c.arpeggioTable, 15, false);
 	if (len > 0)
 		c.arpeggioTable[len++] = 0;
@@ -939,10 +894,10 @@ void EclipseAtariMusicPlayer::buildArpeggioTable(ChannelState &c, byte mask) {
 // Write channel state to YM2149 registers
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::writeYMRegisters() {
+void WallyBebenAtariPlayer::writeYMRegisters() {
 	byte mixer = 0x3F; // Start with all disabled (bits 0-2=tone, bits 3-5=noise)
 
-	// TEMUSIC channel loop runs 2 -> 0; keep that order so global noise register ownership matches.
+	// The channel loop runs 2 -> 0, so the last writer owns the noise register.
 	for (int ch = kTENumChannels - 1; ch >= 0; ch--) {
 		ChannelState &c = _channels[ch];
 
@@ -989,13 +944,12 @@ void EclipseAtariMusicPlayer::writeYMRegisters() {
 // Main tick update — called at 50 Hz
 // ---------------------------------------------------------------------------
 
-void EclipseAtariMusicPlayer::tickUpdate() {
+void WallyBebenAtariPlayer::tickUpdate() {
 	if (!_musicActive)
 		return;
 
 	// Sequencer step occurs when the tick counter is zero, then it advances.
-	// Asm ref: TEXT+$015C tests the speed counter before touching the per
-	// channel duration counter.
+	// Asm ref: $015C tests the speed counter first.
 	bool sequencerTick = (_tickCounter == 0);
 
 	if (sequencerTick) {
@@ -1003,12 +957,12 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 			if (!_channels[ch].active)
 				continue;
 
-			_channels[ch].durationCounter--;
+			ChannelState &c = _channels[ch];
+			c.durationCounter--;
 
-			if (_channels[ch].durationCounter < 0) {
+			if (c.durationCounter < 0) {
 				// Original step boundary reset: clear transient note/effect flags
 				// before parsing the next command stream, except mode 2 persistence.
-				ChannelState &c = _channels[ch];
 				if (c.effectMode != 2) {
 					c.effectMode = 0;
 					c.arpeggioMask = 0;
@@ -1022,6 +976,24 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 				c.freqSweep = false;
 				c.envelopeDone = false;
 				readPatternCommands(ch);
+			} else if (c.portaUp || c.portaDown) {
+				// Asm ref: $0172 — a chromatic walk of one semitone per step
+				// for the whole note, skipping the other effects.
+				if (c.portaUp)
+					c.note--;
+				else
+					c.note++;
+
+				int n = c.note;
+				if (!c.skipTranspose)
+					n += c.transpose;
+				if (n < 1)
+					n = 1;
+				if (n >= kTENumPeriods)
+					n = kTENumPeriods - 1;
+				c.basePeriod = getPeriod(n);
+				c.outputPeriod = c.basePeriod;
+				c.skipEffects = true;
 			}
 		}
 	}
@@ -1031,12 +1003,14 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 		if (!_channels[ch].active)
 			continue;
 
-		processEffects(ch);
+		if (_channels[ch].skipEffects)
+			_channels[ch].skipEffects = false;
+		else
+			processEffects(ch);
 		processEnvelope(ch);
 	}
 
-	// Asm ref: TEXT+$0808 — the speed counter is decremented every tick and
-	// only reloaded from the speed value ($C54) once it goes negative, so a
+	// Asm ref: $0808 — the counter only reloads once it goes negative, so a
 	// speed of N leaves N + 1 ticks between sequencer steps.
 	_tickCounter++;
 	if (_tickCounter > _tickSpeed)
@@ -1049,14 +1023,15 @@ void EclipseAtariMusicPlayer::tickUpdate() {
 // Factory function
 // ---------------------------------------------------------------------------
 
-MusicPlayer *makeEclipseAtariMusicPlayer(const byte *data, uint32 dataSize,
-                                                  int songNum) {
+MusicPlayer *makeWallyBebenAtariPlayer(const byte *data, uint32 dataSize,
+                                       const WBAtariTableOffsets &offsets,
+                                       int songNum) {
 	if (!data || dataSize < 0x1000) {
-		warning("TE-Atari music: invalid data (size %u)", dataSize);
+		warning("WB-Atari music: invalid data (size %u)", dataSize);
 		return nullptr;
 	}
 
-	return new EclipseAtariMusicPlayer(data, dataSize, songNum);
+	return new WallyBebenAtariPlayer(data, dataSize, offsets, songNum);
 }
 
 } // End of namespace Freescape


Commit: 448320e7a2fbee42b724c477b0da2f2d411faaf4
    https://github.com/scummvm/scummvm/commit/448320e7a2fbee42b724c477b0da2f2d411faaf4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: implemented missing dark UI for atari

Changed paths:
    engines/freescape/games/dark/amiga.cpp
    engines/freescape/games/dark/atari.cpp
    engines/freescape/games/dark/dark.h


diff --git a/engines/freescape/games/dark/amiga.cpp b/engines/freescape/games/dark/amiga.cpp
index 1eb38530560..7833ea42ebd 100644
--- a/engines/freescape/games/dark/amiga.cpp
+++ b/engines/freescape/games/dark/amiga.cpp
@@ -214,9 +214,9 @@ void DarkEngine::loadAssetsAmigaFullGame() {
 	_fontLoaded = true;
 
 	byte *palette = getPaletteFromNeoImage(stream, 0x1b762);
-	loadAmigaCompass(stream, palette);
-	loadAmigaIndicatorSprites(stream, palette);
-	loadJetpackRawFrames(stream);
+	loadAmigaCompass(stream, palette, 0);
+	loadAmigaIndicatorSprites(stream, palette, 0);
+	loadJetpackRawFrames(stream, 0);
 	free(palette);
 
 	for (auto &area : _areaMap) {
@@ -225,7 +225,7 @@ void DarkEngine::loadAssetsAmigaFullGame() {
 	}
 }
 
-void DarkEngine::loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byte *palette) {
+void DarkEngine::loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byte *palette, int delta) {
 	if (!palette)
 		return;
 
@@ -236,7 +236,7 @@ void DarkEngine::loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byt
 		auto *surf = new Graphics::ManagedSurface();
 		surf->create(32, 3, _gfx->_texturePixelFormat);
 		surf->fillRect(Common::Rect(0, 0, 32, 3), transparent);
-		decodeAmigaSprite(file, surf, amigaProgToFile(0x2784E) + frame * 0x30, 2, 3, palette);
+		decodeAmigaSprite(file, surf, amigaProgToFile(0x2784E) - delta + frame * 0x30, 2, 3, palette);
 		_amigaCompassNeedleFrames.push_back(surf);
 	}
 
@@ -250,7 +250,7 @@ void DarkEngine::loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byt
 		auto *surf = new Graphics::ManagedSurface();
 		surf->create(32, 21, _gfx->_texturePixelFormat);
 		surf->fillRect(Common::Rect(0, 0, 32, 21), transparent);
-		decodeMaskedAmigaSprite(file, surf, amigaProgToFile(0x29B34) + frameIndex * 0x150, 2, 21,
+		decodeMaskedAmigaSprite(file, surf, amigaProgToFile(0x29B34) - delta + frameIndex * 0x150, 2, 21,
 			kLeftMasks, _gfx->_texturePixelFormat, palette);
 		_amigaCompassLeftFrames.push_back(surf);
 	}
@@ -261,13 +261,13 @@ void DarkEngine::loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byt
 		auto *surf = new Graphics::ManagedSurface();
 		surf->create(32, 21, _gfx->_texturePixelFormat);
 		surf->fillRect(Common::Rect(0, 0, 32, 21), transparent);
-		decodeMaskedAmigaSprite(file, surf, amigaProgToFile(0x2A07E) + frameIndex * 0x150, 2, 21,
+		decodeMaskedAmigaSprite(file, surf, amigaProgToFile(0x2A07E) - delta + frameIndex * 0x150, 2, 21,
 			kRightMasks, _gfx->_texturePixelFormat, palette);
 		_amigaCompassRightFrames.push_back(surf);
 	}
 }
 
-void DarkEngine::loadAmigaCompass(Common::SeekableReadStream *file, byte *palette) {
+void DarkEngine::loadAmigaCompass(Common::SeekableReadStream *file, byte *palette, int delta) {
 	if (!palette)
 		return;
 
@@ -277,10 +277,10 @@ void DarkEngine::loadAmigaCompass(Common::SeekableReadStream *file, byte *palett
 	Graphics::ManagedSurface base;
 	base.create(32, 5, _gfx->_texturePixelFormat);
 	base.fillRect(Common::Rect(0, 0, 32, 5), transparent);
-	decodeAmigaSprite(file, &base, amigaProgToFile(0x238B4), 2, 5, palette);
+	decodeAmigaSprite(file, &base, amigaProgToFile(0x238B4) - delta, 2, 5, palette);
 
 	_amigaCompassYawFrames.clear();
-	file->seek(amigaProgToFile(0x234CC));
+	file->seek(amigaProgToFile(0x234CC) - delta);
 	uint32 cursorMaskBase = file->readUint32BE();
 	for (int pos = 0; pos < 72; pos++) {
 		auto *surf = new Graphics::ManagedSurface();
@@ -288,7 +288,7 @@ void DarkEngine::loadAmigaCompass(Common::SeekableReadStream *file, byte *palett
 		surf->fillRect(Common::Rect(0, 0, 32, 5), transparent);
 		surf->copyRectToSurface(base, 0, 0, Common::Rect(base.w, base.h));
 
-		int rowOffset = amigaProgToFile(0x234D0) + ((pos >> 3) & 0xFFFE);
+		int rowOffset = amigaProgToFile(0x234D0) - delta + ((pos >> 3) & 0xFFFE);
 		int shift = pos & 0xF;
 		for (int row = 0; row < 5; row++) {
 			file->seek(rowOffset + row * 14);
@@ -310,18 +310,18 @@ void DarkEngine::loadAmigaCompass(Common::SeekableReadStream *file, byte *palett
 	_amigaCompassPitchMarker = new Graphics::ManagedSurface();
 	_amigaCompassPitchMarker->create(16, 9, _gfx->_texturePixelFormat);
 	_amigaCompassPitchMarker->fillRect(Common::Rect(0, 0, 16, 9), transparent);
-	decodeAmigaSprite(file, _amigaCompassPitchMarker, amigaProgToFile(0x27AC6), 1, 9, palette);
+	decodeAmigaSprite(file, _amigaCompassPitchMarker, amigaProgToFile(0x27AC6) - delta, 1, 9, palette);
 }
 
-void DarkEngine::loadJetpackRawFrames(Common::SeekableReadStream *file) {
+void DarkEngine::loadJetpackRawFrames(Common::SeekableReadStream *file, int delta) {
 	// The executable stream still includes the 0x1C-byte GEMDOS header, so the
 	// original program addresses need to be converted back to file offsets here.
 	// Original Amiga layout:
 	// - transition strip at prog 0x23B9E, 9 frames, stride 0x160
 	// - crouch frame at prog 0x2481E
-	const int kTransitionBaseOffset = 0x23B9E + kAmigaGemdosHeaderSize;
+	const int kTransitionBaseOffset = 0x23B9E + kAmigaGemdosHeaderSize - delta;
 	const int kTransitionFrameCount = 9;
-	const int kCrouchFrameOffset = 0x2481E + kAmigaGemdosHeaderSize;
+	const int kCrouchFrameOffset = 0x2481E + kAmigaGemdosHeaderSize - delta;
 	const int kFrameSize = 0x160; // 2 word columns * 22 rows * 8 bytes/row
 	_jetpackTransitionFrames.clear();
 	for (int i = 0; i < kTransitionFrameCount; i++) {
diff --git a/engines/freescape/games/dark/atari.cpp b/engines/freescape/games/dark/atari.cpp
index c9be75d5429..3ed35c03197 100644
--- a/engines/freescape/games/dark/atari.cpp
+++ b/engines/freescape/games/dark/atari.cpp
@@ -238,6 +238,12 @@ void DarkEngine::loadAssetsAtariFullGame() {
 			kDarkSideAtariOffsets);
 	}
 
+	byte *palette = getPaletteFromNeoImage(stream, 0xd710);
+	loadAmigaCompass(stream, palette, kAtariSpriteDelta);
+	loadAmigaIndicatorSprites(stream, palette, kAtariSpriteDelta);
+	loadJetpackRawFrames(stream, kAtariSpriteDelta);
+	free(palette);
+
 	for (auto &area : _areaMap) {
 		// Center and pad each area name so we do not have to do it at each frame
 		area._value->_name = centerAndPadString(area._value->_name, 26);
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index 38575e68cf1..bc19f84b180 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -123,6 +123,10 @@ public:
 	// 4-plane bitplane data. The executable drives those frames through a tiny
 	// fixed color ramp, so the renderer keeps the raw planes and applies a
 	// hardcoded palette at draw time.
+	// The Atari ST release carries byte-identical sprite data, $E052 below the
+	// Amiga addresses, so one set of loaders serves both.
+	static const int kAtariSpriteDelta = 0xE052;
+
 	Common::Array<Common::Array<byte>> _jetpackTransitionFrames;
 	Common::Array<byte> _jetpackCrouchFrame;
 	Common::Array<Graphics::ManagedSurface *> _amigaCompassYawFrames;
@@ -138,9 +142,9 @@ public:
 	int _jetpackIndicatorTransitionFrame;
 	int _jetpackIndicatorTransitionDirection;
 	uint32 _jetpackIndicatorNextFrameMillis;
-	void loadJetpackRawFrames(Common::SeekableReadStream *file);
-	void loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byte *palette);
-	void loadAmigaCompass(Common::SeekableReadStream *file, byte *palette);
+	void loadJetpackRawFrames(Common::SeekableReadStream *file, int delta);
+	void loadAmigaIndicatorSprites(Common::SeekableReadStream *file, byte *palette, int delta);
+	void loadAmigaCompass(Common::SeekableReadStream *file, byte *palette, int delta);
 	void drawAmigaCompass(Graphics::Surface *surface);
 	void drawAmigaAmbientIndicators(Graphics::Surface *surface);
 	void drawJetpackIndicator(Graphics::Surface *surface);


Commit: cede8369599d12e1a9a2b13b06861becf6d4af33
    https://github.com/scummvm/scummvm/commit/cede8369599d12e1a9a2b13b06861becf6d4af33
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: implemented dark opl music for DOS

Changed paths:
  A engines/freescape/games/dark/dark.musicdata.h
  A engines/freescape/games/dark/opl.music.cpp
  A engines/freescape/games/dark/opl.music.h
    engines/freescape/detection.cpp
    engines/freescape/games/dark/c64.music.cpp
    engines/freescape/games/dark/dark.cpp
    engines/freescape/games/dark/dos.cpp
    engines/freescape/module.mk


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index deb59b02077..319ba31b9df 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -458,7 +458,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
+		GUIO5(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN, GAMEOPTION_OPL_MUSIC)
 	},
 	{
 		"darkside",
@@ -473,7 +473,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
+		GUIO5(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN, GAMEOPTION_OPL_MUSIC)
 	},
 	{
 		"darkside",
@@ -488,7 +488,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
+		GUIO5(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN, GAMEOPTION_OPL_MUSIC)
 	},
 	{
 		"darkside",
diff --git a/engines/freescape/games/dark/c64.music.cpp b/engines/freescape/games/dark/c64.music.cpp
index cfc1b81ceeb..49cf61a91a3 100644
--- a/engines/freescape/games/dark/c64.music.cpp
+++ b/engines/freescape/games/dark/c64.music.cpp
@@ -23,6 +23,9 @@
 #include "common/endian.h"
 #include "common/textconsole.h"
 #include "freescape/games/dark/c64.music.h"
+#include "freescape/games/dark/dark.musicdata.h"
+
+using namespace Freescape::DarkMusicData;
 
 namespace Freescape {
 
@@ -30,148 +33,8 @@ namespace Freescape {
 // Data tables extracted from darkside.prg (load address $0400)
 // ============================================================
 
-// Frequency table: hi bytes at $0F38, lo bytes at $0F97 (95 entries each)
-// Index 0 = rest (freq 0), indices 1-94 = notes spanning 8 octaves
-const uint8 kFreqHi[96] = {
-	0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02,
-	0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x06, 0x06,
-	0x06, 0x07, 0x07, 0x08, 0x08, 0x09, 0x09, 0x0A, 0x0A, 0x0B, 0x0C, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
-	0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1B, 0x1C, 0x1E, 0x20, 0x22, 0x24, 0x26, 0x28,
-	0x2B, 0x2D, 0x30, 0x33, 0x36, 0x39, 0x3D, 0x40, 0x44, 0x48, 0x4C, 0x51, 0x56, 0x5B, 0x60, 0x66,
-	0x6C, 0x73, 0x7A, 0x81, 0x89, 0x91, 0x99, 0xA3, 0xAC, 0xB7, 0xC1, 0xCD, 0xD9, 0xE6, 0xF4,
-	0x00 // safety padding
-};
-
-const uint8 kFreqLo[96] = {
-	0x00, 0x23, 0x34, 0x46, 0x5A, 0x6E, 0x84, 0x9B, 0xB3, 0xCD, 0xE9, 0x06, 0x25, 0x45, 0x68, 0x8C,
-	0xB3, 0xDC, 0x08, 0x36, 0x67, 0x9B, 0xD2, 0x0C, 0x49, 0x8B, 0xD0, 0x19, 0x67, 0xB9, 0x10, 0x6C,
-	0xCE, 0x35, 0xA3, 0x17, 0x93, 0x15, 0x9F, 0x3C, 0xCD, 0x72, 0x20, 0xD8, 0x9C, 0x6B, 0x46, 0x2F,
-	0x25, 0x2A, 0x3F, 0x64, 0x9A, 0xE3, 0x3F, 0xB1, 0x38, 0xD6, 0x8D, 0x5E, 0x4B, 0x55, 0x7E, 0xC8,
-	0x34, 0xC6, 0x7F, 0x61, 0x6F, 0xAC, 0x7E, 0xBC, 0x95, 0xA9, 0xFC, 0xA1, 0x69, 0x8C, 0xFE, 0xC2,
-	0xDF, 0x58, 0x34, 0x78, 0x2B, 0x53, 0xF7, 0x1F, 0xD2, 0x19, 0xFC, 0x85, 0xBD, 0xB0, 0x67,
-	0x00 // safety padding
-};
-
-// Instrument table at $1010 (18 instruments x 8 bytes)
-// Bytes: ctrl, AD, SR, initPW, vib/env mode, pwMod, autoFx, flags
-// Instruments 16-17 are stored between $1090-$109F (past the nominal 16-entry table)
-const uint8 kInstruments[18 * 8] = {
-	0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0: Pulse (silence)
-	0x41, 0x0C, 0xDC, 0x02, 0x00, 0x33, 0x00, 0x08, // 1: Pulse+G
-	0x41, 0xDC, 0xDD, 0x00, 0x00, 0x15, 0x00, 0x80, // 2: Pulse+G
-	0x41, 0x1A, 0x04, 0x00, 0x00, 0x05, 0x00, 0x04, // 3: Pulse+G
-	0x11, 0x32, 0x3A, 0x00, 0x00, 0x11, 0x00, 0x80, // 4: Triangle+G
-	0x41, 0x0B, 0xAC, 0x20, 0x00, 0x01, 0x00, 0x04, // 5: Pulse+G
-	0x41, 0x0B, 0x6C, 0x40, 0x33, 0x10, 0x00, 0x00, // 6: Pulse+G
-	0x45, 0x0A, 0x8B, 0x88, 0x46, 0x13, 0x00, 0x80, // 7: Pulse+Ring+G
-	0x41, 0x16, 0x00, 0x60, 0x00, 0x17, 0x00, 0x80, // 8: Pulse+G
-	0x21, 0x08, 0x17, 0x00, 0x00, 0x00, 0x80, 0x00, // 9: Sawtooth+G
-	0x15, 0xCC, 0xDC, 0x40, 0x00, 0x11, 0x00, 0x80, // 10: Tri+Ring+G
-	0x81, 0x42, 0x38, 0x10, 0x00, 0x01, 0x03, 0x02, // 11: Noise+G
-	0x41, 0x8B, 0xAF, 0x80, 0x00, 0x00, 0x00, 0x04, // 12: Pulse+G
-	0x41, 0x1B, 0x4A, 0x14, 0x53, 0x02, 0x00, 0x00, // 13: Pulse+G
-	0x81, 0x04, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, // 14: Noise+G
-	0x41, 0x0B, 0x6C, 0x62, 0x62, 0x42, 0x00, 0x00, // 15: Pulse+G
-	0x41, 0x0C, 0x00, 0x88, 0x11, 0x00, 0x00, 0x01, // 16: Pulse+G (percussion, envelope seq)
-	0x81, 0x0B, 0x20, 0x66, 0x00, 0x00, 0x00, 0x01, // 17: Noise+G (percussion, envelope seq)
-};
-
-// Auxiliary envelope data tables ($154B and $156B, 16 entries each)
-const uint8 kEnvData[2][16] = {
-	{ 0xFA, 0x01, 0xFF, 0x20, 0x0A, 0x12, 0x04, 0x16, 0x0E, 0x0C, 0x0A, 0x08, 0x06, 0x04, 0x02, 0x00 },
-	{ 0x10, 0x0A, 0x06, 0x00, 0x04, 0x00, 0x00, 0x00, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00 },
-};
-
-// Envelope waveform control tables ($155B and $157B, 16 entries each)
-const uint8 kEnvControl[2][16] = {
-	{ 0x81, 0x41, 0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10 },
-	{ 0x81, 0x41, 0x81, 0x80, 0x40, 0x40, 0x40, 0x40, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10 },
-};
-
-// Arpeggio semitone intervals ($1591)
-const uint8 kArpIntervals[8] = { 3, 4, 5, 7, 8, 9, 10, 12 };
-
-// SID register base offset per channel
 const int kSIDOffset[3] = { 0, 7, 14 };
 
-// ---- Pattern data (29 patterns from $122D-$1542) ----
-
-const uint8 kPattern00[] = { 0xC0, 0xBF, 0x00, 0xFF };
-const uint8 kPattern01[] = { 0xF5, 0xC2, 0xBF, 0x10, 0x10, 0xFF };
-const uint8 kPattern02[] = { 0xC1, 0x8F, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0xFF };
-const uint8 kPattern03[] = { 0xC4, 0x80, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0xFF };
-const uint8 kPattern04[] = { 0xC6, 0x80, 0x7A, 0x08, 0x34, 0x36, 0x9D, 0x37, 0xFF };
-const uint8 kPattern05[] = { 0x81, 0x34, 0x36, 0x34, 0x2F, 0x32, 0x30, 0x2B, 0x91, 0x2F, 0xFF };
-const uint8 kPattern06[] = { 0x83, 0x34, 0x37, 0x34, 0x8F, 0x3C, 0x83, 0x3B, 0x8F, 0x3A, 0x8F, 0x36, 0xFF };
-const uint8 kPattern07[] = { 0xC1, 0x81, 0x10, 0x10, 0x12, 0x10, 0x13, 0x10, 0x15, 0x13, 0xFF };
-const uint8 kPattern08[] = { 0xC5, 0x9F, 0x7D, 0x89, 0x40, 0x7D, 0x91, 0x40, 0x7D, 0xA1, 0x40, 0x7D, 0x91, 0x40, 0xFF };
-const uint8 kPattern09[] = { 0xC0, 0x9F, 0x00, 0xFF };
-const uint8 kPattern10[] = { 0xCA, 0x9F, 0x7E, 0x0A, 0x7F, 0x4C, 0x83, 0x1C, 0x23, 0x28, 0x2F, 0x1C, 0x23, 0x28, 0x2F, 0x8F, 0x7F, 0x40, 0x7E, 0x10, 0x9F, 0x7B, 0x40, 0x3B, 0x21, 0x8F, 0x34, 0x40, 0x9F, 0x7B, 0x0D, 0x3B, 0x4C, 0x7F, 0x3C, 0xFF };
-const uint8 kPattern11[] = { 0xC7, 0xFF };
-const uint8 kPattern12[] = { 0xC2, 0xBF, 0x10, 0xFF };
-const uint8 kPattern13[] = { 0xC8, 0x80, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0xFF };
-const uint8 kPattern14[] = { 0x80, 0x28, 0x34, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0x28, 0x34, 0x34, 0x28, 0x28, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0x34, 0x28, 0x28, 0x34, 0x28, 0x28, 0x34, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0xFF };
-const uint8 kPattern15[] = { 0xCB, 0x81, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0xFF };
-const uint8 kPattern16[] = { 0xC1, 0x87, 0x10, 0x1C, 0x10, 0x1C, 0x10, 0x1C, 0x10, 0x83, 0x1C, 0x81, 0x10, 0x0E, 0x87, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x83, 0x17, 0x81, 0x0B, 0x0A, 0x87, 0x09, 0x15, 0x09, 0x15, 0x09, 0x15, 0x09, 0x83, 0x15, 0x81, 0x09, 0x0A, 0x87, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x83, 0x17, 0x81, 0x0B, 0x0E, 0xFF };
-const uint8 kPattern17[] = { 0xCC, 0xBF, 0x7D, 0x89, 0x40, 0x7C, 0xA2, 0x3E, 0x3C, 0x7D, 0x91, 0x3F, 0xFF };
-const uint8 kPattern18[] = { 0x83, 0x1C, 0xFF };
-const uint8 kPattern19[] = { 0xC0, 0x93, 0x00, 0xC1, 0x81, 0x23, 0x83, 0x21, 0x81, 0x1F, 0x83, 0x1E, 0xFF };
-const uint8 kPattern20[] = { 0xC1, 0x8B, 0x1C, 0x83, 0x23, 0x97, 0x23, 0x83, 0x21, 0x23, 0x21, 0x1F, 0x1E, 0x1F, 0x8B, 0x1E, 0x83, 0x23, 0x9B, 0x23, 0x81, 0x21, 0x23, 0x83, 0x21, 0x1F, 0x1E, 0x1F, 0x8B, 0x21, 0x83, 0x23, 0x93, 0x21, 0x83, 0x21, 0x23, 0x24, 0x26, 0x24, 0x23, 0x21, 0x9B, 0x1E, 0x83, 0x23, 0x93, 0x23, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xD1, 0x30, 0x30, 0xD0, 0x7A, 0x18, 0x12, 0xFF };
-const uint8 kPattern21[] = { 0xC8, 0xFF };
-const uint8 kPattern22[] = { 0xC4, 0xFF };
-const uint8 kPattern23[] = { 0xCF, 0xA3, 0x7B, 0x37, 0x02, 0x36, 0x83, 0x2F, 0x34, 0x36, 0x7B, 0x39, 0x02, 0x37, 0x37, 0x36, 0x34, 0x8B, 0x7B, 0x36, 0x02, 0x34, 0x83, 0x2F, 0x93, 0x2F, 0x83, 0x36, 0x36, 0x37, 0x7B, 0x39, 0x02, 0x37, 0x37, 0x36, 0x7B, 0x34, 0x04, 0x37, 0x8B, 0x34, 0x81, 0x36, 0x37, 0x93, 0x34, 0x83, 0x34, 0x36, 0x37, 0x39, 0x37, 0x36, 0x34, 0xA3, 0x7B, 0x36, 0x04, 0x34, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xC0, 0x85, 0x00, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xC0, 0x83, 0x00, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xFF };
-const uint8 kPattern24[] = { 0xC1, 0x87, 0x10, 0x85, 0x1C, 0x81, 0x10, 0x83, 0x10, 0x10, 0x81, 0x1C, 0x83, 0x10, 0x81, 0x1C, 0x87, 0x10, 0x85, 0x1C, 0x81, 0x10, 0x83, 0x10, 0x10, 0x81, 0x1C, 0x83, 0x10, 0x81, 0x1C, 0xFF };
-const uint8 kPattern25[] = { 0xCF, 0x99, 0x7B, 0x34, 0x04, 0x32, 0x85, 0x7B, 0x32, 0x02, 0x34, 0x93, 0x7B, 0x34, 0x04, 0x32, 0x83, 0x7B, 0x37, 0x02, 0x36, 0x81, 0x37, 0x85, 0x7B, 0x32, 0x02, 0x34, 0x93, 0x32, 0x83, 0x32, 0x36, 0x7B, 0x36, 0x02, 0x37, 0x9B, 0x36, 0x83, 0x7B, 0x30, 0x03, 0x32, 0x30, 0x32, 0x81, 0x30, 0x85, 0x7B, 0x30, 0x02, 0x32, 0x93, 0x30, 0x83, 0x30, 0x32, 0x34, 0x8B, 0x7B, 0x36, 0x04, 0x37, 0x83, 0x34, 0x97, 0x7B, 0x36, 0x04, 0x34, 0x81, 0x36, 0x37, 0x36, 0x34, 0x99, 0x7B, 0x36, 0x04, 0x34, 0xD1, 0x81, 0x12, 0x83, 0x12, 0xFF };
-const uint8 kPattern26[] = { 0xCC, 0xBF, 0x7D, 0x8A, 0x40, 0x7D, 0x91, 0x3F, 0x7D, 0x94, 0x3D, 0x7D, 0x91, 0x3F, 0xFF };
-const uint8 kPattern27[] = { 0xCF, 0x83, 0x2F, 0x34, 0x91, 0x34, 0x85, 0x7B, 0x34, 0x12, 0x36, 0x2F, 0x81, 0x34, 0x34, 0x36, 0x8D, 0x34, 0x85, 0x7B, 0x33, 0x12, 0x34, 0x93, 0x33, 0x83, 0x33, 0x81, 0x33, 0x85, 0x7B, 0x33, 0x12, 0x34, 0x99, 0x33, 0x81, 0x2F, 0x83, 0x7B, 0x2D, 0x12, 0x2F, 0x8B, 0x2D, 0x83, 0x34, 0x34, 0x8B, 0x7B, 0x37, 0x22, 0x36, 0x93, 0x37, 0x87, 0x7B, 0x34, 0x04, 0x36, 0x83, 0x34, 0xA1, 0x7B, 0x36, 0x12, 0x34, 0xD1, 0x81, 0x1C, 0x8D, 0x1C, 0x81, 0x1C, 0x83, 0x1C, 0x1C, 0x81, 0x1C, 0x1C, 0xFF };
-const uint8 kPattern28[] = { 0xCF, 0x80, 0x7A, 0x08, 0x34, 0x36, 0x9D, 0x37, 0xD0, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0xCF, 0x80, 0x32, 0x34, 0x9D, 0x36, 0xD0, 0x81, 0x12, 0x83, 0x12, 0x12, 0x81, 0x0E, 0x83, 0x12, 0x1C, 0x85, 0x12, 0x81, 0x0E, 0x83, 0x12, 0xCF, 0x80, 0x30, 0x32, 0x9D, 0x34, 0xD1, 0x83, 0x1C, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x1C, 0xCF, 0x87, 0x30, 0x80, 0x33, 0x34, 0x9D, 0x36, 0xC0, 0x9B, 0x00, 0xD1, 0x81, 0x18, 0x18, 0xFF };
-
-const uint8 *const kPatterns[29] = {
-	kPattern00, kPattern01, kPattern02, kPattern03, kPattern04,
-	kPattern05, kPattern06, kPattern07, kPattern08, kPattern09,
-	kPattern10, kPattern11, kPattern12, kPattern13, kPattern14,
-	kPattern15, kPattern16, kPattern17, kPattern18, kPattern19,
-	kPattern20, kPattern21, kPattern22, kPattern23, kPattern24,
-	kPattern25, kPattern26, kPattern27, kPattern28,
-};
-
-// ---- Order lists (song 0, 3 channels) ----
-
-const uint8 kOrderList0[] = {
-	0xC2, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
-	0x02, 0x02, 0x02, 0x02, 0x01, 0x10, 0x10, 0x10, 0x10, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9,
-	0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9, 0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18,
-	0xC9, 0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9, 0x18, 0xC2, 0x18, 0x18, 0x18, 0x18,
-	0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x10, 0x10, 0x01,
-	0x01, 0xFF
-};
-
-const uint8 kOrderList1[] = {
-	0xCE, 0x09, 0x01, 0x0C, 0x09, 0x01, 0x01, 0x01, 0x09, 0x01, 0x01, 0x09, 0x00, 0x03, 0x03,
-	0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xC2,
-	0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
-	0x03, 0x08, 0x08, 0x02, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1A, 0x1A, 0x08, 0x08, 0xB6,
-	0x08, 0x08, 0xC2, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
-	0x03, 0x03, 0x03, 0x03, 0xCE, 0x0C, 0xC9, 0x0C, 0xC7, 0x0C, 0xC9, 0x0C, 0xC2, 0x16, 0x0E,
-	0x0E, 0xBD, 0x0E, 0x0E, 0xBB, 0x0E, 0x0E, 0xBD, 0x0E, 0x0E, 0xCE, 0x09, 0x01, 0x0C, 0x09,
-	0xFF
-};
-
-const uint8 kOrderList2[] = {
-	0xC2, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x09, 0x04, 0x05, 0x09, 0x04, 0x06, 0x09, 0x08, 0x08,
-	0x08, 0x08, 0x0B, 0x05, 0x06, 0x09, 0xAA, 0x04, 0xC2, 0x09, 0xB6, 0x04, 0xC2, 0x09, 0x0D,
-	0x09, 0xC4, 0x0D, 0xC2, 0x09, 0xC5, 0x0D, 0xC2, 0x09, 0xC7, 0x0D, 0xC2, 0x09, 0xCE, 0x0D,
-	0x12, 0xC0, 0x0F, 0xD0, 0x0D, 0x12, 0xC0, 0x0F, 0xD1, 0x0D, 0x12, 0xC0, 0x0F, 0xD3, 0x0D,
-	0x12, 0xC0, 0x0F, 0xB6, 0x15, 0x0E, 0x0E, 0x15, 0x0E, 0x0E, 0xC2, 0x15, 0x0E, 0x0E, 0x16,
-	0x0E, 0x0E, 0x09, 0xCE, 0x0C, 0xC2, 0x13, 0x14, 0x14, 0x17, 0xCE, 0x17, 0xC2, 0x19, 0xCE,
-	0x19, 0xC2, 0x1B, 0xB6, 0x1B, 0xCE, 0x01, 0xC2, 0x01, 0xCE, 0x03, 0x03, 0x03, 0x03, 0xC2,
-	0x03, 0x03, 0x03, 0x03, 0x0B, 0x05, 0x06, 0x09, 0xAA, 0x04, 0xC2, 0x09, 0xB6, 0x04, 0xC2,
-	0x09, 0x0D, 0x12, 0x0F, 0xC4, 0x0D, 0x12, 0xC2, 0x0F, 0xC5, 0x0D, 0x12, 0xC2, 0x0F, 0xC7,
-	0x0D, 0x12, 0xC2, 0x0F, 0x15, 0x0E, 0x0E, 0xBD, 0x0E, 0x0E, 0xBB, 0x0E, 0x0E, 0xBD, 0x0E,
-	0x0E, 0xC2, 0x1C, 0x02, 0x02, 0xFF
-};
-
-const uint8 *const kOrderLists[3] = { kOrderList0, kOrderList1, kOrderList2 };
 
 // ============================================================
 // Implementation
@@ -247,7 +110,7 @@ void DarkSideC64MusicPlayer::initSID() {
 		warning("DarkSideC64MusicPlayer: Failed to create SID emulator");
 		return;
 	}
-	_sid->start(new Common::Functor0Mem<void, DarkSideC64MusicPlayer>(this, &DarkSideC64MusicPlayer::onTimer), 50);
+	_sid->start(new Common::Functor0Mem<void, DarkSideC64MusicPlayer>(this, &DarkSideC64MusicPlayer::onTimer), 60);
 }
 
 void DarkSideC64MusicPlayer::sidWrite(int reg, uint8 data) {
diff --git a/engines/freescape/games/dark/dark.cpp b/engines/freescape/games/dark/dark.cpp
index ab186fd3c8e..3132718f8c7 100644
--- a/engines/freescape/games/dark/dark.cpp
+++ b/engines/freescape/games/dark/dark.cpp
@@ -353,7 +353,7 @@ void DarkEngine::initGameState() {
 		}
 	}
 
-	if ((isC64() || isAtariST()) && _playerMusic)
+	if ((isC64() || isAtariST() || isDOS()) && _playerMusic)
 		_playerMusic->startMusic();
 }
 
diff --git a/engines/freescape/games/dark/dark.musicdata.h b/engines/freescape/games/dark/dark.musicdata.h
new file mode 100644
index 00000000000..3e9b0f472b8
--- /dev/null
+++ b/engines/freescape/games/dark/dark.musicdata.h
@@ -0,0 +1,178 @@
+/* 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 FREESCAPE_DARK_MUSICDATA_H
+#define FREESCAPE_DARK_MUSICDATA_H
+
+/**
+ * Dark Side song data, extracted from the C64 build (darkside.prg, load
+ * address $0400). Shared by the SID player and the AdLib rendition; the
+ * patterns are byte-identical to the ones in the Atari ST and Amiga modules.
+ */
+
+namespace Freescape {
+namespace DarkMusicData {
+
+// Frequency table: hi bytes at $0F38, lo bytes at $0F97 (95 entries each)
+// Index 0 = rest (freq 0), indices 1-94 = notes spanning 8 octaves
+const uint8 kFreqHi[96] = {
+	0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02,
+	0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, 0x06, 0x06,
+	0x06, 0x07, 0x07, 0x08, 0x08, 0x09, 0x09, 0x0A, 0x0A, 0x0B, 0x0C, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
+	0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1B, 0x1C, 0x1E, 0x20, 0x22, 0x24, 0x26, 0x28,
+	0x2B, 0x2D, 0x30, 0x33, 0x36, 0x39, 0x3D, 0x40, 0x44, 0x48, 0x4C, 0x51, 0x56, 0x5B, 0x60, 0x66,
+	0x6C, 0x73, 0x7A, 0x81, 0x89, 0x91, 0x99, 0xA3, 0xAC, 0xB7, 0xC1, 0xCD, 0xD9, 0xE6, 0xF4,
+	0x00 // safety padding
+};
+
+const uint8 kFreqLo[96] = {
+	0x00, 0x23, 0x34, 0x46, 0x5A, 0x6E, 0x84, 0x9B, 0xB3, 0xCD, 0xE9, 0x06, 0x25, 0x45, 0x68, 0x8C,
+	0xB3, 0xDC, 0x08, 0x36, 0x67, 0x9B, 0xD2, 0x0C, 0x49, 0x8B, 0xD0, 0x19, 0x67, 0xB9, 0x10, 0x6C,
+	0xCE, 0x35, 0xA3, 0x17, 0x93, 0x15, 0x9F, 0x3C, 0xCD, 0x72, 0x20, 0xD8, 0x9C, 0x6B, 0x46, 0x2F,
+	0x25, 0x2A, 0x3F, 0x64, 0x9A, 0xE3, 0x3F, 0xB1, 0x38, 0xD6, 0x8D, 0x5E, 0x4B, 0x55, 0x7E, 0xC8,
+	0x34, 0xC6, 0x7F, 0x61, 0x6F, 0xAC, 0x7E, 0xBC, 0x95, 0xA9, 0xFC, 0xA1, 0x69, 0x8C, 0xFE, 0xC2,
+	0xDF, 0x58, 0x34, 0x78, 0x2B, 0x53, 0xF7, 0x1F, 0xD2, 0x19, 0xFC, 0x85, 0xBD, 0xB0, 0x67,
+	0x00 // safety padding
+};
+
+// Instrument table at $1010 (18 instruments x 8 bytes)
+// Bytes: ctrl, AD, SR, initPW, vib/env mode, pwMod, autoFx, flags
+// Instruments 16-17 are stored between $1090-$109F (past the nominal 16-entry table)
+const uint8 kInstruments[18 * 8] = {
+	0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0: Pulse (silence)
+	0x41, 0x0C, 0xDC, 0x02, 0x00, 0x33, 0x00, 0x08, // 1: Pulse+G
+	0x41, 0xDC, 0xDD, 0x00, 0x00, 0x15, 0x00, 0x80, // 2: Pulse+G
+	0x41, 0x1A, 0x04, 0x00, 0x00, 0x05, 0x00, 0x04, // 3: Pulse+G
+	0x11, 0x32, 0x3A, 0x00, 0x00, 0x11, 0x00, 0x80, // 4: Triangle+G
+	0x41, 0x0B, 0xAC, 0x20, 0x00, 0x01, 0x00, 0x04, // 5: Pulse+G
+	0x41, 0x0B, 0x6C, 0x40, 0x33, 0x10, 0x00, 0x00, // 6: Pulse+G
+	0x45, 0x0A, 0x8B, 0x88, 0x46, 0x13, 0x00, 0x80, // 7: Pulse+Ring+G
+	0x41, 0x16, 0x00, 0x60, 0x00, 0x17, 0x00, 0x80, // 8: Pulse+G
+	0x21, 0x08, 0x17, 0x00, 0x00, 0x00, 0x80, 0x00, // 9: Sawtooth+G
+	0x15, 0xCC, 0xDC, 0x40, 0x00, 0x11, 0x00, 0x80, // 10: Tri+Ring+G
+	0x81, 0x42, 0x38, 0x10, 0x00, 0x01, 0x03, 0x02, // 11: Noise+G
+	0x41, 0x8B, 0xAF, 0x80, 0x00, 0x00, 0x00, 0x04, // 12: Pulse+G
+	0x41, 0x1B, 0x4A, 0x14, 0x53, 0x02, 0x00, 0x00, // 13: Pulse+G
+	0x81, 0x04, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, // 14: Noise+G
+	0x41, 0x0B, 0x6C, 0x62, 0x62, 0x42, 0x00, 0x00, // 15: Pulse+G
+	0x41, 0x0C, 0x00, 0x88, 0x11, 0x00, 0x00, 0x01, // 16: Pulse+G (percussion, envelope seq)
+	0x81, 0x0B, 0x20, 0x66, 0x00, 0x00, 0x00, 0x01, // 17: Noise+G (percussion, envelope seq)
+};
+
+// Auxiliary envelope data tables ($154B and $156B, 16 entries each)
+const uint8 kEnvData[2][16] = {
+	{ 0xFA, 0x01, 0xFF, 0x20, 0x0A, 0x12, 0x04, 0x16, 0x0E, 0x0C, 0x0A, 0x08, 0x06, 0x04, 0x02, 0x00 },
+	{ 0x10, 0x0A, 0x06, 0x00, 0x04, 0x00, 0x00, 0x00, 0x10, 0x10, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00 },
+};
+
+// Envelope waveform control tables ($155B and $157B, 16 entries each)
+const uint8 kEnvControl[2][16] = {
+	{ 0x81, 0x41, 0x81, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10 },
+	{ 0x81, 0x41, 0x81, 0x80, 0x40, 0x40, 0x40, 0x40, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10 },
+};
+
+// Arpeggio semitone intervals ($1591)
+const uint8 kArpIntervals[8] = { 3, 4, 5, 7, 8, 9, 10, 12 };
+
+// SID register base offset per channel
+// ---- Pattern data (29 patterns from $122D-$1542) ----
+
+const uint8 kPattern00[] = { 0xC0, 0xBF, 0x00, 0xFF };
+const uint8 kPattern01[] = { 0xF5, 0xC2, 0xBF, 0x10, 0x10, 0xFF };
+const uint8 kPattern02[] = { 0xC1, 0x8F, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0xFF };
+const uint8 kPattern03[] = { 0xC4, 0x80, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0x34, 0x2F, 0x37, 0x36, 0x2F, 0x37, 0x34, 0x2F, 0xFF };
+const uint8 kPattern04[] = { 0xC6, 0x80, 0x7A, 0x08, 0x34, 0x36, 0x9D, 0x37, 0xFF };
+const uint8 kPattern05[] = { 0x81, 0x34, 0x36, 0x34, 0x2F, 0x32, 0x30, 0x2B, 0x91, 0x2F, 0xFF };
+const uint8 kPattern06[] = { 0x83, 0x34, 0x37, 0x34, 0x8F, 0x3C, 0x83, 0x3B, 0x8F, 0x3A, 0x8F, 0x36, 0xFF };
+const uint8 kPattern07[] = { 0xC1, 0x81, 0x10, 0x10, 0x12, 0x10, 0x13, 0x10, 0x15, 0x13, 0xFF };
+const uint8 kPattern08[] = { 0xC5, 0x9F, 0x7D, 0x89, 0x40, 0x7D, 0x91, 0x40, 0x7D, 0xA1, 0x40, 0x7D, 0x91, 0x40, 0xFF };
+const uint8 kPattern09[] = { 0xC0, 0x9F, 0x00, 0xFF };
+const uint8 kPattern10[] = { 0xCA, 0x9F, 0x7E, 0x0A, 0x7F, 0x4C, 0x83, 0x1C, 0x23, 0x28, 0x2F, 0x1C, 0x23, 0x28, 0x2F, 0x8F, 0x7F, 0x40, 0x7E, 0x10, 0x9F, 0x7B, 0x40, 0x3B, 0x21, 0x8F, 0x34, 0x40, 0x9F, 0x7B, 0x0D, 0x3B, 0x4C, 0x7F, 0x3C, 0xFF };
+const uint8 kPattern11[] = { 0xC7, 0xFF };
+const uint8 kPattern12[] = { 0xC2, 0xBF, 0x10, 0xFF };
+const uint8 kPattern13[] = { 0xC8, 0x80, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0x1C, 0xFF };
+const uint8 kPattern14[] = { 0x80, 0x28, 0x34, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0x28, 0x34, 0x34, 0x28, 0x28, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0x34, 0x28, 0x28, 0x34, 0x28, 0x28, 0x34, 0x34, 0x28, 0x34, 0x28, 0x28, 0x34, 0xFF };
+const uint8 kPattern15[] = { 0xCB, 0x81, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0x50, 0xC0, 0x00, 0xCB, 0x54, 0x52, 0xFF };
+const uint8 kPattern16[] = { 0xC1, 0x87, 0x10, 0x1C, 0x10, 0x1C, 0x10, 0x1C, 0x10, 0x83, 0x1C, 0x81, 0x10, 0x0E, 0x87, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x83, 0x17, 0x81, 0x0B, 0x0A, 0x87, 0x09, 0x15, 0x09, 0x15, 0x09, 0x15, 0x09, 0x83, 0x15, 0x81, 0x09, 0x0A, 0x87, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x17, 0x0B, 0x83, 0x17, 0x81, 0x0B, 0x0E, 0xFF };
+const uint8 kPattern17[] = { 0xCC, 0xBF, 0x7D, 0x89, 0x40, 0x7C, 0xA2, 0x3E, 0x3C, 0x7D, 0x91, 0x3F, 0xFF };
+const uint8 kPattern18[] = { 0x83, 0x1C, 0xFF };
+const uint8 kPattern19[] = { 0xC0, 0x93, 0x00, 0xC1, 0x81, 0x23, 0x83, 0x21, 0x81, 0x1F, 0x83, 0x1E, 0xFF };
+const uint8 kPattern20[] = { 0xC1, 0x8B, 0x1C, 0x83, 0x23, 0x97, 0x23, 0x83, 0x21, 0x23, 0x21, 0x1F, 0x1E, 0x1F, 0x8B, 0x1E, 0x83, 0x23, 0x9B, 0x23, 0x81, 0x21, 0x23, 0x83, 0x21, 0x1F, 0x1E, 0x1F, 0x8B, 0x21, 0x83, 0x23, 0x93, 0x21, 0x83, 0x21, 0x23, 0x24, 0x26, 0x24, 0x23, 0x21, 0x9B, 0x1E, 0x83, 0x23, 0x93, 0x23, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xD1, 0x30, 0x30, 0xD0, 0x7A, 0x18, 0x12, 0xFF };
+const uint8 kPattern21[] = { 0xC8, 0xFF };
+const uint8 kPattern22[] = { 0xC4, 0xFF };
+const uint8 kPattern23[] = { 0xCF, 0xA3, 0x7B, 0x37, 0x02, 0x36, 0x83, 0x2F, 0x34, 0x36, 0x7B, 0x39, 0x02, 0x37, 0x37, 0x36, 0x34, 0x8B, 0x7B, 0x36, 0x02, 0x34, 0x83, 0x2F, 0x93, 0x2F, 0x83, 0x36, 0x36, 0x37, 0x7B, 0x39, 0x02, 0x37, 0x37, 0x36, 0x7B, 0x34, 0x04, 0x37, 0x8B, 0x34, 0x81, 0x36, 0x37, 0x93, 0x34, 0x83, 0x34, 0x36, 0x37, 0x39, 0x37, 0x36, 0x34, 0xA3, 0x7B, 0x36, 0x04, 0x34, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xC0, 0x85, 0x00, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xC0, 0x83, 0x00, 0xD1, 0x81, 0x30, 0x30, 0xD0, 0x12, 0xFF };
+const uint8 kPattern24[] = { 0xC1, 0x87, 0x10, 0x85, 0x1C, 0x81, 0x10, 0x83, 0x10, 0x10, 0x81, 0x1C, 0x83, 0x10, 0x81, 0x1C, 0x87, 0x10, 0x85, 0x1C, 0x81, 0x10, 0x83, 0x10, 0x10, 0x81, 0x1C, 0x83, 0x10, 0x81, 0x1C, 0xFF };
+const uint8 kPattern25[] = { 0xCF, 0x99, 0x7B, 0x34, 0x04, 0x32, 0x85, 0x7B, 0x32, 0x02, 0x34, 0x93, 0x7B, 0x34, 0x04, 0x32, 0x83, 0x7B, 0x37, 0x02, 0x36, 0x81, 0x37, 0x85, 0x7B, 0x32, 0x02, 0x34, 0x93, 0x32, 0x83, 0x32, 0x36, 0x7B, 0x36, 0x02, 0x37, 0x9B, 0x36, 0x83, 0x7B, 0x30, 0x03, 0x32, 0x30, 0x32, 0x81, 0x30, 0x85, 0x7B, 0x30, 0x02, 0x32, 0x93, 0x30, 0x83, 0x30, 0x32, 0x34, 0x8B, 0x7B, 0x36, 0x04, 0x37, 0x83, 0x34, 0x97, 0x7B, 0x36, 0x04, 0x34, 0x81, 0x36, 0x37, 0x36, 0x34, 0x99, 0x7B, 0x36, 0x04, 0x34, 0xD1, 0x81, 0x12, 0x83, 0x12, 0xFF };
+const uint8 kPattern26[] = { 0xCC, 0xBF, 0x7D, 0x8A, 0x40, 0x7D, 0x91, 0x3F, 0x7D, 0x94, 0x3D, 0x7D, 0x91, 0x3F, 0xFF };
+const uint8 kPattern27[] = { 0xCF, 0x83, 0x2F, 0x34, 0x91, 0x34, 0x85, 0x7B, 0x34, 0x12, 0x36, 0x2F, 0x81, 0x34, 0x34, 0x36, 0x8D, 0x34, 0x85, 0x7B, 0x33, 0x12, 0x34, 0x93, 0x33, 0x83, 0x33, 0x81, 0x33, 0x85, 0x7B, 0x33, 0x12, 0x34, 0x99, 0x33, 0x81, 0x2F, 0x83, 0x7B, 0x2D, 0x12, 0x2F, 0x8B, 0x2D, 0x83, 0x34, 0x34, 0x8B, 0x7B, 0x37, 0x22, 0x36, 0x93, 0x37, 0x87, 0x7B, 0x34, 0x04, 0x36, 0x83, 0x34, 0xA1, 0x7B, 0x36, 0x12, 0x34, 0xD1, 0x81, 0x1C, 0x8D, 0x1C, 0x81, 0x1C, 0x83, 0x1C, 0x1C, 0x81, 0x1C, 0x1C, 0xFF };
+const uint8 kPattern28[] = { 0xCF, 0x80, 0x7A, 0x08, 0x34, 0x36, 0x9D, 0x37, 0xD0, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0xCF, 0x80, 0x32, 0x34, 0x9D, 0x36, 0xD0, 0x81, 0x12, 0x83, 0x12, 0x12, 0x81, 0x0E, 0x83, 0x12, 0x1C, 0x85, 0x12, 0x81, 0x0E, 0x83, 0x12, 0xCF, 0x80, 0x30, 0x32, 0x9D, 0x34, 0xD1, 0x83, 0x1C, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x83, 0x1C, 0x81, 0x18, 0x1C, 0xCF, 0x87, 0x30, 0x80, 0x33, 0x34, 0x9D, 0x36, 0xC0, 0x9B, 0x00, 0xD1, 0x81, 0x18, 0x18, 0xFF };
+
+const uint8 *const kPatterns[29] = {
+	kPattern00, kPattern01, kPattern02, kPattern03, kPattern04,
+	kPattern05, kPattern06, kPattern07, kPattern08, kPattern09,
+	kPattern10, kPattern11, kPattern12, kPattern13, kPattern14,
+	kPattern15, kPattern16, kPattern17, kPattern18, kPattern19,
+	kPattern20, kPattern21, kPattern22, kPattern23, kPattern24,
+	kPattern25, kPattern26, kPattern27, kPattern28,
+};
+
+// ---- Order lists (song 0, 3 channels) ----
+
+const uint8 kOrderList0[] = {
+	0xC2, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+	0x02, 0x02, 0x02, 0x02, 0x01, 0x10, 0x10, 0x10, 0x10, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9,
+	0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9, 0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18,
+	0xC9, 0x18, 0xC2, 0x18, 0xC9, 0x18, 0xC7, 0x18, 0xC9, 0x18, 0xC2, 0x18, 0x18, 0x18, 0x18,
+	0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x10, 0x10, 0x01,
+	0x01, 0xFF
+};
+
+const uint8 kOrderList1[] = {
+	0xCE, 0x09, 0x01, 0x0C, 0x09, 0x01, 0x01, 0x01, 0x09, 0x01, 0x01, 0x09, 0x00, 0x03, 0x03,
+	0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xC2,
+	0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
+	0x03, 0x08, 0x08, 0x02, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1A, 0x1A, 0x08, 0x08, 0xB6,
+	0x08, 0x08, 0xC2, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
+	0x03, 0x03, 0x03, 0x03, 0xCE, 0x0C, 0xC9, 0x0C, 0xC7, 0x0C, 0xC9, 0x0C, 0xC2, 0x16, 0x0E,
+	0x0E, 0xBD, 0x0E, 0x0E, 0xBB, 0x0E, 0x0E, 0xBD, 0x0E, 0x0E, 0xCE, 0x09, 0x01, 0x0C, 0x09,
+	0xFF
+};
+
+const uint8 kOrderList2[] = {
+	0xC2, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x09, 0x04, 0x05, 0x09, 0x04, 0x06, 0x09, 0x08, 0x08,
+	0x08, 0x08, 0x0B, 0x05, 0x06, 0x09, 0xAA, 0x04, 0xC2, 0x09, 0xB6, 0x04, 0xC2, 0x09, 0x0D,
+	0x09, 0xC4, 0x0D, 0xC2, 0x09, 0xC5, 0x0D, 0xC2, 0x09, 0xC7, 0x0D, 0xC2, 0x09, 0xCE, 0x0D,
+	0x12, 0xC0, 0x0F, 0xD0, 0x0D, 0x12, 0xC0, 0x0F, 0xD1, 0x0D, 0x12, 0xC0, 0x0F, 0xD3, 0x0D,
+	0x12, 0xC0, 0x0F, 0xB6, 0x15, 0x0E, 0x0E, 0x15, 0x0E, 0x0E, 0xC2, 0x15, 0x0E, 0x0E, 0x16,
+	0x0E, 0x0E, 0x09, 0xCE, 0x0C, 0xC2, 0x13, 0x14, 0x14, 0x17, 0xCE, 0x17, 0xC2, 0x19, 0xCE,
+	0x19, 0xC2, 0x1B, 0xB6, 0x1B, 0xCE, 0x01, 0xC2, 0x01, 0xCE, 0x03, 0x03, 0x03, 0x03, 0xC2,
+	0x03, 0x03, 0x03, 0x03, 0x0B, 0x05, 0x06, 0x09, 0xAA, 0x04, 0xC2, 0x09, 0xB6, 0x04, 0xC2,
+	0x09, 0x0D, 0x12, 0x0F, 0xC4, 0x0D, 0x12, 0xC2, 0x0F, 0xC5, 0x0D, 0x12, 0xC2, 0x0F, 0xC7,
+	0x0D, 0x12, 0xC2, 0x0F, 0x15, 0x0E, 0x0E, 0xBD, 0x0E, 0x0E, 0xBB, 0x0E, 0x0E, 0xBD, 0x0E,
+	0x0E, 0xC2, 0x1C, 0x02, 0x02, 0xFF
+};
+
+const uint8 *const kOrderLists[3] = { kOrderList0, kOrderList1, kOrderList2 };
+
+} // End of namespace DarkMusicData
+} // End of namespace Freescape
+
+#endif
diff --git a/engines/freescape/games/dark/dos.cpp b/engines/freescape/games/dark/dos.cpp
index e29a7d5dff7..ff06bf4cea9 100644
--- a/engines/freescape/games/dark/dos.cpp
+++ b/engines/freescape/games/dark/dos.cpp
@@ -19,10 +19,12 @@
  *
  */
 
+#include "common/config-manager.h"
 #include "common/file.h"
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
+#include "freescape/games/dark/opl.music.h"
 #include "freescape/language/8bitDetokeniser.h"
 
 namespace Freescape {
@@ -228,6 +230,11 @@ void DarkEngine::loadAssetsDOSFullGame() {
 		updateIndicatorsDOS((byte *)&kHerculesPaletteGreen);
 	} else
 		error("Invalid or unsupported render mode %s for Dark Side", Common::getRenderModeDescription(_renderMode));
+
+	if (ConfMan.getBool("opl_music")) {
+		delete _playerMusic;
+		_playerMusic = new DarkSideOPLMusicPlayer();
+	}
 }
 
 void DarkEngine::drawDOSUI(Graphics::Surface *surface) {
diff --git a/engines/freescape/games/dark/opl.music.cpp b/engines/freescape/games/dark/opl.music.cpp
new file mode 100644
index 00000000000..15f1da96b37
--- /dev/null
+++ b/engines/freescape/games/dark/opl.music.cpp
@@ -0,0 +1,945 @@
+/* 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 "engines/freescape/games/dark/opl.music.h"
+
+#include "common/debug.h"
+#include "common/textconsole.h"
+#include "common/util.h"
+#include "freescape/freescape.h"
+#include "freescape/games/dark/dark.musicdata.h"
+
+using namespace Freescape::DarkMusicData;
+
+namespace Freescape {
+
+// Converted from the SID frequency table in dark.musicdata.h, within 2.3 cents.
+// The tune is written at A4 = 433.5 Hz, so this is not concert pitch.
+const uint16 kDarkOPLFreqs[96] = {
+	0x0000, 0x0168, 0x017D, 0x0194, 0x01AD, 0x01C5,
+	0x01E1, 0x01FD, 0x021B, 0x023B, 0x025E, 0x0282,
+	0x02A8, 0x02D0, 0x02FB, 0x0328, 0x0358, 0x038B,
+	0x03C1, 0x03FA, 0x061B, 0x063C, 0x065E, 0x0682,
+	0x06A7, 0x06D0, 0x06FB, 0x0728, 0x0758, 0x078B,
+	0x07C1, 0x07FA, 0x0A1B, 0x0A3B, 0x0A5D, 0x0A81,
+	0x0AA8, 0x0AD0, 0x0AFB, 0x0B2B, 0x0B58, 0x0B8B,
+	0x0BC1, 0x0BFA, 0x0E1B, 0x0E3B, 0x0E5D, 0x0E81,
+	0x0EA8, 0x0ED0, 0x0EFB, 0x0F28, 0x0F58, 0x0F8B,
+	0x0FC1, 0x0FFA, 0x121B, 0x123B, 0x125D, 0x1281,
+	0x12A8, 0x12D0, 0x12FB, 0x1328, 0x1358, 0x138B,
+	0x13C1, 0x13FA, 0x161B, 0x163B, 0x1661, 0x1681,
+	0x16A8, 0x16D0, 0x16FB, 0x1729, 0x1758, 0x178B,
+	0x17C1, 0x17FA, 0x1A1B, 0x1A3B, 0x1A5D, 0x1A81,
+	0x1AA8, 0x1AD0, 0x1AFB, 0x1B28, 0x1B58, 0x1B8B,
+	0x1BC1, 0x1BFA, 0x1E1B, 0x1E3B, 0x1E5D, 0x0000
+};
+
+const byte kDarkOPLModOffset[] = { 0x00, 0x01, 0x02 };
+const byte kDarkOPLCarOffset[] = { 0x03, 0x04, 0x05 };
+
+struct DarkOPLPatch {
+	byte modChar;            // reg 0x20: AM | VIB | EGT | KSR | MULT
+	byte carChar;
+	byte modLevel;           // reg 0x40: modulator total level == FM index
+	byte carLevel;
+	byte modWave;            // reg 0xE0
+	byte carWave;
+	byte feedbackConnection; // reg 0xC0
+};
+
+// Every operator sets EGT (0x20) so the envelope holds at the sustain level
+// until key-off, like the SID gate bit.
+//
+// The melodic voices are least-squares fits of the two-operator FM spectrum to
+// the analytic SID spectra over the first 16 harmonics: triangle is odd
+// harmonics at 1/n^2, sawtooth every harmonic at 1/n, pulse of duty d is
+// |sin(pi*n*d)|/n. The fit wants a saw-like modulator, which is what moderate
+// feedback produces. Feedback 7 is chaotic and harsh, none at all is dull and
+// quiet; 4 to 5 is where it sits.
+const DarkOPLPatch kDarkOPLPatches[] = {
+	// 0: silent
+	{ 0x20, 0x20, 0x3F, 0x3F, 0x00, 0x00, 0x00 },
+	// 1: triangle - 2:1, gentle
+	{ 0x22, 0x21, 0x28, 0x00, 0x00, 0x00, 0x08 },
+	// 2: sawtooth - 1:1, the classic fed-back OPL ramp
+	{ 0x21, 0x21, 0x19, 0x00, 0x00, 0x00, 0x0A },
+	// 3: pulse - 1:1, index set per frame from the pulse width
+	{ 0x21, 0x21, 0x18, 0x00, 0x00, 0x00, 0x08 },
+	// 4: noise - single operator, played on the rhythm-mode hi-hat
+	{ 0x2E, 0x20, 0x08, 0x3F, 0x00, 0x00, 0x00 }
+};
+
+// FM index and carrier attenuation per pulse-width high nibble. The index is
+// from the same fit; the carrier term is the RMS a pulse of that duty loses
+// against a square, at half strength because the harmonics a thin pulse leans
+// on sit where the ear is most sensitive.
+const byte kPulseWidthModLevel[16] = {
+	18, 22, 23, 24, 26, 28, 29, 29, 29, 29, 28, 26, 24, 23, 22, 18
+};
+
+const byte kPulseWidthCarLevel[16] = {
+	 6,  3,  2,  1,  0,  0,  0,  0,  0,  0,  0,  0,  1,  2,  3,  6
+};
+
+const byte kDarkNoiseFamily = 4;
+
+// Flag bit 3 opens a note with one frame of noise at a fixed pitch. The OPL has
+// no melodic noise, so that frame is a dense inharmonic burst at the SID's
+// pitch ($4800, 1084 Hz).
+const uint16 kSpecialAttackFnum = 714;
+const byte kSpecialAttackBlock = 5;
+const byte kSpecialAttackChar = 0x2F;  // EGT | MULT 15
+
+// OPL2 rhythm mode: bit 5 of register 0xBD turns channels 6-8 into percussion,
+// the only real noise on an OPL2. Melodic playback only uses channels 0-2.
+const byte kDarkRhythmEnable   = 0x20;
+const byte kDarkRhythmHiHatBit = 0x01;
+const byte kDarkRhythmHiHatOp  = 0x11; // channel 7 modulator operator offset
+const byte kDarkRhythmChannel  = 7;
+
+// SID envelope nibbles to OPL2 rates, matched in log space against
+// 0.22 * 2^(14-AR) ms for attack and 1.27 * 2^(15-rate) ms for decay/release.
+// SID's slowest rates are beyond the OPL and clamp to 1.
+const byte kDarkAttackToOPL[16] = {
+	11, 9, 8, 7, 7, 6, 6, 5, 5, 4, 3, 2, 2, 1, 1, 1
+};
+
+const byte kDarkDecayToOPL[16] = {
+	13, 11, 10, 9, 9, 8, 8, 7, 7, 6, 5, 4, 4, 2, 1, 1
+};
+
+// SID sustain is a linear fraction S/15, OPL sustain level is attenuation in
+// 3 dB steps. Nibble 0 means silence.
+const byte kDarkSustainToOPL[16] = {
+	15, 8, 6, 5, 4, 3, 3, 2, 2, 1, 1, 1, 1, 0, 0, 0
+};
+
+byte darkWaveformFamily(byte ctrl) {
+	if (ctrl & 0x80)
+		return 4;
+	if (ctrl & 0x40)
+		return 3;
+	if (ctrl & 0x20)
+		return 2;
+	if (ctrl & 0x10)
+		return 1;
+	return 0;
+}
+
+// ============================================================================
+// ChannelState
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::ChannelState::reset() {
+	orderList = nullptr;
+	orderPos = 0;
+	pattern = nullptr;
+	patternPos = 0;
+	instrumentOffset = 0;
+	currentNote = 0;
+	transpose = 0;
+	frequencyFnum = 0;
+	frequencyBlock = 0;
+	durationReload = 0;
+	durationCounter = 0;
+	effectMode = 0;
+	effectParam = 0;
+	slideTarget = 0;
+	slideParam = 0;
+	arpeggioPos = 0;
+	memset(arpeggioSequence, 0, sizeof(arpeggioSequence));
+	arpeggioSequenceLen = 0;
+	noteStepCommand = 0;
+	stepDownCounter = 0;
+	vibratoPhase = 0;
+	vibratoCounter = 0;
+	delayValue = 0;
+	delayCounter = 0;
+	waveform = 0;
+	instrumentFlags = 0;
+	specialAttack = false;
+	attackDone = false;
+	envCounter = 0;
+	gateOffDisabled = false;
+	keyOn = false;
+	pulseWidth = 0;
+	pulseWidthMod = 0;
+	pulseWidthDirection = 0;
+	modBaseLevel = 0x3F;
+	carBaseLevel = 0x3F;
+	modLevel = 0x3F;
+	carLevel = 0x3F;
+	rhythmVoice = false;
+}
+
+// ============================================================================
+// Construction / public interface
+// ============================================================================
+
+DarkSideOPLMusicPlayer::DarkSideOPLMusicPlayer()
+	: _opl(nullptr), _musicActive(false), _speedDivider(1), _speedCounter(0),
+	  _rhythmReg(kDarkRhythmEnable) {
+	_opl = OPL::Config::create();
+	if (!_opl || !_opl->init()) {
+		warning("DarkSideOPLMusicPlayer: Failed to create OPL emulator");
+		delete _opl;
+		_opl = nullptr;
+	}
+}
+
+DarkSideOPLMusicPlayer::~DarkSideOPLMusicPlayer() {
+	stopMusic();
+	delete _opl;
+}
+
+void DarkSideOPLMusicPlayer::startMusic() {
+	if (!_opl)
+		return;
+	stopMusic();
+	// The C64 leaves CIA#1 Timer A at the KERNAL default and ticks the music
+	// once per IRQ, so the sequencer runs at 60 Hz, not the 50 Hz video rate.
+	_opl->start(new Common::Functor0Mem<void, DarkSideOPLMusicPlayer>(
+		this, &DarkSideOPLMusicPlayer::onTimer), 60);
+	setupSong();
+}
+
+void DarkSideOPLMusicPlayer::stopMusic() {
+	_musicActive = false;
+	if (_opl) {
+		silenceAll();
+		_opl->stop();
+	}
+}
+
+bool DarkSideOPLMusicPlayer::isPlaying() const {
+	return _musicActive;
+}
+
+// ============================================================================
+// OPL register helpers
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::noteToFnumBlock(byte note, uint16 &fnum, byte &block) const {
+	if (note > kMaxNote)
+		note = kMaxNote;
+	uint16 combined = kDarkOPLFreqs[note];
+	fnum = combined & 0x03FF;
+	block = (combined >> 10) & 0x07;
+}
+
+// Push a pitch to the chip without disturbing the note's base frequency: the
+// per-frame effects offset from that base, so storing their result would make
+// each frame modulate the last one and walk the note away. Only note-on and the
+// timed slide, which is meant to accumulate, go through setFrequency().
+void DarkSideOPLMusicPlayer::writeFrequency(int channel, uint16 fnum, byte block) {
+	if (!_opl)
+		return;
+
+	// A rhythm voice is pitched from the percussion channel and must never
+	// have the key-on bit set.
+	if (_channels[channel].rhythmVoice) {
+		_opl->writeReg(0xA0 + kDarkRhythmChannel, fnum & 0xFF);
+		_opl->writeReg(0xB0 + kDarkRhythmChannel, ((fnum >> 8) & 0x03) | (block << 2));
+		return;
+	}
+
+	_opl->writeReg(0xA0 + channel, fnum & 0xFF);
+	byte b0 = ((fnum >> 8) & 0x03) | (block << 2);
+	if (_channels[channel].keyOn)
+		b0 |= 0x20;
+	_opl->writeReg(0xB0 + channel, b0);
+}
+
+void DarkSideOPLMusicPlayer::setFrequency(int channel, uint16 fnum, byte block) {
+	_channels[channel].frequencyFnum = fnum;
+	_channels[channel].frequencyBlock = block;
+	writeFrequency(channel, fnum, block);
+}
+
+void DarkSideOPLMusicPlayer::programEnvelope(byte op, byte attack, byte decay, byte sustain, byte release) {
+	_opl->writeReg(0x60 + op, (attack << 4) | decay);
+	_opl->writeReg(0x80 + op, (sustain << 4) | release);
+}
+
+void DarkSideOPLMusicPlayer::setOPLInstrument(int channel, byte instrumentOffset) {
+	if (!_opl)
+		return;
+
+	byte ctrl = kInstruments[instrumentOffset + 0];
+	byte attackDecay = kInstruments[instrumentOffset + 1];
+	byte sustainRelease = kInstruments[instrumentOffset + 2];
+	byte initialPulseWidth = kInstruments[instrumentOffset + 3];
+	byte pulseWidthMod = kInstruments[instrumentOffset + 5];
+	byte family = darkWaveformFamily(ctrl);
+	const DarkOPLPatch &patch = kDarkOPLPatches[family];
+
+	byte attack = kDarkAttackToOPL[attackDecay >> 4];
+	byte decay = kDarkDecayToOPL[attackDecay & 0x0F];
+	byte sustain = kDarkSustainToOPL[sustainRelease >> 4];
+	byte release = kDarkDecayToOPL[sustainRelease & 0x0F];
+
+	bool wasRhythm = _channels[channel].rhythmVoice;
+	_channels[channel].rhythmVoice = (family == kDarkNoiseFamily);
+
+	// Flag bit 7 carries the pulse width on instead of restarting it. The main
+	// bass begins at 0% duty and relies on this: resetting every note pins it
+	// at the thinnest, quietest end of the sweep.
+	if ((kInstruments[instrumentOffset + 7] & 0x80) == 0) {
+		_channels[channel].pulseWidth = ((initialPulseWidth & 0x0F) << 8) | (initialPulseWidth & 0xF0);
+		_channels[channel].pulseWidthDirection = 0;
+	}
+	_channels[channel].pulseWidthMod = pulseWidthMod;
+	_channels[channel].modBaseLevel = patch.modLevel;
+	_channels[channel].carBaseLevel = patch.carLevel;
+	_channels[channel].modLevel = patch.modLevel;
+	_channels[channel].carLevel = patch.carLevel;
+
+	if (_channels[channel].rhythmVoice) {
+		// Silence the melodic channel this voice would otherwise have used.
+		_channels[channel].keyOn = false;
+		_opl->writeReg(0xB0 + channel, 0x00);
+		_opl->writeReg(0x40 + kDarkOPLModOffset[channel], 0x3F);
+		_opl->writeReg(0x40 + kDarkOPLCarOffset[channel], 0x3F);
+
+		_opl->writeReg(0x20 + kDarkRhythmHiHatOp, patch.modChar);
+		_opl->writeReg(0xE0 + kDarkRhythmHiHatOp, patch.modWave);
+		programEnvelope(kDarkRhythmHiHatOp, attack, decay, sustain, release);
+		updatePulseWidth(channel, false);
+		applyOperatorLevels(channel);
+		return;
+	}
+
+	if (wasRhythm) {
+		_rhythmReg &= ~kDarkRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+	}
+
+	byte mod = kDarkOPLModOffset[channel];
+	byte car = kDarkOPLCarOffset[channel];
+
+	_opl->writeReg(0x20 + mod, patch.modChar);
+	_opl->writeReg(0x20 + car, patch.carChar);
+
+	// The FM index follows the modulator's absolute output, and a SID
+	// oscillator keeps its waveform as the note decays, so hold the modulator
+	// flat and let only the carrier follow the ADSR. Enveloping it collapses
+	// the index in milliseconds and leaves a bass note inaudible.
+	programEnvelope(mod, 15, 0, 0, 0);
+	programEnvelope(car, attack, decay, sustain, release);
+	_opl->writeReg(0xE0 + mod, patch.modWave);
+	_opl->writeReg(0xE0 + car, patch.carWave);
+	_opl->writeReg(0xC0 + channel, patch.feedbackConnection);
+
+	updatePulseWidth(channel, false);
+	applyOperatorLevels(channel);
+}
+
+void DarkSideOPLMusicPlayer::noteOn(int channel) {
+	if (!_opl)
+		return;
+	_channels[channel].keyOn = true;
+
+	if (_channels[channel].rhythmVoice) {
+		// Percussion voices are keyed from register 0xBD, not from 0xB0.
+		_opl->writeReg(0xBD, _rhythmReg & ~kDarkRhythmHiHatBit);
+		_rhythmReg |= kDarkRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+		return;
+	}
+
+	_opl->writeReg(0xA0 + channel, _channels[channel].frequencyFnum & 0xFF);
+	_opl->writeReg(0xB0 + channel, 0x20 | (_channels[channel].frequencyBlock << 2) |
+	                                 ((_channels[channel].frequencyFnum >> 8) & 0x03));
+}
+
+void DarkSideOPLMusicPlayer::noteOff(int channel) {
+	if (!_opl)
+		return;
+	_channels[channel].keyOn = false;
+
+	if (_channels[channel].rhythmVoice) {
+		_rhythmReg &= ~kDarkRhythmHiHatBit;
+		_opl->writeReg(0xBD, _rhythmReg);
+		return;
+	}
+
+	_opl->writeReg(0xB0 + channel, ((_channels[channel].frequencyFnum >> 8) & 0x03) |
+	                                 (_channels[channel].frequencyBlock << 2));
+}
+
+// ============================================================================
+// Timer / sequencer core
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::onTimer() {
+	if (!_musicActive)
+		return;
+
+	for (int channel = 0; channel < kChannelCount; channel++)
+		_channels[channel].envCounter++;
+
+	bool newBeat = (_speedCounter == 0);
+
+	for (int channel = kChannelCount - 1; channel >= 0; channel--)
+		processChannel(channel, newBeat);
+
+	if (!_musicActive)
+		return;
+
+	if (newBeat)
+		_speedCounter = _speedDivider;
+	else
+		_speedCounter--;
+}
+
+void DarkSideOPLMusicPlayer::processChannel(int channel, bool newBeat) {
+	if (newBeat) {
+		_channels[channel].durationCounter--;
+		if (_channels[channel].durationCounter == 0xFF) {
+			parseCommands(channel);
+			if (!_musicActive)
+				return;
+			finalizeChannel(channel);
+			return;
+		}
+
+		if (_channels[channel].noteStepCommand != 0) {
+			if (_channels[channel].noteStepCommand == 0xDE) {
+				if (_channels[channel].currentNote > 0)
+					_channels[channel].currentNote--;
+			} else if (_channels[channel].currentNote < kMaxNote) {
+				_channels[channel].currentNote++;
+			}
+			loadCurrentFrequency(channel);
+			finalizeChannel(channel);
+			return;
+		}
+	} else if (_channels[channel].stepDownCounter != 0) {
+		_channels[channel].stepDownCounter--;
+		if (_channels[channel].currentNote > 0)
+			_channels[channel].currentNote--;
+		loadCurrentFrequency(channel);
+		finalizeChannel(channel);
+		return;
+	}
+
+	applyFrameEffects(channel);
+	finalizeChannel(channel);
+}
+
+void DarkSideOPLMusicPlayer::finalizeChannel(int channel) {
+	// Mirrors the SID engine clearing the gate bit halfway through the note.
+	if (_channels[channel].durationReload != 0 &&
+	    !_channels[channel].gateOffDisabled &&
+	    ((_channels[channel].durationReload >> 1) == _channels[channel].durationCounter) &&
+	    _channels[channel].keyOn) {
+		noteOff(channel);
+	}
+
+	updatePulseWidth(channel, true);
+	applyOperatorLevels(channel);
+}
+
+// ============================================================================
+// Song setup
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::setupSong() {
+	silenceAll();
+
+	if (_opl)
+		_opl->writeReg(0x01, 0x20); // wave select, needed for non-sine waveforms
+
+	_speedDivider = 1;
+	_speedCounter = 0;
+
+	for (int i = 0; i < kChannelCount; i++) {
+		_channels[i].reset();
+		_channels[i].orderList = kOrderLists[i];
+		loadNextPattern(i);
+	}
+
+	_musicActive = true;
+}
+
+void DarkSideOPLMusicPlayer::silenceAll() {
+	if (!_opl)
+		return;
+	for (int ch = 0; ch < kChannelCount; ch++) {
+		_channels[ch].keyOn = false;
+		_opl->writeReg(0xB0 + ch, 0x00);
+		_opl->writeReg(0x40 + kDarkOPLModOffset[ch], 0x3F);
+		_opl->writeReg(0x40 + kDarkOPLCarOffset[ch], 0x3F);
+	}
+
+	// Leave rhythm mode armed with every trigger released.
+	_rhythmReg = kDarkRhythmEnable;
+	_opl->writeReg(0xBD, _rhythmReg);
+	_opl->writeReg(0x40 + kDarkRhythmHiHatOp, 0x3F);
+}
+
+// ============================================================================
+// Order list / pattern navigation
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::loadNextPattern(int channel) {
+	int safety = 200;
+	while (safety-- > 0) {
+		byte value = _channels[channel].orderList[_channels[channel].orderPos];
+		_channels[channel].orderPos++;
+
+		if (value == 0xFF) {
+			_channels[channel].orderPos = 0;
+			continue;
+		}
+
+		// The C64 order lists encode a transpose differently from the Amiga and
+		// Atari ones: anything from $80 up, biased by $40 rather than $20.
+		if (value >= 0x80) {
+			_channels[channel].transpose = (byte)((value + 0x40) & 0xFF);
+			continue;
+		}
+
+		if (value < ARRAYSIZE(kPatterns)) {
+			_channels[channel].pattern = kPatterns[value];
+			_channels[channel].patternPos = 0;
+			debugC(3, kFreescapeDebugMedia, "Dark-AdLib: ch%d order -> pattern %d (transpose %d)",
+				channel, value, (int8)_channels[channel].transpose);
+		}
+		break;
+	}
+}
+
+byte DarkSideOPLMusicPlayer::readPatternByte(int channel) {
+	return _channels[channel].pattern[_channels[channel].patternPos++];
+}
+
+byte DarkSideOPLMusicPlayer::clampNote(int note) const {
+	if (note < 0)
+		return 0;
+	return note > kMaxNote ? (byte)kMaxNote : (byte)note;
+}
+
+// ============================================================================
+// Pattern command parser
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::parseCommands(int channel) {
+	if (_channels[channel].effectMode != 2) {
+		_channels[channel].effectParam = 0;
+		_channels[channel].effectMode = 0;
+		_channels[channel].arpeggioSequenceLen = 0;
+		_channels[channel].arpeggioPos = 0;
+	}
+	_channels[channel].slideTarget = 0;
+	_channels[channel].noteStepCommand = 0;
+
+	int safety = 200;
+	while (safety-- > 0) {
+		byte cmd = readPatternByte(channel);
+
+		if (cmd == 0xFF) {
+			loadNextPattern(channel);
+			continue;
+		}
+
+		if (cmd == 0xFE) {
+			stopMusic();
+			return;
+		}
+
+		if (cmd == 0xFD) {
+			// SID filter, no OPL equivalent
+			readPatternByte(channel);
+			cmd = readPatternByte(channel);
+			if (cmd == 0xFF) {
+				loadNextPattern(channel);
+				continue;
+			}
+		}
+
+		if (cmd >= 0xF0) {
+			_speedDivider = cmd & 0x0F;
+			debugC(2, kFreescapeDebugMedia, "Dark-AdLib: ch%d speed $%02X -> %d ticks per beat",
+				channel, cmd, _speedDivider + 1);
+			continue;
+		}
+
+		if (cmd >= 0xC0) {
+			byte instrument = cmd & 0x1F;
+			if (instrument < 18)
+				_channels[channel].instrumentOffset = instrument * 8;
+			continue;
+		}
+
+		if (cmd >= 0x80) {
+			_channels[channel].durationReload = cmd & 0x3F;
+			continue;
+		}
+
+		if (cmd == 0x7F) {
+			_channels[channel].noteStepCommand = 0xDE;
+			_channels[channel].effectMode = 0xDE;
+			continue;
+		}
+
+		if (cmd == 0x7E) {
+			_channels[channel].noteStepCommand = 0xFE;
+			_channels[channel].effectMode = 0xFE;
+			continue;
+		}
+
+		if (cmd == 0x7D) {
+			_channels[channel].effectMode = 1;
+			_channels[channel].effectParam = readPatternByte(channel);
+			buildEffectArpeggio(channel);
+			continue;
+		}
+
+		if (cmd == 0x7C) {
+			_channels[channel].effectMode = 2;
+			_channels[channel].effectParam = readPatternByte(channel);
+			buildEffectArpeggio(channel);
+			continue;
+		}
+
+		if (cmd == 0x7B) {
+			_channels[channel].effectParam = 0;
+			_channels[channel].effectMode = 1;
+			_channels[channel].slideTarget = readPatternByte(channel) + _channels[channel].transpose;
+			_channels[channel].slideParam = readPatternByte(channel);
+			continue;
+		}
+
+		if (cmd == 0x7A) {
+			_channels[channel].delayValue = readPatternByte(channel);
+			cmd = readPatternByte(channel);
+		}
+
+		applyNote(channel, cmd);
+		return;
+	}
+}
+
+// ============================================================================
+// Note application
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::applyNote(int channel, byte note) {
+	byte instrumentOffset = _channels[channel].instrumentOffset;
+	byte ctrl = kInstruments[instrumentOffset + 0];
+	byte sustainRelease = kInstruments[instrumentOffset + 2];
+	byte autoEffect = kInstruments[instrumentOffset + 6];
+	byte flags = kInstruments[instrumentOffset + 7];
+	byte actualNote = note;
+	bool gateEnabled = (ctrl & 0x01) != 0;
+
+	if (actualNote != 0)
+		actualNote = clampNote((actualNote + _channels[channel].transpose) & 0xFF);
+
+	_channels[channel].currentNote = actualNote;
+	_channels[channel].waveform = ctrl;
+	_channels[channel].instrumentFlags = flags;
+	_channels[channel].stepDownCounter = 0;
+	_channels[channel].specialAttack = (flags & 0x08) != 0;
+	_channels[channel].attackDone = false;
+	_channels[channel].envCounter = 0xFF;
+
+	if (actualNote != 0 && _channels[channel].effectParam == 0 && autoEffect != 0) {
+		_channels[channel].effectParam = autoEffect;
+		buildEffectArpeggio(channel);
+	}
+
+	if (actualNote != 0 && (flags & 0x02) != 0) {
+		_channels[channel].stepDownCounter = 2;
+		_channels[channel].currentNote = clampNote(_channels[channel].currentNote + 2);
+	}
+
+	setOPLInstrument(channel, instrumentOffset);
+	_channels[channel].gateOffDisabled = (sustainRelease & 0x0F) == 0x0F;
+
+	if (actualNote != 0)
+		loadCurrentFrequency(channel);
+
+	byte instrument = instrumentOffset / 8;
+	byte family = darkWaveformFamily(ctrl);
+
+	if (actualNote == 0 || !gateEnabled) {
+		noteOff(channel);
+		debugC(1, kFreescapeDebugMedia, "Dark-AdLib: ch%d rest  inst=%-2d dur=%d",
+			channel, instrument, _channels[channel].durationReload);
+	} else {
+		// Key-off then key-on restarts the envelope from its current level,
+		// like re-gating a held SID voice.
+		noteOff(channel);
+		noteOn(channel);
+		debugC(1, kFreescapeDebugMedia,
+			"Dark-AdLib: ch%d NOTE %3d (%d%+d) %5dHz inst=%-2d dur=%d%s%s",
+			channel, _channels[channel].currentNote, note, (int8)_channels[channel].transpose,
+			(int)(((uint32)_channels[channel].frequencyFnum * 49716) >> (20 - _channels[channel].frequencyBlock)),
+			instrument, _channels[channel].durationReload,
+			family == kDarkNoiseFamily ? " [rhythm hi-hat]" : "",
+			_channels[channel].effectParam ? " [arpeggio]" : "");
+	}
+
+	_channels[channel].durationCounter = _channels[channel].durationReload;
+	_channels[channel].delayCounter = _channels[channel].delayValue;
+	_channels[channel].arpeggioPos = 0;
+}
+
+void DarkSideOPLMusicPlayer::loadCurrentFrequency(int channel) {
+	uint16 fnum;
+	byte block;
+	noteToFnumBlock(clampNote(_channels[channel].currentNote), fnum, block);
+	setFrequency(channel, fnum, block);
+}
+
+// ============================================================================
+// Effects
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::buildEffectArpeggio(int channel) {
+	byte len = 0;
+	for (int i = 0; i < 8 && len < 8; i++) {
+		if (_channels[channel].effectParam & (1 << i))
+			_channels[channel].arpeggioSequence[len++] = kArpIntervals[i];
+	}
+	if (len > 0)
+		_channels[channel].arpeggioSequence[len++] = 0;
+	_channels[channel].arpeggioSequenceLen = len;
+	_channels[channel].arpeggioPos = 0;
+}
+
+void DarkSideOPLMusicPlayer::applyFrameEffects(int channel) {
+	if (_channels[channel].currentNote == 0)
+		return;
+
+	if (applySpecialAttack(channel))
+		return;
+
+	if (applyInstrumentVibrato(channel))
+		return;
+
+	applyEffectArpeggio(channel);
+	applyTimedSlide(channel);
+}
+
+bool DarkSideOPLMusicPlayer::applySpecialAttack(int channel) {
+	ChannelState &c = _channels[channel];
+	if (!c.specialAttack || c.attackDone || c.rhythmVoice || !_opl)
+		return false;
+
+	byte mod = kDarkOPLModOffset[channel];
+	if (c.envCounter < 1) {
+		_opl->writeReg(0x20 + mod, kSpecialAttackChar);
+		_opl->writeReg(0x40 + mod, 0x00);
+		writeFrequency(channel, kSpecialAttackFnum, kSpecialAttackBlock);
+	} else {
+		_opl->writeReg(0x20 + mod, kDarkOPLPatches[darkWaveformFamily(c.waveform)].modChar);
+		applyOperatorLevels(channel);
+		loadCurrentFrequency(channel);
+		c.attackDone = true;
+	}
+	return true;
+}
+
+bool DarkSideOPLMusicPlayer::applyInstrumentVibrato(int channel) {
+	byte vibrato = kInstruments[_channels[channel].instrumentOffset + 4];
+	if (vibrato == 0 || _channels[channel].currentNote >= kMaxNote)
+		return false;
+
+	byte shift = vibrato & 0x0F;
+	byte span = vibrato >> 4;
+	if (span == 0)
+		return false;
+
+	uint16 noteFnum, nextFnum;
+	byte noteBlock, nextBlock;
+	noteToFnumBlock(_channels[channel].currentNote, noteFnum, noteBlock);
+	noteToFnumBlock(_channels[channel].currentNote + 1, nextFnum, nextBlock);
+
+	int32 delta = ((int32)nextFnum << nextBlock) - ((int32)noteFnum << noteBlock);
+	if (delta <= 0)
+		return false;
+
+	while (shift-- != 0)
+		delta >>= 1;
+
+	if (_channels[channel].vibratoPhase & 0x80) {
+		if (_channels[channel].vibratoCounter != 0)
+			_channels[channel].vibratoCounter--;
+		if (_channels[channel].vibratoCounter == 0)
+			_channels[channel].vibratoPhase = 0;
+	} else {
+		_channels[channel].vibratoCounter++;
+		if (_channels[channel].vibratoCounter >= span)
+			_channels[channel].vibratoPhase = 0xFF;
+	}
+
+	if (_channels[channel].delayCounter != 0) {
+		_channels[channel].delayCounter--;
+		return false;
+	}
+
+	int32 freq = (int32)_channels[channel].frequencyFnum << _channels[channel].frequencyBlock;
+	for (byte i = 0; i < (span >> 1); i++)
+		freq -= delta;
+	for (byte i = 0; i < _channels[channel].vibratoCounter; i++)
+		freq += delta;
+
+	if (freq < 1)
+		freq = 1;
+
+	byte block = 0;
+	while (freq > 1023 && block < 7) {
+		freq >>= 1;
+		block++;
+	}
+	writeFrequency(channel, freq & 0x3FF, block);
+	return true;
+}
+
+void DarkSideOPLMusicPlayer::applyEffectArpeggio(int channel) {
+	if (_channels[channel].effectParam == 0 || _channels[channel].arpeggioSequenceLen == 0)
+		return;
+
+	if (_channels[channel].arpeggioPos >= _channels[channel].arpeggioSequenceLen)
+		_channels[channel].arpeggioPos = 0;
+
+	byte note = clampNote(_channels[channel].currentNote +
+	                      _channels[channel].arpeggioSequence[_channels[channel].arpeggioPos]);
+	_channels[channel].arpeggioPos++;
+
+	uint16 fnum;
+	byte block;
+	noteToFnumBlock(note, fnum, block);
+	writeFrequency(channel, fnum, block);
+}
+
+void DarkSideOPLMusicPlayer::applyTimedSlide(int channel) {
+	if (_channels[channel].slideTarget == 0)
+		return;
+
+	byte total = _channels[channel].durationReload;
+	byte remaining = _channels[channel].durationCounter;
+	byte start = _channels[channel].slideParam >> 4;
+	byte span = _channels[channel].slideParam & 0x0F;
+	byte elapsed = total - remaining;
+
+	if (elapsed <= start || elapsed > start + span || span == 0)
+		return;
+
+	byte currentNote = clampNote(_channels[channel].currentNote);
+	byte targetNote = clampNote(_channels[channel].slideTarget);
+	if (currentNote == targetNote)
+		return;
+
+	uint16 srcFnum, tgtFnum;
+	byte srcBlock, tgtBlock;
+	noteToFnumBlock(currentNote, srcFnum, srcBlock);
+	noteToFnumBlock(targetNote, tgtFnum, tgtBlock);
+
+	int32 srcFreq = (int32)srcFnum << srcBlock;
+	int32 tgtFreq = (int32)tgtFnum << tgtBlock;
+	int32 difference = ABS(srcFreq - tgtFreq);
+	uint16 divisor = span * (_speedDivider + 1);
+	if (divisor == 0)
+		return;
+
+	int32 delta = difference / divisor;
+	if (delta == 0)
+		return;
+
+	int32 curFreq = (int32)_channels[channel].frequencyFnum << _channels[channel].frequencyBlock;
+	curFreq += (tgtFreq > srcFreq) ? delta : -delta;
+	if (curFreq < 1)
+		curFreq = 1;
+
+	byte block = 0;
+	while (curFreq > 1023 && block < 7) {
+		curFreq >>= 1;
+		block++;
+	}
+	setFrequency(channel, curFreq & 0x3FF, block);
+}
+
+// ============================================================================
+// Operator levels
+// ============================================================================
+
+void DarkSideOPLMusicPlayer::updatePulseWidth(int channel, bool advance) {
+	if ((_channels[channel].waveform & 0x40) == 0) {
+		_channels[channel].modLevel = _channels[channel].modBaseLevel;
+		_channels[channel].carLevel = _channels[channel].carBaseLevel;
+		return;
+	}
+
+	if (advance && _channels[channel].pulseWidthMod != 0) {
+		if ((_channels[channel].instrumentFlags & 0x04) != 0) {
+			uint16 pulseWidth = _channels[channel].pulseWidth;
+			pulseWidth = (pulseWidth & 0x0F00) |
+			             (((pulseWidth & 0x00FF) + _channels[channel].pulseWidthMod) & 0x00FF);
+			_channels[channel].pulseWidth = pulseWidth;
+		} else if (_channels[channel].pulseWidthDirection == 0) {
+			_channels[channel].pulseWidth += _channels[channel].pulseWidthMod;
+			if ((_channels[channel].pulseWidth >> 8) >= 0x0F)
+				_channels[channel].pulseWidthDirection = 1;
+		} else {
+			_channels[channel].pulseWidth -= _channels[channel].pulseWidthMod;
+			if ((_channels[channel].pulseWidth >> 8) < 0x08)
+				_channels[channel].pulseWidthDirection = 0;
+		}
+	}
+
+	// The SID pulse width is 12 bits and sweeps continuously, so interpolate
+	// between entries. The high nibble alone quantises it to sixteen steps,
+	// which reads as a static tone -- and on a sustained note that sweep is the
+	// whole character of the voice.
+	uint16 pw = _channels[channel].pulseWidth & 0x0FFF;
+	byte low = pw & 0xFF;
+	byte duty = pw >> 8;
+	byte next = (duty + 1) & 0x0F;
+
+	int mod = kPulseWidthModLevel[duty] +
+	          ((kPulseWidthModLevel[next] - kPulseWidthModLevel[duty]) * low) / 256;
+	int car = kPulseWidthCarLevel[duty] +
+	          ((kPulseWidthCarLevel[next] - kPulseWidthCarLevel[duty]) * low) / 256;
+
+	_channels[channel].modLevel = (byte)CLIP(mod, 0, 63);
+	_channels[channel].carLevel = MIN<byte>(_channels[channel].carBaseLevel + car, 0x3F);
+}
+
+// The envelope lives in the chip, so the total-level registers only carry the
+// patch levels plus the pulse-width brightness motion.
+void DarkSideOPLMusicPlayer::applyOperatorLevels(int channel) {
+	if (!_opl)
+		return;
+
+	if (_channels[channel].rhythmVoice) {
+		_opl->writeReg(0x40 + kDarkRhythmHiHatOp, _channels[channel].modLevel & 0x3F);
+		return;
+	}
+
+	_opl->writeReg(0x40 + kDarkOPLModOffset[channel], _channels[channel].modLevel & 0x3F);
+	_opl->writeReg(0x40 + kDarkOPLCarOffset[channel], _channels[channel].carLevel & 0x3F);
+}
+
+} // End of namespace Freescape
diff --git a/engines/freescape/games/dark/opl.music.h b/engines/freescape/games/dark/opl.music.h
new file mode 100644
index 00000000000..4171fba3494
--- /dev/null
+++ b/engines/freescape/games/dark/opl.music.h
@@ -0,0 +1,143 @@
+/* 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 FREESCAPE_DARK_OPL_MUSIC_H
+#define FREESCAPE_DARK_OPL_MUSIC_H
+
+#include "audio/fmopl.h"
+#include "freescape/music.h"
+
+namespace Freescape {
+
+/**
+ * OPL2/AdLib rendition of the Dark Side theme.
+ *
+ * Runs the C64 sequencer over the song data in dark.musicdata.h and voices it
+ * on the OPL2: SID note numbers become F-number/block pairs, the SID ADSR
+ * drives the chip's own envelope generator, and the noise waveform is played
+ * on the rhythm-mode hi-hat.
+ */
+class DarkSideOPLMusicPlayer : public MusicPlayer {
+public:
+	DarkSideOPLMusicPlayer();
+	~DarkSideOPLMusicPlayer();
+
+	void startMusic() override;
+	void stopMusic() override;
+	bool isPlaying() const override;
+
+private:
+	enum {
+		kChannelCount = 3,
+		kMaxNote = 94
+	};
+
+	struct ChannelState {
+		const byte *orderList;
+		byte orderPos;
+		const byte *pattern;
+		uint16 patternPos;
+
+		byte instrumentOffset;
+		byte currentNote;
+		byte transpose;
+		uint16 frequencyFnum;
+		byte frequencyBlock;
+
+		byte durationReload;
+		byte durationCounter;
+
+		byte effectMode;
+		byte effectParam;
+		byte slideTarget;
+		byte slideParam;
+		byte arpeggioPos;
+		byte arpeggioSequence[9];
+		byte arpeggioSequenceLen;
+
+		byte noteStepCommand;
+		byte stepDownCounter;
+
+		byte vibratoPhase;
+		byte vibratoCounter;
+
+		byte delayValue;
+		byte delayCounter;
+
+		byte waveform;
+		byte instrumentFlags;
+		bool specialAttack;
+		bool attackDone;
+		byte envCounter;
+		bool gateOffDisabled;
+		bool keyOn;
+		uint16 pulseWidth;
+		byte pulseWidthMod;
+		byte pulseWidthDirection;
+		byte modBaseLevel;
+		byte carBaseLevel;
+		byte modLevel;
+		byte carLevel;
+		bool rhythmVoice;
+
+		void reset();
+	};
+
+	OPL::OPL *_opl;
+	bool _musicActive;
+	byte _speedDivider;
+	byte _speedCounter;
+	byte _rhythmReg;
+	ChannelState _channels[kChannelCount];
+
+	void onTimer();
+	void setupSong();
+	void silenceAll();
+	void loadNextPattern(int channel);
+	void buildEffectArpeggio(int channel);
+	void loadCurrentFrequency(int channel);
+	void finalizeChannel(int channel);
+	void processChannel(int channel, bool newBeat);
+	void parseCommands(int channel);
+	void applyNote(int channel, byte note);
+	void applyFrameEffects(int channel);
+	bool applySpecialAttack(int channel);
+	bool applyInstrumentVibrato(int channel);
+	void applyEffectArpeggio(int channel);
+	void applyTimedSlide(int channel);
+	void programEnvelope(byte op, byte attack, byte decay, byte sustain, byte release);
+	void updatePulseWidth(int channel, bool advance);
+	void applyOperatorLevels(int channel);
+
+	void setOPLInstrument(int channel, byte instrumentOffset);
+	void noteOn(int channel);
+	void noteOff(int channel);
+	void writeFrequency(int channel, uint16 fnum, byte block);
+	void setFrequency(int channel, uint16 fnum, byte block);
+	void noteToFnumBlock(byte note, uint16 &fnum, byte &block) const;
+
+	byte readPatternByte(int channel);
+	byte clampNote(int note) const;
+};
+
+} // End of namespace Freescape
+
+#endif
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index a4586ce866e..e63dc3a387d 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -29,6 +29,7 @@ MODULE_OBJS := \
 	games/dark/cpc.o \
 	games/dark/dark.o \
 	games/dark/dos.o \
+	games/dark/opl.music.o \
 	games/dark/zx.o \
 	games/driller/amiga.o \
 	games/driller/atari.o \


Commit: 1533504287bdbb74c401a6058f0534db2d95d067
    https://github.com/scummvm/scummvm/commit/1533504287bdbb74c401a6058f0534db2d95d067
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:08+02:00

Commit Message:
FREESCAPE: refine driller opl music for DOS

Changed paths:
    engines/freescape/games/driller/opl.music.cpp


diff --git a/engines/freescape/games/driller/opl.music.cpp b/engines/freescape/games/driller/opl.music.cpp
index 6fd3c41886f..a57e63394de 100644
--- a/engines/freescape/games/driller/opl.music.cpp
+++ b/engines/freescape/games/driller/opl.music.cpp
@@ -64,21 +64,37 @@ const uint16 kDrillerOPLFreqs[] = {
 const byte kOPLModOffset[] = { 0x00, 0x01, 0x02 };
 const byte kOPLCarOffset[] = { 0x03, 0x04, 0x05 };
 
+// Fitted against the analytic SID spectra: triangle is odd harmonics at 1/n^2,
+// sawtooth every harmonic at 1/n, pulse of duty d is |sin(pi*n*d)|/n.
+//
+// The modulator envelope is flat (AR 15, no decay or release) on purpose. The FM
+// index follows the modulator's absolute output and a SID oscillator keeps its
+// waveform as the note decays, so enveloping it collapses the index within
+// milliseconds and leaves low notes as near-pure sines.
 const DrillerOPLBasePatch kDrillerOPLBasePatches[] = {
-	{ 0x21, 0x21, 0x22, 0x04, 0xF2, 0xF3, 0x74, 0x45, 0x00, 0x00, 0x04 }, // triangle
-	{ 0x02, 0x01, 0x1C, 0x00, 0xE3, 0xF2, 0x63, 0x35, 0x00, 0x01, 0x06 }, // pulse
-	{ 0x31, 0x21, 0x25, 0x03, 0xD3, 0xE3, 0x64, 0x46, 0x00, 0x00, 0x02 }, // saw
-	{ 0x01, 0x01, 0x2A, 0x08, 0xF4, 0xF2, 0x42, 0x31, 0x00, 0x00, 0x0E }, // noise/percussion
-	{ 0x22, 0x21, 0x18, 0x00, 0xF4, 0xF2, 0x55, 0x36, 0x00, 0x00, 0x08 }  // default lead
+	{ 0x22, 0x21, 0x28, 0x04, 0xF0, 0xF3, 0x00, 0x45, 0x00, 0x00, 0x08 }, // triangle, 2:1
+	{ 0x21, 0x21, 0x1D, 0x00, 0xF0, 0xF2, 0x00, 0x35, 0x00, 0x00, 0x08 }, // pulse, 1:1
+	{ 0x21, 0x21, 0x19, 0x03, 0xF0, 0xE3, 0x00, 0x46, 0x00, 0x00, 0x0A }, // saw, 1:1
+	{ 0x21, 0x21, 0x2A, 0x08, 0xF0, 0xF2, 0x00, 0x31, 0x00, 0x00, 0x0E }, // noise/percussion
+	{ 0x22, 0x21, 0x1D, 0x00, 0xF0, 0xF2, 0x00, 0x36, 0x00, 0x00, 0x08 }  // default lead
 };
 
 const byte kDrillerMusicAttenuation = 4;
 const byte kDrillerNoiseAttenuation = 0;
-const byte kDrillerNarrowPulseAttenuation = 2;
-const uint16 kDrillerNarrowPulseEdgeDistance = 0x0300;
-const byte kDrillerPulseBrightnessBoostMax = 24;
 const int kDrillerArpeggioSize = 3;
 
+// Modulator attenuation per pulse duty, indexed by the top nibble of the 12-bit
+// pulse width, so the FM spectral centroid tracks the pulse's own: 1.9 at a
+// square wave up to 3.3 at a hairline, against the SID's 1.8 to 3.4.
+//
+// The band is the point. In a 1:1 pair the fundamental is |J0 - J2| of the index,
+// which nulls at 1.84 and 5.31; instrument 1 sweeps its pulse width every 1.7 s,
+// so a range straddling a null drops the fundamental 29 dB twice a cycle. These
+// levels keep the sweep inside 3.1 to 4.9, between the nulls.
+const byte kDrillerPulseModLevel[17] = {
+	11, 11, 12, 12, 13, 14, 15, 16, 16, 16, 15, 14, 13, 12, 12, 11, 11
+};
+
 byte attenuateDrillerOPLLevel(byte level, byte attenuation) {
 	return MIN<byte>((level & 0x3F) + attenuation, 0x3F) | (level & 0xC0);
 }
@@ -122,31 +138,39 @@ const byte kDrillerSidSustainToOPL[16] = {
 	15, 8, 6, 5, 4, 3, 3, 2, 2, 2, 1, 1, 1, 0, 0, 0
 };
 
-// SID rate (0=fast..15=slow) -> OPL rate (15=fast..0=never), inverted and
-// compressed into [5,15] so even the slowest SID rate stays audible.
-byte sidRateToOPL(byte sidRate) {
-	return 15 - (sidRate * 10) / 15;
-}
+// SID rate nibbles to OPL2 rates, matched in log space against dbopl's own attack
+// table (2265 ms at AR 1 down to 0.18 ms at AR 15, halving per step) and
+// 1.27 * 2^(15-rate) ms for decay/release.
+//
+// The tail is one step off that fit on purpose. SID attacks 13-15 are 3, 5 and 8
+// seconds and OPL2 bottoms out at 2265 ms, so the shape is unreachable anyway;
+// the OPL ramp is also exponential where the SID's is linear, leaving AR 1 some
+// 18 dB below the SID over the first half second. That is audible at the very
+// first bar, which instrument 1 (attack 15) carries alone.
+const byte kSidAttackToOPL[16] = {
+	11, 9, 8, 7, 7, 6, 6, 5, 5, 4, 3, 2, 2, 2, 2, 2
+};
+
+const byte kSidDecayToOPL[16] = {
+	13, 11, 10, 9, 9, 8, 8, 7, 7, 6, 5, 4, 4, 2, 1, 1
+};
 
-// Convert a SID instrument's AD/SR bytes to OPL2 carrier envelope registers.
 void deriveDrillerOPLEnvelope(byte sidAD, byte sidSR, byte &oplAD, byte &oplSR, bool &sustaining) {
 	byte sidAttack = sidAD >> 4;
 	byte sidSustain = sidSR >> 4;
-	byte attack = sidRateToOPL(sidAttack);
-	byte decay = sidRateToOPL(sidAD & 0x0F);
-	byte release = sidRateToOPL(sidSR & 0x0F);
+	byte attack = kSidAttackToOPL[sidAttack];
+	byte decay = kSidDecayToOPL[sidAD & 0x0F];
+	byte release = kSidDecayToOPL[sidSR & 0x0F];
 
 	byte sustainLevel;
 	if (sidSustain != 0) {
 		sustaining = true;
 		sustainLevel = kDrillerSidSustainToOPL[sidSustain];
 	} else if (sidAttack >= 10) {
-		// Slow-attack swell, no SID sustain: hold it so the note does not
-		// collapse into a short puff (instruments 1, 2, 14).
+		// A slow swell has to hold, or it decays while still rising.
 		sustaining = true;
 		sustainLevel = 0;
 	} else {
-		// Fast attack, no sustain: plucked voice that decays away.
 		sustaining = false;
 		sustainLevel = 15;
 	}
@@ -501,9 +525,15 @@ void DrillerOPLMusicPlayer::applyNote(int channel, VoiceState &v, const uint8_t
 	v.stuff_freq_base = v.stuff_freq_porta_vib;
 	v.baseSIDFrequency = v.stuff_freq_base;
 	v.currentControl = getDrillerInstrumentControl(instA0, instA1, false);
+	byte carAD, carSR;
+	bool sustaining;
+	deriveDrillerOPLEnvelope(instA0[2], instA0[3], carAD, carSR, sustaining);
 	debugC(1, kFreescapeDebugMedia,
-		"Driller OPL note tick=%u ch=%d inst=%u note=%u ctrl=%02x track=%u pat=%u",
-		_tick, channel, v.instrumentIndex / 8, note, v.currentControl, v.trackIndex, v.patternIndex);
+		"Driller OPL note tick=%u ch=%d inst=%u note=%u ctrl=%02x dur=%u pw=%03x "
+		"sid_ad=%02x sid_sr=%02x opl_ad=%02x opl_sr=%02x sus=%d track=%u pat=%u",
+		_tick, channel, v.instrumentIndex / 8, note, v.currentControl, v.noteDuration,
+		(v.something_else[0] | (v.something_else[2] << 8)) & 0x0FFF,
+		instA0[2], instA0[3], carAD, carSR, sustaining ? 1 : 0, v.trackIndex, v.patternIndex);
 	setOPLInstrument(channel, v);
 	noteOn(channel, v, note);
 }
@@ -688,8 +718,8 @@ void DrillerOPLMusicPlayer::setOPLInstrument(int channel, VoiceState &v) {
 	byte mod = kOPLModOffset[channel];
 	byte car = kOPLCarOffset[channel];
 
-	// Give the carrier the instrument's own envelope (from its SID AD/SR) so the
-	// 22 instruments stay distinct and sustained voices hold instead of fading.
+	// The carrier takes the instrument's own SID envelope, so the 22 instruments
+	// stay distinct and sustained voices hold instead of fading.
 	int instBase = v.instrumentIndex;
 	if (instBase < 0 || instBase >= NUM_INSTRUMENTS * 8)
 		instBase = 0;
@@ -717,28 +747,21 @@ void DrillerOPLMusicPlayer::applyPulseWidth(int channel, const VoiceState &v) {
 	const DrillerOPLBasePatch &patch = kDrillerOPLBasePatches[getDrillerWaveformFamily(v.currentControl)];
 	byte modLevel = patch.modLevel;
 	byte carLevel = patch.carLevel;
-	byte feedbackConnection = patch.feedbackConnection;
 	byte attenuation = getDrillerOPLAttenuation(v.currentControl);
 
 	if (v.currentControl & 0x40) {
+		// Interpolated across the low byte: the sweep moves as little as one unit
+		// per tick, and bare table steps would turn a glide into a staircase.
 		uint16 pulseWidth = (v.something_else[0] | (v.something_else[2] << 8)) & 0x0FFF;
-		uint16 edgeDistance = MIN<uint16>(pulseWidth, 0x1000 - pulseWidth);
-		uint16 centerDistance = pulseWidth < 0x0800 ? 0x0800 - pulseWidth : pulseWidth - 0x0800;
-		byte brightnessBoost = MIN<byte>(centerDistance >> 5, kDrillerPulseBrightnessBoostMax);
-
-		modLevel = patch.modLevel > brightnessBoost ? patch.modLevel - brightnessBoost : 0;
-
-		byte feedback = (patch.feedbackConnection >> 1) & 0x07;
-		// Only a little feedback; near the max (7) the FM tone turns to noise.
-		feedback = MIN<byte>(4, feedback + (centerDistance >> 9));
-		feedbackConnection = (patch.feedbackConnection & 0x01) | (feedback << 1);
-		if (edgeDistance <= kDrillerNarrowPulseEdgeDistance)
-			attenuation = kDrillerNarrowPulseAttenuation;
+		int bucket = pulseWidth >> 8;
+		int frac = pulseWidth & 0xFF;
+		int level = kDrillerPulseModLevel[bucket] +
+			(((int)kDrillerPulseModLevel[bucket + 1] - (int)kDrillerPulseModLevel[bucket]) * frac >> 8);
+		modLevel = (byte)level;
 	}
 
 	byte mod = kOPLModOffset[channel];
 	byte car = kOPLCarOffset[channel];
-	_opl->writeReg(0xC0 + channel, feedbackConnection);
 	_opl->writeReg(0x40 + mod, attenuateDrillerOPLLevel(modLevel, attenuation));
 	_opl->writeReg(0x40 + car, attenuateDrillerOPLLevel(carLevel, attenuation));
 }


Commit: ccebc182882fcac259327fdb6b1663504ed54833
    https://github.com/scummvm/scummvm/commit/ccebc182882fcac259327fdb6b1663504ed54833
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: initial support for crypt atari

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/castle/atari.cpp
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/loaders/8bitBinaryLoader.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 319ba31b9df..54f9811d1bf 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1109,6 +1109,21 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO3(GUIO_NOMIDI, GUIO_RENDERZX, GAMEOPTION_WASD_CONTROLS)
 	},
+	// Castle Master 2, Atari ST, the second disc of "Castle Master & The Crypt"
+	// by Incentive. C.PRG carries no Copylock, unlike the Castle Master disc.
+	{
+		"castlemaster2",
+		"",
+		{
+			{"C.PRG", 0, "0953c495ea8fd11adf25c98885f1fa60", 225666},
+			{"L.PRG", 0, "9526c32330ae9b2b046f29ed95864a8b", 33946},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAtariST,
+		ADGF_UNSTABLE,
+		GUIO3(GUIO_NOMIDI, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
+	},
 	{
 		// Only an EGA executable is shipped, unlike Castle Master
 		"castlemaster2",
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index 049268e3383..8437cfd2f38 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -174,21 +174,67 @@ static uint32 getProTrackerModuleSize(Common::SeekableReadStream *file, uint32 o
 extern byte kAmigaCastlePalette[16][3];
 extern byte kAmigaCastleRiddlePalette[16][3];
 
+// Castle Master, located by matching the shared asset bytes against the Amiga
+// "x" file.
+const CastleAtariLayout kAtariCastleLayout = {
+	0x27946, 178, 0x28410, 0x27928, 0x2f32a, 0x32594, 0x33694,
+	3, 0x49284, 0x4a364, 0x04f24, 0x55d40, 0x55ed0,
+	0x55f20, 0x569d0, 0x56bb0, 0x56ed0, 0x57ef0, 0x594a0, 0x5974a,
+	0x59ae4, 0x59b04, 0x10fb6
+};
+
+// "The Crypt", the second disc of "Castle Master & The Crypt". It carries the
+// interface artwork and the music module byte for byte, 0xf512 lower in the
+// image; what differs is the world, and it has no riddles (the one left in the
+// data is Castle Master's).
+const CastleAtariLayout kAtariCryptLayout = {
+	0x273ec, 165, 0, 0x2731a, 0x2e366, 0x315d0, 0x326d0,
+	0, 0, 0x3ae52, 0x04914, 0x4682e, 0x469be,
+	0x46a0e, 0x474be, 0x4769e, 0x479be, 0x489de, 0x49f8e, 0x4a238,
+	0x4a5d2, 0x4a5f2, 0x109a6
+};
+
+// L.PRG, the loader program that launches The Crypt, copies a palette and then
+// 1000 * 32 bytes straight to the screen, from program $6f4 and $770.
+void CastleEngine::loadAtariLoadingScreen() {
+	Common::File file;
+	if (!file.open("L.PRG"))
+		return;
+
+	byte palette[16][3];
+	file.seek(0x710);
+	for (int i = 0; i < 16; i++) {
+		uint16 color = file.readUint16BE();
+		for (int c = 0; c < 3; c++) {
+			byte v = (color >> (8 - 4 * c)) & 7;
+			palette[i][c] = v * 255 / 7;
+		}
+	}
+
+	file.seek(0x78c);
+	_title = loadFrameFromPlanesInterleaved(&file, 20, 200);
+	_title->convertToInPlace(_gfx->_texturePixelFormat, (byte *)palette, 16);
+}
+
 void CastleEngine::loadAssetsAtariFullGame() {
-	// The player provides the Copylock-decrypted executable as "M.PRG"; it is
-	// still Huffman-packed, so decompress it to obtain the real game binary.
-	// The Atari ST build shares the Amiga data *format* (68000, big-endian) but
-	// lays everything out at different offsets. These offsets were located by
-	// matching the shared world/asset bytes against the Amiga "x" file.
-	Common::SeekableReadStream *file = decompressAtari("M.PRG");
+	// The player provides the Copylock-decrypted executable as "M.PRG" ("C.PRG"
+	// for The Crypt, which carries no Copylock); it is still Huffman-packed, so
+	// decompress it to obtain the real game binary. The Atari ST build shares
+	// the Amiga data *format* (68000, big-endian) at different offsets.
+	const CastleAtariLayout *layout = isCastleMaster2() ? &kAtariCryptLayout : &kAtariCastleLayout;
+	Common::SeekableReadStream *file = decompressAtari(isCastleMaster2() ? "C.PRG" : "M.PRG");
+
+	if (isCastleMaster2())
+		loadAtariLoadingScreen();
 
 	_viewArea = Common::Rect(40, 29, 280, 154);
-	loadMessagesVariableSize(file, 0x27946, 178);
-	loadRiddles(file, 0x28410, 19);
+	loadMessagesVariableSize(file, layout->messages, layout->messageCount);
+	if (layout->riddles)
+		loadRiddles(file, layout->riddles, 19);
 
 	// Font: 90 characters, 8x8, 4 interleaved bitplanes (identical bytes to the
 	// Amiga build, so the Amiga 16-colour palette applies).
-	file->seek(0x2f32a);
+	file->seek(layout->fonts);
 	Common::Array<Graphics::ManagedSurface *> chars;
 	Common::Array<Graphics::ManagedSurface *> charsRiddle;
 	for (int i = 0; i < 90; i++) {
@@ -207,10 +253,10 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	_fontRiddle = Font(charsRiddle);
 	_fontRiddle.setCharWidth(9);
 
-	// Area database: 87 rooms followed by 3 trailing areas, then the global
-	// area 255.
-	load8bitBinary(file, 0x33694, 16);
-	for (int i = 0; i < 3; i++) {
+	// Castle Master has 87 rooms followed by 3 trailing areas and then the
+	// global area 255; The Crypt lists all 49 of its areas, 255 included.
+	load8bitBinary(file, layout->areaDB, 16);
+	for (int i = 0; i < layout->extraAreas; i++) {
 		Area *newArea = load8bitArea(file, 16);
 		if (newArea) {
 			if (!_areaMap.contains(newArea->getAreaID()))
@@ -221,10 +267,10 @@ void CastleEngine::loadAssetsAtariFullGame() {
 			error("Invalid area %d?", i);
 	}
 
-	loadPalettes(file, 0x32594);
+	loadPalettes(file, layout->palettes);
 
 	// COLOR15 cycling table, terminated by 0xFFFF.
-	file->seek(0x27928);
+	file->seek(layout->colorCycling);
 	while (true) {
 		uint16 val = file->readUint16BE();
 		if (val == 0xFFFF)
@@ -232,49 +278,51 @@ void CastleEngine::loadAssetsAtariFullGame() {
 		_gfx->_colorCyclingTable.push_back(val);
 	}
 
-	file->seek(0x49284); // Global area 255
-	_areaMap[255] = load8bitArea(file, 16);
+	if (layout->area255) {
+		file->seek(layout->area255);
+		_areaMap[255] = load8bitArea(file, 16);
+	}
 
-	// In-game border frame (the "Castle Master" title + castle walls + bottom
-	// UI bar surrounding the 3D viewport). 320x200, stored as Atari ST
+	// In-game border frame (the game title + castle walls + bottom UI bar
+	// surrounding the 3D viewport). 320x200, stored as Atari ST
 	// word-interleaved bitplanes; identical artwork to the Amiga build (which
 	// keeps it in vertical-planar form), so the Amiga palette applies.
-	file->seek(0x4a364);
+	file->seek(layout->border);
 	_border = loadFrameFromPlanesInterleaved(file, 20, 200);
 	_border->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Mountains panorama (63 words x 22 rows, interleaved) - same bytes/format
 	// as the Amiga build.
-	file->seek(0x4f24);
+	file->seek(layout->mountains);
 	_background = loadFrameFromPlanesInterleaved(file, 63, 22);
 	_background->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Spirit meter, strength-weight and key/eye sprites (shared with the Amiga
 	// build, relocated in the Atari binary).
-	file->seek(0x55d40);
+	file->seek(layout->spiritMeterBg);
 	_spiritsMeterIndicatorBackgroundFrame = loadFrameFromPlanesInterleaved(file, 5, 10);
 	_spiritsMeterIndicatorBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	file->seek(0x55ed0);
+	file->seek(layout->spiritMeter);
 	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(file, 1, 10);
 	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Weight discs of the strength barbell: 4 frames of 1 word x 15 rows,
 	// followed by the 5 word x 3 row shaft.
-	file->seek(0x569d0);
+	file->seek(layout->weights);
 	for (int i = 0; i < 4; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 15);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_strenghtWeightsFrames.push_back(frame);
 	}
 
-	file->seek(0x56bb0);
+	file->seek(layout->bar);
 	_strenghtBarFrame = loadFrameFromPlanesInterleaved(file, 5, 3);
 	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	loadThunderFramesAmiga(file, 0x55f20);
+	loadThunderFramesAmiga(file, layout->thunder);
 
-	file->seek(0x594a0);
+	file->seek(layout->eyeIcons);
 	for (int i = 0; i < 12; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 7);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -282,7 +330,7 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	}
 
 	// Flag animation: 5 frames x 2 words x 11 rows.
-	file->seek(0x5974a);
+	file->seek(layout->flag);
 	for (int i = 0; i < 5; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 2, 11);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -291,12 +339,12 @@ void CastleEngine::loadAssetsAtariFullGame() {
 
 	// Riddle frames: a 16-word transparency mask followed by the top/background/
 	// bottom frames, masked and drawn with the riddle palette.
-	file->seek(0x59ae4);
+	file->seek(layout->riddleMask);
 	uint16 riddleMask[16];
 	for (int i = 0; i < 16; i++)
 		riddleMask[i] = file->readUint16BE();
 
-	file->seek(0x59b04);
+	file->seek(layout->riddleTop);
 	_riddleTopFrame = loadFrameFromPlanesInterleaved(file, 16, 20);
 	_riddleBackgroundFrame = loadFrameFromPlanesInterleaved(file, 16, 1);
 	_riddleBottomFrame = loadFrameFromPlanesInterleaved(file, 16, 8);
@@ -332,9 +380,9 @@ void CastleEngine::loadAssetsAtariFullGame() {
 
 		byte pixelData[kTotalSrcRows * kPixelBytesPerRow];
 		byte maskData[kTotalSrcRows * kMaskBytesPerRow];
-		file->seek(0x56ed0);
+		file->seek(layout->gatePixels);
 		file->read(pixelData, sizeof(pixelData));
-		file->seek(0x57ef0);
+		file->seek(layout->gateMask);
 		file->read(maskData, sizeof(maskData));
 
 		uint32 keyColor = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x24, 0xA5);
@@ -408,12 +456,11 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	// sprites also still need to be located.
 
 	// The full Atari ST binary embeds the same ProTracker module used by the
-	// Amiga full game. It starts at TEXT $10F9A / stream offset $10FB6.
-	static const uint32 kAtariMusicDataOffset = 0x10fb6;
-	uint32 modSize = getProTrackerModuleSize(file, kAtariMusicDataOffset);
+	// Amiga full game; The Crypt ships that module again, unchanged.
+	uint32 modSize = getProTrackerModuleSize(file, layout->mod);
 	if (modSize > 0) {
 		_modData.resize(modSize);
-		file->seek(kAtariMusicDataOffset);
+		file->seek(layout->mod);
 		file->read(_modData.data(), modSize);
 	}
 
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index c87ee52caae..66f68639d93 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -737,14 +737,15 @@ void CastleEngine::initGameState() {
 	_playerHeightNumber = 1;
 
 	// Platform-specific message strings (indices differ between DOS and Amiga)
-	if (isAmiga() || isAtariST()) {
+	if ((isAmiga() || isAtariST()) && !isCastleMaster2()) {
 		_notEnoughRoomMessage = _messagesList[21];
 		_tooWeakMessage = _messagesList[22];
 		_crawlSelectedMessage = _messagesList[23];
 		_walkSelectedMessage = _messagesList[24];
 		_runSelectedMessage = _messagesList[25];
 		_ghostInAreaMessage = _messagesList[126];
-	} else if (isDOS()) {
+	} else if (isDOS() || isAtariST()) {
+		// The Crypt's Atari ST entries 0 to 124 are the DOS ones, in order
 		_notEnoughRoomMessage = _messagesList[11];
 		_tooWeakMessage = _messagesList[12];
 		_crawlSelectedMessage = _messagesList[13];
@@ -844,7 +845,7 @@ void CastleEngine::endGame() {
 	_endGamePlayerEndArea = true;
 
 	if (hasEscaped()) {
-		insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], INT_MIN);
+		insertTemporaryMessage(_messagesList[((isAmiga() || isAtariST()) && !isCastleMaster2()) ? 15 : 5], INT_MIN);
 
 		if (isDOS() && !isCastleMaster2()) {
 			drawFullscreenEndGameAndWait();
@@ -1085,14 +1086,22 @@ void CastleEngine::drawInfoMenu() {
 		// Score at (167, 71): move.w #$a7,d0; move.w #$47,d1
 		drawStringInSurface(Common::String::format("%07d", score), 167, 71, front, black, surface);
 
+		// The Crypt dropped Castle Master's ten key names from the front of the
+		// string table, so every index below moves. Its entries were read off the
+		// data, not shifted: Castle Master's own constants sit two entries above
+		// the strings they name.
+		const bool crypt = isCastleMaster2();
+
 		// Shield at (154, 102): move.w #$9a,d0; move.w #$66,d1
 		// Index = (shield - 1) / 4 (from: subq #1,d0; lsr #2,d0; muls #$c,d0)
-		// Amiga shield text at message indices 171-177 (skipping 174 which is empty)
+		// Shield text at message indices 171-177 (skipping 174 which is empty)
 		{
 			static const int kAmigaShieldMsgIdx[] = {171, 172, 173, 175, 176, 177};
+			static const int kCryptShieldMsgIdx[] = {158, 159, 160, 162, 163, 164};
 			int shieldIdx = (shield > 0) ? (shield - 1) / 4 : 0;
 			if (shieldIdx > 5) shieldIdx = 5;
-			drawStringInSurface(centerAndPadString(_messagesList[kAmigaShieldMsgIdx[shieldIdx]], 10), 154, 102, front, black, surface);
+			int idx = crypt ? kCryptShieldMsgIdx[shieldIdx] : kAmigaShieldMsgIdx[shieldIdx];
+			drawStringInSurface(centerAndPadString(_messagesList[idx], 10), 154, 102, front, black, surface);
 		}
 
 		// Keys collected at (104, 41): move.w #$68,d0; move.w #$29,d1 (from FUN_22CC)
@@ -1101,11 +1110,11 @@ void CastleEngine::drawInfoMenu() {
 			Common::String keysText;
 			int numKeys = _keysCollected.size();
 			if (numKeys == 0)
-				keysText = _messagesList[162];
+				keysText = _messagesList[crypt ? 149 : 162];
 			else if (numKeys == 1)
-				keysText = _messagesList[164];
+				keysText = _messagesList[crypt ? 151 : 164];
 			else {
-				keysText = _messagesList[163];
+				keysText = _messagesList[crypt ? 150 : 163];
 				Common::replace(keysText, "XX", Common::String::format("%2d", numKeys));
 			}
 			drawStringInSurface(keysText, 104, 41, front, black, surface);
@@ -1116,9 +1125,9 @@ void CastleEngine::drawInfoMenu() {
 		{
 			Common::String spiritsText;
 			if (spiritsDestroyed == 0)
-				spiritsText = _messagesList[156];
+				spiritsText = _messagesList[crypt ? 143 : 156];
 			else {
-				spiritsText = _messagesList[157];
+				spiritsText = _messagesList[crypt ? 144 : 157];
 				Common::replace(spiritsText, "XX", Common::String::format("%2d", spiritsDestroyed));
 			}
 			drawStringInSurface(spiritsText, 145, 133, front, black, surface);
@@ -1505,7 +1514,7 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	}
 
 	if (!isDOS() && hasEscaped()) {
-		insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], _countdown - 1);
+		insertTemporaryMessage(_messagesList[((isAmiga() || isAtariST()) && !isCastleMaster2()) ? 15 : 5], _countdown - 1);
 	}
 
 	while (!shouldQuit() && cont) {
@@ -1514,7 +1523,7 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 			insertTemporaryMessage(spiritsDestroyedString, _countdown - 4);
 			insertTemporaryMessage(keysCollectedString, _countdown - 6);
 			if (!isDOS() && hasEscaped()) {
-				insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], _countdown - 8);
+				insertTemporaryMessage(_messagesList[((isAmiga() || isAtariST()) && !isCastleMaster2()) ? 15 : 5], _countdown - 8);
 			}
 		}
 
@@ -2164,7 +2173,9 @@ void CastleEngine::updateTimeVariables() {
 void CastleEngine::borderScreen() {
 	if (isAmiga() && isDemo())
 		return; // Skip character selection
-	if (isAtariST()) {
+	// The Crypt ships no intro program, so it drops through to the plain
+	// configuration menu as it does on DOS
+	if (isAtariST() && !isCastleMaster2()) {
 		playAtariIntro();
 		return;
 	}
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index c722ba7f8a7..2be8239528e 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -29,6 +29,16 @@ struct CastleAmigaLayout {
 	int gatePixels, gateMask, mod;
 };
 
+// The same, for a decompressed Atari ST game program. Castle Master and its
+// sequel share the layout; riddles, extraAreas and area255 are zero when the
+// release has no such block.
+struct CastleAtariLayout {
+	int messages, messageCount, riddles, colorCycling, fonts, palettes, areaDB;
+	int extraAreas, area255, border, mountains, spiritMeterBg, spiritMeter;
+	int thunder, weights, bar, gatePixels, gateMask, eyeIcons, flag;
+	int riddleMask, riddleTop, mod;
+};
+
 class MusicPlayer;
 
 struct RiddleText {
@@ -219,6 +229,7 @@ public:
 private:
 	Common::SeekableReadStream *decryptFile(const Common::Path &filename);
 	Common::SeekableReadStream *decompressAtari(const Common::Path &filename);
+	void loadAtariLoadingScreen();
 	void loadRiddles(Common::SeekableReadStream *file, int offset, int number);
 	void loadMessagesC64(Common::SeekableReadStream *file, int offset, int number);
 	void loadRiddlesC64(Common::SeekableReadStream *file, int offset, int number);
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 0691378ed21..8b732d7903e 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -757,7 +757,8 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 			// The room structure is not an area, the byte above is unrelated data
 			name = "GLOBAL";
 		} else if (isAmiga() || isAtariST())
-			name = _messagesList[idx + 51];
+			// The Crypt's area names sit where the DOS ones do
+			name = _messagesList[idx + (isCastleMaster2() ? 41 : 51)];
 		else if (isSpectrum() || isCPC() || isC64())
 			name = _messagesList[idx + (isCastleMaster2() ? 41 : 16)];
 		else
@@ -875,7 +876,8 @@ void FreescapeEngine::load8bitBinary(Common::SeekableReadStream *file, int offse
 	// The Castle Master Amiga/Atari ST binaries store the count as 0x68 (104)
 	// but the area pointer table only has 87 valid entries; the demo and the
 	// full game share the same asset section so the same override applies.
-	if ((isAmiga() || isAtariST()) && isCastle())
+	// The Crypt stores its real count (49), so it must not be overridden.
+	if ((isAmiga() || isAtariST()) && isCastle() && !isCastleMaster2())
 		numberOfAreas = 87;
 	debugC(1, kFreescapeDebugParser, "Number of areas: %d", numberOfAreas);
 


Commit: ff32415ac6c116a8c248057252ce8f15aa329afc
    https://github.com/scummvm/scummvm/commit/ff32415ac6c116a8c248057252ce8f15aa329afc
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: initial support for crypt amiga

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/castle/amiga.cpp
    engines/freescape/games/castle/castle.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 54f9811d1bf..1912467e211 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1109,6 +1109,21 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO3(GUIO_NOMIDI, GUIO_RENDERZX, GAMEOPTION_WASD_CONTROLS)
 	},
+	// Castle Master 2, Amiga, the other game on the "Castle Master & The Crypt"
+	// disc by Incentive, packed into "crypt.com" like its companion.
+	{
+		"castlemaster2",
+		"",
+		{
+			{"crypt.com", 0, "6b56e849a9487a3c46e2ff9bbaf1f351", 177218},
+			{"thecrypt.neo", 0, "358873947261352242c75788066dfbc9", 32000},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAmiga,
+		ADGF_UNSTABLE,
+		GUIO3(GUIO_NOMIDI, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
+	},
 	// Castle Master 2, Atari ST, the second disc of "Castle Master & The Crypt"
 	// by Incentive. C.PRG carries no Copylock, unlike the Castle Master disc.
 	{
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index f7bd1944f81..42e6a289164 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -162,11 +162,43 @@ const CastleAmigaLayout kAmigaIncentiveLayout = {
 	0x3d684
 };
 
+// "The Crypt", the other game on the Incentive disc, packed the same way into
+// "crypt.com". It carries the Castle Master interface artwork and the music
+// module byte for byte, 0xf740 lower in the image; it has no riddles, and the
+// global area 255 is listed in the area table rather than sitting on its own.
+const CastleAmigaLayout kAmigaCryptLayout = {
+	0x09d1c, 0, 0x09c4a, 0x10dd8, 0x14092, 0x1358a, 0x15192,
+	0, 0x1d914, 0x04086, 0x25a84, 0x27554, 0x292f0, 0x29480,
+	0x28d44, 0x294d0, 0x29f80, 0x2a160, 0x2ca50, 0x2ccfa, 0x2d094, 0x2d0b4,
+	0x2a480, 0x2b4a0, 0x2df44
+};
+
+// "thecrypt.neo" ships without a palette; the intro program that displays it
+// holds this one at offset 0x20ca.
+byte kAmigaCryptTitlePalette[16][3] = {
+	{0x00, 0x00, 0x00}, {0x22, 0x44, 0x66}, {0x66, 0x11, 0xaa}, {0x22, 0x22, 0x44},
+	{0x44, 0x44, 0x88}, {0x66, 0x66, 0xaa}, {0x22, 0x22, 0x44}, {0x22, 0x22, 0x66},
+	{0x66, 0x44, 0x88}, {0x88, 0x88, 0xcc}, {0x22, 0x22, 0x66}, {0x44, 0x22, 0x88},
+	{0x44, 0x22, 0x88}, {0x44, 0x66, 0x88}, {0xff, 0xff, 0xff}, {0x00, 0x00, 0x22}
+};
+
 // The game image, either read as is or unpacked, with the matching layout
 Common::SeekableReadStream *CastleEngine::openAmigaGameFile(const CastleAmigaLayout *&layout) {
 	Common::File file;
 	Common::SeekableReadStream *stream = nullptr;
 
+	if (isCastleMaster2()) {
+		if (!file.open("crypt.com"))
+			error("Failed to open 'crypt.com'");
+		stream = decompressCastle(&file, 0);
+		if (stream->size() != 290098) {
+			delete stream;
+			error("Unknown Castle Master 2 (Amiga) build");
+		}
+		layout = &kAmigaCryptLayout;
+		return stream;
+	}
+
 	if (file.open("x"))
 		stream = file.readStream(file.size());
 	else if (file.open("cmstr.com"))
@@ -1588,8 +1620,9 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	Common::SeekableReadStream &file = *stream;
 
 	_viewArea = Common::Rect(40, 29, 280, 154);
-	loadMessagesVariableSize(&file, layout->messages, 178);
-	loadRiddles(&file, layout->riddles, 19);
+	loadMessagesVariableSize(&file, layout->messages, isCastleMaster2() ? 165 : 178);
+	if (layout->riddles)
+		loadRiddles(&file, layout->riddles, 19);
 
 	file.seek(layout->fonts);
 	Common::Array<Graphics::ManagedSurface *> chars;
@@ -1612,8 +1645,10 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	_fontRiddle = Font(charsRiddle);
 	_fontRiddle.setCharWidth(9);
 
+	// Castle Master has 3 areas trailing the pointer table; The Crypt lists all
+	// of its 49, the global 255 included.
 	load8bitBinary(&file, layout->areaDB, 16);
-	for (int i = 0; i < 3; i++) {
+	for (int i = 0; i < (isCastleMaster2() ? 0 : 3); i++) {
 		debugC(1, kFreescapeDebugParser, "Continue to parse area index %d at offset %x", _areaMap.size() + i + 1, (int)file.pos());
 		Area *newArea = load8bitArea(&file, 16);
 		if (newArea) {
@@ -1636,19 +1671,29 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 		_gfx->_colorCyclingTable.push_back(val);
 	}
 
-	file.seek(layout->area255);
-	_areaMap[255] = load8bitArea(&file, 16);
+	if (layout->area255) {
+		file.seek(layout->area255);
+		_areaMap[255] = load8bitArea(&file, 16);
+	}
 
 	// Border NEO image (demo loaded at 0x2cf28 + 0x28 - 0x2 + 0x28 = 0x2cf76)
 	file.seek(layout->border);
 	_border = loadFrameFromPlanesVertical(&file, 160, 200);
 	_border->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
+	// The Crypt's loading screen, shown by the intro program before the game.
+	Common::File titleFile;
+	if (isCastleMaster2() && titleFile.open("thecrypt.neo")) {
+		_title = loadFrameFromPlanesVertical(&titleFile, 160, 200);
+		_title->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCryptTitlePalette, 16);
+		titleFile.close();
+	}
+
 	// End-game throne picture. The original executable opens "W" during the
 	// escaped ending and displays the first 114 rows as a 16-word-wide
 	// interleaved Amiga bitplane image.
 	Common::File endGameFile;
-	if (endGameFile.open("w")) {
+	if (!isCastleMaster2() && endGameFile.open("w")) {
 		_endGameBackgroundFrame = loadFrameFromPlanesInterleaved(&endGameFile, 16, 114);
 		_endGameBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		endGameFile.close();
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 66f68639d93..82f1b7c082d 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -744,8 +744,8 @@ void CastleEngine::initGameState() {
 		_walkSelectedMessage = _messagesList[24];
 		_runSelectedMessage = _messagesList[25];
 		_ghostInAreaMessage = _messagesList[126];
-	} else if (isDOS() || isAtariST()) {
-		// The Crypt's Atari ST entries 0 to 124 are the DOS ones, in order
+	} else if (isDOS() || isAtariST() || isAmiga()) {
+		// The Crypt's Amiga and Atari ST entries 0 to 124 are the DOS ones
 		_notEnoughRoomMessage = _messagesList[11];
 		_tooWeakMessage = _messagesList[12];
 		_crawlSelectedMessage = _messagesList[13];
@@ -2173,13 +2173,13 @@ void CastleEngine::updateTimeVariables() {
 void CastleEngine::borderScreen() {
 	if (isAmiga() && isDemo())
 		return; // Skip character selection
-	// The Crypt ships no intro program, so it drops through to the plain
+	// The Crypt has no intro of its own, so it drops through to the plain
 	// configuration menu as it does on DOS
 	if (isAtariST() && !isCastleMaster2()) {
 		playAtariIntro();
 		return;
 	}
-	if (isAmiga()) {
+	if (isAmiga() && !isCastleMaster2()) {
 		if (playAmigaIntro())
 			return;
 		selectCharacterScreen();


Commit: 2567e00261dbd8078ef9465c6009ab71e4c617bf
    https://github.com/scummvm/scummvm/commit/2567e00261dbd8078ef9465c6009ab71e4c617bf
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: fixed text messages in castle amiga and atari

Changed paths:
    engines/freescape/games/castle/castle.cpp


diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 82f1b7c082d..38b181c26d8 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -1094,9 +1094,11 @@ void CastleEngine::drawInfoMenu() {
 
 		// Shield at (154, 102): move.w #$9a,d0; move.w #$66,d1
 		// Index = (shield - 1) / 4 (from: subq #1,d0; lsr #2,d0; muls #$c,d0)
-		// Shield text at message indices 171-177 (skipping 174 which is empty)
+		// The original indexes 12-byte records; the two whose text is only 10
+		// characters long leave a spare terminator that reaches the engine as an
+		// extra empty string, which is why 172 and 176 are skipped here.
 		{
-			static const int kAmigaShieldMsgIdx[] = {171, 172, 173, 175, 176, 177};
+			static const int kAmigaShieldMsgIdx[] = {169, 170, 171, 173, 174, 175};
 			static const int kCryptShieldMsgIdx[] = {158, 159, 160, 162, 163, 164};
 			int shieldIdx = (shield > 0) ? (shield - 1) / 4 : 0;
 			if (shieldIdx > 5) shieldIdx = 5;
@@ -1105,29 +1107,29 @@ void CastleEngine::drawInfoMenu() {
 		}
 
 		// Keys collected at (104, 41): move.w #$68,d0; move.w #$29,d1 (from FUN_22CC)
-		// Messages: 162="NO KEYS COLLECTED", 163="XX KEYS COLLECTED", 164=" 1 KEY COLLECTED"
+		// Messages: 160="NO KEYS COLLECTED", 161="XX KEYS COLLECTED", 162=" 1 KEY COLLECTED"
 		{
 			Common::String keysText;
 			int numKeys = _keysCollected.size();
 			if (numKeys == 0)
-				keysText = _messagesList[crypt ? 149 : 162];
+				keysText = _messagesList[crypt ? 149 : 160];
 			else if (numKeys == 1)
-				keysText = _messagesList[crypt ? 151 : 164];
+				keysText = _messagesList[crypt ? 151 : 162];
 			else {
-				keysText = _messagesList[crypt ? 150 : 163];
+				keysText = _messagesList[crypt ? 150 : 161];
 				Common::replace(keysText, "XX", Common::String::format("%2d", numKeys));
 			}
 			drawStringInSurface(keysText, 104, 41, front, black, surface);
 		}
 
 		// Spirits destroyed at (145, 133): move.w #$91,d0; move.w #$85,d1
-		// Messages: 156="NONE DESTROYED", 157=" XX DESTROYED "
+		// Messages: 154="NONE DESTROYED", 155=" XX DESTROYED "
 		{
 			Common::String spiritsText;
 			if (spiritsDestroyed == 0)
-				spiritsText = _messagesList[crypt ? 143 : 156];
+				spiritsText = _messagesList[crypt ? 143 : 154];
 			else {
-				spiritsText = _messagesList[crypt ? 144 : 157];
+				spiritsText = _messagesList[crypt ? 144 : 155];
 				Common::replace(spiritsText, "XX", Common::String::format("%2d", spiritsDestroyed));
 			}
 			drawStringInSurface(spiritsText, 145, 133, front, black, surface);
@@ -1599,7 +1601,9 @@ void CastleEngine::executePrint(FCLInstruction &instruction) {
 		drawFullscreenRiddleAndWait(index);
 		return;
 	}
-	if (isAmiga() || isAtariST()) {
+	// Past the ten key names, as everywhere else in the Amiga and Atari ST
+	// tables; The Crypt has none of them
+	if ((isAmiga() || isAtariST()) && !isCastleMaster2()) {
 		index = index + 10;
 	}
 	debugC(1, kFreescapeDebugCode, "Printing message %d: \"%s\"", index, _messagesList[index].c_str());
@@ -1618,13 +1622,26 @@ void CastleEngine::loadAssets() {
 	_endArea = 1;
 	_endEntrance = 42;
 
-	_timeoutMessage = _messagesList[1];
+	// Castle Master's Amiga and Atari ST string tables begin with the ten key
+	// names, so every index into the game text that follows moves by 10. The
+	// Crypt has no such prefix and keeps the DOS numbering.
+	const int msg = ((isAmiga() || isAtariST()) && !isCastleMaster2()) ? 10 : 0;
+
+	_timeoutMessage = _messagesList[msg + 1];
 	// Shield is unused in Castle Master
-	_noEnergyMessage = _messagesList[2];
-	_crushedMessage = _messagesList[3];
-	_fallenMessage = _messagesList[4];
-	_outOfReachMessage = _messagesList[7];
-	_noEffectMessage = _messagesList[8];
+	_noEnergyMessage = _messagesList[msg + 2];
+	_crushedMessage = _messagesList[msg + 3];
+	_fallenMessage = _messagesList[msg + 4];
+	_outOfReachMessage = _messagesList[msg + 7];
+	_noEffectMessage = _messagesList[msg + 8];
+
+	// Castle Master's Amiga and Atari ST discs hold the last two riddles the
+	// other way round: 17 is the Magister ending and 18 the drawbridge hint,
+	// while the area scripts ask for them by the DOS numbering. Swap them so
+	// every platform indexes the same riddle. The demo asks for its one riddle
+	// by number instead of through a script, so it is left as it is.
+	if ((isAmiga() || isAtariST()) && !isCastleMaster2() && !isDemo() && _riddleList.size() > 18)
+		SWAP(_riddleList[17], _riddleList[18]);
 
 	if (!isAmiga() && !isAtariST() && !isCPC() && !isC64()) {
 		Graphics::Surface *tmp;
@@ -1866,7 +1883,7 @@ void CastleEngine::drawRiddle(uint16 riddle, uint32 front, uint32 back, Graphics
 	} else if (isSpectrum()) {
 		x = 64;
 		y = 37;
-	} else if (isAmiga()) {
+	} else if (isAmiga() || isAtariST()) {
 		x = 32;
 		y = 33;
 		maxWidth = 139;
@@ -1926,7 +1943,10 @@ void CastleEngine::drawRiddle(uint16 riddle, uint32 front, uint32 back, Graphics
 	} else if (isSpectrum()) {
 		x = 64;
 		y = 36;
-	} else if (isAmiga()) {
+	} else if (isAmiga() || isAtariST()) {
+		// Every release overrides the origin stored with the riddle; on the
+		// Amiga and Atari ST that field is not a coordinate pair at all, but the
+		// pointer table the original used to find each riddle.
 		x = 40;
 		y = 32;
 	}


Commit: 628bb33e2ec6dbecd244a8cfeec445b9667e30e9
    https://github.com/scummvm/scummvm/commit/628bb33e2ec6dbecd244a8cfeec445b9667e30e9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: fixed incorrect palette in castle amiga and atari

Changed paths:
    engines/freescape/games/castle/castle.cpp


diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 38b181c26d8..3b66a2de526 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -695,8 +695,11 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 		_gfx->_colorPair[_currentArea->_paperColor] = _currentArea->_extraColor[2];
 		_gfx->_colorPair[_currentArea->_inkColor] = _currentArea->_extraColor[3];
 	} else if (isAmiga() || isAtariST()) {
-		// Unclear why these colors are always overwritten (the Atari ST build
-		// shares the Amiga rendering and needs the same 3D-world greys).
+		// The 3D world always draws its structural greys from a fixed ramp of
+		// 0x44, 0x66, 0x88, 0xaa and 0xcc, whatever the area palette holds. 126
+		// of the 128 area palettes already store 0xaaa at index 4, so writing it
+		// only matters where one does not: the Wizard's Hut leaves 0 through 5
+		// unset, and its door and cabinet came out black.
 		byte (*palette)[16][3] = (byte (*)[16][3])_gfx->_palette;
 
 		(*palette)[1][0] = 0x44;
@@ -711,6 +714,10 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 		(*palette)[3][1] = 0x88;
 		(*palette)[3][2] = 0x88;
 
+		(*palette)[4][0] = 0xaa;
+		(*palette)[4][1] = 0xaa;
+		(*palette)[4][2] = 0xaa;
+
 		(*palette)[5][0] = 0xcc;
 		(*palette)[5][1] = 0xcc;
 		(*palette)[5][2] = 0xcc;


Commit: aa4b9e764bb12e6debc06ff37ef07af587863155
    https://github.com/scummvm/scummvm/commit/aa4b9e764bb12e6debc06ff37ef07af587863155
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: removed invalid sounds from castle amiga demo

Changed paths:
    engines/freescape/sound/amiga.cpp


diff --git a/engines/freescape/sound/amiga.cpp b/engines/freescape/sound/amiga.cpp
index a8be617a3c1..2285530a29d 100644
--- a/engines/freescape/sound/amiga.cpp
+++ b/engines/freescape/sound/amiga.cpp
@@ -436,6 +436,7 @@ public:
 
 private:
 	void loadDmaSamples(Common::SeekableReadStream *file, const Common::Path &sampleBank, int modOffset);
+	void dropSoundsWithoutSamples();
 
 	Common::Array<AmigaSfxEntry> _amigaSfxTable;
 	Common::Array<AmigaDmaSample> _amigaDmaSamples;
@@ -462,6 +463,41 @@ void SoundAmigaDemo::loadSounds(Common::SeekableReadStream *file, int offset, in
 	debugC(1, kFreescapeDebugParser, "Loaded %d Amiga sound effects", numSounds);
 
 	loadDmaSamples(file, sampleBank, modOffset);
+	dropSoundsWithoutSamples();
+}
+
+// A missing sample leaves AUD0 on the shared square wave, so the volume command
+// that follows turns a sampled effect into a bare tone. The demo disc ships no
+// bank, so drop those entries instead of beeping.
+void SoundAmigaDemo::dropSoundsWithoutSamples() {
+	int dropped = 0;
+	for (uint i = 0; i < _amigaSfxTable.size(); i++) {
+		AmigaSfxEntry &entry = _amigaSfxTable[i];
+		bool missing = false;
+
+		for (uint j = 0; j < entry.commands.size(); j++) {
+			uint16 command = entry.commands[j];
+			if ((command >> 12) != 5)
+				continue;
+
+			// 0x5NNN carries three parameter words of its own
+			uint16 slot = command & 0x0fff;
+			j += 3;
+			if (slot == 0 || slot >= _amigaDmaSamples.size() || _amigaDmaSamples[slot].data.empty()) {
+				missing = true;
+				break;
+			}
+		}
+
+		if (missing) {
+			entry.commands.clear();
+			dropped++;
+			debugC(1, kFreescapeDebugParser, "Amiga SFX %d needs a sample that is not present, dropped", i);
+		}
+	}
+
+	if (dropped > 0)
+		debugC(1, kFreescapeDebugParser, "Dropped %d Amiga sound effects with no sample data", dropped);
 }
 
 // The samples played by 0x5NNN come from an external bank, which the original


Commit: 97e8e907fef0a5ecad6ac295bc5a25f81ef1d183
    https://github.com/scummvm/scummvm/commit/97e8e907fef0a5ecad6ac295bc5a25f81ef1d183
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: implemented sounds from castle atari

Changed paths:
  A engines/freescape/sound/atari.cpp
    engines/freescape/freescape.h
    engines/freescape/games/castle/atari.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/module.mk


diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index edd64114b7b..f8dcd533c2a 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -535,6 +535,7 @@ public:
 	// sampleBank names the external PCM bank, modOffset points at the embedded
 	// module used for one extra sample; both are optional (empty path, -1)
 	Sound *loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, const Common::Path &sampleBank, int modOffset);
+	Sound *loadSoundsAtariCastle(Common::SeekableReadStream *file, int offset, int numSounds, int bankOffset);
 
 	int _soundIndexShoot;
 	int _soundIndexCollide;
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index 8437cfd2f38..e5c252bfaa9 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -180,7 +180,7 @@ const CastleAtariLayout kAtariCastleLayout = {
 	0x27946, 178, 0x28410, 0x27928, 0x2f32a, 0x32594, 0x33694,
 	3, 0x49284, 0x4a364, 0x04f24, 0x55d40, 0x55ed0,
 	0x55f20, 0x569d0, 0x56bb0, 0x56ed0, 0x57ef0, 0x594a0, 0x5974a,
-	0x59ae4, 0x59b04, 0x10fb6
+	0x59ae4, 0x59b04, 0x10fb6, 0x31adc, 0x5a994
 };
 
 // "The Crypt", the second disc of "Castle Master & The Crypt". It carries the
@@ -191,7 +191,7 @@ const CastleAtariLayout kAtariCryptLayout = {
 	0x273ec, 165, 0, 0x2731a, 0x2e366, 0x315d0, 0x326d0,
 	0, 0, 0x3ae52, 0x04914, 0x4682e, 0x469be,
 	0x46a0e, 0x474be, 0x4769e, 0x479be, 0x489de, 0x49f8e, 0x4a238,
-	0x4a5d2, 0x4a5f2, 0x109a6
+	0x4a5d2, 0x4a5f2, 0x109a6, 0x30b18, 0x4b482
 };
 
 // L.PRG, the loader program that launches The Crypt, copies a palette and then
@@ -455,6 +455,10 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	// menu is guarded against the missing surfaces. The mouse cursor / crosshair
 	// sprites also still need to be located.
 
+	// Same command table as the Amiga, and the bank the Amiga ships as the
+	// external "cmsnds2" is embedded here instead
+	_sound = loadSoundsAtariCastle(file, layout->soundTable, 36, layout->soundBank);
+
 	// The full Atari ST binary embeds the same ProTracker module used by the
 	// Amiga full game; The Crypt ships that module again, unchanged.
 	uint32 modSize = getProTrackerModuleSize(file, layout->mod);
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 2be8239528e..a6b7669fe2f 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -36,7 +36,7 @@ struct CastleAtariLayout {
 	int messages, messageCount, riddles, colorCycling, fonts, palettes, areaDB;
 	int extraAreas, area255, border, mountains, spiritMeterBg, spiritMeter;
 	int thunder, weights, bar, gatePixels, gateMask, eyeIcons, flag;
-	int riddleMask, riddleTop, mod;
+	int riddleMask, riddleTop, mod, soundTable, soundBank;
 };
 
 class MusicPlayer;
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index e63dc3a387d..cf4c1f7cbee 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -66,6 +66,7 @@ MODULE_OBJS := \
 	objects/sensor.o \
 	sweepAABB.o \
 	sound/amiga.o \
+	sound/atari.o \
 	sound/common.o \
 	sound/cpc.o \
 	sound/dos.o \
diff --git a/engines/freescape/sound/atari.cpp b/engines/freescape/sound/atari.cpp
new file mode 100644
index 00000000000..e43ef943e11
--- /dev/null
+++ b/engines/freescape/sound/atari.cpp
@@ -0,0 +1,553 @@
+/* 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 "audio/audiostream.h"
+#include "audio/softsynth/ay8912.h"
+
+#include "common/mutex.h"
+#include "common/ptr.h"
+
+#include "freescape/freescape.h"
+
+namespace Freescape {
+
+struct AtariSfxEntry {
+	byte priority;
+	Common::Array<uint16> commands;
+};
+
+struct AtariPcmSample {
+	Common::Array<int8> data;
+};
+
+struct AtariSfxPriority {
+	AtariSfxPriority() : value(0) {}
+	int value;
+};
+
+// YM2149 clock and MFP timer clock of a standard ST
+const int kAtariYmClock = 2000000;
+const int kAtariMfpClock = 2457600;
+// Timer A runs with prescaler 3, and the driver refuses to go below 0x11
+const int kAtariTimerPrescale = 16;
+const int kAtariMinTimerData = 0x11;
+// Register 7: tone A/B/C on (bits 0-2 clear), noise off everywhere
+const byte kAtariMixerBase = 0xF8;
+
+/**
+ * Castle Master Atari ST sound engine: the same 50Hz command interpreter and the
+ * same sound table as the Amiga release, over completely different hardware.
+ *
+ * The three YM2149 channels carry the tones. Sampled effects are played by an
+ * MFP Timer A interrupt (prog $29fe) that walks the PCM byte by byte and writes
+ * all three volume registers from a table at $2aee, which is how an 8-bit sample
+ * comes out of three 4-bit DACs. That table is a linearisation of the signed
+ * sample, so the samples are mixed here as the plain signed PCM they are.
+ *
+ * Commands are 16-bit big-endian words: type in bits 15-12, parameter below.
+ *
+ *   0x0xxx/0x1xxx/0x2xxx  tone period of channel A/B/C (0 silences it)
+ *   0x8xxx/0x9xxx/0xAxxx  same channels, period += sign_extend_12(xxx)
+ *   0x3xxx                sample rate: Timer A data = max(xxx & 0xff, 0x11),
+ *                         so 2457600 / (16 * data), at most 9035 Hz
+ *   0x4Yxx                volume = (xx & 0x3f) >> 2 (Y=1: A, Y=2: B, else C)
+ *   0xCYxx                volume += sign_extend_8(xx), same channel mapping
+ *   0x5NNN                play sample NNN, consuming three more words: start
+ *                         offset, end trim (ignored here, as in the original)
+ *                         and repeat count
+ *   0x6xxx                wait xxx ticks
+ *   0x7000                stop everything, 0x7001 wait for the sample to end,
+ *                         0x7002 loop back while the counter lasts
+ *   0xBxxx                noise period += sign_extend_12(xxx), and the sample
+ *                         rate moves by the same amount
+ *   0xDxxx                mark loop position, loop counter = xxx
+ *   0xExxx                xx < 0 silences the noise, otherwise noise period =
+ *                         xxx & 0x1f and noise is mixed into channel A
+ *   0xFxxx                end, leaving the channels running
+ */
+class AtariSfxStream : public Audio::AudioStream {
+public:
+	AtariSfxStream(const uint16 *commands, int numCommands, const Common::Array<AtariPcmSample> *samples,
+				   const Common::SharedPtr<AtariSfxPriority> &priority, int rate = 44100)
+		: _samples(samples), _priority(priority), _rate(rate),
+		  _cmdPos(0), _delay(0), _finished(false), _waitForSample(false),
+		  _tickSamples(0), _loopPos(0), _loopCounter(0), _graceTicks(kEndGraceTicks),
+		  _pcmData(nullptr), _pcmLength(0), _pcmPos(0), _pcmFrac(0), _pcmStep(0),
+		  _pcmRepeats(0), _pcmActive(false), _mixer(kAtariMixerBase), _noisePeriod(0) {
+
+		_commands.resize(numCommands);
+		for (int i = 0; i < numCommands; i++)
+			_commands[i] = commands[i];
+
+		for (int i = 0; i < 3; i++) {
+			_tonePeriod[i] = 0;
+			_volume[i] = 0;
+		}
+
+		_ym = new Audio::AY8912Stream(rate, kAtariYmClock);
+		_ym->setReg(7, _mixer);
+		for (int i = 0; i < 3; i++)
+			_ym->setReg(8 + i, 0);
+
+		setTimerData(kAtariMinTimerData);
+	}
+
+	~AtariSfxStream() override {
+		delete _ym;
+	}
+
+	int readBuffer(int16 *buffer, const int numSamples) override {
+		int done = 0;
+		while (done < numSamples) {
+			if (_tickSamples <= 0) {
+				runTick();
+				_tickSamples = _rate / 50;
+			}
+
+			int chunk = MIN<int>(numSamples - done, _tickSamples);
+			chunk &= ~1; // stereo pairs
+			if (chunk <= 0)
+				chunk = MIN<int>(numSamples - done, 2);
+
+			_ym->readBuffer(buffer + done, chunk);
+			mixSample(buffer + done, chunk);
+
+			done += chunk;
+			_tickSamples -= chunk;
+		}
+		return done;
+	}
+
+	bool isStereo() const override { return true; }
+	int getRate() const override { return _rate; }
+	bool endOfData() const override { return _finished && !_pcmActive && _graceTicks <= 0; }
+
+private:
+	// Ticks a finished program keeps its channels running for
+	static const int kEndGraceTicks = 25;
+
+	Common::Array<uint16> _commands;
+	const Common::Array<AtariPcmSample> *_samples;
+	Common::SharedPtr<AtariSfxPriority> _priority;
+	Audio::AY8912Stream *_ym;
+	int _rate;
+
+	int _cmdPos;
+	int _delay;
+	bool _finished;
+	bool _waitForSample;
+	int _tickSamples;
+	int _loopPos;
+	int _loopCounter;
+	int _graceTicks;
+
+	const int8 *_pcmData;
+	int _pcmLength;
+	int _pcmPos;
+	uint32 _pcmFrac;
+	uint32 _pcmStep;
+	int _pcmRepeats;
+	bool _pcmActive;
+
+	byte _mixer;
+	uint16 _tonePeriod[3];
+	int _volume[3];
+	uint16 _noisePeriod;
+	uint16 _timerData;
+
+	void releasePriority() {
+		if (_priority)
+			_priority->value = 0;
+	}
+
+	// The driver clamps the data register at 0x11, which is what makes 9035 Hz
+	// the fastest these effects can play
+	void setTimerData(uint16 data) {
+		_timerData = MAX<uint16>(data & 0xff, kAtariMinTimerData);
+		int pcmRate = kAtariMfpClock / (kAtariTimerPrescale * _timerData);
+		_pcmStep = (uint32)((((uint64)pcmRate) << 16) / _rate);
+	}
+
+	void setTonePeriod(int channel, uint16 period) {
+		_tonePeriod[channel] = period;
+		_ym->setReg(2 * channel, period & 0xff);
+		_ym->setReg(2 * channel + 1, (period >> 8) & 0x0f);
+	}
+
+	void setVolume(int channel, int value) {
+		_volume[channel] = value;
+		_ym->setReg(8 + channel, (value & 0x3f) >> 2);
+	}
+
+	// 0x4Yxx and 0xCYxx: Y=1 is channel A, Y=2 channel B, anything else C
+	static int volumeSelectToChannel(uint16 param) {
+		uint16 sel = param & 0x0f00;
+		if (sel == 0x0100)
+			return 0;
+		if (sel == 0x0200)
+			return 1;
+		return 2;
+	}
+
+	static int16 signExtend12(uint16 value) {
+		return (int16)((value & 0x0fff) << 4) >> 4;
+	}
+
+	void setNoiseEnabled(bool enabled) {
+		if (enabled)
+			_mixer &= ~0x08;
+		else
+			_mixer |= 0x08;
+		_ym->setReg(7, _mixer);
+	}
+
+	void triggerSample(int sampleNum, uint16 startOffset, uint16 repeats) {
+		_pcmActive = false;
+		if (sampleNum <= 0 || !_samples || sampleNum >= (int)_samples->size())
+			return;
+
+		const AtariPcmSample &sample = (*_samples)[sampleNum];
+		if (sample.data.empty())
+			return;
+
+		int start = (startOffset < sample.data.size()) ? startOffset : 0;
+		_pcmData = sample.data.data() + start;
+		_pcmLength = sample.data.size() - start;
+		if (_pcmLength <= 0)
+			return;
+
+		_pcmPos = 0;
+		_pcmFrac = 0;
+		// The counter is pre-decremented, so a repeat word of N plays N times
+		_pcmRepeats = (repeats > 0) ? repeats - 1 : 0;
+		_pcmActive = true;
+	}
+
+	void silenceTones() {
+		for (int i = 0; i < 3; i++) {
+			setVolume(i, 0);
+			setTonePeriod(i, 0);
+		}
+		_mixer = kAtariMixerBase;
+		_ym->setReg(7, _mixer);
+		_ym->setReg(6, 0);
+	}
+
+	void stopEverything() {
+		silenceTones();
+		_pcmActive = false;
+	}
+
+	void endInterpreter() {
+		_finished = true;
+		_delay = -1;
+		releasePriority();
+	}
+
+	void runTick() {
+		// 0xFxxx leaves the channels running, so wind the tones down after a
+		// grace; a sample started just before it keeps its repeat count.
+		if (_finished) {
+			if (_graceTicks > 0 && --_graceTicks == 0)
+				silenceTones();
+			return;
+		}
+
+		if (_waitForSample) {
+			if (_pcmActive)
+				return;
+			_waitForSample = false;
+		}
+
+		if (_delay > 0) {
+			_delay--;
+			return;
+		}
+
+		while (executeCommand())
+			;
+	}
+
+	// One command per call; returns true while the interpreter should keep
+	// running commands within this tick
+	bool executeCommand() {
+		if (_cmdPos >= (int)_commands.size()) {
+			endInterpreter();
+			return false;
+		}
+
+		uint16 command = _commands[_cmdPos++];
+		uint16 type = command & 0xf000;
+		uint16 param = command & 0x0fff;
+
+		switch (type >> 12) {
+		case 0:
+		case 1:
+		case 2:
+			setTonePeriod(type >> 12, param);
+			break;
+
+		case 8:
+		case 9:
+		case 0xa: {
+			int channel = (type >> 12) - 8;
+			setTonePeriod(channel, (uint16)(_tonePeriod[channel] + signExtend12(param)));
+			break;
+		}
+
+		case 3:
+			setTimerData(param);
+			break;
+
+		case 4:
+			setVolume(volumeSelectToChannel(param), param & 0x3f);
+			break;
+
+		case 0xc: {
+			int channel = volumeSelectToChannel(param);
+			setVolume(channel, _volume[channel] + (int8)(param & 0xff));
+			break;
+		}
+
+		case 5: {
+			if (_cmdPos + 3 > (int)_commands.size()) {
+				endInterpreter();
+				return false;
+			}
+			uint16 startOffset = _commands[_cmdPos++];
+			_cmdPos++; // end trim, which the Atari driver never reads
+			uint16 repeats = _commands[_cmdPos++];
+
+			// The same volume registers carry the PCM, so everything else goes
+			// quiet first
+			setNoiseEnabled(false);
+			for (int i = 0; i < 3; i++) {
+				setTonePeriod(i, 0);
+				setVolume(i, 0);
+			}
+			_ym->setReg(6, 0);
+			triggerSample(param, startOffset, repeats);
+			break;
+		}
+
+		case 6:
+			_delay = param;
+			return false;
+
+		case 7:
+			if (param == 0x000) {
+				stopEverything();
+				endInterpreter();
+				return false;
+			} else if (param == 0x001) {
+				_waitForSample = true;
+				return false;
+			} else if (param == 0x002) {
+				if (--_loopCounter > 0)
+					_cmdPos = _loopPos;
+			}
+			break;
+
+		case 0xb: {
+			int16 delta = signExtend12(param);
+			_noisePeriod = (uint16)(_noisePeriod + delta);
+			_ym->setReg(6, _noisePeriod & 0x1f);
+			setTimerData((uint16)(_timerData + delta));
+			break;
+		}
+
+		case 0xd:
+			_loopCounter = param;
+			_loopPos = _cmdPos;
+			break;
+
+		case 0xe:
+			if ((param & 0x80) != 0) {
+				setNoiseEnabled(false);
+			} else {
+				_noisePeriod = param & 0x1f;
+				_ym->setReg(6, _noisePeriod);
+				setNoiseEnabled(true);
+			}
+			break;
+
+		case 0xf:
+			endInterpreter();
+			return false;
+
+		default:
+			break;
+		}
+
+		return true;
+	}
+
+	void mixSample(int16 *buffer, int numSamples) {
+		if (!_pcmActive)
+			return;
+
+		for (int i = 0; i < numSamples; i += 2) {
+			if (_pcmPos >= _pcmLength) {
+				if (_pcmRepeats <= 0) {
+					_pcmActive = false;
+					return;
+				}
+				_pcmRepeats--;
+				_pcmPos = 0;
+				_pcmFrac = 0;
+			}
+
+			int mixed = buffer[i] + (int)_pcmData[_pcmPos] * 128;
+			int16 value = (int16)CLIP<int>(mixed, -32768, 32767);
+			buffer[i] = value;
+			buffer[i + 1] = value;
+
+			_pcmFrac += _pcmStep;
+			_pcmPos += _pcmFrac >> 16;
+			_pcmFrac &= 0xffff;
+		}
+	}
+};
+
+class SoundAtariCastle final : public Sound {
+public:
+	SoundAtariCastle(Audio::Mixer *mixer) : _mixer(mixer), _priority(new AtariSfxPriority()) {}
+
+	~SoundAtariCastle() override {
+		_mixer->stopHandle(_soundFxHandle);
+	}
+
+	void loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, int bankOffset);
+
+	void playSound(int index, Type type) override;
+
+	void stopSound(Type type) override {
+		_mixer->stopHandle(_soundFxHandle);
+		Common::StackLock lock(_mixer->mutex());
+		_priority->value = 0;
+	}
+
+	bool isPlayingSound(Type type) const override {
+		return _mixer->isSoundHandleActive(_soundFxHandle);
+	}
+
+	bool isSoundAvailable(int index) const override {
+		return index >= 0 && index < (int)_sfxTable.size();
+	}
+
+private:
+	void loadPcmSamples(Common::SeekableReadStream *file, int bankOffset);
+
+	Common::Array<AtariSfxEntry> _sfxTable;
+	Common::Array<AtariPcmSample> _pcmSamples;
+	Common::SharedPtr<AtariSfxPriority> _priority;
+
+	Audio::Mixer *_mixer;
+	Audio::SoundHandle _soundFxHandle;
+};
+
+void SoundAtariCastle::loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, int bankOffset) {
+	file->seek(offset);
+	_sfxTable.clear();
+	for (int i = 0; i < numSounds; i++) {
+		AtariSfxEntry entry;
+		uint16 header = file->readUint16BE();
+		entry.priority = header >> 8;
+		int numWords = header & 0xFF;
+		entry.commands.resize(numWords);
+		for (int j = 0; j < numWords; j++)
+			entry.commands[j] = file->readUint16BE();
+		_sfxTable.push_back(entry);
+		debugC(1, kFreescapeDebugParser, "Atari SFX %d: priority=%d, commands=%d", i, entry.priority, numWords);
+	}
+
+	loadPcmSamples(file, bankOffset);
+}
+
+// The bank the Amiga ships as the external `cmsnds2`, embedded byte for byte:
+// ten entries of a 4-byte big endian length, a 2-byte nominal rate and that many
+// signed 8-bit samples, each ending in the zero byte the handler stops on.
+void SoundAtariCastle::loadPcmSamples(Common::SeekableReadStream *file, int bankOffset) {
+	// Parameter N uses index N, so index 0 stays empty
+	_pcmSamples.clear();
+	_pcmSamples.resize(11);
+
+	if (bankOffset <= 0)
+		return;
+
+	file->seek(bankOffset);
+	for (int index = 1; index <= 10; index++) {
+		if (file->pos() + 6 > file->size())
+			break;
+
+		uint32 length = file->readUint32BE();
+		file->readUint16BE(); // Nominal rate, unused: 0x3xxx sets the timer
+		if (length == 0 || file->pos() + (int64)length > file->size())
+			break;
+
+		_pcmSamples[index].data.resize(length);
+		file->read(_pcmSamples[index].data.data(), length);
+		debugC(1, kFreescapeDebugParser, "Atari PCM sample %d: %d bytes", index, length);
+	}
+}
+
+void SoundAtariCastle::playSound(int index, Type type) {
+	if (index < 0 || index >= (int)_sfxTable.size()) {
+		debugC(1, kFreescapeDebugMedia, "Atari sound %d out of range (have %d)", index, (int)_sfxTable.size());
+		return;
+	}
+
+	const AtariSfxEntry &entry = _sfxTable[index];
+	if (entry.commands.empty()) {
+		debugC(1, kFreescapeDebugMedia, "Atari sound %d has no commands", index);
+		return;
+	}
+
+	// A sound still holding the priority slot cannot be replaced by a lesser one
+	if (_mixer->isSoundHandleActive(_soundFxHandle)) {
+		Common::StackLock lock(_mixer->mutex());
+		if (entry.priority < _priority->value) {
+			debugC(1, kFreescapeDebugMedia, "Atari sound %d skipped (priority %d < %d)",
+				index, entry.priority, _priority->value);
+			return;
+		}
+	}
+
+	debugC(1, kFreescapeDebugMedia, "Playing Atari sound %d (priority=%d, commands=%d)",
+		index, entry.priority, (int)entry.commands.size());
+
+	AtariSfxStream *stream = new AtariSfxStream(entry.commands.data(), entry.commands.size(), &_pcmSamples, _priority);
+	_mixer->stopHandle(_soundFxHandle);
+	{
+		Common::StackLock lock(_mixer->mutex());
+		_priority->value = entry.priority;
+	}
+	_mixer->playStream(Audio::Mixer::kSFXSoundType, &_soundFxHandle, stream, -1,
+		Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::YES);
+}
+
+Sound *FreescapeEngine::loadSoundsAtariCastle(Common::SeekableReadStream *file, int offset, int numSounds, int bankOffset) {
+	SoundAtariCastle *sound = new SoundAtariCastle(_mixer);
+	sound->loadSounds(file, offset, numSounds, bankOffset);
+	return sound;
+}
+
+} // namespace Freescape


Commit: 5b0cb36c41fe94b712e7395792fd9b8594bfe91e
    https://github.com/scummvm/scummvm/commit/5b0cb36c41fe94b712e7395792fd9b8594bfe91e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: implemented key rendering from castle amiga/atari

Changed paths:
    engines/freescape/games/castle/amiga.cpp
    engines/freescape/games/castle/atari.cpp
    engines/freescape/games/castle/castle.h


diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 42e6a289164..81edcd87f09 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -142,7 +142,7 @@ const CastleAmigaLayout kAmigaLayout = {
 	0x99ac, 0xa476, 0x998e, 0x11540, 0x147fa, 0x13cf2, 0x158fa, 0x2b4ea,
 	0x2c5ca, 0x49c8, 0x3473a, 0x3620a, 0x37fa6, 0x38136, 0x379fa, 0x38186,
 	0x38c36, 0x38e16, 0x3b706, 0x3b9b0, 0x3bd4a, 0x3bd6a, 0x39136, 0x3a156,
-	0x3cbfa
+	0x3cbfa, 0x3ab7e
 };
 
 // "Castle Master" by Domark, which ships the game as a plain "x"
@@ -150,7 +150,7 @@ const CastleAmigaLayout kAmigaDomarkLayout = {
 	0xa22a, 0xad18, 0xa20c, 0x11ff2, 0x152ac, 0x147a4, 0x163ac, 0x2bf9c,
 	0x2d07c, 0x40aa, 0x351ec, 0x36cbc, 0x38a58, 0x38be8, 0x384ac, 0x38c38,
 	0x396e8, 0x398c8, 0x3c1b8, 0x3c462, 0x3c7fc, 0x3c81c, 0x39be8, 0x3ac08,
-	0x3d6ac
+	0x3d6ac, 0x3b630
 };
 
 // "Castle Master & The Crypt" by Incentive, which packs the game into
@@ -159,7 +159,7 @@ const CastleAmigaLayout kAmigaIncentiveLayout = {
 	0xa1f6, 0xace4, 0xa1d8, 0x11fca, 0x15284, 0x1477c, 0x16384, 0x2bf74,
 	0x2d054, 0x4086, 0x351c4, 0x36c94, 0x38a30, 0x38bc0, 0x38484, 0x38c10,
 	0x396c0, 0x398a0, 0x3c190, 0x3c43a, 0x3c7d4, 0x3c7f4, 0x39bc0, 0x3abe0,
-	0x3d684
+	0x3d684, 0x3b608
 };
 
 // "The Crypt", the other game on the Incentive disc, packed the same way into
@@ -170,7 +170,7 @@ const CastleAmigaLayout kAmigaCryptLayout = {
 	0x09d1c, 0, 0x09c4a, 0x10dd8, 0x14092, 0x1358a, 0x15192,
 	0, 0x1d914, 0x04086, 0x25a84, 0x27554, 0x292f0, 0x29480,
 	0x28d44, 0x294d0, 0x29f80, 0x2a160, 0x2ca50, 0x2ccfa, 0x2d094, 0x2d0b4,
-	0x2a480, 0x2b4a0, 0x2df44
+	0x2a480, 0x2b4a0, 0x2df44, 0x2bec8
 };
 
 // "thecrypt.neo" ships without a palette; the intro program that displays it
@@ -1374,12 +1374,10 @@ void CastleEngine::loadAssetsAmigaDemo() {
 
 	loadThunderFramesAmiga(&file, 0x38b32); // Memory 0x38B16
 
-	// Eye icon sprites (memory 0x3C096, 12 frames, 16x7 each, interleaved 4-plane)
-	// Used for strength/compass display at screen (224, 164). Header at 0x3C08E.
-	// TODO: load as separate eye icon member, not _keysBorderFrames
-	file.seek(0x3c0b2);
-	for (int i = 0; i < 12; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 7);
+	// Ten collected-key sprites, 2 words x 16 rows each
+	file.seek(0x3b52a);
+	for (int i = 0; i < 10; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 2, 16);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_keysBorderFrames.push_back(frame);
 	}
@@ -1738,10 +1736,11 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 
 	loadThunderFramesAmiga(&file, layout->thunder);
 
-	// Eye icon sprites: 12 frames × 1 word × 7 rows. Header at 0x3b6fe.
-	file.seek(layout->eyeIcons);
-	for (int i = 0; i < 12; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 7);
+	// Ten collected-key sprites, 2 words x 16 rows each. The blit routine
+	// indexes them by key ID with a 0x100 stride (mem $7688).
+	file.seek(layout->keys);
+	for (int i = 0; i < 10; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 2, 16);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_keysBorderFrames.push_back(frame);
 	}
@@ -2105,7 +2104,22 @@ void CastleEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 		}
 	}
 
-	// TODO: Draw collected keys - key sprites location in binary still unknown
+	// Collected keys, from the loop at mem $7660: the first goes at (76, 179)
+	// and each one after it three pixels further left, so they overlap into a
+	// fan. The sprite is picked by key ID, which the table indexes from 1.
+	//
+	// Each frame is 16 rows of a 16 byte stride, but the blit only ever reads
+	// the first 8 of those - one word per plane - so the key is the left half
+	// and the rest is padding.
+	for (int i = 0; i < int(_keysCollected.size()); i++) {
+		int frame = _keysCollected[i] - 1;
+		if (frame < 0 || frame >= int(_keysBorderFrames.size()) || !_keysBorderFrames[frame])
+			continue;
+
+		Graphics::ManagedSurface *key = _keysBorderFrames[frame];
+		surface->copyRectToSurfaceWithKey(*key, 76 - 3 * i, 179,
+			Common::Rect(0, 0, MIN<int>(16, key->w), key->h), black);
+	}
 
 	// Draw flag animation at (288, 5)
 	if (!_flagFrames.empty()) {
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index e5c252bfaa9..142c7c80fe6 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -180,7 +180,7 @@ const CastleAtariLayout kAtariCastleLayout = {
 	0x27946, 178, 0x28410, 0x27928, 0x2f32a, 0x32594, 0x33694,
 	3, 0x49284, 0x4a364, 0x04f24, 0x55d40, 0x55ed0,
 	0x55f20, 0x569d0, 0x56bb0, 0x56ed0, 0x57ef0, 0x594a0, 0x5974a,
-	0x59ae4, 0x59b04, 0x10fb6, 0x31adc, 0x5a994
+	0x59ae4, 0x59b04, 0x10fb6, 0x31adc, 0x5a994, 0x58918
 };
 
 // "The Crypt", the second disc of "Castle Master & The Crypt". It carries the
@@ -191,7 +191,7 @@ const CastleAtariLayout kAtariCryptLayout = {
 	0x273ec, 165, 0, 0x2731a, 0x2e366, 0x315d0, 0x326d0,
 	0, 0, 0x3ae52, 0x04914, 0x4682e, 0x469be,
 	0x46a0e, 0x474be, 0x4769e, 0x479be, 0x489de, 0x49f8e, 0x4a238,
-	0x4a5d2, 0x4a5f2, 0x109a6, 0x30b18, 0x4b482
+	0x4a5d2, 0x4a5f2, 0x109a6, 0x30b18, 0x4b482, 0x49406
 };
 
 // L.PRG, the loader program that launches The Crypt, copies a palette and then
@@ -322,9 +322,11 @@ void CastleEngine::loadAssetsAtariFullGame() {
 
 	loadThunderFramesAmiga(file, layout->thunder);
 
-	file->seek(layout->eyeIcons);
-	for (int i = 0; i < 12; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 7);
+	// Ten collected-key sprites, 2 words x 16 rows each. The blit routine
+	// indexes them by key ID with a 0x100 stride (prog $483a).
+	file->seek(layout->keys);
+	for (int i = 0; i < 10; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 2, 16);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_keysBorderFrames.push_back(frame);
 	}
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index a6b7669fe2f..dedc4dc5bb6 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -26,7 +26,7 @@ struct CastleAmigaLayout {
 	int messages, riddles, colorCycling, fonts, palettes, soundTable, areaDB;
 	int area255, border, mountains, menu, menuButtons, spiritMeterBg, spiritMeter;
 	int indicators, thunder, weights, bar, eyeIcons, flag, riddleMask, riddleTop;
-	int gatePixels, gateMask, mod;
+	int gatePixels, gateMask, mod, keys;
 };
 
 // The same, for a decompressed Atari ST game program. Castle Master and its
@@ -36,7 +36,7 @@ struct CastleAtariLayout {
 	int messages, messageCount, riddles, colorCycling, fonts, palettes, areaDB;
 	int extraAreas, area255, border, mountains, spiritMeterBg, spiritMeter;
 	int thunder, weights, bar, gatePixels, gateMask, eyeIcons, flag;
-	int riddleMask, riddleTop, mod, soundTable, soundBank;
+	int riddleMask, riddleTop, mod, soundTable, soundBank, keys;
 };
 
 class MusicPlayer;


Commit: 69daec4dd08f0d7bd401a9c6e8155c70d0163614
    https://github.com/scummvm/scummvm/commit/69daec4dd08f0d7bd401a9c6e8155c70d0163614
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-08-05T14:05:09+02:00

Commit Message:
FREESCAPE: changed unstable by testing in some castle/crypt releases

Changed paths:
    engines/freescape/detection.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 1912467e211..3f4e6ef5d32 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -870,10 +870,11 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_DEMO,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
-	// Stampede Amiga, Issue 1, July 1990
+	// Stampede Amiga, Issue 1, July 1990: the same demo as above, only its
+	// AmigaDOS loader differs
 	{
 		"castlemaster",
-		"",
+		"Demo",
 		{
 			{"cm", 0, "b7e713a0742fa09aa81c9606bbbba4af", 4068},
 			{"x", 0, "c8c811439da0cf8a193e35feb5b5c6dc", 353388},
@@ -896,7 +897,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// Full Castle Master, Amiga, by Domark: another build of the same game, with
@@ -912,7 +913,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// Full Castle Master, Amiga, from "Castle Master & The Crypt" by Incentive,
@@ -928,7 +929,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// Full Castle Master, Atari ST, as found on the original disk: "M.PRG" is
@@ -944,7 +945,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
 	},
 	// Full Castle Master, Atari ST, from the "Castle Master & The Crypt"
@@ -960,7 +961,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
 	},
 	// The same, with "M.PRG" already decrypted by hand (dec0de and a real or
@@ -976,7 +977,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
 	},
 	{
@@ -1121,7 +1122,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO3(GUIO_NOMIDI, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// Castle Master 2, Atari ST, the second disc of "Castle Master & The Crypt"
@@ -1136,7 +1137,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSTABLE,
+		ADGF_TESTING,
 		GUIO3(GUIO_NOMIDI, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
 	},
 	{




More information about the Scummvm-git-logs mailing list