[Scummvm-git-logs] scummvm master -> 4f45ad0d22b6d8fdeb00bcb64ffe03c12d582f09

neuromancer noreply at scummvm.org
Fri Apr 3 07:07:17 UTC 2026


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

Summary:
acc405f699 FREESCAPE: initial implementation to allow to play castle master 2 in zx
4f45ad0d22 FREESCAPE: reworked opl music for eclipse and make sure music is restarted properly


Commit: acc405f69906f71b146f0283d7db9daf3bd98437
    https://github.com/scummvm/scummvm/commit/acc405f69906f71b146f0283d7db9daf3bd98437
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-03T09:06:59+02:00

Commit Message:
FREESCAPE: initial implementation to allow to play castle master 2 in zx

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/freescape.h
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/games/castle/zx.cpp
    engines/freescape/language/instruction.cpp
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/metaengine.cpp
    engines/freescape/movement.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index e7ba5d6056b..8192c43fa8d 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -35,6 +35,7 @@ static const PlainGameDescriptor freescapeGames[] = {
 	{"totaleclipse", "Total Eclipse"},
 	{"totaleclipse2", "Total Eclipse 2"},
 	{"castlemaster", "Castle Master"},
+	{"castlemaster2", "Castle Master 2: The Crypt"},
 	{0, 0}
 };
 
@@ -985,6 +986,16 @@ static const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO5(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDEREGA, GUIO_RENDERCGA, GAMEOPTION_WASD_CONTROLS)
 	},
+	// Castle Master 2: The Crypt
+	{
+		"castlemaster2",
+		"",
+		AD_ENTRY1s("castlemaster2.zx.data", "a470acb51c7925e921b0de056cdb880f", 35328),
+		Common::EN_ANY,
+		Common::kPlatformZX,
+		ADGF_NO_FLAGS,
+		GUIO3(GUIO_NOMIDI, GUIO_RENDERZX, GAMEOPTION_WASD_CONTROLS)
+	},
 	// 3D Construction Kit games
 	{
 		"3dkit",
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index 3ea2f51b933..e51774ec742 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -205,6 +205,7 @@ public:
 	bool isEclipse() { return _targetName.hasPrefix("totaleclipse"); } // This will match Total Eclipse 1 and 2.
 	bool isEclipse2() { return _targetName.hasPrefix("totaleclipse2"); }
 	bool isCastle() { return _targetName.hasPrefix("castle"); }
+	bool isCastleMaster2() { return _targetName.hasPrefix("castlemaster2"); }
 	bool isAmiga() { return _gameDescription->platform == Common::kPlatformAmiga; }
 	bool isAtariST() { return _gameDescription->platform == Common::kPlatformAtariST; }
 	bool isDOS() { return _gameDescription->platform == Common::kPlatformDOS; }
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 286f76ac8ec..3454462399e 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -741,6 +741,10 @@ bool CastleEngine::triggerWinCondition() {
 		if (!_areaMap.contains(74))
 			return false;
 		gotoArea(74, 0);
+	} else if (isCastleMaster2()) {
+		// CM2: escape is script-driven (game boolean 3 bit 6).
+		// The escape animation plays from the current location.
+		return true;
 	} else {
 		_gameStateVars[31] = 10;
 		gotoArea(16, 136);
@@ -756,8 +760,11 @@ void CastleEngine::endGame() {
 	if (hasEscaped()) {
 		insertTemporaryMessage(_messagesList[5], INT_MIN);
 
-		if (isDOS()) {
+		if (isDOS() && !isCastleMaster2()) {
 			drawFullscreenEndGameAndWait();
+		} else if (isCastleMaster2()) {
+			executeEscapeCameraSequence();
+			drawFullscreenGameOverAndWait();
 		} else
 			drawFullscreenGameOverAndWait();
 	} else {
@@ -768,6 +775,47 @@ void CastleEngine::endGame() {
 	_endGameKeyPressed = false;
 }
 
+void CastleEngine::executeEscapeCameraSequence() {
+	// Escape camera animation from CM2 disassembly (L9fa1_escape_castle_sequence):
+	// 1. Rotate player to face yaw=180 (away from castle), pitch=0 (level)
+	// 2. Walk backward (increase Z) until leaving the map
+
+	// Step 1: Rotate toward yaw=180, pitch=0
+	float yawStep = (_yaw < 180.0f) ? 10.0f : -10.0f;
+	float pitchStep = (_pitch < 180.0f) ? -10.0f : 10.0f;
+
+	while (ABS(_yaw - 180.0f) > 5.0f || (ABS(_pitch) > 5.0f && ABS(_pitch - 360.0f) > 5.0f)) {
+		if (ABS(_yaw - 180.0f) > 5.0f)
+			_yaw += yawStep;
+		if (ABS(_pitch) > 5.0f && ABS(_pitch - 360.0f) > 5.0f)
+			_pitch += pitchStep;
+
+		if (_yaw < 0) _yaw += 360.0f;
+		if (_yaw >= 360.0f) _yaw -= 360.0f;
+		if (_pitch < 0) _pitch += 360.0f;
+		if (_pitch >= 360.0f) _pitch -= 360.0f;
+
+		_cameraFront = directionToVector(_yaw, _pitch, false);
+		drawFrame();
+		_gfx->flipBuffer();
+		g_system->updateScreen();
+		g_system->delayMillis(40);
+	}
+
+	_yaw = 180.0f;
+	_pitch = 0.0f;
+	_cameraFront = directionToVector(_yaw, _pitch, false);
+
+	// Step 2: Move backward (increase Z) until out of bounds
+	for (int i = 0; i < 40; i++) {
+		_position.setValue(2, _position.z() + 256.0f);
+		drawFrame();
+		_gfx->flipBuffer();
+		g_system->updateScreen();
+		g_system->delayMillis(40);
+	}
+}
+
 bool CastleEngine::hasEscaped() {
 	if (isDOS())
 		return _currentArea && _currentArea->getAreaID() == 74;
@@ -1975,7 +2023,9 @@ void CastleEngine::borderScreen() {
 		surface->free();
 		delete surface;
 	}
-	selectCharacterScreen();
+
+	if (!isCastleMaster2())
+		selectCharacterScreen();
 }
 
 void CastleEngine::drawOption() {
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index d5f734a45be..51e390bafea 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -59,6 +59,7 @@ public:
 	void initGameState() override;
 	bool triggerWinCondition() override;
 	void endGame() override;
+	void executeEscapeCameraSequence();
 
 	void drawInfoMenu() override;
 	void loadAssets() override;
diff --git a/engines/freescape/games/castle/zx.cpp b/engines/freescape/games/castle/zx.cpp
index 1aaaf37408a..d1979d13b36 100644
--- a/engines/freescape/games/castle/zx.cpp
+++ b/engines/freescape/games/castle/zx.cpp
@@ -46,69 +46,114 @@ void CastleEngine::loadAssetsZXFullGame() {
 	uint8 r, g, b;
 	Common::Array<Graphics::ManagedSurface *> chars;
 
-	file.open("castlemaster.zx.title");
+	Common::Path titleFile(isCastleMaster2() ? "castlemaster2.zx.title" : "castlemaster.zx.title");
+	Common::Path borderFile(isCastleMaster2() ? "castlemaster2.zx.border" : "castlemaster.zx.border");
+	Common::Path dataFile(isCastleMaster2() ? "castlemaster2.zx.data" : "castlemaster.zx.data");
+
+	file.open(titleFile);
 	if (file.isOpen()) {
 		_title = loadAndConvertScrImage(&file);
 	} else
-		error("Unable to find castlemaster.zx.title");
+		error("Unable to find %s", titleFile.toString().c_str());
 
 	file.close();
-	file.open("castlemaster.zx.border");
+	file.open(borderFile);
 	if (file.isOpen()) {
 		_border = loadAndConvertScrImage(&file);
 	} else
-		error("Unable to find castlemaster.zx.border");
+		error("Unable to find %s", borderFile.toString().c_str());
 	file.close();
 
-	file.open("castlemaster.zx.data");
+	file.open(dataFile);
 	if (!file.isOpen())
-		error("Failed to open castlemaster.zx.data");
-
-	loadMessagesVariableSize(&file, 0x4bd, 71);
-	switch (_language) {
-		case Common::ES_ESP:
-			loadRiddles(&file, 0x1458, 9);
-			load8bitBinary(&file, 0x6aa9, 16);
-			loadSpeakerFxZX(&file, 0xca0, 0xcdc);
-
-			file.seek(0x1228);
-			for (int i = 0; i < 90; i++) {
-				Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
-				surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
-				chars.push_back(loadFrame(&file, surface, 1, 8, 1));
-			}
-			_font = Font(chars);
-			_font.setCharWidth(9);
-			_fontLoaded = true;
-
-			break;
-		case Common::EN_ANY:
-			if (_variant & GF_ZX_RETAIL) {
-				loadRiddles(&file, 0x1448, 9);
-				load8bitBinary(&file, 0x6a3b, 16);
-				loadSpeakerFxZX(&file, 0xc91, 0xccd);
-				file.seek(0x1219);
-			} else if (_variant & GF_ZX_DISC) {
-				loadRiddles(&file, 0x1457, 9);
-				load8bitBinary(&file, 0x6a9b, 16);
+		error("Failed to open %s", dataFile.toString().c_str());
+
+	if (isCastleMaster2()) {
+		// CM2 game text (L6cb9_game_text) and area names (L6f49_area_names)
+		// are both fixed-size: 16 bytes per entry (1-byte indent flag + 15
+		// chars of text). The indent flag is used by Ld01c_draw_string for
+		// display positioning but is not part of the text content.
+		// Game text: 41 entries at offset 0x02B9.
+		// Area names: 40 entries at offset 0x0549.
+		// Both are loaded into _messagesList (game text at indices 0-40,
+		// area names at indices 41-80).
+		file.seek(0x02b9);
+		for (int i = 0; i < 41; i++) {
+			file.readByte(); // skip indent flag
+			char buf[16];
+			file.read(buf, 15);
+			buf[15] = '\0';
+			_messagesList.push_back(Common::String(buf));
+		}
+
+		// Area names follow immediately (L6f49_area_names, 40 entries).
+		for (int i = 0; i < 40; i++) {
+			file.readByte(); // skip indent flag
+			char buf[16];
+			file.read(buf, 15);
+			buf[15] = '\0';
+			_messagesList.push_back(Common::String(buf));
+		}
+
+		load8bitBinary(&file, 0x6682, 16);
+		loadSpeakerFxZX(&file, 0x0bbf, 0x0bfb);
+
+		file.seek(0x1147);
+		for (int i = 0; i < 90; i++) {
+			Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+			surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
+			chars.push_back(loadFrame(&file, surface, 1, 8, 1));
+		}
+		_font = Font(chars);
+		_font.setCharWidth(9);
+		_fontLoaded = true;
+	} else {
+		loadMessagesVariableSize(&file, 0x4bd, 71);
+		switch (_language) {
+			case Common::ES_ESP:
+				loadRiddles(&file, 0x1458, 9);
+				load8bitBinary(&file, 0x6aa9, 16);
 				loadSpeakerFxZX(&file, 0xca0, 0xcdc);
+
 				file.seek(0x1228);
-			} else {
-				error("Unknown Castle Master ZX variant");
-			}
-			for (int i = 0; i < 90; i++) {
-				Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
-				surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
-				chars.push_back(loadFrame(&file, surface, 1, 8, 1));
-			}
-			_font = Font(chars);
-			_font.setCharWidth(9);
-			_fontLoaded = true;
-
-			break;
-		default:
-			error("Language not supported");
-			break;
+				for (int i = 0; i < 90; i++) {
+					Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+					surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
+					chars.push_back(loadFrame(&file, surface, 1, 8, 1));
+				}
+				_font = Font(chars);
+				_font.setCharWidth(9);
+				_fontLoaded = true;
+
+				break;
+			case Common::EN_ANY:
+				if (_variant & GF_ZX_RETAIL) {
+					loadRiddles(&file, 0x1448, 9);
+					load8bitBinary(&file, 0x6a3b, 16);
+					loadSpeakerFxZX(&file, 0xc91, 0xccd);
+					file.seek(0x1219);
+				} else if (_variant & GF_ZX_DISC) {
+					loadRiddles(&file, 0x1457, 9);
+					load8bitBinary(&file, 0x6a9b, 16);
+					loadSpeakerFxZX(&file, 0xca0, 0xcdc);
+					file.seek(0x1228);
+				} else {
+					error("Unknown Castle Master ZX variant");
+				}
+				for (int i = 0; i < 90; i++) {
+					Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+					surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
+					chars.push_back(loadFrame(&file, surface, 1, 8, 1));
+				}
+				_font = Font(chars);
+				_font.setCharWidth(9);
+				_fontLoaded = true;
+
+				break;
+			default:
+				error("Language not supported");
+				break;
+		}
 	}
 
 	loadColorPalette();
@@ -118,10 +163,18 @@ void CastleEngine::loadAssetsZXFullGame() {
 	_gfx->readFromPalette(7, r, g, b);
 	uint32 white = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
-	_keysBorderFrames.push_back(loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xe06 : 0xdf7, red, white));
+	if (isCastleMaster2()) {
+		_keysBorderFrames.push_back(loadFrameWithHeader(&file, 0x0d25, red, white));
+	} else {
+		_keysBorderFrames.push_back(loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xe06 : 0xdf7, red, white));
+	}
 
 	uint32 green = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0, 0xff, 0);
-	_spiritsMeterIndicatorFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xe5e : 0xe4f, green, white);
+	if (isCastleMaster2()) {
+		_spiritsMeterIndicatorFrame = loadFrameWithHeader(&file, 0x0d7d, green, white);
+	} else {
+		_spiritsMeterIndicatorFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xe5e : 0xe4f, green, white);
+	}
 
 	_gfx->readFromPalette(4, r, g, b);
 	uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
@@ -132,17 +185,27 @@ void CastleEngine::loadAssetsZXFullGame() {
 	background->create(backgroundWidth * 8, backgroundHeight, _gfx->_texturePixelFormat);
 	background->fillRect(Common::Rect(0, 0, backgroundWidth * 8, backgroundHeight), 0);
 
-	file.seek(_variant & GF_ZX_DISC ? 0xfd3 : 0xfc4);
+	if (isCastleMaster2()) {
+		file.seek(0x0ef2);
+	} else {
+		file.seek(_variant & GF_ZX_DISC ? 0xfd3 : 0xfc4);
+	}
 	_background = loadFrame(&file, background, backgroundWidth, backgroundHeight, front);
 
 	_gfx->readFromPalette(6, r, g, b);
 	uint32 yellow = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 	uint32 black = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0, 0, 0);
-	_strenghtBackgroundFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xee6 : 0xed7, yellow, black);
-	_strenghtBarFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xf72 : 0xf63, yellow, black);
-	_strenghtWeightsFrames = loadFramesWithHeader(&file, _variant & GF_ZX_DISC ? 0xf92 : 0xf83, 4, yellow, black);
-
-	_flagFrames = loadFramesWithHeader(&file, (_variant & GF_ZX_DISC ? 0x10e4 + 15 : 0x10e4), 4, green, black);
+	if (isCastleMaster2()) {
+		_strenghtBackgroundFrame = loadFrameWithHeader(&file, 0x0e05, yellow, black);
+		_strenghtBarFrame = loadFrameWithHeader(&file, 0x0e91, yellow, black);
+		_strenghtWeightsFrames = loadFramesWithHeader(&file, 0x0eb1, 4, yellow, black);
+		_flagFrames = loadFramesWithHeader(&file, 0x1012, 4, green, black);
+	} else {
+		_strenghtBackgroundFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xee6 : 0xed7, yellow, black);
+		_strenghtBarFrame = loadFrameWithHeader(&file, _variant & GF_ZX_DISC ? 0xf72 : 0xf63, yellow, black);
+		_strenghtWeightsFrames = loadFramesWithHeader(&file, _variant & GF_ZX_DISC ? 0xf92 : 0xf83, 4, yellow, black);
+		_flagFrames = loadFramesWithHeader(&file, (_variant & GF_ZX_DISC ? 0x10e4 + 15 : 0x10e4), 4, green, black);
+	}
 
 	file.skip(24);
 	int thunderWidth = 4;
@@ -161,27 +224,29 @@ void CastleEngine::loadAssetsZXFullGame() {
 	_thunderFrames[0]->copyRectToSurface(*thunderFrame, 0, 0, Common::Rect(0, 0, thunderWidth * 8 / 2, thunderHeight));
 	_thunderFrames[1]->copyRectToSurface(*thunderFrame, 0, 0, Common::Rect(thunderWidth * 8 / 2, 0, thunderWidth * 8, thunderHeight));
 
-	Graphics::Surface *tmp;
-	tmp = loadBundledImage("castle_riddle_top_frame");
-	_riddleTopFrame = new Graphics::ManagedSurface;
-	_riddleTopFrame->copyFrom(*tmp);
-	tmp->free();
-	delete tmp;
-	_riddleTopFrame->convertToInPlace(_gfx->_texturePixelFormat);
-
-	tmp = loadBundledImage("castle_riddle_background_frame");
-	_riddleBackgroundFrame = new Graphics::ManagedSurface();
-	_riddleBackgroundFrame->copyFrom(*tmp);
-	tmp->free();
-	delete tmp;
-	_riddleBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat);
-
-	tmp = loadBundledImage("castle_riddle_bottom_frame");
-	_riddleBottomFrame = new Graphics::ManagedSurface();
-	_riddleBottomFrame->copyFrom(*tmp);
-	tmp->free();
-	delete tmp;
-	_riddleBottomFrame->convertToInPlace(_gfx->_texturePixelFormat);
+	if (!isCastleMaster2()) {
+		Graphics::Surface *tmp;
+		tmp = loadBundledImage("castle_riddle_top_frame");
+		_riddleTopFrame = new Graphics::ManagedSurface;
+		_riddleTopFrame->copyFrom(*tmp);
+		tmp->free();
+		delete tmp;
+		_riddleTopFrame->convertToInPlace(_gfx->_texturePixelFormat);
+
+		tmp = loadBundledImage("castle_riddle_background_frame");
+		_riddleBackgroundFrame = new Graphics::ManagedSurface();
+		_riddleBackgroundFrame->copyFrom(*tmp);
+		tmp->free();
+		delete tmp;
+		_riddleBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat);
+
+		tmp = loadBundledImage("castle_riddle_bottom_frame");
+		_riddleBottomFrame = new Graphics::ManagedSurface();
+		_riddleBottomFrame->copyFrom(*tmp);
+		tmp->free();
+		delete tmp;
+		_riddleBottomFrame->convertToInPlace(_gfx->_texturePixelFormat);
+	}
 }
 
 void CastleEngine::drawZXUI(Graphics::Surface *surface) {
diff --git a/engines/freescape/language/instruction.cpp b/engines/freescape/language/instruction.cpp
index a0457b18707..3309ad2a57a 100644
--- a/engines/freescape/language/instruction.cpp
+++ b/engines/freescape/language/instruction.cpp
@@ -670,6 +670,12 @@ void FreescapeEngine::executeMakeVisible(FCLInstruction &instruction) {
 		if (!obj) {
 			obj = _areaMap[255]->objectWithID(objectID);
 			if (!obj) {
+				if (isCastleMaster2()) {
+					// CM2 Z80 code (Lb286_find_object_by_id) returns silently
+					// when object is not found — the caller skips the rule.
+					debugC(1, kFreescapeDebugCode, "obj %d not found in area %d nor in global area, skipping", objectID, areaID);
+					return;
+				}
 				if (!isCastle() || !isDemo())
 					error("obj %d does not exists in area %d nor in the global one!", objectID, areaID);
 				return;
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 870a90bf01a..a69e1804c72 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -308,7 +308,9 @@ Object *FreescapeEngine::load8bitObject(Common::SeekableReadStream *file) {
 
 	byte rawFlagsAndType = readField(file, 8);
 	debugC(1, kFreescapeDebugParser, "Raw object data flags and type: %d", rawFlagsAndType);
-	ObjectType objectType = (ObjectType)(rawFlagsAndType & 0x1F);
+	// Castle Master uses a 4-bit type field (0x0F mask); other Freescape games use 5 bits (0x1F).
+	byte typeMask = isCastle() ? 0x0F : 0x1F;
+	ObjectType objectType = (ObjectType)(rawFlagsAndType & typeMask);
 
 	if (objectType == ObjectType::kGroupType)
 		return load8bitGroup(file, rawFlagsAndType);
@@ -643,7 +645,9 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 	uint8 skyColor = areaFlags & 15;
 	uint8 groundColor = areaFlags >> 4;
 
-	if (groundColor == 0)
+	// In Castle Master, areaFlags holds sky/floor texture IDs, not colors.
+	// A value of 0 means no sky/floor (indoor area), not "missing color".
+	if (groundColor == 0 && !isCastle())
 		groundColor = 255;
 
 	uint8 usualBackgroundColor = 0;
@@ -744,7 +748,7 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 		if (isAmiga())
 			name = _messagesList[idx + 51];
 		else if (isSpectrum() || isCPC() || isC64())
-			name = areaNumber == 255 ? "GLOBAL" : _messagesList[idx + 16];
+			name = areaNumber == 255 ? "GLOBAL" : _messagesList[idx + (isCastleMaster2() ? 41 : 16)];
 		else
 			name = _messagesList[idx + 41];
 
diff --git a/engines/freescape/metaengine.cpp b/engines/freescape/metaengine.cpp
index 77b1feb7baa..5970768aad1 100644
--- a/engines/freescape/metaengine.cpp
+++ b/engines/freescape/metaengine.cpp
@@ -207,7 +207,7 @@ Common::Error FreescapeMetaEngine::createInstance(OSystem *syst, Engine **engine
 		*engine = (Engine *)new Freescape::DarkEngine(syst, gd);
 	} else if (Common::String(gd->gameId) == "totaleclipse" || Common::String(gd->gameId) == "totaleclipse2") {
 		*engine = (Engine *)new Freescape::EclipseEngine(syst, gd);
-	} else if (Common::String(gd->gameId) == "castlemaster") {
+	} else if (Common::String(gd->gameId) == "castlemaster" || Common::String(gd->gameId) == "castlemaster2") {
 		*engine = (Engine *)new Freescape::CastleEngine(syst, gd);
 	} else
 		*engine = new Freescape::FreescapeEngine(syst, gd);
diff --git a/engines/freescape/movement.cpp b/engines/freescape/movement.cpp
index 1773634a6a5..1a64593c57b 100644
--- a/engines/freescape/movement.cpp
+++ b/engines/freescape/movement.cpp
@@ -199,38 +199,102 @@ void FreescapeEngine::traverseEntrance(uint16 entranceID) {
 }
 
 void FreescapeEngine::activate() {
+	// Castle Master interaction following the original Z80 code (Lb464 in
+	// castlemaster2-annotated.asm). activate() is only called by Castle Master.
+	//
+	// 1. Find object under pointer via ray cast (equivalent to Lb607)
+	// 2. Reject objects where any dimension > room_scale * 5 (line 10216-10223)
+	// 3. Compute manhattan distance to object center (lines 10227-10266)
+	// 4. Normalize by room_scale if scale >= 2 (lines 10269-10277)
+	// 5. If distance >= 300, show "OUT OF REACH" (lines 10279-10283)
+	// 6. Otherwise execute the object's rules
+
 	Common::Point center(_viewArea.left + _viewArea.width() / 2, _viewArea.top + _viewArea.height() / 2);
-	// Convert to normalized coordinates [-1, 1]
 	float ndcX = (2.0f * (_crossairPosition.x - _viewArea.left) / _viewArea.width()) - 1.0f;
 	float ndcY = 1.0f - (2.0f * (_crossairPosition.y - _viewArea.top) / _viewArea.height());
 
-	// Calculate angular offsets using perspective projection
 	float fovHorizontalRad = (float)(75.0f * M_PI / 180.0f);
-	float aspectRatio = isCastle() ? 1.6 : 2.18;
+	float aspectRatio = 1.6;
 	float fovVerticalRad = 2.0f * atan(tan(fovHorizontalRad / 2.0f) / aspectRatio);
 
-	// Convert NDC to angle offset
 	float angleOffsetX = atan(ndcX * tan(fovHorizontalRad / 2.0f)) * 180.0f / M_PI;
 	float angleOffsetY = atan(ndcY * tan(fovVerticalRad / 2.0f)) * 180.0f / M_PI;
 
 	Math::Vector3d direction = directionToVector(_pitch + angleOffsetY, _yaw - angleOffsetX, false);
 	Math::Ray ray(_position, direction);
-	Object *interacted = _currentArea->checkCollisionRay(ray, 1250.0 / _currentArea->getScale());
+	// Use a wide ray to find the object under the pointer. The original code
+	// (Lb607_find_object_under_pointer) works in screen-space projected
+	// coordinates, so it finds objects regardless of their 3D thickness.
+	// The manhattan distance check below handles reachability.
+	Object *interacted = _currentArea->checkCollisionRay(ray, 8000.0 / _currentArea->getScale());
+
 	if (interacted) {
 		GeometricObject *gobj = (GeometricObject *)interacted;
-		debugC(1, kFreescapeDebugMove, "Interact with object %d with flags %x", gobj->getObjectID(), gobj->getObjectFlags());
+		int scale = _currentArea->getScale();
+		// Z80 (line 10216-10222): rejects objects with any raw dimension > scale * 5.
+		// ScummVM sizes are in raw * 32 / scale units, so the equivalent
+		// threshold is: scale * 5 * 32 / scale = 160.
+		int maxSize = 160;
+		Math::Vector3d objOrigin = gobj->getOrigin();
+		Math::Vector3d objSize = gobj->getSize();
+
+		debugC(1, kFreescapeDebugMove, "Activate: found object %d (type %d) at (%.0f,%.0f,%.0f) size (%.0f,%.0f,%.0f) scale %d",
+			gobj->getObjectID(), (int)gobj->getType(),
+			objOrigin.x(), objOrigin.y(), objOrigin.z(),
+			objSize.x(), objSize.y(), objSize.z(), scale);
+
+		// Reject objects where any dimension > room_scale * 5
+		bool tooLarge = false;
+		for (int i = 0; i < 3; i++) {
+			if ((int)objSize.getValue(i) > maxSize) {
+				tooLarge = true;
+				break;
+			}
+		}
 
-		if (!gobj->_conditionSource.empty())
-			debugC(1, kFreescapeDebugMove, "Must use interact = true when executing: %s", gobj->_conditionSource.c_str());
+		if (tooLarge) {
+			debugC(1, kFreescapeDebugMove, "Activate: object %d too large (maxSize %d), skipping", gobj->getObjectID(), maxSize);
+		} else {
+			// Manhattan distance to object center, matching the Z80 code at
+			// Lb486 (castlemaster2-annotated.asm lines 10215-10281).
+			//
+			// Z80 works in *64 space: center = raw_pos*64 + raw_size*32,
+			// player = raw*64+32.
+			// ScummVM stores positions as raw*32/scale (after load8bitObject
+			// multiplies by 32 and load8bitArea divides by scale).
+			// So ScummVM center = origin + size/2, and the Z80 distance
+			// (after its /scale normalization) maps to:
+			//   dist_z80_normalized = dist_scummvm * 2 * scale / scale
+			//                       = dist_scummvm * 2
+			// Threshold in Z80 (post-normalization) = 300, so in ScummVM = 150.
+			int manhattanDist = 0;
+			for (int i = 0; i < 3; i++) {
+				float objCenter = objOrigin.getValue(i) + objSize.getValue(i) / 2.0f;
+				float playerPos = _position.getValue(i);
+				int diff = (int)ABS(objCenter - playerPos);
+				debugC(1, kFreescapeDebugMove, "Activate: axis %d: objCenter=%.1f playerPos=%.1f |diff|=%d",
+					i, objCenter, playerPos, diff);
+				manhattanDist += diff;
+			}
 
-		executeObjectConditions(gobj, false, false, true);
+			debugC(1, kFreescapeDebugMove, "Activate: object %d manhattanDist=%d (threshold 150)",
+				gobj->getObjectID(), manhattanDist);
+
+			if (manhattanDist >= 150) {
+				clearTemporalMessages();
+				insertTemporaryMessage(_outOfReachMessage, _countdown - 2);
+			} else {
+				debugC(1, kFreescapeDebugMove, "Activate: interacting with object %d", gobj->getObjectID());
+				executeObjectConditions(gobj, false, false, true);
+			}
+		}
 	} else {
+		debugC(1, kFreescapeDebugMove, "Activate: no object found under pointer");
 		if (!_outOfReachMessage.empty()) {
 			clearTemporalMessages();
 			insertTemporaryMessage(_outOfReachMessage, _countdown - 2);
 		}
 	}
-	//executeLocalGlobalConditions(true, false, false); // Only execute "on shot" room/global conditions
 }
 
 


Commit: 4f45ad0d22b6d8fdeb00bcb64ffe03c12d582f09
    https://github.com/scummvm/scummvm/commit/4f45ad0d22b6d8fdeb00bcb64ffe03c12d582f09
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-03T09:06:59+02:00

Commit Message:
FREESCAPE: reworked opl music for eclipse and make sure music is restarted properly

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


diff --git a/engines/freescape/games/eclipse/ay.music.cpp b/engines/freescape/games/eclipse/ay.music.cpp
index 56f7c7e9946..c30c09523d6 100644
--- a/engines/freescape/games/eclipse/ay.music.cpp
+++ b/engines/freescape/games/eclipse/ay.music.cpp
@@ -143,6 +143,7 @@ EclipseAYMusicPlayer::~EclipseAYMusicPlayer() {
 // ============================================================================
 
 void EclipseAYMusicPlayer::startMusic() {
+	stopMusic();
 	_mixer->playStream(Audio::Mixer::kMusicSoundType, &_handle, toAudioStream(),
 	                   -1, Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
 	setupSong();
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 77ab804c326..9f2056f4c45 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -109,7 +109,40 @@ EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : Frees
 	_flashlightOn = false;
 }
 
+void EclipseEngine::stopBackgroundMusic() {
+	if (_playerOPLMusic)
+		_playerOPLMusic->stopMusic();
+	if (_playerAYMusic)
+		_playerAYMusic->stopMusic();
+	if (_playerC64Music)
+		_playerC64Music->stopMusic();
+	if (_mixer)
+		_mixer->stopHandle(_musicHandle);
+}
+
+void EclipseEngine::restartBackgroundMusic() {
+	if (isC64() && _playerC64Music) {
+		_playerC64Music->startMusic();
+	} else if ((isCPC() || isSpectrum()) && _playerAYMusic) {
+		_playerAYMusic->startMusic();
+	} else if (isDOS() && _playerOPLMusic) {
+		_playerOPLMusic->startMusic();
+	} else if (isAtariST() && !_musicData.empty()) {
+		if (_mixer)
+			_mixer->stopHandle(_musicHandle);
+		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
+			_musicData.data(), _musicData.size(), 1);
+		if (musicStream) {
+			_mixer->playStream(Audio::Mixer::kMusicSoundType,
+				&_musicHandle, musicStream);
+		}
+	} else {
+		playMusic("Total Eclipse Theme");
+	}
+}
+
 EclipseEngine::~EclipseEngine() {
+	stopBackgroundMusic();
 	delete _playerOPLMusic;
 	delete _playerAYMusic;
 	delete _playerC64Music;
@@ -132,15 +165,7 @@ void EclipseEngine::initGameState() {
 	_lastHeartIndicatorFrame = 1;
 	_resting = false;
 	_flashlightOn = false;
-
-	if (isC64() && _playerC64Music)
-		_playerC64Music->startMusic();
-	else if ((isCPC() || isSpectrum()) && _playerAYMusic)
-		_playerAYMusic->startMusic();
-	else if (isDOS() && _playerOPLMusic)
-		_playerOPLMusic->startMusic();
-	else
-		playMusic("Total Eclipse Theme");
+	restartBackgroundMusic();
 }
 
 void EclipseEngine::loadAssets() {
@@ -237,6 +262,10 @@ bool EclipseEngine::triggerWinCondition() {
 }
 
 void EclipseEngine::endGame() {
+	bool enteringEndArea = (_gameStateControl == kFreescapeGameStateEnd && !_endGamePlayerEndArea);
+	if (enteringEndArea)
+		restartBackgroundMusic();
+
 	FreescapeEngine::endGame();
 
 	if (!_endGamePlayerEndArea)
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index b78a709517c..9ba254de665 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -125,6 +125,8 @@ public:
 
 	EclipseAYMusicPlayer *_playerAYMusic;
 	EclipseOPLMusicPlayer *_playerOPLMusic;
+	void restartBackgroundMusic();
+	void stopBackgroundMusic();
 
 	// Atari ST UI sprites (extracted from binary, pre-converted to target format)
 	Font _fontScore; // Font B (10 score digit glyphs, 4-plane at $249BE)
diff --git a/engines/freescape/games/eclipse/eclipse.musicdata.h b/engines/freescape/games/eclipse/eclipse.musicdata.h
index 466b222f56e..788f99a1cba 100644
--- a/engines/freescape/games/eclipse/eclipse.musicdata.h
+++ b/engines/freescape/games/eclipse/eclipse.musicdata.h
@@ -26,7 +26,8 @@
  * Shared music data for Total Eclipse backported music players.
  *
  * Song data extracted from the C64 version's Wally Beben music engine.
- * SID-only fields (pulse width, PWM) have been stripped from instruments.
+ * The shared instrument table strips SID-only pulse-width fields, but the
+ * original pulse-width values are kept below for ports that can use them.
  * This header is included by both the AY and OPL player implementations.
  */
 
@@ -52,6 +53,16 @@ static const byte kInstruments[] = {
 static const byte kInstrumentSize = 6;
 static const byte kInstrumentCount = 12;
 
+// SID pulse-width data kept alongside the shared instruments so the OPL port
+// can retain some of the original timbre motion.
+static const byte kPulseWidthInit[] = {
+	0x00, 0x02, 0x00, 0x64, 0x20, 0x20, 0x22, 0x43, 0x44, 0x30, 0x80, 0x41
+};
+
+static const byte kPulseWidthMod[] = {
+	0x00, 0x0F, 0x0B, 0x2F, 0x08, 0x01, 0x00, 0x00, 0x22, 0x00, 0x22, 0x40
+};
+
 static const byte kOrderList0[] = {
 	0xE0, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x0B, 0x0B, 0x0B, 0x0B, 0x01, 0x01,
 	0x0B, 0x10, 0x0B, 0x0B, 0x01, 0x10, 0x01, 0x01, 0x13, 0x13, 0x13, 0x13, 0x0B, 0x0B, 0x13, 0x13,
diff --git a/engines/freescape/games/eclipse/opl.music.cpp b/engines/freescape/games/eclipse/opl.music.cpp
index bb77368206c..77b9d0e5297 100644
--- a/engines/freescape/games/eclipse/opl.music.cpp
+++ b/engines/freescape/games/eclipse/opl.music.cpp
@@ -30,6 +30,18 @@ using namespace Freescape::EclipseMusicData;
 
 namespace Freescape {
 
+namespace {
+
+struct OPLBasePatch {
+	byte modChar;
+	byte carChar;
+	byte modLevel;
+	byte carLevel;
+	byte modWave;
+	byte carWave;
+	byte feedbackConnection;
+};
+
 // ============================================================================
 // Embedded music data (shared with AY player, extracted from C64)
 // ============================================================================
@@ -37,7 +49,7 @@ namespace Freescape {
 // OPL2 F-number/block table (95 entries)
 // Format: fnum (bits 0-9) | block (bits 10-12)
 // Derived from SID frequency table
-static const uint16 kOPLFreqs[] = {
+const uint16 kOPLFreqs[] = {
 	0x0000, 0x0168, 0x017D, 0x0194, 0x01AD, 0x01C5,
 	0x01E1, 0x01FD, 0x021B, 0x023B, 0x025E, 0x0282,
 	0x02A8, 0x02D0, 0x02FB, 0x0328, 0x0358, 0x038B,
@@ -63,61 +75,62 @@ static const uint16 kOPLFreqs[] = {
 // (pattern data removed - now in eclipse.musicdata.h)
 //
 // ============================================================================
-// OPL2 FM instrument patches (our own creation, not from C64)
+// OPL2 FM base patches (our own creation, but driven by the original SID data)
 // ============================================================================
 
 // OPL operator register offsets for channels 0-2
 // Each OPL channel has 2 operators (modulator + carrier)
 // Modulator offsets: 0x00, 0x01, 0x02 for channels 0-2
 // Carrier offsets:   0x03, 0x04, 0x05 for channels 0-2
-static const byte kOPLModOffset[] = { 0x00, 0x01, 0x02 };
-static const byte kOPLCarOffset[] = { 0x03, 0x04, 0x05 };
-
-// FM patch definitions, 11 bytes each:
-//  [0] mod: AM/VIB/EG/KSR/MULT  (reg 0x20+mod)
-//  [1] car: AM/VIB/EG/KSR/MULT  (reg 0x20+car)
-//  [2] mod: KSL/output level     (reg 0x40+mod)
-//  [3] car: KSL/output level     (reg 0x40+car)
-//  [4] mod: attack/decay          (reg 0x60+mod)
-//  [5] car: attack/decay          (reg 0x60+car)
-//  [6] mod: sustain/release       (reg 0x80+mod)
-//  [7] car: sustain/release       (reg 0x80+car)
-//  [8] mod: wave select           (reg 0xE0+mod)
-//  [9] car: wave select           (reg 0xE0+car)
-// [10] feedback/connection        (reg 0xC0+ch)
-// Carrier ADSR derived from SID AD/SR bytes using timing-matched conversion.
-// Modulator settings tuned per SID waveform type:
-//   triangle → additive sine (soft, warm)
-//   pulse    → FM with slight harmonic content (brighter)
-//   sawtooth → FM with feedback (rich harmonics)
-//   noise    → inharmonic FM (metallic percussion)
-static const byte kOPLPatches[][11] = {
-	// 0: rest — silent
-	{ 0x00, 0x00, 0x3F, 0x3F, 0xFF, 0xFF, 0x0F, 0x0F, 0x00, 0x00, 0x00 },
-	// 1: bass pulse (AD=0x42 SR=0x24) — punchy FM bass
-	{ 0x02, 0x01, 0x1E, 0x00, 0xF1, 0x8A, 0x07, 0xD9, 0x00, 0x01, 0x06 },
-	// 2: triangle lead (AD=0x8A SR=0xAC) — warm sine lead
-	{ 0x01, 0x01, 0x2A, 0x00, 0xF1, 0x75, 0x07, 0x54, 0x00, 0x00, 0x01 },
-	// 3: triangle pad (AD=0x6C SR=0x4F) — soft sustained pad
-	{ 0x01, 0x01, 0x2A, 0x00, 0xF1, 0x84, 0x07, 0xB1, 0x00, 0x00, 0x01 },
-	// 4: pulse arpeggio (AD=0x3A SR=0xAC) — bright arpeggio
-	{ 0x02, 0x01, 0x1E, 0x00, 0xF1, 0x95, 0x07, 0x54, 0x00, 0x01, 0x06 },
-	// 5: noise percussion (AD=0x42 SR=0x00) — metallic hit
-	{ 0x01, 0x0C, 0x00, 0x00, 0xFA, 0x8A, 0x07, 0xFD, 0x00, 0x00, 0x01 },
-	// 6: triangle melody (AD=0x3D SR=0x1C) — flute-like
-	{ 0x01, 0x01, 0x2A, 0x00, 0xF1, 0x92, 0x07, 0xE4, 0x00, 0x00, 0x01 },
-	// 7: pulse melody (AD=0x4C SR=0x2C) — vibrato lead
-	{ 0x02, 0x01, 0x1E, 0x00, 0xF1, 0x84, 0x07, 0xD4, 0x00, 0x01, 0x06 },
-	// 8: triangle sustain (AD=0x5D SR=0xBC) — organ-like
-	{ 0x01, 0x01, 0x2A, 0x00, 0xF1, 0x82, 0x07, 0x44, 0x00, 0x00, 0x01 },
-	// 9: pulse sustain (AD=0x4C SR=0xAF) — electric piano
-	{ 0x02, 0x01, 0x1E, 0x00, 0xF1, 0x84, 0x07, 0x51, 0x00, 0x01, 0x06 },
-	// 10: sawtooth lead (AD=0x4A SR=0x2A) — brass-like
-	{ 0x21, 0x21, 0x1A, 0x00, 0xF1, 0x85, 0x07, 0xD5, 0x02, 0x00, 0x0E },
-	// 11: pulse w/ arpeggio (AD=0x6A SR=0x6B) — harpsichord-like
-	{ 0x02, 0x01, 0x1E, 0x00, 0xF1, 0x85, 0x07, 0x94, 0x00, 0x01, 0x06 },
+const byte kOPLModOffset[] = { 0x00, 0x01, 0x02 };
+const byte kOPLCarOffset[] = { 0x03, 0x04, 0x05 };
+
+const OPLBasePatch 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
+	{ 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 },
 };
 
+// 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 uint16 kDecayReleaseRate[16] = {
+	0x0F00, 0x0C80, 0x0640, 0x042B,
+	0x02A2, 0x01C9, 0x0178, 0x0140,
+	0x0100, 0x0066, 0x0033, 0x0020,
+	0x001A, 0x0009, 0x0005, 0x0003
+};
+
+byte getWaveformFamily(byte ctrl) {
+	if ((ctrl & 0x80) != 0)
+		return 4;
+	if ((ctrl & 0x40) != 0)
+		return 3;
+	if ((ctrl & 0x20) != 0)
+		return 2;
+	if ((ctrl & 0x10) != 0)
+		return 1;
+	return 0;
+}
+
+uint16 decodePulseWidth(byte packed) {
+	return ((packed & 0x0F) << 8) | (packed & 0xF0);
+}
+
+} // namespace
+
 // ============================================================================
 // ChannelState
 // ============================================================================
@@ -130,6 +143,8 @@ void EclipseOPLMusicPlayer::ChannelState::reset() {
 	instrumentOffset = 0;
 	currentNote = 0;
 	transpose = 0;
+	frequencyFnum = 0;
+	frequencyBlock = 0;
 	durationReload = 0;
 	durationCounter = 0;
 	effectMode = 0;
@@ -149,6 +164,19 @@ void EclipseOPLMusicPlayer::ChannelState::reset() {
 	instrumentFlags = 0;
 	gateOffDisabled = false;
 	keyOn = false;
+	pulseWidth = 0;
+	pulseWidthMod = 0;
+	pulseWidthDirection = 0;
+	modBaseLevel = 0x3F;
+	carBaseLevel = 0x3F;
+	modLevel = 0x3F;
+	carLevel = 0x3F;
+	adsrPhase = kPhaseOff;
+	adsrVolume = 0;
+	attackRate = 0;
+	decayRate = 0;
+	sustainLevel = 0;
+	releaseRate = 0;
 }
 
 // ============================================================================
@@ -182,6 +210,7 @@ EclipseOPLMusicPlayer::~EclipseOPLMusicPlayer() {
 void EclipseOPLMusicPlayer::startMusic() {
 	if (!_opl)
 		return;
+	stopMusic();
 	_opl->start(new Common::Functor0Mem<void, EclipseOPLMusicPlayer>(
 		this, &EclipseOPLMusicPlayer::onTimer), 50);
 	setupSong();
@@ -212,6 +241,12 @@ void EclipseOPLMusicPlayer::noteToFnumBlock(byte note, uint16 &fnum, byte &block
 }
 
 void EclipseOPLMusicPlayer::setFrequency(int channel, uint16 fnum, byte block) {
+	_channels[channel].frequencyFnum = fnum;
+	_channels[channel].frequencyBlock = block;
+	writeFrequency(channel, fnum, block);
+}
+
+void EclipseOPLMusicPlayer::writeFrequency(int channel, uint16 fnum, byte block) {
 	if (!_opl)
 		return;
 	_opl->writeReg(0xA0 + channel, fnum & 0xFF);
@@ -226,44 +261,51 @@ void EclipseOPLMusicPlayer::setOPLInstrument(int channel, byte instrumentOffset)
 	if (!_opl)
 		return;
 	byte patchIdx = instrumentOffset / kInstrumentSize;
-	if (patchIdx >= ARRAYSIZE(kOPLPatches))
+	if (patchIdx >= kInstrumentCount)
 		patchIdx = 0;
 
-	const byte *patch = kOPLPatches[patchIdx];
+	byte ctrl = kInstruments[instrumentOffset + 0];
+	const OPLBasePatch &patch = kOPLBasePatches[getWaveformFamily(ctrl)];
 	byte mod = kOPLModOffset[channel];
 	byte car = kOPLCarOffset[channel];
 
-	_opl->writeReg(0x20 + mod, patch[0]);
-	_opl->writeReg(0x20 + car, patch[1]);
-	_opl->writeReg(0x40 + mod, patch[2]);
-	_opl->writeReg(0x40 + car, patch[3]);
-	_opl->writeReg(0x60 + mod, patch[4]);
-	_opl->writeReg(0x60 + car, patch[5]);
-	_opl->writeReg(0x80 + mod, patch[6]);
-	_opl->writeReg(0x80 + car, patch[7]);
-	_opl->writeReg(0xE0 + mod, patch[8]);
-	_opl->writeReg(0xE0 + car, patch[9]);
-	_opl->writeReg(0xC0 + channel, patch[10]);
+	_channels[channel].pulseWidth = decodePulseWidth(kPulseWidthInit[patchIdx]);
+	_channels[channel].pulseWidthMod = kPulseWidthMod[patchIdx];
+	_channels[channel].pulseWidthDirection = 0;
+	_channels[channel].modBaseLevel = patch.modLevel;
+	_channels[channel].carBaseLevel = patch.carLevel;
+	_channels[channel].modLevel = patch.modLevel;
+	_channels[channel].carLevel = patch.carLevel;
+
+	_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);
+	_opl->writeReg(0xE0 + mod, patch.modWave);
+	_opl->writeReg(0xE0 + car, patch.carWave);
+	_opl->writeReg(0xC0 + channel, patch.feedbackConnection);
+
+	updatePulseWidth(channel, false);
+	applyOperatorLevels(channel);
 }
 
-void EclipseOPLMusicPlayer::noteOn(int channel, byte note) {
+void EclipseOPLMusicPlayer::noteOn(int channel) {
 	if (!_opl)
 		return;
-	uint16 fnum;
-	byte block;
-	noteToFnumBlock(note, fnum, block);
-
 	_channels[channel].keyOn = true;
-	_opl->writeReg(0xA0 + channel, fnum & 0xFF);
-	_opl->writeReg(0xB0 + channel, 0x20 | (block << 2) | ((fnum >> 8) & 0x03));
+	_opl->writeReg(0xA0 + channel, _channels[channel].frequencyFnum & 0xFF);
+	_opl->writeReg(0xB0 + channel, 0x20 | (_channels[channel].frequencyBlock << 2) |
+	                                 ((_channels[channel].frequencyFnum >> 8) & 0x03));
 }
 
 void EclipseOPLMusicPlayer::noteOff(int channel) {
 	if (!_opl)
 		return;
 	_channels[channel].keyOn = false;
-	// Clear key-on bit, preserve frequency
-	byte b0 = _opl ? 0x00 : 0x00; // just clear everything
+	byte b0 = ((_channels[channel].frequencyFnum >> 8) & 0x03) |
+	          (_channels[channel].frequencyBlock << 2);
 	_opl->writeReg(0xB0 + channel, b0);
 }
 
@@ -325,12 +367,14 @@ void EclipseOPLMusicPlayer::processChannel(int channel, bool newBeat) {
 }
 
 void EclipseOPLMusicPlayer::finalizeChannel(int channel) {
-	// Gate off at half duration: trigger release via key-off
 	if (_channels[channel].durationReload != 0 &&
 	    !_channels[channel].gateOffDisabled &&
 	    ((_channels[channel].durationReload >> 1) == _channels[channel].durationCounter)) {
-		noteOff(channel);
+		releaseADSR(channel);
 	}
+
+	updatePulseWidth(channel, true);
+	updateADSR(channel);
 }
 
 // ============================================================================
@@ -361,10 +405,11 @@ void EclipseOPLMusicPlayer::setupSong() {
 void EclipseOPLMusicPlayer::silenceAll() {
 	if (!_opl)
 		return;
-	for (int ch = 0; ch < 9; ch++) {
+	for (int ch = 0; ch < kChannelCount; ch++) {
+		_channels[ch].keyOn = false;
 		_opl->writeReg(0xB0 + ch, 0x00); // key off
-		_opl->writeReg(0x40 + kOPLModOffset[ch < 3 ? ch : 0], 0x3F); // silence mod
-		_opl->writeReg(0x40 + kOPLCarOffset[ch < 3 ? ch : 0], 0x3F); // silence car
+		_opl->writeReg(0x40 + kOPLModOffset[ch], 0x3F); // silence mod
+		_opl->writeReg(0x40 + kOPLCarOffset[ch], 0x3F); // silence car
 	}
 }
 
@@ -511,10 +556,12 @@ 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];
-	byte sustainRelease = kInstruments[instrumentOffset + 2];
 	byte actualNote = note;
+	bool gateEnabled = (ctrl & 0x01) != 0;
 
 	if (actualNote != 0)
 		actualNote = clampNote(actualNote + _channels[channel].transpose);
@@ -539,12 +586,19 @@ void EclipseOPLMusicPlayer::applyNote(int channel, byte note) {
 
 	_channels[channel].gateOffDisabled = (sustainRelease & 0x0F) == 0x0F;
 
-	if (actualNote == 0) {
+	if (actualNote != 0)
+		loadCurrentFrequency(channel);
+
+	if (actualNote == 0 || !gateEnabled) {
+		_channels[channel].adsrPhase = kPhaseOff;
+		_channels[channel].adsrVolume = 0;
+		applyOperatorLevels(channel);
 		noteOff(channel);
 	} else {
-		// Key off then on to retrigger envelope
+		triggerADSR(channel, attackDecay, sustainRelease);
+		applyOperatorLevels(channel);
 		noteOff(channel);
-		noteOn(channel, actualNote);
+		noteOn(channel);
 	}
 
 	_channels[channel].durationCounter = _channels[channel].durationReload;
@@ -630,7 +684,7 @@ bool EclipseOPLMusicPlayer::applyInstrumentVibrato(int channel) {
 		return false;
 	}
 
-	int32 freq = noteFreq;
+	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++)
@@ -644,7 +698,7 @@ bool EclipseOPLMusicPlayer::applyInstrumentVibrato(int channel) {
 		freq >>= 1;
 		block++;
 	}
-	setFrequency(channel, freq & 0x3FF, block);
+	writeFrequency(channel, freq & 0x3FF, block);
 	return true;
 }
 
@@ -662,7 +716,7 @@ void EclipseOPLMusicPlayer::applyEffectArpeggio(int channel) {
 	uint16 fnum;
 	byte block;
 	noteToFnumBlock(note, fnum, block);
-	setFrequency(channel, fnum, block);
+	writeFrequency(channel, fnum, block);
 }
 
 void EclipseOPLMusicPlayer::applyTimedSlide(int channel) {
@@ -699,11 +753,7 @@ void EclipseOPLMusicPlayer::applyTimedSlide(int channel) {
 	if (delta == 0)
 		return;
 
-	// Read current frequency from stored fnum/block
-	uint16 curFnum;
-	byte curBlock;
-	noteToFnumBlock(currentNote, curFnum, curBlock);
-	int32 curFreq = curFnum << curBlock;
+	int32 curFreq = (int32)_channels[channel].frequencyFnum << _channels[channel].frequencyBlock;
 
 	if (tgtFreq > srcFreq)
 		curFreq += delta;
@@ -720,4 +770,111 @@ 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;
+		_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;
+		}
+	}
+
+	uint16 pulseWidth = MIN<uint16>(_channels[channel].pulseWidth & 0x0FFF, 0x0800);
+	byte brightnessBoost = pulseWidth < 0x0800 ? (0x0800 - pulseWidth) >> 7 : 0;
+	if (brightnessBoost > 12)
+		brightnessBoost = 12;
+
+	_channels[channel].modLevel = (_channels[channel].modBaseLevel > brightnessBoost) ?
+	                              (_channels[channel].modBaseLevel - brightnessBoost) : 0;
+	_channels[channel].carLevel = _channels[channel].carBaseLevel;
+}
+
+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);
+
+	_opl->writeReg(0x40 + mod, modLevel);
+	_opl->writeReg(0x40 + car, carLevel);
+}
+
 } // namespace Freescape
diff --git a/engines/freescape/games/eclipse/opl.music.h b/engines/freescape/games/eclipse/opl.music.h
index 0f49e9c6ffd..466a898bf7d 100644
--- a/engines/freescape/games/eclipse/opl.music.h
+++ b/engines/freescape/games/eclipse/opl.music.h
@@ -33,7 +33,7 @@ namespace Freescape {
  * - 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
- * - Using OPL's built-in ADSR envelopes
+ * - Rebuilding the SID envelope and pulse-width motion on top of AdLib timbres
  */
 class EclipseOPLMusicPlayer {
 public:
@@ -45,8 +45,18 @@ public:
 	bool isPlaying() const;
 
 private:
-	static const byte kChannelCount = 3;
-	static const byte kMaxNote = 94;
+	enum {
+		kChannelCount = 3,
+		kMaxNote = 94
+	};
+
+	enum ADSRPhase {
+		kPhaseOff,
+		kPhaseAttack,
+		kPhaseDecay,
+		kPhaseSustain,
+		kPhaseRelease
+	};
 
 	struct ChannelState {
 		const byte *orderList;
@@ -58,6 +68,8 @@ private:
 		byte instrumentOffset;
 		byte currentNote;
 		byte transpose;
+		uint16 frequencyFnum;
+		byte frequencyBlock;
 
 		byte durationReload;
 		byte durationCounter;
@@ -83,6 +95,19 @@ private:
 		byte instrumentFlags;
 		bool gateOffDisabled;
 		bool keyOn;
+		uint16 pulseWidth;
+		byte pulseWidthMod;
+		byte pulseWidthDirection;
+		byte modBaseLevel;
+		byte carBaseLevel;
+		byte modLevel;
+		byte carLevel;
+		ADSRPhase adsrPhase;
+		uint16 adsrVolume;
+		uint16 attackRate;
+		uint16 decayRate;
+		byte sustainLevel;
+		uint16 releaseRate;
 
 		void reset();
 	};
@@ -108,11 +133,17 @@ 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 updatePulseWidth(int channel, bool advance);
+	void applyOperatorLevels(int channel);
 
 	void setOPLInstrument(int channel, byte instrumentOffset);
-	void noteOn(int channel, byte note);
+	void noteOn(int channel);
 	void noteOff(int channel);
 	void setFrequency(int channel, uint16 fnum, byte block);
+	void writeFrequency(int channel, uint16 fnum, byte block);
 	void noteToFnumBlock(byte note, uint16 &fnum, byte &block) const;
 
 	byte readPatternByte(int channel);




More information about the Scummvm-git-logs mailing list