[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