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

dreammaster noreply at scummvm.org
Sat Aug 8 07:07:13 UTC 2026


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

Summary:
c24d729b57 MADS: NEBULAR: Fix ISOUND alternating note lookup
bb1573866f MADS: Add engine-local PIT PC speaker renderer
2872289cc6 MADS: NEBULAR: Use PIT PC speaker renderer
8658d38493 MADS: NEBULAR: Match native ISOUND host cadence
ea6ab5faf0 MADS: NEBULAR: Preserve fractional ISOUND timing


Commit: c24d729b57c5c5a1ff22e2aed471b1148f94ca6f
    https://github.com/scummvm/scummvm/commit/c24d729b57c5c5a1ff22e2aed471b1148f94ca6f
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-08T17:07:07+10:00

Commit Message:
MADS: NEBULAR: Fix ISOUND alternating note lookup

The original driver uses an unrestricted byte index for note-table
lookups, and ISOUND streams rely on values beyond the nominal 90-word
table. Route ordinary and alternating lookups through the loaded
data-segment bounds check instead of rejecting valid stream data.
Fixes Rex Nebular intro crash:
ERROR: ISOUND alternating note-table index 92 is out of range!

Changed paths:
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound.h


diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index f5be73f389a..f413a001ca0 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -31,9 +31,10 @@ namespace Sound {
 
 namespace {
 
+const uint32 kNominalFrequencyTableEntries = 90;
 const uint32 kMinimumDataSegmentSize =
-ISound::kFrequencyTableOffset +
-ISound::kFrequencyTableEntries * 2;
+	ISound::kFrequencyTableOffset +
+	kNominalFrequencyTableEntries * 2;
 
 } // namespace
 
@@ -365,12 +366,12 @@ void ISound::processRandomMutation() {
 
 uint16 ISound::calculateNoteDivisor(byte note) const {
 	const byte tableIndex = (byte)(note + _transpose);
-	if (tableIndex >= kFrequencyTableEntries)
-		error("ISOUND note-table index %u is out of range", tableIndex);
 
-	const uint32 tableOffset =
-		kFrequencyTableOffset + (uint32)tableIndex * 2;
-	const uint16 divisor = READ_LE_UINT16(&_soundData[tableOffset]);
+	// The native driver uses an unrestricted byte index here. Some original
+	// sequences deliberately read words beyond the nominal 90-entry table.
+	const uint16 tableOffset =
+		kFrequencyTableOffset + (uint16)tableIndex * 2;
+	const uint16 divisor = readSequenceUint16(tableOffset);
 	return (uint16)(divisor + _fineOffset);
 }
 
@@ -533,16 +534,8 @@ void ISound::updateAlternation() {
 		_alternationToggle = false;
 	}
 
-	const byte tableIndex = (byte)(
-		_note + _transpose + alternatingOffset);
-	if (tableIndex >= kFrequencyTableEntries)
-		error("ISOUND alternating note-table index %u is out of range",
-			tableIndex);
-
-	const uint32 tableOffset =
-		kFrequencyTableOffset + (uint32)tableIndex * 2;
-	_currentDivisor = (uint16)(
-		READ_LE_UINT16(&_soundData[tableOffset]) + _fineOffset);
+	_currentDivisor = calculateNoteDivisor(
+		(byte)(_note + alternatingOffset));
 }
 
 void ISound::updatePitch() {
diff --git a/engines/mads/nebular/sound/isound.h b/engines/mads/nebular/sound/isound.h
index 439453dc5d8..bc5a71b5f1e 100644
--- a/engines/mads/nebular/sound/isound.h
+++ b/engines/mads/nebular/sound/isound.h
@@ -47,7 +47,6 @@ public:
 		kNoiseRateHz = 60,
 		kDefaultOutputVolume = 20,
 		kFrequencyTableOffset = 0x0114,
-		kFrequencyTableEntries = 90,
 		kInitialNullSequenceOffset = 0x00f0,
 		kMaxOperationsPerTick = 1024
 	};


Commit: bb1573866f735d1266160a5000c87ff679d7f3ba
    https://github.com/scummvm/scummvm/commit/bb1573866f735d1266160a5000c87ff679d7f3ba
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-08T17:07:07+10:00

Commit Message:
MADS: Add engine-local PIT PC speaker renderer

Assisted-by: Codex:GPT-5.6-sol

Changed paths:
  A engines/mads/core/pcspk_pit.cpp
  A engines/mads/core/pcspk_pit.h
    engines/mads/module.mk


diff --git a/engines/mads/core/pcspk_pit.cpp b/engines/mads/core/pcspk_pit.cpp
new file mode 100644
index 00000000000..ab16516a7f2
--- /dev/null
+++ b/engines/mads/core/pcspk_pit.cpp
@@ -0,0 +1,514 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "mads/core/pcspk_pit.h"
+
+#include "common/hashmap.h"
+#include "common/mutex.h"
+#include "common/util.h"
+
+#include <math.h>
+
+namespace MADS {
+
+namespace {
+
+int32 saturateInt32(int64 value) {
+	if (value > 0x7fffffffLL)
+		return 0x7fffffff;
+	if (value < -0x80000000LL)
+		return -0x7fffffff - 1;
+	return (int32)value;
+}
+
+int32 fixedFromDouble(double value, uint fracBits) {
+	const double scale = (double)((uint64)1 << fracBits);
+	const double scaled = value * scale;
+	return saturateInt32((int64)(scaled < 0.0 ? scaled - 0.5 : scaled + 0.5));
+}
+
+int32 multiplyFixed(int32 value, int32 coefficient, uint fracBits) {
+	const int64 product = (int64)value * coefficient;
+	const int64 half = (int64)1 << (fracBits - 1);
+	const int64 rounded = product < 0 ?
+		-(((-product) + half) >> fracBits) :
+		(product + half) >> fracBits;
+	return saturateInt32(rounded);
+}
+
+} // namespace
+
+class PCSpeakerPITRenderer::PCSpeakerOutputStage {
+private:
+	struct Section {
+		int32 b0;
+		int32 b1;
+		int32 b2;
+		int32 a1;
+		int32 a2;
+		int32 z1;
+		int32 z2;
+
+		Section() :
+			b0(0), b1(0), b2(0), a1(0), a2(0), z1(0), z2(0) {
+		}
+
+		int32 process(int32 input) {
+			const int32 output = saturateInt32((int64)
+				multiplyFixed(input, b0, kCoefficientFracBits) + z1);
+			z1 = saturateInt32((int64)
+				multiplyFixed(input, b1, kCoefficientFracBits) -
+				multiplyFixed(output, a1, kCoefficientFracBits) + z2);
+			z2 = saturateInt32((int64)
+				multiplyFixed(input, b2, kCoefficientFracBits) -
+				multiplyFixed(output, a2, kCoefficientFracBits));
+			return output;
+		}
+
+		void clear() {
+			z1 = 0;
+			z2 = 0;
+		}
+	};
+
+	struct FilterConfiguration {
+		Section highPassFirst;
+		Section highPassSecond;
+		Section lowPassFirst;
+		Section lowPassSecond;
+	};
+
+	Section _highPassFirst;
+	Section _highPassSecond;
+	Section _lowPassFirst;
+	Section _lowPassSecond;
+	int32 _accumulatorDecay;
+
+	static void configureFirstOrder(Section &section, double sampleRate,
+			double cutoff, bool highPass) {
+		const double k = tan(M_PI * cutoff / sampleRate);
+		const double normalization = 1.0 / (1.0 + k);
+
+		section.b0 = fixedFromDouble(
+			(highPass ? 1.0 : k) * normalization, kCoefficientFracBits);
+		section.b1 = fixedFromDouble(
+			(highPass ? -1.0 : k) * normalization, kCoefficientFracBits);
+		section.b2 = 0;
+		section.a1 = fixedFromDouble(
+			(k - 1.0) * normalization, kCoefficientFracBits);
+		section.a2 = 0;
+	}
+
+	static void configureSecondOrder(Section &section, double sampleRate,
+			double cutoff, bool highPass) {
+		// The complex pole pair of a third-order Butterworth filter has Q=1.
+		const double k = tan(M_PI * cutoff / sampleRate);
+		const double normalization = 1.0 / (1.0 + k + k * k);
+		const double b0 = (highPass ? 1.0 : k * k) * normalization;
+
+		section.b0 = fixedFromDouble(b0, kCoefficientFracBits);
+		section.b1 = fixedFromDouble(
+			(highPass ? -2.0 : 2.0) * b0, kCoefficientFracBits);
+		section.b2 = section.b0;
+		section.a1 = fixedFromDouble(
+			2.0 * (k * k - 1.0) * normalization,
+			kCoefficientFracBits);
+		section.a2 = fixedFromDouble(
+			(1.0 - k + k * k) * normalization,
+			kCoefficientFracBits);
+	}
+
+public:
+	explicit PCSpeakerOutputStage(uint32 sampleRate) :
+		_accumulatorDecay(0) {
+		static Common::Mutex decayCacheMutex;
+		static Common::HashMap<uint32, int32> decayCache;
+		{
+			Common::StackLock lock(decayCacheMutex);
+			if (!decayCache.contains(sampleRate)) {
+				// DOSBox applies 0.999 at its fixed 48-kHz device rate. Preserve
+				// that time constant at the active ScummVM mixer rate.
+				decayCache.setVal(sampleRate, fixedFromDouble(
+					pow(0.999, (double)kReferenceSampleRate / sampleRate),
+					kCoefficientFracBits));
+			}
+			_accumulatorDecay = decayCache.getVal(sampleRate);
+		}
+
+		static Common::Mutex filterCacheMutex;
+		static Common::HashMap<uint32, FilterConfiguration> filterCache;
+		{
+			Common::StackLock lock(filterCacheMutex);
+			if (!filterCache.contains(sampleRate)) {
+				FilterConfiguration configuration;
+				const double highPassCutoff =
+					MIN<double>(120.0, sampleRate * 0.1);
+				const double lowPassCutoff =
+					MIN<double>(4300.0, sampleRate * 0.45);
+				configureFirstOrder(configuration.highPassFirst,
+					sampleRate, highPassCutoff, true);
+				configureSecondOrder(configuration.highPassSecond,
+					sampleRate, highPassCutoff, true);
+				configureFirstOrder(configuration.lowPassFirst,
+					sampleRate, lowPassCutoff, false);
+				configureSecondOrder(configuration.lowPassSecond,
+					sampleRate, lowPassCutoff, false);
+				filterCache.setVal(sampleRate, configuration);
+			}
+
+			const FilterConfiguration &configuration =
+				filterCache.getVal(sampleRate);
+			_highPassFirst = configuration.highPassFirst;
+			_highPassSecond = configuration.highPassSecond;
+			_lowPassFirst = configuration.lowPassFirst;
+			_lowPassSecond = configuration.lowPassSecond;
+		}
+		reset();
+	}
+
+	void reset() {
+		_highPassFirst.clear();
+		_highPassSecond.clear();
+		_lowPassFirst.clear();
+		_lowPassSecond.clear();
+	}
+
+	int32 process(int32 input) {
+		int32 output = _highPassFirst.process(input);
+		output = _highPassSecond.process(output);
+		output = _lowPassFirst.process(output);
+		return _lowPassSecond.process(output);
+	}
+
+	int32 decay(int32 input) const {
+		return multiplyFixed(input, _accumulatorDecay,
+			kCoefficientFracBits);
+	}
+};
+
+PCSpeakerPITRenderer::PCSpeakerPITRenderer(uint32 sampleRate,
+		uint32 pitClock) :
+	_sampleRate(sampleRate),
+	_pitClock(pitClock),
+	_outputStage(nullptr) {
+	assert(_sampleRate);
+	assert(_pitClock);
+	_outputStage = new PCSpeakerOutputStage(sampleRate);
+	initializeImpulse();
+	resetState();
+}
+
+PCSpeakerPITRenderer::~PCSpeakerPITRenderer() {
+	delete _outputStage;
+}
+
+void PCSpeakerPITRenderer::initializeImpulse() {
+	struct ImpulseConfiguration {
+		uint32 length;
+		Common::Array<int32> coefficients;
+
+		ImpulseConfiguration() : length(0) {
+		}
+	};
+
+	static Common::Mutex impulseCacheMutex;
+	static Common::HashMap<uint32, ImpulseConfiguration> impulseCache;
+	Common::StackLock cacheLock(impulseCacheMutex);
+
+	if (impulseCache.contains(_sampleRate)) {
+		const ImpulseConfiguration &configuration =
+			impulseCache.getVal(_sampleRate);
+		_impulseLength = configuration.length;
+		_impulseLut = configuration.coefficients;
+		_impulseBuffer.resize(_impulseLength + 2, 0);
+		return;
+	}
+
+	_impulseLength = MAX<uint32>(
+		2, ((uint64)_sampleRate * kImpulseDurationUs + 999999) / 1000000);
+	_impulseLut.resize(_impulseLength * kFractionalPhases, 0);
+	_impulseBuffer.resize(_impulseLength + 2, 0);
+
+	// Coefficients are constructed once per mixer rate, then quantized. The
+	// mixer hot path below contains no floating-point operations.
+	const double cutoff = MIN<double>(14500.0, _sampleRate * 0.45);
+	const double center = _impulseLength / 2.0;
+	Common::Array<double> phaseCoefficients(_impulseLength, 0.0);
+
+	for (uint32 phase = 0; phase < kFractionalPhases; ++phase) {
+		const double fraction = (double)phase / kFractionalPhases;
+		double sum = 0.0;
+
+		for (uint32 tap = 0; tap < _impulseLength; ++tap) {
+			// Samples are emitted at the end of their interval. A transition
+			// occurring partway through the current sample is this far in the
+			// past when tap zero is emitted.
+			const double time = tap + 1.0 - fraction;
+			double coefficient = 0.0;
+			if (time > 0.0 && time < _impulseLength) {
+				const double distance = time - center;
+				const double window =
+					0.5 * (1.0 + cos(2.0 * M_PI * distance /
+						_impulseLength));
+				const double argument =
+					2.0 * M_PI * cutoff * distance / _sampleRate;
+				const double sinc = argument == 0.0 ?
+					1.0 : sin(argument) / argument;
+				coefficient = window * sinc;
+			}
+
+			phaseCoefficients[tap] = coefficient;
+			sum += coefficient;
+		}
+
+		if (fabs(sum) < 1e-12) {
+			phaseCoefficients[0] = 1.0;
+			sum = 1.0;
+		}
+		uint32 largestTap = 0;
+		int32 largestCoefficient = 0;
+		int64 fixedSum = 0;
+		for (uint32 tap = 0; tap < _impulseLength; ++tap) {
+			const uint32 index = phase * _impulseLength + tap;
+			const int32 coefficient = fixedFromDouble(
+				phaseCoefficients[tap] / sum, kSampleFracBits);
+			_impulseLut[index] = coefficient;
+			fixedSum += coefficient;
+			if (ABS<int32>(coefficient) > ABS<int32>(largestCoefficient)) {
+				largestCoefficient = coefficient;
+				largestTap = tap;
+			}
+		}
+
+		// Correct quantization residue at the strongest tap so every phase
+		// has exactly unit integrated gain in Q8.24.
+		const uint32 largestIndex = phase * _impulseLength + largestTap;
+		const int32 previousCoefficient = _impulseLut[largestIndex];
+		const int64 adjustedCoefficient = (int64)previousCoefficient +
+			((int64)1 << kSampleFracBits) - fixedSum;
+		assert(adjustedCoefficient >= -0x80000000LL &&
+			adjustedCoefficient <= 0x7fffffffLL);
+		_impulseLut[largestIndex] = (int32)adjustedCoefficient;
+		fixedSum += adjustedCoefficient - previousCoefficient;
+		assert(fixedSum == ((int64)1 << kSampleFracBits));
+	}
+
+	ImpulseConfiguration configuration;
+	configuration.length = _impulseLength;
+	configuration.coefficients = _impulseLut;
+	impulseCache.setVal(_sampleRate, configuration);
+}
+
+void PCSpeakerPITRenderer::resetState() {
+	_phase = 0;
+	_sampleCounter = 0;
+	_count = 0;
+	_pendingCount = 0;
+	_hasPendingCount = false;
+	_counterLoaded = false;
+	_timerGate = false;
+	_speakerEnabled = false;
+	_high = true;
+	_undersampled = false;
+	_hasUndersampledReload = false;
+	_lastUndersampledReloadSample = 0;
+	_impulseHead = 0;
+	for (uint i = 0; i < _impulseBuffer.size(); ++i)
+		_impulseBuffer[i] = 0;
+	_reconstructedLevel = 0;
+	_targetLevel = -1;
+	_outputStage->reset();
+}
+
+bool PCSpeakerPITRenderer::isUndersampled(uint16 count) const {
+	const uint32 minimumCount =
+		((uint64)2 * _pitClock + _sampleRate - 1) / _sampleRate;
+	// Mode 3 formally requires count >= 2. Treat count 1 as undersampled
+	// compatibility input rather than inventing specified 8254 behavior.
+	return count && count < minimumCount;
+}
+
+int PCSpeakerPITRenderer::outputLevel() const {
+	if (!_speakerEnabled)
+		return -1;
+
+	// Disabling the timer gate forces mode-3 OUT high. Port 0x61 bit 1
+	// controls whether that output reaches the physical speaker separately.
+	if (!_timerGate)
+		return 1;
+
+	return _high ? 1 : -1;
+}
+
+void PCSpeakerPITRenderer::addTransition(int level,
+		uint64 elapsedPitClocks) {
+	if (level == _targetLevel)
+		return;
+
+	const int delta = level - _targetLevel;
+	_targetLevel = level;
+
+	elapsedPitClocks = MIN<uint64>(elapsedPitClocks, _pitClock);
+	uint32 phase = (uint32)((elapsedPitClocks * kFractionalPhases +
+		_pitClock / 2) / _pitClock);
+	uint32 sampleOffset = 0;
+	if (phase == kFractionalPhases) {
+		phase = 0;
+		sampleOffset = 1;
+	}
+
+	for (uint32 tap = 0; tap < _impulseLength; ++tap) {
+		const uint32 bufferIndex =
+			(_impulseHead + sampleOffset + tap) % _impulseBuffer.size();
+		_impulseBuffer[bufferIndex] = saturateInt32((int64)
+			_impulseBuffer[bufferIndex] +
+			(int64)delta * _impulseLut[phase * _impulseLength + tap]);
+	}
+}
+
+void PCSpeakerPITRenderer::writeMode3Count(uint16 count) {
+	if (isUndersampled(count)) {
+		// Counts above Nyquist cannot be represented as ordinary oscillation.
+		// Rapid reloads are nevertheless used as a noise source, so preserve
+		// that compatibility behavior by toggling the last physical level.
+		const uint64 sampleGap = _sampleCounter -
+			_lastUndersampledReloadSample;
+		const bool rapidReload = _hasUndersampledReload &&
+			sampleGap * 1000 <= _sampleRate;
+		if (!_undersampled)
+			_high = false;
+		else if (_timerGate && _speakerEnabled && rapidReload)
+			_high = !_high;
+
+		_count = count;
+		_pendingCount = 0;
+		_hasPendingCount = false;
+		_counterLoaded = true;
+		_phase = 0;
+		_undersampled = true;
+		_hasUndersampledReload = true;
+		_lastUndersampledReloadSample = _sampleCounter;
+		addTransition(outputLevel());
+		return;
+	}
+
+	const bool wasUndersampled = _undersampled;
+	_undersampled = false;
+	_hasUndersampledReload = false;
+
+	// A mode-3 count written while the counter is running is transferred at
+	// the next half-cycle rather than restarting the current waveform.
+	if (_timerGate && _counterLoaded && !wasUndersampled) {
+		_pendingCount = count;
+		_hasPendingCount = true;
+	} else {
+		_count = count;
+		_pendingCount = 0;
+		_hasPendingCount = false;
+		_counterLoaded = true;
+		_phase = 0;
+		_high = true;
+		addTransition(outputLevel());
+	}
+}
+
+void PCSpeakerPITRenderer::setControl(bool timerGate,
+		bool speakerEnabled) {
+	const bool timerGateChanged = timerGate != _timerGate;
+	_timerGate = timerGate;
+	_speakerEnabled = speakerEnabled;
+
+	if (timerGateChanged) {
+		if (!timerGate && _hasPendingCount) {
+			// A gate stop cancels the current half-cycle. Retain the newest
+			// programmed count for the next start.
+			_count = _pendingCount;
+			_pendingCount = 0;
+			_hasPendingCount = false;
+		}
+
+		_phase = 0;
+		_high = !timerGate || !_undersampled;
+	}
+
+	// Evaluate both port-0x61 controls together so a combined write never
+	// exposes an intermediate speaker level.
+	addTransition(outputLevel());
+}
+
+void PCSpeakerPITRenderer::advanceCounter() {
+	if (!_timerGate || !_counterLoaded || _undersampled)
+		return;
+
+	// Phase is expressed in PIT-clock/output-rate products. This avoids
+	// timer drift and retains every edge's fractional sample position.
+	uint64 phaseToAdvance = _pitClock;
+	uint64 elapsed = 0;
+
+	while (phaseToAdvance) {
+		// A programmed PIT count of zero represents 65536.
+		const uint32 effectiveCount = _count ? _count : 0x10000;
+		const uint32 halfCount = _high ?
+			(effectiveCount + 1) / 2 : effectiveCount / 2;
+		const uint64 halfPeriod =
+			(uint64)MAX<uint32>(halfCount, 1) * _sampleRate;
+		const uint64 toTransition = halfPeriod - _phase;
+		const uint64 advance = MIN<uint64>(phaseToAdvance, toTransition);
+
+		_phase += advance;
+		phaseToAdvance -= advance;
+		elapsed += advance;
+
+		if (_phase == halfPeriod) {
+			_phase = 0;
+			_high = !_high;
+			if (_hasPendingCount) {
+				_count = _pendingCount;
+				_pendingCount = 0;
+				_hasPendingCount = false;
+			}
+			addTransition(outputLevel(), elapsed);
+		}
+	}
+}
+
+int16 PCSpeakerPITRenderer::generateSample(byte volume) {
+	advanceCounter();
+
+	_reconstructedLevel = saturateInt32((int64)_reconstructedLevel +
+		_impulseBuffer[_impulseHead]);
+	_impulseBuffer[_impulseHead] = 0;
+	_impulseHead = (_impulseHead + 1) % _impulseBuffer.size();
+	++_sampleCounter;
+
+	// Output coupling and coloration are device state, so they continue while
+	// muted. Volume is applied only after both stages have advanced.
+	const int32 output = _outputStage->process(_reconstructedLevel);
+	_reconstructedLevel = _outputStage->decay(_reconstructedLevel);
+	const int64 scaled = (int64)output * 127 * volume;
+	const int64 half = (int64)1 << (kSampleFracBits - 1);
+	const int32 rounded = saturateInt32(scaled < 0 ?
+		-(((-scaled) + half) >> kSampleFracBits) :
+		(scaled + half) >> kSampleFracBits);
+	return (int16)CLIP<int32>(rounded, -32768, 32767);
+}
+
+} // End of namespace MADS
diff --git a/engines/mads/core/pcspk_pit.h b/engines/mads/core/pcspk_pit.h
new file mode 100644
index 00000000000..8fa11dfaec4
--- /dev/null
+++ b/engines/mads/core/pcspk_pit.h
@@ -0,0 +1,94 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MADS_CORE_PCSPK_PIT_H
+#define MADS_CORE_PCSPK_PIT_H
+
+#include "common/array.h"
+#include "common/scummsys.h"
+
+namespace MADS {
+
+/**
+ * Renders the mode-3 PIT state driven by the MADS PC speaker sound driver.
+ *
+ * Unlike Audio::PCSpeakerStream, this class accepts the raw counter and
+ * port-control writes made by the original DOS driver. It owns no mixer
+ * stream or synchronization; the owning MADS sound driver serializes access.
+ */
+class PCSpeakerPITRenderer {
+public:
+	explicit PCSpeakerPITRenderer(uint32 sampleRate,
+		uint32 pitClock = 1193182);
+	~PCSpeakerPITRenderer();
+
+	void writeMode3Count(uint16 count);
+	void setControl(bool timerGate, bool speakerEnabled);
+	int16 generateSample(byte volume);
+
+private:
+	class PCSpeakerOutputStage;
+
+	enum {
+		kFractionalPhases = 32,
+		kImpulseDurationUs = 3125,
+		kSampleFracBits = 24,
+		kCoefficientFracBits = 30,
+		kReferenceSampleRate = 48000
+	};
+
+	void initializeImpulse();
+	void resetState();
+	void addTransition(int level, uint64 elapsedPitClocks = 0);
+	void advanceCounter();
+	bool isUndersampled(uint16 count) const;
+	int outputLevel() const;
+
+	uint32 _sampleRate;
+	uint32 _pitClock;
+	uint64 _phase;
+	uint64 _sampleCounter;
+	uint16 _count;
+	uint16 _pendingCount;
+	bool _hasPendingCount;
+	bool _counterLoaded;
+	bool _timerGate;
+	bool _speakerEnabled;
+	bool _high;
+	bool _undersampled;
+	bool _hasUndersampledReload;
+	uint64 _lastUndersampledReloadSample;
+
+	uint32 _impulseLength;
+	uint32 _impulseHead;
+	Common::Array<int32> _impulseLut;
+	Common::Array<int32> _impulseBuffer;
+	int32 _reconstructedLevel;
+	int _targetLevel;
+	PCSpeakerOutputStage *_outputStage;
+
+	PCSpeakerPITRenderer(const PCSpeakerPITRenderer &);
+	PCSpeakerPITRenderer &operator=(const PCSpeakerPITRenderer &);
+};
+
+} // End of namespace MADS
+
+#endif // MADS_CORE_PCSPK_PIT_H
diff --git a/engines/mads/module.mk b/engines/mads/module.mk
index 7ae486eae4a..a5610f87a95 100644
--- a/engines/mads/module.mk
+++ b/engines/mads/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS := \
 	core/pack.o \
 	core/pack_dcl.o \
 	core/pal.o \
+	core/pcspk_pit.o \
 	core/pfab.o \
 	core/player.o \
 	core/popup.o \


Commit: 2872289cc60e943e489224ff0fc5665f3fcbd143
    https://github.com/scummvm/scummvm/commit/2872289cc60e943e489224ff0fc5665f3fcbd143
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-08T17:07:07+10:00

Commit Message:
MADS: NEBULAR: Use PIT PC speaker renderer

Assisted-by: Codex:GPT-5.6-sol

Changed paths:
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound.h


diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index f413a001ca0..83a6211ae9f 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -103,14 +103,13 @@ ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename) :
 
 ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename, const OverlayLayout &layout) :
 		SoundDriver(mixer, filename, (int)layout.dataOffset, (int)layout.initializedDataSize),
-	_speakerGate(false),
 	_noiseEnabled(false),
 	_updatesEnabled(false),
 	_masterVolume(255),
 	_outputRate(mixer->getOutputRate()),
 	_sequenceAccumulator(0),
 	_noiseAccumulator(0),
-	_oscillatorPhase(0),
+	_pitRenderer(_outputRate, kPitClockHz),
 	_frameCounter(0),
 	_randomSeed(0),
 	_commandParam(0),
@@ -132,10 +131,6 @@ ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename, const OverlayL
 	_fineOffset(0),
 	_noiseMask(0),
 	_currentDivisor(0),
-	_lastOutputDivisor(0),
-	_pendingDivisor(0),
-	_hasPendingDivisor(false),
-	_speakerHigh(true),
 	_pitchStep(0),
 	_directDivisor(0),
 	_alternationReload(0),
@@ -196,8 +191,6 @@ void ISound::resetDriver() {
 	_outerLoopCount = 0;
 	_noiseMask = 0;
 	_currentDivisor = 0;
-	_pendingDivisor = 0;
-	_hasPendingDivisor = false;
 	_pitchStep = 0;
 	_gateOffset = 0;
 	_fineOffset = 0;
@@ -380,20 +373,7 @@ byte ISound::outputVolume() const {
 }
 
 void ISound::outputDivisor(uint16 divisor) {
-	// Reprogramming an 8254 counter in mode 3 does not restart the current
-	// half-cycle. The new count is loaded at the next output transition.
-	// This is especially important while the noise service rewrites the
-	// divisor: restarting the waveform on every write creates false tones.
-	if (_speakerGate && _lastOutputDivisor) {
-		_pendingDivisor = divisor;
-		_hasPendingDivisor = true;
-	} else {
-		_lastOutputDivisor = divisor;
-		_pendingDivisor = 0;
-		_hasPendingDivisor = false;
-		_oscillatorPhase = 0;
-		_speakerHigh = true;
-	}
+	_pitRenderer.writeMode3Count(divisor);
 }
 
 void ISound::startSpeaker() {
@@ -404,15 +384,12 @@ void ISound::startSpeaker() {
 	_sweepInitialized = false;
 	_alternationToggle = false;
 	outputDivisor(_currentDivisor);
-	_speakerGate = true;
+	_pitRenderer.setControl(true, true);
 }
 
 void ISound::stopSpeaker() {
-	_speakerGate = false;
+	_pitRenderer.setControl(false, false);
 	_sweepInitialized = false;
-	_hasPendingDivisor = false;
-	_oscillatorPhase = 0;
-	_speakerHigh = true;
 }
 
 void ISound::processOrdinaryEvent() {
@@ -632,36 +609,7 @@ void ISound::setVolume(int volume) {
 }
 
 int16 ISound::generateSample() {
-	if (!_speakerGate)
-		return 0;
-
-	// A PIT divisor of zero represents 65536.
-	_oscillatorPhase += kPitClockHz;
-
-	for (;;) {
-		const uint32 effectiveDivisor =
-			_lastOutputDivisor ? _lastOutputDivisor : 0x10000;
-		const uint32 halfCount = _speakerHigh ?
-			(effectiveDivisor + 1) / 2 : effectiveDivisor / 2;
-		const uint64 halfPeriod = (uint64)MAX<uint32>(halfCount, 1) *
-			_outputRate;
-		if (_oscillatorPhase < halfPeriod)
-			break;
-
-		_oscillatorPhase -= halfPeriod;
-		_speakerHigh = !_speakerHigh;
-		if (_hasPendingDivisor) {
-			_lastOutputDivisor = _pendingDivisor;
-			_pendingDivisor = 0;
-			_hasPendingDivisor = false;
-		}
-	}
-
-	if (!_masterVolume)
-		return 0;
-
-	const int amplitude = 127 * outputVolume();
-	return _speakerHigh ? amplitude : -amplitude;
+	return _pitRenderer.generateSample(outputVolume());
 }
 
 int ISound::readBuffer(int16 *buffer, int numSamples) {
diff --git a/engines/mads/nebular/sound/isound.h b/engines/mads/nebular/sound/isound.h
index bc5a71b5f1e..98fd46bf14b 100644
--- a/engines/mads/nebular/sound/isound.h
+++ b/engines/mads/nebular/sound/isound.h
@@ -24,6 +24,7 @@
 
 #include "audio/audiostream.h"
 #include "audio/mixer.h"
+#include "mads/core/pcspk_pit.h"
 #include "mads/core/sound_manager.h"
 
 namespace MADS {
@@ -59,14 +60,13 @@ protected:
 	};
 
 	Audio::SoundHandle _speakerHandle;
-	bool _speakerGate;
 	bool _noiseEnabled;
 	bool _updatesEnabled;
 	int _masterVolume;
 	int _outputRate;
 	uint32 _sequenceAccumulator;
 	uint32 _noiseAccumulator;
-	uint64 _oscillatorPhase;
+	PCSpeakerPITRenderer _pitRenderer;
 
 	uint16 _frameCounter;
 	uint16 _randomSeed;
@@ -92,10 +92,6 @@ protected:
 
 	uint16 _noiseMask;
 	uint16 _currentDivisor;
-	uint16 _lastOutputDivisor;
-	uint16 _pendingDivisor;
-	bool _hasPendingDivisor;
-	bool _speakerHigh;
 	uint16 _pitchStep;
 	uint16 _directDivisor;
 


Commit: 8658d38493cc62e75e70022224587d887efca3c3
    https://github.com/scummvm/scummvm/commit/8658d38493cc62e75e70022224587d887efca3c3
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-08T17:07:07+10:00

Commit Message:
MADS: NEBULAR: Match native ISOUND host cadence

The original host does not call the ISOUND exports at the descriptor's
nominal 100 Hz rate. It programs PIT channel 0 in mode 3 with divisor
0x07A8 (1,960), handles every raw interrupt, and services the sound
driver on every second interrupt:

PIT input clock: 1,193,182 Hz
raw IRQ rate 1,193,182 / 1,960 = 608.7663265 Hz
driver service rate 1,193,182 / (1,960 * 2) = 304.3831633 Hz
sequence poll rate 304.3831633 / 5 = 60.87663265 Hz

On every service tick the host calls export 4 if noise service is
enabled. It then decrements a five-tick countdown and calls export 3
when the countdown reaches zero. The countdown is initialized to one and
reloaded with five, so the first service tick polls and subsequent polls
occur on service ticks 6, 11, 16, and so on.

When both exports run on one service tick, export 4 runs first. A result
from export 3 which enables or disables noise consequently affects the
following service tick, not the current tick.

Assisted-by: Codex:GPT-5.6-sol

Changed paths:
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound.h


diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index 83a6211ae9f..1755a977481 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -88,6 +88,9 @@ ISound::OverlayLayout ISound::readOverlayLayout(
 	result.initializedDataSize = (uint32)(fileSize - dataOffset);
 	result.dataSegmentSize = dataSegmentSize;
 
+	// The descriptor's nominal 100 Hz value identifies the expected overlay
+	// ABI. The native host supplies its actual callbacks from a separate PIT
+	// cascade reconstructed in readBuffer().
 	if (result.dataSegmentSize < result.initializedDataSize ||
 		result.dataSegmentSize < kMinimumDataSegmentSize ||
 		timerHz != 100 || exportCount != 11)
@@ -107,8 +110,8 @@ ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename, const OverlayL
 	_updatesEnabled(false),
 	_masterVolume(255),
 	_outputRate(mixer->getOutputRate()),
-	_sequenceAccumulator(0),
-	_noiseAccumulator(0),
+	_hostTimerAccumulator(0),
+	_sequenceServiceCountdown(1),
 	_pitRenderer(_outputRate, kPitClockHz),
 	_frameCounter(0),
 	_randomSeed(0),
@@ -585,7 +588,7 @@ void ISound::noiseTick() {
 }
 
 int ISound::poll() {
-	// Playback advances from the audio stream at the overlay's 100 Hz rate.
+	// Playback advances from the audio stream at the native host cadence.
 	return 0;
 }
 
@@ -615,23 +618,24 @@ int16 ISound::generateSample() {
 int ISound::readBuffer(int16 *buffer, int numSamples) {
 	Common::StackLock lock(_driverMutex);
 
-	for (int sample = 0; sample < numSamples; ++sample) {
-		_sequenceAccumulator += kSequenceRateHz;
-		if (_sequenceAccumulator >= (uint32)_outputRate) {
-			_sequenceAccumulator -= _outputRate;
-			timerTick();
-		}
+	const uint64 serviceThreshold = (uint64)_outputRate *
+		kHostTimerDivisor * kHostServiceDivider;
 
-		_noiseAccumulator += kNoiseRateHz;
-		if (_noiseAccumulator >= (uint32)_outputRate) {
-			_noiseAccumulator -= _outputRate;
+	for (int sample = 0; sample < numSamples; ++sample) {
+		_hostTimerAccumulator += kPitClockHz;
+		while (_hostTimerAccumulator >= serviceThreshold) {
+			_hostTimerAccumulator -= serviceThreshold;
 
-			// The overlay proves that noise is serviced independently from
-			// its 100 Hz sequencer, but not the host interval. The original
-			// capture is consistent with the surviving MADS 60 Hz service
-			// cadence; the 600 Hz counter is a timing clock.
+			// The host calls export 4 before export 3 when both are due.
+			// Consequently, a poll result changes noise on the next service
+			// tick rather than the current one.
 			if (_noiseEnabled)
 				noiseTick();
+
+			if (!--_sequenceServiceCountdown) {
+				_sequenceServiceCountdown = kSequenceServiceDivider;
+				timerTick();
+			}
 		}
 
 		buffer[sample] = generateSample();
diff --git a/engines/mads/nebular/sound/isound.h b/engines/mads/nebular/sound/isound.h
index 98fd46bf14b..a474cdd61f8 100644
--- a/engines/mads/nebular/sound/isound.h
+++ b/engines/mads/nebular/sound/isound.h
@@ -44,8 +44,9 @@ class ISound : public SoundDriver, public Audio::AudioStream {
 public:
 	enum {
 		kPitClockHz = 1193182,
-		kSequenceRateHz = 100,
-		kNoiseRateHz = 60,
+		kHostTimerDivisor = 0x07a8,
+		kHostServiceDivider = 2,
+		kSequenceServiceDivider = 5,
 		kDefaultOutputVolume = 20,
 		kFrequencyTableOffset = 0x0114,
 		kInitialNullSequenceOffset = 0x00f0,
@@ -64,8 +65,8 @@ protected:
 	bool _updatesEnabled;
 	int _masterVolume;
 	int _outputRate;
-	uint32 _sequenceAccumulator;
-	uint32 _noiseAccumulator;
+	uint64 _hostTimerAccumulator;
+	byte _sequenceServiceCountdown;
 	PCSpeakerPITRenderer _pitRenderer;
 
 	uint16 _frameCounter;


Commit: ea6ab5faf0d70643e7cca6b6a6ca23fcc7e1ebf4
    https://github.com/scummvm/scummvm/commit/ea6ab5faf0d70643e7cca6b6a6ca23fcc7e1ebf4
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-08T17:07:07+10:00

Commit Message:
MADS: NEBULAR: Preserve fractional ISOUND timing

Render the PC speaker impulse model at its fixed 48 kHz rate and advance
the PIT to each recovered host-service interrupt's exact position within
the current sample before applying divisor and control writes.
Let Audio::Mixer handle conversion to the device rate.

Assisted-by: Codex:GPT-5.6-sol

Changed paths:
    engines/mads/core/pcspk_pit.cpp
    engines/mads/core/pcspk_pit.h
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound.h


diff --git a/engines/mads/core/pcspk_pit.cpp b/engines/mads/core/pcspk_pit.cpp
index ab16516a7f2..e1e7f5d7642 100644
--- a/engines/mads/core/pcspk_pit.cpp
+++ b/engines/mads/core/pcspk_pit.cpp
@@ -138,21 +138,7 @@ private:
 
 public:
 	explicit PCSpeakerOutputStage(uint32 sampleRate) :
-		_accumulatorDecay(0) {
-		static Common::Mutex decayCacheMutex;
-		static Common::HashMap<uint32, int32> decayCache;
-		{
-			Common::StackLock lock(decayCacheMutex);
-			if (!decayCache.contains(sampleRate)) {
-				// DOSBox applies 0.999 at its fixed 48-kHz device rate. Preserve
-				// that time constant at the active ScummVM mixer rate.
-				decayCache.setVal(sampleRate, fixedFromDouble(
-					pow(0.999, (double)kReferenceSampleRate / sampleRate),
-					kCoefficientFracBits));
-			}
-			_accumulatorDecay = decayCache.getVal(sampleRate);
-		}
-
+		_accumulatorDecay(fixedFromDouble(0.999, kCoefficientFracBits)) {
 		static Common::Mutex filterCacheMutex;
 		static Common::HashMap<uint32, FilterConfiguration> filterCache;
 		{
@@ -319,6 +305,7 @@ void PCSpeakerPITRenderer::initializeImpulse() {
 
 void PCSpeakerPITRenderer::resetState() {
 	_phase = 0;
+	_samplePhase = 0;
 	_sampleCounter = 0;
 	_count = 0;
 	_pendingCount = 0;
@@ -338,6 +325,17 @@ void PCSpeakerPITRenderer::resetState() {
 	_outputStage->reset();
 }
 
+void PCSpeakerPITRenderer::advanceToSampleFraction(uint32 numerator,
+		uint32 denominator) {
+	assert(denominator);
+	assert(numerator <= denominator);
+
+	const uint64 samplePhase = ((uint64)numerator * _pitClock +
+		denominator / 2) / denominator;
+	assert(samplePhase >= _samplePhase);
+	advanceCounter(samplePhase - _samplePhase);
+}
+
 bool PCSpeakerPITRenderer::isUndersampled(uint16 count) const {
 	const uint32 minimumCount =
 		((uint64)2 * _pitClock + _sampleRate - 1) / _sampleRate;
@@ -406,7 +404,7 @@ void PCSpeakerPITRenderer::writeMode3Count(uint16 count) {
 		_undersampled = true;
 		_hasUndersampledReload = true;
 		_lastUndersampledReloadSample = _sampleCounter;
-		addTransition(outputLevel());
+		addTransition(outputLevel(), _samplePhase);
 		return;
 	}
 
@@ -426,7 +424,7 @@ void PCSpeakerPITRenderer::writeMode3Count(uint16 count) {
 		_counterLoaded = true;
 		_phase = 0;
 		_high = true;
-		addTransition(outputLevel());
+		addTransition(outputLevel(), _samplePhase);
 	}
 }
 
@@ -451,17 +449,21 @@ void PCSpeakerPITRenderer::setControl(bool timerGate,
 
 	// Evaluate both port-0x61 controls together so a combined write never
 	// exposes an intermediate speaker level.
-	addTransition(outputLevel());
+	addTransition(outputLevel(), _samplePhase);
 }
 
-void PCSpeakerPITRenderer::advanceCounter() {
-	if (!_timerGate || !_counterLoaded || _undersampled)
+void PCSpeakerPITRenderer::advanceCounter(uint64 pitClockUnits) {
+	assert(_samplePhase + pitClockUnits <= _pitClock);
+
+	if (!_timerGate || !_counterLoaded || _undersampled) {
+		_samplePhase += pitClockUnits;
 		return;
+	}
 
 	// Phase is expressed in PIT-clock/output-rate products. This avoids
 	// timer drift and retains every edge's fractional sample position.
-	uint64 phaseToAdvance = _pitClock;
-	uint64 elapsed = 0;
+	uint64 phaseToAdvance = pitClockUnits;
+	uint64 elapsed = _samplePhase;
 
 	while (phaseToAdvance) {
 		// A programmed PIT count of zero represents 65536.
@@ -488,15 +490,18 @@ void PCSpeakerPITRenderer::advanceCounter() {
 			addTransition(outputLevel(), elapsed);
 		}
 	}
+
+	_samplePhase += pitClockUnits;
 }
 
 int16 PCSpeakerPITRenderer::generateSample(byte volume) {
-	advanceCounter();
+	advanceCounter(_pitClock - _samplePhase);
 
 	_reconstructedLevel = saturateInt32((int64)_reconstructedLevel +
 		_impulseBuffer[_impulseHead]);
 	_impulseBuffer[_impulseHead] = 0;
 	_impulseHead = (_impulseHead + 1) % _impulseBuffer.size();
+	_samplePhase = 0;
 	++_sampleCounter;
 
 	// Output coupling and coloration are device state, so they continue while
diff --git a/engines/mads/core/pcspk_pit.h b/engines/mads/core/pcspk_pit.h
index 8fa11dfaec4..04aafbf0df3 100644
--- a/engines/mads/core/pcspk_pit.h
+++ b/engines/mads/core/pcspk_pit.h
@@ -40,6 +40,8 @@ public:
 		uint32 pitClock = 1193182);
 	~PCSpeakerPITRenderer();
 
+	/** Advance the PIT to a fractional position in the current output sample. */
+	void advanceToSampleFraction(uint32 numerator, uint32 denominator);
 	void writeMode3Count(uint16 count);
 	void setControl(bool timerGate, bool speakerEnabled);
 	int16 generateSample(byte volume);
@@ -51,20 +53,20 @@ private:
 		kFractionalPhases = 32,
 		kImpulseDurationUs = 3125,
 		kSampleFracBits = 24,
-		kCoefficientFracBits = 30,
-		kReferenceSampleRate = 48000
+		kCoefficientFracBits = 30
 	};
 
 	void initializeImpulse();
 	void resetState();
 	void addTransition(int level, uint64 elapsedPitClocks = 0);
-	void advanceCounter();
+	void advanceCounter(uint64 pitClockUnits);
 	bool isUndersampled(uint16 count) const;
 	int outputLevel() const;
 
 	uint32 _sampleRate;
 	uint32 _pitClock;
 	uint64 _phase;
+	uint64 _samplePhase;
 	uint64 _sampleCounter;
 	uint16 _count;
 	uint16 _pendingCount;
diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index 1755a977481..505de6e43d3 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -109,7 +109,7 @@ ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename, const OverlayL
 	_noiseEnabled(false),
 	_updatesEnabled(false),
 	_masterVolume(255),
-	_outputRate(mixer->getOutputRate()),
+	_outputRate(kPCSpeakerSampleRate),
 	_hostTimerAccumulator(0),
 	_sequenceServiceCountdown(1),
 	_pitRenderer(_outputRate, kPitClockHz),
@@ -620,10 +620,20 @@ int ISound::readBuffer(int16 *buffer, int numSamples) {
 
 	const uint64 serviceThreshold = (uint64)_outputRate *
 		kHostTimerDivisor * kHostServiceDivider;
+	assert(serviceThreshold > kPitClockHz);
 
 	for (int sample = 0; sample < numSamples; ++sample) {
+		const uint64 previousHostTimerAccumulator = _hostTimerAccumulator;
 		_hostTimerAccumulator += kPitClockHz;
-		while (_hostTimerAccumulator >= serviceThreshold) {
+		if (_hostTimerAccumulator >= serviceThreshold) {
+			// The accumulator crossing identifies the service interrupt's exact
+			// position inside this output sample. Advance the PIT there before
+			// applying the native count and control writes.
+			const uint64 servicePosition = serviceThreshold -
+				previousHostTimerAccumulator;
+			assert(servicePosition <= kPitClockHz);
+			_pitRenderer.advanceToSampleFraction(
+				(uint32)servicePosition, kPitClockHz);
 			_hostTimerAccumulator -= serviceThreshold;
 
 			// The host calls export 4 before export 3 when both are due.
diff --git a/engines/mads/nebular/sound/isound.h b/engines/mads/nebular/sound/isound.h
index a474cdd61f8..76c73bd6d75 100644
--- a/engines/mads/nebular/sound/isound.h
+++ b/engines/mads/nebular/sound/isound.h
@@ -47,6 +47,9 @@ public:
 		kHostTimerDivisor = 0x07a8,
 		kHostServiceDivider = 2,
 		kSequenceServiceDivider = 5,
+		// Match the fixed 48-kHz rate used by DOSBox Staging's post-0.82.2
+		// impulse model (b53ac15). Audio::Mixer handles device conversion.
+		kPCSpeakerSampleRate = 48000,
 		kDefaultOutputVolume = 20,
 		kFrequencyTableOffset = 0x0114,
 		kInitialNullSequenceOffset = 0x00f0,
@@ -110,7 +113,7 @@ protected:
 
 	static OverlayLayout readOverlayLayout(const Common::Path &filename);
 
-	ISound(Audio::Mixer *mixer,const Common::Path &filename, const OverlayLayout &layout);
+	ISound(Audio::Mixer *mixer, const Common::Path &filename, const OverlayLayout &layout);
 
 	byte readSequenceByte(uint16 offset) const;
 	uint16 readSequenceUint16(uint16 offset) const;




More information about the Scummvm-git-logs mailing list