[Scummvm-git-logs] scummvm master -> 719b757e0d0d120023f4c7835634d52a0e96db2e

neuromancer noreply at scummvm.org
Wed Jul 29 15:13:48 UTC 2026


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

Summary:
13c68084c8 FREESCAPE: Workaround for CM2 to avoid going out of bounds
f0d3bb02c9 FREESCAPE: added support for castle 2 (DOS)
eced867f05 FREESCAPE: added support for castle master demo (DOS, CGA)
193ee5cad8 FREESCAPE: improved support for eclipse (DOS, CGA)
524bc071fe FREESCAPE: added hercurles support for eclipse (DOS)
1a783101fb FREESCAPE: fixed incorrect colors in hercurles support for eclipse (DOS)
3714c7576f FREESCAPE: fixed incorrect colors in cga support for dark (DOS)
175a4558e4 FREESCAPE: add hercules support for dark (DOS)
7f5e00c05e FREESCAPE: fixed castle sounds for amiga
2849df996c FREESCAPE: no sounds of the castle amiga demo
d137c33dca FREESCAPE: fixed ui indicator in castle amiga
4a6c5730e9 FREESCAPE: fixed ui indicator in castle zx
fc8bbd0131 FREESCAPE: adding thunder frames for castle amiga
d8c7d15765 FREESCAPE: added support to decrypt copylock directly from files
4b4a4d545e FREESCAPE: support for more castle amiga variants
b633ef2dae FREESCAPE: support for more dark amiga variants
719b757e0d FREESCAPE: support for more dark atari variants


Commit: 13c68084c8d2ef47ed8e389c37c472746012c776
    https://github.com/scummvm/scummvm/commit/13c68084c8d2ef47ed8e389c37c472746012c776
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: Workaround for CM2 to avoid going out of bounds

Changed paths:
    engines/freescape/movement.cpp


diff --git a/engines/freescape/movement.cpp b/engines/freescape/movement.cpp
index def9cda67f4..c6753ab517b 100644
--- a/engines/freescape/movement.cpp
+++ b/engines/freescape/movement.cpp
@@ -474,6 +474,18 @@ void FreescapeEngine::checkIfStillInArea() {
 	}
 	if (_position.y() >= 2016)
 		_position.y() = _lastPosition.y();
+
+	// Workaround for Castle Master 2: the first area (the crypt) is indoors, but it shares
+	// its area ID with the Castle Master courtyard, so it also gets the unbounded synthetic
+	// floor added by Area::addFloor(). The player can then walk past the room walls and keep
+	// going, ending up far outside the room. Keep them within the room instead, which is the
+	// area covered by the crypt floor: 127 units, as in the original bound (MAX_COORDINATE is
+	// 127 * 64 in La5d9_move_player, expressed in 1/64th of a unit).
+	if (isCastleMaster2() && _currentArea->getAreaID() == _startArea) {
+		float roomSize = 127.0f * 32.0f / _currentArea->getScale();
+		_position.x() = CLIP(_position.x(), 0.0f, roomSize);
+		_position.z() = CLIP(_position.z(), 0.0f, roomSize);
+	}
 }
 
 void FreescapeEngine::updatePlayerMovement(float deltaTime) {


Commit: f0d3bb02c922fbcb99dbee8b00c0894f63cab649
    https://github.com/scummvm/scummvm/commit/f0d3bb02c922fbcb99dbee8b00c0894f63cab649
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: added support for castle 2 (DOS)

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/games/castle/dos.cpp
    engines/freescape/games/castle/zx.cpp
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/objects/geometricobject.cpp
    engines/freescape/ui.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index b6db9b61304..8140e6ad76d 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1046,6 +1046,21 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO3(GUIO_NOMIDI, GUIO_RENDERZX, GAMEOPTION_WASD_CONTROLS)
 	},
+	{
+		// Only an EGA executable is shipped, unlike Castle Master
+		"castlemaster2",
+		"",
+		{
+			{"CRYPT.EXE", 0, "c1fdbb7cfbb4cb35fe9ccebf9883d8b8", 2582},
+			{"CRE.EXE", 0, "91838da45e67a0362a0658038a5f125f", 76878},
+			{"CREDF", 0, "cab9a101a8632927a96a635d796edffe", 17279},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_NO_FLAGS,
+		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GAMEOPTION_WASD_CONTROLS)
+	},
 	// 3D Construction Kit games
 	{
 		"3dkit",
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index b3d65fbad4c..88d16fabe92 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -1562,7 +1562,8 @@ void CastleEngine::loadAssets() {
 
 		it._value->addStructure(_areaMap[255]);
 
-		if (isDOS() || isAmiga() || isAtariST()) {
+		// CM2 reuses these object IDs for unrelated geometry, not spirit groups
+		if ((isDOS() || isAmiga() || isAtariST()) && !isCastleMaster2()) {
 			if (it._value->objectWithID(125)) {
 				_areaMap[it._key]->addGroupFromArea(195, _areaMap[255]);
 				//group = (Group *)_areaMap[it._key]->objectWithID(195);
@@ -1598,6 +1599,23 @@ void CastleEngine::loadAssets() {
 
 }
 
+void CastleEngine::loadMessagesCastleMaster2(Common::SeekableReadStream *file, int offset, int number) {
+	// Game text (L6cb9_game_text) and area names (L6f49_area_names) form a single
+	// table of 16-byte entries: an indent flag, then 15 characters. A zero flag
+	// would terminate the string, so loadMessagesFixedSize() cannot be used.
+	file->seek(offset);
+	debugC(1, kFreescapeDebugParser, "String table:");
+
+	for (int i = 0; i < number; i++) {
+		file->readByte(); // skip indent flag
+		char buf[16];
+		file->read(buf, 15);
+		buf[15] = '\0';
+		_messagesList.push_back(Common::String(buf));
+		debugC(1, kFreescapeDebugParser, "%d: '%s'", i, buf);
+	}
+}
+
 void CastleEngine::loadRiddles(Common::SeekableReadStream *file, int offset, int number) {
 	file->seek(offset);
 
@@ -2073,7 +2091,8 @@ void CastleEngine::borderScreen() {
 		return;
 	}
 
-	if (isSpectrum() || isCPC() || isC64())
+	// CM2 has no character selection, so it uses the plain configuration menu
+	if (isSpectrum() || isCPC() || isC64() || isCastleMaster2())
 		FreescapeEngine::borderScreen();
 	else {
 		uint32 color = _gfx->_texturePixelFormat.ARGBToColor(0x00, 0x00, 0x00, 0x00);
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 525bb4184dc..f55d5ddccd6 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -72,6 +72,7 @@ public:
 	void loadAssetsAtariFullGame() override;
 	void loadAssetsZXFullGame() override;
 	void loadAssetsCPCFullGame() override;
+	void loadMessagesCastleMaster2(Common::SeekableReadStream *file, int offset, int number);
 	void borderScreen() override;
 	void selectCharacterScreen();
 	bool playAmigaIntro();
diff --git a/engines/freescape/games/castle/dos.cpp b/engines/freescape/games/castle/dos.cpp
index abaae81b605..755f4b9d89d 100644
--- a/engines/freescape/games/castle/dos.cpp
+++ b/engines/freescape/games/castle/dos.cpp
@@ -152,12 +152,18 @@ void CastleEngine::loadAssetsDOSFullGame() {
 	Common::SeekableReadStream *stream = nullptr;
 
 	if (_renderMode == Common::kRenderEGA) {
-		file.open("CME.EXE");
+		// Every block below is byte identical in CRE.EXE and CME.EXE, but sits
+		// 0x30a0 earlier since CM2 has a smaller code section. The speaker
+		// tables are at the same offset in both.
+		const int delta = isCastleMaster2() ? -0x30a0 : 0;
+
+		file.open(isCastleMaster2() ? "CRE.EXE" : "CME.EXE");
 		stream = unpackEXE(file);
 		if (stream) {
 			_sound = loadSpeakerFxDOS(stream, 0x636d + 0x200, 0x63ed + 0x200, 30);
 
-			stream->seek(0x197c0);
+			stream->seek(0x197c0 + delta);
+			// Blank in CM2, but still parsed to reach the background after it
 			_endGameBackgroundFrame = loadFrameFromPlanes(stream, 112, 108);
 			_endGameBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
 
@@ -166,7 +172,7 @@ void CastleEngine::loadAssetsDOSFullGame() {
 			debug("%x", (int32)stream->pos());
 			// Eye widget is next to 0x1f058
 
-			stream->seek(0x1f4e3);
+			stream->seek(0x1f4e3 + delta);
 			for (int i = 0; i < 6; i++)
 				debug("i: %d -> %x", i, stream->readByte());
 			debug("%x", (int32)stream->pos());
@@ -191,7 +197,7 @@ void CastleEngine::loadAssetsDOSFullGame() {
 			//debug("%lx", stream->pos());
 			//assert(0);
 
-			stream->seek(0x20262);
+			stream->seek(0x20262 + delta);
 			_strenghtBackgroundFrame = loadFrameWithHeaderDOS(stream);
 			_strenghtBarFrame = loadFrameWithHeaderDOS(stream);
 			_strenghtWeightsFrames = loadFramesWithHeaderDOS(stream, 4);
@@ -205,7 +211,7 @@ void CastleEngine::loadAssetsDOSFullGame() {
 			debug("%lx", stream->pos());*/
 			//assert(0);
 
-			stream->seek(0x221ae);
+			stream->seek(0x221ae + delta);
 			// No header?
 			_menu = loadFrameFromPlanes(stream, 112, 115);
 			_menu->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
@@ -218,6 +224,7 @@ void CastleEngine::loadAssetsDOSFullGame() {
 			_menuFxOnIndicator = menuFrames[4];
 
 			_flagFrames = loadFramesWithHeaderDOS(stream, 4);
+			// Unused by CM2, but shipped, and parsed to keep reading in order
 			_riddleTopFrame = loadFrameWithHeaderDOS(stream);
 			_riddleBackgroundFrame = loadFrameWithHeaderDOS(stream);
 			_riddleBottomFrame = loadFrameWithHeaderDOS(stream);
@@ -234,7 +241,7 @@ void CastleEngine::loadAssetsDOSFullGame() {
 			thunderFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
 			_thunderFrames.push_back(thunderFrame);
 
-			stream->seek(0x29696);
+			stream->seek(0x29696 + delta);
 			Common::Array<Graphics::ManagedSurface *> chars;
 			Common::Array<Graphics::ManagedSurface *> charsRiddle;
 			for (int i = 0; i < 90; i++) {
@@ -259,21 +266,36 @@ void CastleEngine::loadAssetsDOSFullGame() {
 		delete stream;
 		file.close();
 
-		file.open("CMLE.DAT");
+		file.open(isCastleMaster2() ? "CRLE.DAT" : "CMLE.DAT");
 		_title = load8bitBinImage(&file, 0x0);
 		_title->setPalette((byte *)&kEGADefaultPalette, 0, 16);
 		file.close();
 
-		file.open("CMOE.DAT");
-		_option = load8bitBinImage(&file, 0x0);
-		_option->setPalette((byte *)&kEGADefaultPalette, 0, 16);
-		file.close();
+		// CM2 draws its configuration menu as text, so it ships no CMOE.DAT
+		if (!isCastleMaster2()) {
+			file.open("CMOE.DAT");
+			_option = load8bitBinImage(&file, 0x0);
+			_option->setPalette((byte *)&kEGADefaultPalette, 0, 16);
+			file.close();
+		}
 
-		file.open("CME.DAT");
+		file.open(isCastleMaster2() ? "CRE.DAT" : "CME.DAT");
 		_border = load8bitBinImage(&file, 0x0);
 		_border->setPalette((byte *)&kEGADefaultPalette, 0, 16);
 		file.close();
 
+		if (isCastleMaster2()) {
+			// 132 entries: game text, area names from 41, then front end strings
+			stream = decryptFile("CRLE");
+			loadMessagesCastleMaster2(stream, 0x10, 132);
+			delete stream;
+
+			stream = decryptFile("CREDF");
+			load8bitBinary(stream, 0, 16);
+			delete stream;
+			return;
+		}
+
 		switch (_language) {
 			case Common::ES_ESP:
 				stream = decryptFile("CMLS");
diff --git a/engines/freescape/games/castle/zx.cpp b/engines/freescape/games/castle/zx.cpp
index c572c213b8f..d3d1abedeb1 100644
--- a/engines/freescape/games/castle/zx.cpp
+++ b/engines/freescape/games/castle/zx.cpp
@@ -69,31 +69,8 @@ void CastleEngine::loadAssetsZXFullGame() {
 		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));
-		}
+		// 41 game text entries at 0x02B9, then 40 area names at 0x0549
+		loadMessagesCastleMaster2(&file, 0x02b9, 41 + 40);
 
 		load8bitBinary(&file, 0x6682, 16);
 		_sound = loadSpeakerFxZX(&file, 0x0bbf, 0x0bfb, 25);
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 0e8391c5e34..def662d3651 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -753,10 +753,13 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 			skyColor = 0;
 	} else if (isCastle()) {
 		byte idx = readField(file, 8);
-		if (isAmiga() || isAtariST())
+		if (areaNumber == 255) {
+			// The room structure is not an area, the byte above is unrelated data
+			name = "GLOBAL";
+		} else if (isAmiga() || isAtariST())
 			name = _messagesList[idx + 51];
 		else if (isSpectrum() || isCPC() || isC64())
-			name = areaNumber == 255 ? "GLOBAL" : _messagesList[idx + (isCastleMaster2() ? 41 : 16)];
+			name = _messagesList[idx + (isCastleMaster2() ? 41 : 16)];
 		else
 			name = _messagesList[idx + 41];
 
diff --git a/engines/freescape/objects/geometricobject.cpp b/engines/freescape/objects/geometricobject.cpp
index e98717b8ba0..9741105a7b3 100644
--- a/engines/freescape/objects/geometricobject.cpp
+++ b/engines/freescape/objects/geometricobject.cpp
@@ -178,8 +178,17 @@ GeometricObject::GeometricObject(
 			_ordinates->push_back(_origin.y() + _size.y());
 			_ordinates->push_back(_origin.z() + _size.z());
 		}
-	} else if (isPyramid(_type))
-		assert(_size.x() > 0 && _size.y() > 0 && _size.z() > 0);
+	} else if (isPyramid(_type)) {
+		// A pyramid flat along one axis has its base and apex faces in the same
+		// plane, and is drawn flat (Castle Master 2 object 191). Two flat axes
+		// would leave nothing to draw, and means a misparsed object.
+		int flatAxes = 0;
+		for (int i = 0; i < 3; i++) {
+			if (_size.getValue(i) == 0)
+				flatAxes++;
+		}
+		assert(flatAxes <= 1);
+	}
 
 	computeBoundingBox();
 }
diff --git a/engines/freescape/ui.cpp b/engines/freescape/ui.cpp
index 351c6a20eba..0ef9f6b0ce2 100644
--- a/engines/freescape/ui.cpp
+++ b/engines/freescape/ui.cpp
@@ -227,11 +227,16 @@ void FreescapeEngine::borderScreen() {
 			lines.push_back(centerAndPadString("1: KEYBOARD ONLY   ", pad));
 			lines.push_back(centerAndPadString("2: IBM JOYSTICK    ", pad));
 			lines.push_back(centerAndPadString("3: AMSTRAD JOYSTICK", pad));
-			lines.push_back("");
+			if (isCastleMaster2()) {
+				// Castle Master 2 also offers mouse control here
+				lines.push_back(centerAndPadString("4: SERIAL MOUSE    ", pad));
+				lines.push_back(centerAndPadString("5: AMSTRAD MOUSE   ", pad));
+			} else
+				lines.push_back("");
 			lines.push_back("");
 			lines.push_back(centerAndPadString("SPACEBAR:  BEGIN MISSION", pad));
 			lines.push_back("");
-			lines.push_back(centerAndPadString("COPYRIGHT 1988 INCENTIVE", pad));
+			lines.push_back(centerAndPadString(isCastleMaster2() ? "COPYRIGHT 1990 INCENTIVE" : "COPYRIGHT 1988 INCENTIVE", pad));
 		} else if (isSpectrum() || isCPC()) {
 			if (isCastle())
 				pad = 22;


Commit: eced867f05afd27105249973d9304ef1edbb5222
    https://github.com/scummvm/scummvm/commit/eced867f05afd27105249973d9304ef1edbb5222
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: added support for castle master demo (DOS, CGA)

Changed paths:
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/games/castle/dos.cpp
    engines/freescape/games/palettes.cpp
    engines/freescape/gfx.cpp


diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 88d16fabe92..ec7cbcb304e 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -93,6 +93,8 @@ CastleEngine::CastleEngine(OSystem *syst, const ADGameDescription *gd) : Freesca
 	_spiritsMeterBgCLUT8 = nullptr;
 	_spiritsMeterIndCLUT8 = nullptr;
 	_keysBorderCLUT8 = nullptr;
+	_spiritsMeterSideCLUT8 = nullptr;
+	_backgroundCLUT8 = nullptr;
 	_menu = nullptr;
 	_menuButtons = nullptr;
 	_cursorData = nullptr;
@@ -412,6 +414,43 @@ void CastleEngine::updateCPCSpritesPalette() {
 		palette[c * 3 + 2] = b;
 	}
 
+	updateFourColorSpritesPalette(palette);
+}
+
+void CastleEngine::updateCGASpritesPalette() {
+	// swapPalette() already picked the palette for the current area
+	if (_gfx->_palette)
+		updateCGAPalette(_gfx->_palette);
+}
+
+void CastleEngine::updateCGAPalette(const byte *palette) {
+	updateFourColorSpritesPalette(palette);
+
+	// The CGA glyphs use color 2 for their body and 3 for the highlights, which
+	// Font::drawChar() maps to the secondary and tertiary colors
+	uint32 secondary = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[6], palette[7], palette[8]);
+	uint32 tertiary = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[9], palette[10], palette[11]);
+
+	_font.setSecondaryColor(secondary);
+	_font.setTertiaryColor(tertiary);
+	_fontRiddle.setSecondaryColor(secondary);
+	_fontRiddle.setTertiaryColor(tertiary);
+}
+
+void CastleEngine::updateFourColorSpritesPalette(const byte *palette) {
+	// convertCPCSprite() writes through a reference, so the destinations must be
+	// as long as the indexed arrays. This also covers the conversion after loading
+	while (_keysBorderCLUT8 && _keysBorderFrames.empty())
+		_keysBorderFrames.push_back(nullptr);
+	while (_strenghtWeightsFrames.size() < _strenghtWeightsCLUT8.size())
+		_strenghtWeightsFrames.push_back(nullptr);
+	while (_flagFrames.size() < _flagCLUT8.size())
+		_flagFrames.push_back(nullptr);
+	while (_keysBorderFrames.size() < _keysBorderCLUT8Frames.size())
+		_keysBorderFrames.push_back(nullptr);
+	while (_keysMenuFrames.size() < _keysMenuCLUT8Frames.size())
+		_keysMenuFrames.push_back(nullptr);
+
 	if (_keysBorderCLUT8) {
 		_keysBorderCLUT8->setPalette(palette, 0, 4);
 		convertCPCSprite(_keysBorderCLUT8, _keysBorderFrames[0], true);
@@ -424,6 +463,25 @@ void CastleEngine::updateCPCSpritesPalette() {
 		_spiritsMeterIndCLUT8->setPalette(palette, 0, 4);
 		convertCPCSprite(_spiritsMeterIndCLUT8, _spiritsMeterIndicatorFrame, true);
 	}
+	for (int f = 0; f < (int)_keysBorderCLUT8Frames.size(); f++) {
+		_keysBorderCLUT8Frames[f]->setPalette(palette, 0, 4);
+		convertCPCSprite(_keysBorderCLUT8Frames[f], _keysBorderFrames[f], true);
+	}
+	for (int f = 0; f < (int)_keysMenuCLUT8Frames.size(); f++) {
+		_keysMenuCLUT8Frames[f]->setPalette(palette, 0, 4);
+		convertCPCSprite(_keysMenuCLUT8Frames[f], _keysMenuFrames[f], true);
+	}
+	if (_spiritsMeterSideCLUT8) {
+		_spiritsMeterSideCLUT8->setPalette(palette, 0, 4);
+		convertCPCSprite(_spiritsMeterSideCLUT8, _spiritsMeterIndicatorSideFrame);
+	}
+	if (_backgroundCLUT8) {
+		// Uploaded once as a texture, so drop it to have it rebuilt recolored
+		_backgroundCLUT8->setPalette(palette, 0, 4);
+		convertCPCSprite(_backgroundCLUT8, _background);
+		delete _skyTexture;
+		_skyTexture = nullptr;
+	}
 	if (_strenghtBackgroundCLUT8) {
 		_strenghtBackgroundCLUT8->setPalette(palette, 0, 4);
 		convertCPCSprite(_strenghtBackgroundCLUT8, _strenghtBackgroundFrame);
@@ -608,7 +666,8 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 	// Ignore sky/ground fields
 	_gfx->_keyColor = 0;
 	_gfx->clearColorPairArray();
-	if (isCPC() || isC64())
+	// CGA takes its color pairs from the area color map, like CPC and C64 do
+	if (isCPC() || isC64() || _renderMode == Common::kRenderCGA)
 		_gfx->fillColorPairArray();
 
 	swapPalette(areaID);
@@ -619,8 +678,11 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 
 	if (isCPC())
 		updateCPCSpritesPalette();
+	else if (isDOS() && _renderMode == Common::kRenderCGA)
+		updateCGASpritesPalette();
 
-	if (isDOS()) {
+	// The per area extra colors are pairs of EGA indexes, useless in CGA
+	if (isDOS() && _renderMode != Common::kRenderCGA) {
 		_gfx->_colorPair[_currentArea->_underFireBackgroundColor] = _currentArea->_extraColor[1];
 		_gfx->_colorPair[_currentArea->_usualBackgroundColor] = _currentArea->_extraColor[0];
 		_gfx->_colorPair[_currentArea->_paperColor] = _currentArea->_extraColor[2];
@@ -973,7 +1035,7 @@ void CastleEngine::drawInfoMenu() {
 		CursorMan.showMouse(true);
 		surface->copyRectToSurface(*_menu, 47, 35, Common::Rect(0, 0, _menu->w, _menu->h));
 
-		_gfx->readFromPalette(10, r, g, b);
+		_gfx->readFromPalette(_renderMode == Common::kRenderCGA ? 3 : 10, r, g, b);
 		front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 		drawStringInSurface(Common::String::format("%07d", score), 166, 71, front, black, surface);
 		drawStringInSurface(centerAndPadString(Common::String::format("%s", _messagesList[135 + shield / 6].c_str()), 10), 151, 102,  front, black, surface);
@@ -1704,6 +1766,9 @@ void CastleEngine::drawFullscreenRiddleAndWait(uint16 riddle) {
 		case Common::kRenderZX:
 			frontColor = 7;
 			break;
+		case Common::kRenderCGA:
+			frontColor = 3;
+			break;
 		case Common::kRenderCPC:
 			frontColor = _gfx->_inkColor;
 			break;
@@ -1921,6 +1986,11 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 		weightStep = 3;
 		weightOffset = 10;
 		rightWeightPos = 62;
+	} else if (_renderMode == Common::kRenderCGA) {
+		// The CGA discs are 4 pixels wide instead of 8
+		weightStep = 3;
+		weightOffset = 9;
+		rightWeightPos = 67;
 	} else { // DOS
 		weightStep = 3;
 		weightOffset = 10;
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index f55d5ddccd6..2c28628a7b0 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -67,6 +67,7 @@ public:
 	void loadAssets() override;
 	void loadAssetsDOSFullGame() override;
 	void loadAssetsDOSDemo() override;
+	void loadAssetsDOSDemoCGA();
 	void loadAssetsAmigaDemo() override;
 	void loadAssetsAmigaFullGame() override;
 	void loadAssetsAtariFullGame() override;
@@ -118,6 +119,10 @@ public:
 	void drawRiddleStringInSurface(const Common::String &str, int x, int y, uint32 fontColor, uint32 backColor, Graphics::Surface *surface);
 	Graphics::ManagedSurface *loadFrameWithHeaderDOS(Common::SeekableReadStream *file);
 	Common::Array <Graphics::ManagedSurface *>loadFramesWithHeaderDOS(Common::SeekableReadStream *file, int numFrames);
+	Graphics::ManagedSurface *loadFrameWithHeaderDOSIndexed(Common::SeekableReadStream *file);
+	Common::Array<Graphics::ManagedSurface *> loadFramesWithHeaderDOSIndexed(Common::SeekableReadStream *file, int numFrames);
+	Graphics::ManagedSurface *loadFrameDOS(Common::SeekableReadStream *file, int widthInBytes, int height);
+	void convertFrameDOS(Graphics::ManagedSurface *frame);
 
 	Common::Array<Graphics::ManagedSurface *> loadFramesWithHeader(Common::SeekableReadStream *file, int pos, int numFrames, uint32 front, uint32 back);
 	Graphics::ManagedSurface *loadFrameWithHeader(Common::SeekableReadStream *file, int pos, uint32 front, uint32 back);
@@ -129,6 +134,7 @@ public:
 	Graphics::ManagedSurface *loadFrameCPC(Common::SeekableReadStream *file, Graphics::ManagedSurface *surface, int width, int height, const uint32 *cpcPalette);
 
 	Graphics::ManagedSurface *loadFrameFromPlanes(Common::SeekableReadStream *file, int widthInBytes, int height);
+	Graphics::ManagedSurface *loadFrameFromPackedPixels(Common::SeekableReadStream *file, int widthInBytes, int height);
 	Graphics::ManagedSurface *loadFrameFromPlanesInternal(Common::SeekableReadStream *file, Graphics::ManagedSurface *surface, int width, int height);
 
 	Graphics::ManagedSurface *loadFrameFromPlanesVertical(Common::SeekableReadStream *file, int widthInBytes, int height);
@@ -163,12 +169,19 @@ public:
 	Graphics::ManagedSurface *_spiritsMeterBgCLUT8;
 	Graphics::ManagedSurface *_spiritsMeterIndCLUT8;
 	Graphics::ManagedSurface *_keysBorderCLUT8;
+	Graphics::ManagedSurface *_spiritsMeterSideCLUT8;
+	Common::Array<Graphics::ManagedSurface *> _keysBorderCLUT8Frames;
+	Common::Array<Graphics::ManagedSurface *> _keysMenuCLUT8Frames;
+	Graphics::ManagedSurface *_backgroundCLUT8;
 	Common::Array<Graphics::ManagedSurface *> _flagCLUT8;
 	uint32 _cpcUIPalette[4]; // used by gate rendering
 	void convertCPCSprite(Graphics::ManagedSurface *clut8, Graphics::ManagedSurface *&argb, bool transparentInk0 = false);
 	Graphics::ManagedSurface *loadFrameWithHeaderCPCIndexed(Common::SeekableReadStream *file, int pos);
 	Common::Array<Graphics::ManagedSurface *> loadFramesWithHeaderCPCIndexed(Common::SeekableReadStream *file, int pos, int numFrames);
 	void updateCPCSpritesPalette();
+	void updateCGASpritesPalette();
+	void updateCGAPalette(const byte *palette);
+	void updateFourColorSpritesPalette(const byte *palette);
 
 	Common::String _notEnoughRoomMessage;
 	Common::String _tooWeakMessage;
diff --git a/engines/freescape/games/castle/dos.cpp b/engines/freescape/games/castle/dos.cpp
index 755f4b9d89d..871a1e11be5 100644
--- a/engines/freescape/games/castle/dos.cpp
+++ b/engines/freescape/games/castle/dos.cpp
@@ -104,6 +104,42 @@ Graphics::ManagedSurface *CastleEngine::loadFrameFromPlanesInternal(Common::Seek
 	return surface;
 }
 
+// CGA frames are not planar: each byte holds four 2 bit pixels, high bits first
+Graphics::ManagedSurface *CastleEngine::loadFrameFromPackedPixels(Common::SeekableReadStream *file, int widthInBytes, int height) {
+	int width = widthInBytes * 4;
+	Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+	surface->create(width, height, Graphics::PixelFormat::createFormatCLUT8());
+	surface->fillRect(Common::Rect(0, 0, width, height), 0);
+
+	byte *pixels = (byte *)malloc(sizeof(byte) * height * widthInBytes);
+	file->read(pixels, height * widthInBytes);
+
+	for (int y = 0; y < height; y++) {
+		for (int i = 0; i < widthInBytes; i++) {
+			byte packed = pixels[y * widthInBytes + i];
+			for (int p = 0; p < 4; p++)
+				surface->setPixel(i * 4 + p, y, (packed >> (6 - 2 * p)) & 0x3);
+		}
+	}
+
+	free(pixels);
+	return surface;
+}
+
+Graphics::ManagedSurface *CastleEngine::loadFrameDOS(Common::SeekableReadStream *file, int widthInBytes, int height) {
+	if (_renderMode == Common::kRenderCGA)
+		return loadFrameFromPackedPixels(file, widthInBytes, height);
+
+	return loadFrameFromPlanes(file, widthInBytes, height);
+}
+
+void CastleEngine::convertFrameDOS(Graphics::ManagedSurface *frame) {
+	if (_renderMode == Common::kRenderCGA)
+		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kCGAPalettePinkBlueBright, 4);
+	else
+		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
+}
+
 Common::Array <Graphics::ManagedSurface *>CastleEngine::loadFramesWithHeaderDOS(Common::SeekableReadStream *file, int numFrames) {
 	uint8 header1 = file->readByte();
 	uint8 header2 = file->readByte();
@@ -116,8 +152,8 @@ Common::Array <Graphics::ManagedSurface *>CastleEngine::loadFramesWithHeaderDOS(
 
 	Common::Array<Graphics::ManagedSurface *> frames;
 	for (int i = 0; i < numFrames; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanes(file, widthBytes, height);
-		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
+		Graphics::ManagedSurface *frame = loadFrameDOS(file, widthBytes, height);
+		convertFrameDOS(frame);
 		frames.push_back(frame);
 	}
 
@@ -135,14 +171,43 @@ Graphics::ManagedSurface *CastleEngine::loadFrameWithHeaderDOS(Common::SeekableR
 	assert(size % height == 0);
 	int widthBytes = (size / height);
 
-	Graphics::ManagedSurface *frame = loadFrameFromPlanes(file, widthBytes, height);
-	frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)&kEGADefaultPalette, 16);
+	Graphics::ManagedSurface *frame = loadFrameDOS(file, widthBytes, height);
+	convertFrameDOS(frame);
 
 	debug("header: %x %x, height: %d, mask: %x, widthBytes: %d, size: %d", header1, header2, height, mask, widthBytes, size);
 	debug("pos: %x", (int32)file->pos());
 	return frame;
 }
 
+// As above, but keeping the indexed surface so the sprite can be recolored later
+Graphics::ManagedSurface *CastleEngine::loadFrameWithHeaderDOSIndexed(Common::SeekableReadStream *file) {
+	file->readByte();
+	file->readByte();
+	int height = file->readByte();
+	file->readByte();
+	int size = file->readUint16LE();
+
+	assert(size % height == 0);
+	return loadFrameDOS(file, size / height, height);
+}
+
+Common::Array<Graphics::ManagedSurface *> CastleEngine::loadFramesWithHeaderDOSIndexed(Common::SeekableReadStream *file, int numFrames) {
+	file->readByte();
+	file->readByte();
+	int height = file->readByte();
+	file->readByte();
+	int size = file->readUint16LE();
+
+	assert(size % height == 0);
+	int widthBytes = (size / height);
+
+	Common::Array<Graphics::ManagedSurface *> frames;
+	for (int i = 0; i < numFrames; i++)
+		frames.push_back(loadFrameDOS(file, widthBytes, height));
+
+	return frames;
+}
+
 void CastleEngine::initDOS() {
 	_viewArea = Common::Rect(40, 33 - 2, 280, 152);
 }
@@ -463,13 +528,121 @@ void CastleEngine::loadAssetsDOSDemo() {
 
 		if (ConfMan.getBool("opl_music"))
 			_playerMusic = new CastleOPLMusicPlayer();
+	} else if (_renderMode == Common::kRenderCGA) {
+		loadAssetsDOSDemoCGA();
 	} else
 		error("Not implemented yet");
 
 }
 
+void CastleEngine::loadAssetsDOSDemoCGA() {
+	Common::File file;
+	Common::SeekableReadStream *stream = nullptr;
+
+	// CMDC.EXE holds the same assets in the same order as the EGA build, but
+	// redrawn for CGA, so none of its offsets apply. The graphics segment starts
+	// at 0x188b0 in the unpacked executable, with the background at offset zero
+	file.open("CMDC.EXE");
+	stream = unpackEXE(file);
+	if (stream) {
+		// The PC speaker tables are the only part shared with CMDE.EXE
+		_sound = loadSpeakerFxDOS(stream, 0x46fd + 0x200, 0x477d + 0x200, 30);
+
+		stream->seek(0x17110);
+		_endGameBackgroundFrame = loadFrameDOS(stream, 56, 108);
+		convertFrameDOS(_endGameBackgroundFrame);
+
+		// Drawn inside the view, so it follows the palette of the current area
+		_backgroundCLUT8 = loadFrameDOS(stream, 252, 18);
+
+		// Two groups of ten key sprites, each with its own header
+		stream->seek(0x19eac);
+		_keysBorderCLUT8Frames = loadFramesWithHeaderDOSIndexed(stream, 10);
+		_keysMenuCLUT8Frames = loadFramesWithHeaderDOSIndexed(stream, 10);
+
+		stream->seek(0x1a328);
+		_strenghtBackgroundCLUT8 = loadFrameWithHeaderDOSIndexed(stream);
+		_strenghtBarCLUT8 = loadFrameWithHeaderDOSIndexed(stream);
+		_strenghtWeightsCLUT8 = loadFramesWithHeaderDOSIndexed(stream, 4);
+		_spiritsMeterBgCLUT8 = loadFrameWithHeaderDOSIndexed(stream);
+		_spiritsMeterIndCLUT8 = loadFrameWithHeaderDOSIndexed(stream);
+		_spiritsMeterSideCLUT8 = loadFrameWithHeaderDOSIndexed(stream);
+
+		stream->seek(0x1b9fe);
+		_menu = loadFrameDOS(stream, 56, 115);
+		convertFrameDOS(_menu);
+
+		Common::Array <Graphics::ManagedSurface *> menuFrames = loadFramesWithHeaderDOS(stream, 5);
+		_menuCrawlIndicator = menuFrames[0];
+		_menuWalkIndicator = menuFrames[1];
+		_menuRunIndicator = menuFrames[2];
+		_menuFxOffIndicator = menuFrames[3];
+		_menuFxOnIndicator = menuFrames[4];
+
+		_flagCLUT8 = loadFramesWithHeaderDOSIndexed(stream, 4);
+
+		_riddleTopFrame = loadFrameWithHeaderDOS(stream);
+		_riddleBackgroundFrame = loadFrameWithHeaderDOS(stream);
+		_riddleBottomFrame = loadFrameWithHeaderDOS(stream);
+		_endGameThroneFrame = loadFrameWithHeaderDOS(stream);
+		// No lightning frames are shipped; updateThunder() skips an empty array
+
+		stream->seek(0x1f05c);
+		Common::Array<Graphics::ManagedSurface *> chars;
+		Common::Array<Graphics::ManagedSurface *> charsRiddle;
+		// 95 glyphs of 3x8 bytes after the usual header. They are left indexed so
+		// Font::drawChar() recolors them with what updateCGAPalette() selected
+		stream->skip(6);
+		for (int i = 0; i < 90; i++) {
+			Graphics::ManagedSurface *img = loadFrameDOS(stream, 3, 8);
+			Graphics::ManagedSurface *imgRiddle = new Graphics::ManagedSurface();
+			imgRiddle->copyFrom(*img);
+
+			chars.push_back(img);
+			charsRiddle.push_back(imgRiddle);
+		}
+		_font = Font(chars);
+		_font.setCharWidth(9);
+
+		_fontRiddle = Font(charsRiddle);
+		_fontRiddle.setCharWidth(9);
+		_fontLoaded = true;
+	}
+
+	delete stream;
+	file.close();
+
+	file.open("CMLC.DAT");
+	_title = load8bitBinImage(&file, 0x0);
+	_title->setPalette((byte *)&kCGAPalettePinkBlueBright, 0, 4);
+	file.close();
+
+	file.open("CMOC.DAT");
+	_option = load8bitBinImage(&file, 0x0);
+	_option->setPalette((byte *)&kCGAPalettePinkBlueBright, 0, 4);
+	file.close();
+
+	file.open("CMC.DAT");
+	_border = load8bitBinImage(&file, 0x0);
+	_border->setPalette((byte *)&kCGAPalettePinkBlueBright, 0, 4);
+	file.close();
+
+	stream = decryptFile("CMLD"); // Only english
+	loadMessagesVariableSize(stream, 0x11, 164);
+	loadRiddles(stream, 0xaae - 2 - 22 * 2, 22);
+	delete stream;
+
+	stream = decryptFile("CDCDF");
+	load8bitBinary(stream, 0, 4);
+	delete stream;
+
+	// Build the sprite surfaces and font colors; swapPalette() redoes this per area
+	updateCGAPalette((byte *)&kCGAPalettePinkBlueBright);
+}
+
 void CastleEngine::drawDOSUI(Graphics::Surface *surface) {
-	uint32 color = 10;
+	// EGA picks a bright color from its 16 entry palette; CGA only has four
+	uint32 color = _renderMode == Common::kRenderCGA ? 3 : 10;
 	uint32 black = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00);
 	uint8 r, g, b;
 	drawLiftingGate(surface);
@@ -508,11 +681,17 @@ void CastleEngine::drawDOSUI(Graphics::Surface *surface) {
 
 	drawEnergyMeter(surface, Common::Point(38, 158));
 	int flagFrameIndex = (_ticks / 10) % 4;
-	surface->copyRectToSurface(*_flagFrames[flagFrameIndex], 285, 5, Common::Rect(0, 0, _flagFrames[flagFrameIndex]->w, _flagFrames[flagFrameIndex]->h));
+	// The CGA flag is 20 pixels wide instead of 32, with its pole further left
+	int flagX = _renderMode == Common::kRenderCGA ? 288 : 285;
+	surface->copyRectToSurface(*_flagFrames[flagFrameIndex], flagX, 5, Common::Rect(0, 0, _flagFrames[flagFrameIndex]->w, _flagFrames[flagFrameIndex]->h));
 
 	surface->copyRectToSurface((const Graphics::Surface)*_spiritsMeterIndicatorBackgroundFrame, 136, 162, Common::Rect(0, 0, _spiritsMeterIndicatorBackgroundFrame->w, _spiritsMeterIndicatorBackgroundFrame->h));
 	surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_spiritsMeterIndicatorFrame, 125 + 6 + _spiritsMeterPosition, 161, Common::Rect(0, 0, _spiritsMeterIndicatorFrame->w, _spiritsMeterIndicatorFrame->h), black);
-	surface->copyRectToSurface((const Graphics::Surface)*_spiritsMeterIndicatorSideFrame, 122 + 5 + 1, 157 + 5 - 1, Common::Rect(0, 0, _spiritsMeterIndicatorSideFrame->w / 2, _spiritsMeterIndicatorSideFrame->h));
+	// The EGA sprite holds two copies of the cap, the CGA one only holds a single
+	int sideWidth = _spiritsMeterIndicatorSideFrame->w;
+	if (_renderMode != Common::kRenderCGA)
+		sideWidth /= 2;
+	surface->copyRectToSurface((const Graphics::Surface)*_spiritsMeterIndicatorSideFrame, 122 + 5 + 1, 157 + 5 - 1, Common::Rect(0, 0, sideWidth, _spiritsMeterIndicatorSideFrame->h));
 	//surface->copyRectToSurface(*_spiritsMeterIndicatorFrame, 100, 50, Common::Rect(0, 0, _spiritsMeterIndicatorFrame->w, _spiritsMeterIndicatorFrame->h));
 }
 
diff --git a/engines/freescape/games/palettes.cpp b/engines/freescape/games/palettes.cpp
index bffb9800962..062f1c0498c 100644
--- a/engines/freescape/games/palettes.cpp
+++ b/engines/freescape/games/palettes.cpp
@@ -286,7 +286,16 @@ void FreescapeEngine::swapPalette(uint16 levelID) {
 }
 
 byte *FreescapeEngine::findCGAPalette(uint16 levelID) {
-	if (isDriller() || isDark() || isCastle()) {
+	if (isCastle()) {
+		// Castle Master always sets the intensity bit of the color select register
+		// (BIOS AH=0Bh, BH=0, BL=0x10 is a constant), so it runs bright. Driller
+		// reads that byte from a variable and keeps the dim palettes below
+		if (levelID % 2 == 0)
+			return (byte *)&kCGAPalettePinkBlueBright;
+		else
+			return (byte *)&kCGAPaletteRedGreenBright;
+	}
+	if (isDriller() || isDark()) {
 		if (levelID % 2 == 0)
 			return (byte *)&kCGAPalettePinkBlue;
 		else
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index 16423e02ca6..80a8e68a0a9 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -323,7 +323,10 @@ void Renderer::setColorMap(ColorMap *colorMap_) {
 		}
 	} else if (_renderMode == Common::kRenderCGA) {
 		fillColorPairArray();
-		for (int i = 4; i < 15; i++) {
+		// As with CPC above, Castle Master uses color-map entry 3 as a genuine
+		// checker, so all 15 entries need a stipple. Harmless for the other
+		// games, since getRGBAtCGA() drops it whenever both colors are equal
+		for (int i = 0; i < 15; i++) {
 			byte pair = _colorPair[i];
 			byte c1 = pair & 0xf;
 			byte c2 = (pair >> 4) & 0xf;


Commit: 193ee5cad8cc891c97d600ba1ddeb190a68d84d1
    https://github.com/scummvm/scummvm/commit/193ee5cad8cc891c97d600ba1ddeb190a68d84d1
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: improved support for eclipse (DOS, CGA)

Changed paths:
    engines/freescape/games/eclipse/cpc.cpp
    engines/freescape/games/eclipse/dos.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/games/eclipse/cpc.cpp b/engines/freescape/games/eclipse/cpc.cpp
index 6bb24396765..bf50e68e373 100644
--- a/engines/freescape/games/eclipse/cpc.cpp
+++ b/engines/freescape/games/eclipse/cpc.cpp
@@ -79,7 +79,7 @@ void EclipseEngine::loadHeartFramesCPC(Common::SeekableReadStream *file, int res
 		auto *indexed = new Graphics::ManagedSurface();
 		indexed->create(widthBytes * 4, height, Graphics::PixelFormat::createFormatCLUT8());
 		loadFrameCPCIndexed(file, indexed, widthBytes, height);
-		_heartFramesCPCIndexed.push_back(indexed);
+		_heartFramesIndexed.push_back(indexed);
 	}
 }
 
@@ -191,9 +191,6 @@ void EclipseEngine::loadAssetsCPCDemo() {
 }
 
 void EclipseEngine::updateHeartFramesCPC() {
-	if (_heartFramesCPCIndexed.empty())
-		return;
-
 	uint8 r, g, b;
 	byte palette[4 * 3];
 	for (int c = 0; c < 4; c++) {
@@ -203,15 +200,22 @@ void EclipseEngine::updateHeartFramesCPC() {
 		palette[c * 3 + 2] = b;
 	}
 
+	updateHeartFrames(palette);
+}
+
+void EclipseEngine::updateHeartFrames(const byte *palette) {
+	if (_heartFramesIndexed.empty())
+		return;
+
 	for (auto &sprite : _eclipseSprites) {
 		sprite->free();
 		delete sprite;
 	}
 	_eclipseSprites.clear();
 
-	for (uint i = 0; i < _heartFramesCPCIndexed.size(); i++) {
+	for (uint i = 0; i < _heartFramesIndexed.size(); i++) {
 		Graphics::ManagedSurface clut8;
-		clut8.copyFrom(*_heartFramesCPCIndexed[i]);
+		clut8.copyFrom(*_heartFramesIndexed[i]);
 		clut8.setPalette(palette, 0, 4);
 
 		Graphics::Surface *converted = _gfx->convertImageFormatIfNecessary(&clut8);
diff --git a/engines/freescape/games/eclipse/dos.cpp b/engines/freescape/games/eclipse/dos.cpp
index 03f64f3902b..9d52f22a46a 100644
--- a/engines/freescape/games/eclipse/dos.cpp
+++ b/engines/freescape/games/eclipse/dos.cpp
@@ -94,16 +94,74 @@ void EclipseEngine::loadHeartFramesDOS(Common::SeekableReadStream *file, int res
 						clut8.setPixel(col * 4 + px, y, (b >> (6 - px * 2)) & 3);
 				}
 
-			clut8.setPalette((byte *)kCGAPaletteRedGreenBright, 0, 4);
+			// Kept indexed, since the CGA palette changes per area
+			auto *indexed = new Graphics::ManagedSurface();
+			indexed->copyFrom(clut8);
+			_heartFramesIndexed.push_back(indexed);
+		}
+	}
+}
 
-			Graphics::Surface *converted = _gfx->convertImageFormatIfNecessary(&clut8);
-			auto *surf = new Graphics::ManagedSurface();
-			surf->copyFrom(*converted);
-			converted->free();
-			delete converted;
-			_eclipseSprites.push_back(surf);
+// The bundle has no CGA ankh, so the EGA one is reduced to a mask and colored here
+// The solid ankh the original paints over collected slots is already in the border
+void EclipseEngine::loadAnkhCollectedMaskCGA() {
+	_ankhCollectedMask = new Graphics::ManagedSurface();
+	_ankhCollectedMask->create(7, 12, Graphics::PixelFormat::createFormatCLUT8());
+
+	for (int y = 0; y < 12; y++)
+		for (int x = 0; x < 7; x++)
+			_ankhCollectedMask->setPixel(x, y, _border->getPixel(45 + x, 4 + y) ? 1 : 0);
+}
+
+void EclipseEngine::loadAnkhIndicatorCGA() {
+	Graphics::Surface *ega = loadBundledImage("eclipse_ankh_indicator_ega", false);
+	ega->convertToInPlace(_gfx->_texturePixelFormat);
+
+	_ankhIndicatorMask = new Graphics::ManagedSurface();
+	_ankhIndicatorMask->create(ega->w, ega->h, Graphics::PixelFormat::createFormatCLUT8());
+
+	for (int y = 0; y < ega->h; y++) {
+		for (int x = 0; x < ega->w; x++) {
+			uint8 r, g, b;
+			ega->format.colorToRGB(ega->getPixel(x, y), r, g, b);
+			_ankhIndicatorMask->setPixel(x, y, (r || g || b) ? 1 : 0);
 		}
 	}
+
+	ega->free();
+	delete ega;
+}
+
+void EclipseEngine::updateAnkhIndicator(const byte *palette) {
+	if (!_ankhIndicatorMask)
+		return;
+
+	for (auto &it : _indicators) {
+		it->free();
+		delete it;
+	}
+	_indicators.clear();
+
+	// The original draws the ankhs with color 1 of the area palette
+	uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[3], palette[4], palette[5]);
+	uint32 back = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[0], palette[1], palette[2]);
+
+	// [0] goes over the slots still to collect, [1] over the collected ones
+	Graphics::ManagedSurface *masks[2] = { _ankhIndicatorMask, _ankhCollectedMask };
+
+	for (int i = 0; i < 2; i++) {
+		if (!masks[i])
+			continue;
+
+		Graphics::Surface *surface = new Graphics::Surface();
+		surface->create(masks[i]->w, masks[i]->h, _gfx->_texturePixelFormat);
+
+		for (int y = 0; y < surface->h; y++)
+			for (int x = 0; x < surface->w; x++)
+				surface->setPixel(x, y, masks[i]->getPixel(x, y) ? front : back);
+
+		_indicators.push_back(surface);
+	}
 }
 
 void EclipseEngine::loadAssetsDOSFullGame() {
@@ -155,9 +213,13 @@ void EclipseEngine::loadAssetsDOSFullGame() {
 		load8bitBinary(&file, 0x2530, 4);
 		_border = load8bitBinImage(&file, 0x210);
 		_border->setPalette((byte *)&kCGAPaletteRedGreen, 0, 4);
-		// TODO: CGA heart palette changes per area, needs re-decoding on area change
-		// loadHeartFramesDOS(&file, 0x5F52, 0x5F84);
+		loadAnkhCollectedMaskCGA();
+		loadHeartFramesDOS(&file, 0x5F52, 0x5F84);
 		swapPalette(_startArea);
+		updateHeartFrames(_gfx->_palette);
+
+		loadAnkhIndicatorCGA();
+		updateAnkhIndicator(_gfx->_palette);
 	} else
 		error("Invalid or unsupported render mode %s for Total Eclipse", Common::getRenderModeDescription(_renderMode));
 
@@ -235,7 +297,10 @@ void EclipseEngine::drawDOSUI(Graphics::Surface *surface) {
 	
 	Common::Rect jarWater(124, 192 - _gameStateVars[k8bitVariableEnergy], 148, 192);
 
-	drawIndicator(surface, 41, 4, 16);
+	if (_renderMode == Common::kRenderCGA)
+		drawIndicator(surface, 45, 4, 12);
+	else
+		drawIndicator(surface, 41, 4, 16);
 	drawHeartIndicator(surface, 176, 168);
 	if (_renderMode == Common::kRenderEGA) {
 		surface->fillRect(jarWater, blue);
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 827e5f364e5..85832333f42 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -54,6 +54,8 @@ const WBTableOffsets kEclipseAmigaMusicOffsets = {
 };
 
 EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : FreescapeEngine(syst, gd) {
+	_ankhIndicatorMask = nullptr;
+	_ankhCollectedMask = nullptr;
 	_playerC64Sfx = nullptr;
 	_playerMusic = nullptr;
 	_c64UseSFX = false;
@@ -454,6 +456,10 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	swapPalette(areaID);
 	if (isCPC())
 		updateHeartFramesCPC();
+	else if (isDOS() && _renderMode == Common::kRenderCGA) {
+		updateHeartFrames(_gfx->_palette);
+		updateAnkhIndicator(_gfx->_palette);
+	}
 	if (isAmiga() || isAtariST())
 		_currentArea->_skyColor = 15;
 
@@ -987,12 +993,17 @@ void EclipseEngine::drawIndicator(Graphics::Surface *surface, int xPosition, int
 		return;
 
 	for (int i = 0; i < 5; i++) {
+		int frame = 0;
 		if (isSpectrum() || isC64()) {
 			if (_gameStateVars[kVariableEclipseAnkhs] <= i)
 				continue;
-		} else if (_gameStateVars[kVariableEclipseAnkhs] > i)
-			continue;
-		surface->copyRectToSurface(*_indicators[0], xPosition + separation * i, yPosition, Common::Rect(_indicators[0]->w, _indicators[0]->h));
+		} else if (_gameStateVars[kVariableEclipseAnkhs] > i) {
+			// CGA repaints these too: the border ankhs use another color
+			if (_indicators.size() < 2)
+				continue;
+			frame = 1;
+		}
+		surface->copyRectToSurface(*_indicators[frame], xPosition + separation * i, yPosition, Common::Rect(_indicators[frame]->w, _indicators[frame]->h));
 	}
 }
 
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index eb275b6d243..0d92ac49cb9 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -114,9 +114,17 @@ public:
 	void loadHeartFramesDOS(Common::SeekableReadStream *file, int restOffset, int beatOffset);
 	void drawHeartIndicator(Graphics::Surface *surface, int x, int y);
 
-	// CPC heart frames stored as indexed (CLUT8) for per-area re-paletting
-	Common::Array<Graphics::ManagedSurface *> _heartFramesCPCIndexed;
+	// Heart frames stored as indexed (CLUT8) for per-area re-paletting (CPC, CGA)
+	Common::Array<Graphics::ManagedSurface *> _heartFramesIndexed;
 	void updateHeartFramesCPC();
+	void updateHeartFrames(const byte *palette);
+
+	// No CGA ankh in the bundle, so the masks are built at load time
+	Graphics::ManagedSurface *_ankhIndicatorMask;
+	Graphics::ManagedSurface *_ankhCollectedMask;
+	void loadAnkhIndicatorCGA();
+	void loadAnkhCollectedMaskCGA();
+	void updateAnkhIndicator(const byte *palette);
 
 	Common::Array<byte> _musicData; // TEMUSIC.ST TEXT segment (Atari ST)
 	Common::Array<byte> _c64MusicData;


Commit: 524bc071fe929af37927c895ad35a0fdfc30cbd4
    https://github.com/scummvm/scummvm/commit/524bc071fe929af37927c895ad35a0fdfc30cbd4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: added hercurles support for eclipse (DOS)

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/eclipse/dos.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 8140e6ad76d..9648c01a82a 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -706,7 +706,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO6(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS, GAMEOPTION_OPL_MUSIC)
+		GUIO7(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS, GAMEOPTION_OPL_MUSIC)
 	},
 	{
 		// Erbe Software release
@@ -723,7 +723,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO6(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS, GAMEOPTION_OPL_MUSIC)
+		GUIO7(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS, GAMEOPTION_OPL_MUSIC)
 	},
 	{
 		"totaleclipse", // Tape relese
diff --git a/engines/freescape/games/eclipse/dos.cpp b/engines/freescape/games/eclipse/dos.cpp
index 9d52f22a46a..f342960b15f 100644
--- a/engines/freescape/games/eclipse/dos.cpp
+++ b/engines/freescape/games/eclipse/dos.cpp
@@ -31,7 +31,10 @@
 namespace Freescape {
 
 void EclipseEngine::initDOS() {
-	_viewArea = Common::Rect(40, 33, 280, 133);
+	if (_renderMode == Common::kRenderHercG)
+		_viewArea = Common::Rect(110, 79, 594, 242);
+	else
+		_viewArea = Common::Rect(40, 33, 280, 133);
 	_soundIndexShoot = 18;
 	_soundIndexCollide = 1;
 	_soundIndexStepDown = 3;
@@ -76,6 +79,26 @@ void EclipseEngine::loadHeartFramesDOS(Common::SeekableReadStream *file, int res
 
 			clut8.setPalette((byte *)kEGADefaultPalette, 0, 16);
 
+			Graphics::Surface *converted = _gfx->convertImageFormatIfNecessary(&clut8);
+			auto *surf = new Graphics::ManagedSurface();
+			surf->copyFrom(*converted);
+			converted->free();
+			delete converted;
+			_eclipseSprites.push_back(surf);
+		} else if (_renderMode == Common::kRenderHercG) {
+			// Hercules: one bit per pixel, greys are dithered in the artwork
+			Graphics::ManagedSurface clut8;
+			clut8.create(widthBytes * 8, height, Graphics::PixelFormat::createFormatCLUT8());
+
+			for (int y = 0; y < height; y++)
+				for (int col = 0; col < widthBytes; col++) {
+					byte b = file->readByte();
+					for (int px = 0; px < 8; px++)
+						clut8.setPixel(col * 8 + px, y, (b >> (7 - px)) & 1);
+				}
+
+			clut8.setPalette((byte *)kHerculesPaletteGreen, 0, 2);
+
 			Graphics::Surface *converted = _gfx->convertImageFormatIfNecessary(&clut8);
 			auto *surf = new Graphics::ManagedSurface();
 			surf->copyFrom(*converted);
@@ -102,29 +125,36 @@ void EclipseEngine::loadHeartFramesDOS(Common::SeekableReadStream *file, int res
 	}
 }
 
-// The bundle has no CGA ankh, so the EGA one is reduced to a mask and colored here
-// The solid ankh the original paints over collected slots is already in the border
-void EclipseEngine::loadAnkhCollectedMaskCGA() {
+// The bundle has no CGA or Hercules ankh, so the EGA one is reduced to a mask and
+// colored here. The solid ankh over collected slots is lifted from the border.
+void EclipseEngine::loadAnkhCollectedMask() {
+	int scale = _renderMode == Common::kRenderHercG ? 2 : 1;
+	int srcX = _renderMode == Common::kRenderHercG ? 122 : 45;
+	int srcY = _renderMode == Common::kRenderHercG ? 52 : 4;
+
 	_ankhCollectedMask = new Graphics::ManagedSurface();
-	_ankhCollectedMask->create(7, 12, Graphics::PixelFormat::createFormatCLUT8());
+	_ankhCollectedMask->create(7 * scale, 12, Graphics::PixelFormat::createFormatCLUT8());
 
 	for (int y = 0; y < 12; y++)
-		for (int x = 0; x < 7; x++)
-			_ankhCollectedMask->setPixel(x, y, _border->getPixel(45 + x, 4 + y) ? 1 : 0);
+		for (int x = 0; x < 7 * scale; x++)
+			_ankhCollectedMask->setPixel(x, y, _border->getPixel(srcX + x, srcY + y) ? 1 : 0);
 }
 
-void EclipseEngine::loadAnkhIndicatorCGA() {
+void EclipseEngine::loadAnkhIndicatorMask() {
+	// Hercules pixels are half as wide, so the original doubles every column
+	int scale = _renderMode == Common::kRenderHercG ? 2 : 1;
 	Graphics::Surface *ega = loadBundledImage("eclipse_ankh_indicator_ega", false);
 	ega->convertToInPlace(_gfx->_texturePixelFormat);
 
 	_ankhIndicatorMask = new Graphics::ManagedSurface();
-	_ankhIndicatorMask->create(ega->w, ega->h, Graphics::PixelFormat::createFormatCLUT8());
+	_ankhIndicatorMask->create(ega->w * scale, ega->h, Graphics::PixelFormat::createFormatCLUT8());
 
 	for (int y = 0; y < ega->h; y++) {
 		for (int x = 0; x < ega->w; x++) {
 			uint8 r, g, b;
 			ega->format.colorToRGB(ega->getPixel(x, y), r, g, b);
-			_ankhIndicatorMask->setPixel(x, y, (r || g || b) ? 1 : 0);
+			for (int i = 0; i < scale; i++)
+				_ankhIndicatorMask->setPixel(x * scale + i, y, (r || g || b) ? 1 : 0);
 		}
 	}
 
@@ -213,13 +243,40 @@ void EclipseEngine::loadAssetsDOSFullGame() {
 		load8bitBinary(&file, 0x2530, 4);
 		_border = load8bitBinImage(&file, 0x210);
 		_border->setPalette((byte *)&kCGAPaletteRedGreen, 0, 4);
-		loadAnkhCollectedMaskCGA();
+		loadAnkhCollectedMask();
 		loadHeartFramesDOS(&file, 0x5F52, 0x5F84);
 		swapPalette(_startArea);
 		updateHeartFrames(_gfx->_palette);
 
-		loadAnkhIndicatorCGA();
+		loadAnkhIndicatorMask();
 		updateAnkhIndicator(_gfx->_palette);
+	} else if (_renderMode == Common::kRenderHercG) {
+		file.open("SCN1H.DAT");
+		if (file.isOpen()) {
+			_title = load8bitBinImage(&file, 0x0);
+			_title->setPalette((byte *)&kHerculesPaletteGreen, 0, 2);
+		}
+		file.close();
+		file.open("TOTEH.EXE");
+
+		if (!file.isOpen())
+			error("Failed to open TOTEH.EXE");
+
+		loadMessagesFixedSize(&file, 0x688b, 16, 20);
+		_soundFx = loadSoundsFxDOS(&file, 0xc880, 5);
+		_sound = loadSpeakerFxDOS(&file, 0x6b12 + 0x200, 0x6a1d + 0x200, 20);
+		loadFonts(&file, 0xc609);
+		load8bitBinary(&file, 0x3340, 4);
+
+		_border = load8bitBinImage(&file, 0x210);
+		_border->setPalette((byte *)&kHerculesPaletteGreen, 0, 2);
+
+		loadAnkhCollectedMask();
+		loadHeartFramesDOS(&file, 0x6E8E, 0x6EC0);
+
+		// The Hercules palette never changes, so build the ankhs once
+		loadAnkhIndicatorMask();
+		updateAnkhIndicator((byte *)&kHerculesPaletteGreen);
 	} else
 		error("Invalid or unsupported render mode %s for Total Eclipse", Common::getRenderModeDescription(_renderMode));
 
@@ -247,7 +304,11 @@ void EclipseEngine::drawDOSUI(Graphics::Surface *surface) {
 
     bool isCGAAltPalette = (_renderMode == Common::kRenderCGA && _currentArea && (_currentArea->_extraColor[0] & 0x01));
 
-    if (_renderMode == Common::kRenderEGA || isCGAAltPalette) {
+    if (_renderMode == Common::kRenderHercG) {
+        uint8 r, g, b;
+        _gfx->readFromPalette(1, r, g, b);
+        color1 = color2 = color3 = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
+    } else if (_renderMode == Common::kRenderEGA || isCGAAltPalette) {
         color1 = green;
         color2 = redish;
         color3 = yellow;
@@ -257,51 +318,66 @@ void EclipseEngine::drawDOSUI(Graphics::Surface *surface) {
         color3 = white;
     }
 
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	int textRow = isHercules ? 243 : 135;
+
 	Common::String message;
 	int deadline;
 	getLatestMessages(message, deadline);
 	if (deadline <= _countdown) {
-		drawStringInSurface(message, 102, 135, black, color3, surface);
+		drawStringInSurface(message, isHercules ? 240 : 102, textRow, black, color3, surface);
 		_temporaryMessages.push_back(message);
 		_temporaryMessageDeadlines.push_back(deadline);
 	} else if (!_currentAreaMessages.empty())
-		drawStringInSurface(_currentArea->_name, 102, 135, black, color3, surface);
+		drawStringInSurface(_currentArea->_name, isHercules ? 240 : 102, textRow, black, color3, surface);
 
 	if (_renderMode == Common::kRenderEGA)
 		drawScoreString(score, 136, 6, black, white, surface);
 	else if (_renderMode == Common::kRenderCGA)
 		drawScoreString(score, 136, 6, black, color2, surface);
+	else if (isHercules)
+		drawScoreString(score, 304, 54, black, color2, surface);
+
+	// The Hercules build does not print the shield percentage on the heart
+	if (!isHercules) {
+		int x = 171;
+		if (shield < 10)
+			x = 179;
+		else if (shield < 100)
+			x = 175;
+
+		Common::String shieldStr = Common::String::format("%d", shield);
+		drawStringInSurface(shieldStr, x, 162, black, color2, surface);
+	}
 
-	int x = 171;
-	if (shield < 10)
-		x = 179;
-	else if (shield < 100)
-		x = 175;
-
-	Common::String shieldStr = Common::String::format("%d", shield);
-	drawStringInSurface(shieldStr, x, 162, black, color2, surface);
-
-	drawStringInSurface(shiftStr("0", 'Z' - '$' + 1 - _angleRotationIndex), 79, 135, black, color3, surface);
-	drawStringInSurface(shiftStr("3", 'Z' - '$' + 1 - _playerStepIndex), 63, 135, black, color3, surface);
-	drawStringInSurface(shiftStr("7", 'Z' - '$' + 1 - _playerHeightNumber), 240, 135, black, color3, surface);
+	drawStringInSurface(shiftStr("0", 'Z' - '$' + 1 - _angleRotationIndex), isHercules ? 192 : 79, textRow, black, color3, surface);
+	drawStringInSurface(shiftStr("3", 'Z' - '$' + 1 - _playerStepIndex), isHercules ? 160 : 63, textRow, black, color3, surface);
+	// The height is a pair of glyphs, which the shoot indicator below overwrites
+	drawStringInSurface(shiftStr("67", 'Z' - '$' + 1 - _playerHeightNumber), isHercules ? 496 : 232, textRow, black, color3, surface);
 
 	if (_shootingFrames > 0) {
-		drawStringInSurface(shiftStr("4", 'Z' - '$' + 1), 232, 135, black, color3, surface);
-		drawStringInSurface(shiftStr("<", 'Z' - '$' + 1), 240, 135, black, color3, surface);
+		drawStringInSurface(shiftStr("4", 'Z' - '$' + 1), isHercules ? 496 : 232, textRow, black, color3, surface);
+		drawStringInSurface(shiftStr("<", 'Z' - '$' + 1), isHercules ? 512 : 240, textRow, black, color3, surface);
 	}
-	drawAnalogClock(surface, 90, 172, black, redish, white);
+	if (isHercules)
+		drawAnalogClock(surface, 212, 280, black, black, color1);
+	else
+		drawAnalogClock(surface, 90, 172, black, redish, white);
 
-	Common::Rect jarBackground(124, 165, 148, 192);
+	Common::Rect jarBackground = isHercules ? Common::Rect(280, 273, 328, 301) : Common::Rect(124, 165, 148, 192);
 	surface->fillRect(jarBackground, black);
 
-	
-	Common::Rect jarWater(124, 192 - _gameStateVars[k8bitVariableEnergy], 148, 192);
+	Common::Rect jarWater = isHercules
+		? Common::Rect(280, 300 - _gameStateVars[k8bitVariableEnergy], 328, 301)
+		: Common::Rect(124, 192 - _gameStateVars[k8bitVariableEnergy], 148, 192);
 
-	if (_renderMode == Common::kRenderCGA)
+	if (isHercules)
+		drawIndicator(surface, 122, 52, 24);
+	else if (_renderMode == Common::kRenderCGA)
 		drawIndicator(surface, 45, 4, 12);
 	else
 		drawIndicator(surface, 41, 4, 16);
-	drawHeartIndicator(surface, 176, 168);
+	drawHeartIndicator(surface, isHercules ? 384 : 176, isHercules ? 276 : 168);
 	if (_renderMode == Common::kRenderEGA) {
 		surface->fillRect(jarWater, blue);
 		drawEclipseIndicator(surface, 228, 0, color3, color1);
@@ -312,7 +388,15 @@ void EclipseEngine::drawDOSUI(Graphics::Surface *surface) {
 		drawEclipseIndicator(surface, 228, 0, color3, color2, color1);
 		surface->fillRect(Common::Rect(225, 168, 235, 187), color3);
 	}
-	drawCompass(surface, 229, 177, _yaw, 10, black);
+	else if (isHercules) {
+		// Hercules has no second ink, so the water level is a 50% stipple
+		for (int wy = jarWater.top; wy < jarWater.bottom; wy++)
+			for (int wx = jarWater.left + 1; wx < jarWater.right; wx += 2)
+				surface->setPixel(wx, wy, color1);
+		drawEclipseIndicator(surface, 488, 48, color1, color1, black);
+		surface->fillRect(Common::Rect(482, 276, 502, 296), color3);
+	}
+	drawCompass(surface, isHercules ? 490 : 229, isHercules ? 286 : 177, _yaw, isHercules ? 20 : 10, black);
 }
 
 void EclipseEngine::playSoundFx(int index, bool sync, Sound::Type type) {
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 85832333f42..f52a4e29ef6 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -849,37 +849,44 @@ void EclipseEngine::releasedKey(const int keycode) {
 }
 
 void EclipseEngine::drawAnalogClock(Graphics::Surface *surface, int x, int y, uint32 colorHand1, uint32 colorHand2, uint32 colorBack) {
+	// The Hercules border draws everything at twice the width
+	int scale = _renderMode == Common::kRenderHercG ? 2 : 1;
+
 	// These calls will cover the pixels of the hardcoded clock image
-	drawAnalogClockHand(surface, x, y, 6 * 6 - 90, 12, colorBack);
-	drawAnalogClockHand(surface, x, y, 7 * 6 - 90, 12, colorBack);
-	drawAnalogClockHand(surface, x, y, 41 * 6 - 90, 11, colorBack);
-	drawAnalogClockHand(surface, x, y, 42 * 6 - 90, 11, colorBack);
-	drawAnalogClockHand(surface, x, y, 0 * 6 - 90, 11, colorBack);
+	drawAnalogClockHand(surface, x, y, 6 * 6 - 90, 12 * scale, colorBack);
+	drawAnalogClockHand(surface, x, y, 7 * 6 - 90, 12 * scale, colorBack);
+	drawAnalogClockHand(surface, x, y, 41 * 6 - 90, 11 * scale, colorBack);
+	drawAnalogClockHand(surface, x, y, 42 * 6 - 90, 11 * scale, colorBack);
+	drawAnalogClockHand(surface, x, y, 0 * 6 - 90, 11 * scale, colorBack);
 
 	int seconds, minutes, hours;
 	getTimeFromCountdown(seconds, minutes, hours);
 	hours = 7 + 2 - hours; // It's 7 o-clock when the game starts
 	minutes = 59 - minutes;
 	seconds = 59 - seconds;
-	drawAnalogClockHand(surface, x, y, hours * 30 - 90, 11, colorHand1);
-	drawAnalogClockHand(surface, x, y, minutes * 6 - 90, 11, colorHand1);
-	drawAnalogClockHand(surface, x, y, seconds * 6 - 90, 11, colorHand2);
+	drawAnalogClockHand(surface, x, y, hours * 30 - 90, 11 * scale, colorHand1);
+	drawAnalogClockHand(surface, x, y, minutes * 6 - 90, 11 * scale, colorHand1);
+	drawAnalogClockHand(surface, x, y, seconds * 6 - 90, 11 * scale, colorHand2);
 }
 
 void EclipseEngine::drawAnalogClockHand(Graphics::Surface *surface, int x, int y, double degrees, double magnitude, uint32 color) {
 	const double degtorad = (M_PI * 2) / 360;
+	// Hercules panels are twice as wide but barely taller
+	double aspect = _renderMode == Common::kRenderHercG ? 0.63 : 1.0;
 	double w = magnitude * cos(degrees * degtorad);
-	double h = magnitude * sin(degrees * degtorad);
+	double h = magnitude * sin(degrees * degtorad) * aspect;
 	surface->drawLine(x, y, x+(int)w, y+(int)h, color);
-	if (isC64()) {
+	if (isC64() || _renderMode == Common::kRenderHercG) {
 		surface->drawLine(x+1, y, x+1+(int)w, y+(int)h, color);
 	}
 }
 
 void EclipseEngine::drawCompass(Graphics::Surface *surface, int x, int y, double degrees, double magnitude, uint32 color) {
 	const double degtorad = (M_PI * 2) / 360;
+	// The needle keeps its height while the Hercules panel doubles in width
+	double aspect = _renderMode == Common::kRenderHercG ? 0.5 : 1.0;
 	double w = magnitude * cos(-degrees * degtorad);
-	double h = magnitude * sin(-degrees * degtorad);
+	double h = magnitude * sin(-degrees * degtorad) * aspect;
 
 	int dx = 0;
 	int dy = 0;
@@ -919,6 +926,16 @@ void EclipseEngine::drawCompass(Graphics::Surface *surface, int x, int y, double
 	surface->drawLine(x + dx, y + dy, x+(int)-w, y+(int)-h, color);
 }
 
+// A circle sampled every `squash` rows, to keep the eclipse discs round on
+// Hercules where the pixels are much narrower than they are tall
+void fillSquashedCircle(Graphics::Surface *surface, int x, int y, int radius, int squash, int color) {
+	int rows = radius / squash;
+	for (int dy = -rows; dy <= rows; dy++) {
+		int span = (int)sqrt(double(radius * radius - squash * squash * dy * dy));
+		surface->hLine(x - span, y + dy, x + span, color);
+	}
+}
+
 // Copied from BITMAP::circlefill in engines/ags/lib/allegro/surface.cpp
 void fillCircle(Graphics::Surface *surface, int x, int y, int radius, int color) {
 	int cx = 0;
@@ -958,24 +975,32 @@ void fillCircle(Graphics::Surface *surface, int x, int y, int radius, int color)
 
 void EclipseEngine::drawEclipseIndicator(Graphics::Surface *surface, int x, int y, uint32 color1, uint32 color2, uint32 color3) {
 	uint32 black = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00);
-	surface->fillRect(Common::Rect(x, y, x + 50, y + 20), black);
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	// Measured from the original: the moon is a bit smaller than the sun
+	int radius = isHercules ? 15 : 7;
+	int moonRadius = isHercules ? 13 : radius;
+	int squash = isHercules ? 2 : 1;
+	int spread = isHercules ? 28 : 14;
+
+	surface->fillRect(Common::Rect(x, y, x + (isHercules ? 100 : 50), y + 20), black);
 	float progress = 0;
 	if (_countdown >= 0)
 		progress = float(_countdown) / _initialCountdown;
-	int difference = 14 * progress;
-	int radius = 7;
-	int sunX = x + 7;
+	int difference = spread * progress;
+	int sunX = x + (isHercules ? 29 : 7);
 	int sunY = y + 10;
-	int moonX = x + 7 + difference;
-	int moonY = y + 10;
-	fillCircle(surface, sunX, sunY, radius, color1);
+	int moonX = sunX + difference;
+	int moonY = sunY;
+	fillSquashedCircle(surface, sunX, sunY, radius, squash, color1);
 	if (color3 != 0) {
-		for (int dy = -radius; dy <= radius; ++dy) {
-			for (int dx = -radius; dx <= radius; ++dx) {
-				if (dx * dx + dy * dy <= radius * radius) {
+		int rows = moonRadius / squash;
+		for (int dy = -rows; dy <= rows; ++dy) {
+			for (int dx = -moonRadius; dx <= moonRadius; ++dx) {
+				if (dx * dx + squash * squash * dy * dy <= moonRadius * moonRadius) {
 					int px = moonX + dx;
 					int py = moonY + dy;
-					if ((px + py) % 2 == 0) {
+					// The checker follows unsquashed pixel pairs
+					if (((px + squash - 1) / squash + py) % 2 == 0) {
 						surface->setPixel(px, py, color2);
 					} else {
 						surface->setPixel(px, py, color3);
@@ -984,7 +1009,7 @@ void EclipseEngine::drawEclipseIndicator(Graphics::Surface *surface, int x, int
 			}
 		}
 	} else {
-		fillCircle(surface, moonX, moonY, radius, color2);
+		fillSquashedCircle(surface, moonX, moonY, moonRadius, squash, color2);
 	}
 }
 
@@ -998,7 +1023,7 @@ void EclipseEngine::drawIndicator(Graphics::Surface *surface, int xPosition, int
 			if (_gameStateVars[kVariableEclipseAnkhs] <= i)
 				continue;
 		} else if (_gameStateVars[kVariableEclipseAnkhs] > i) {
-			// CGA repaints these too: the border ankhs use another color
+			// DOS repaints these too: the border ankhs use another color
 			if (_indicators.size() < 2)
 				continue;
 			frame = 1;
@@ -1140,8 +1165,9 @@ void EclipseEngine::drawScoreString(int score, int x, int y, uint32 front, uint3
 	}
 
 	// Start in x,y and draw each digit, from left to right, adding a gap every 3 digits
-	int gapSize = isC64() ? 8 : 4;
-	int charStep = 8;
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	int gapSize = isC64() ? 8 : (isHercules ? 8 : 4);
+	int charStep = isHercules ? 16 : 8;
 
 	Font *scoreFont = &_font;
 	scoreFont->setBackground(back);
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 0d92ac49cb9..9c8932dfc17 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -122,8 +122,8 @@ public:
 	// No CGA ankh in the bundle, so the masks are built at load time
 	Graphics::ManagedSurface *_ankhIndicatorMask;
 	Graphics::ManagedSurface *_ankhCollectedMask;
-	void loadAnkhIndicatorCGA();
-	void loadAnkhCollectedMaskCGA();
+	void loadAnkhIndicatorMask();
+	void loadAnkhCollectedMask();
 	void updateAnkhIndicator(const byte *palette);
 
 	Common::Array<byte> _musicData; // TEMUSIC.ST TEXT segment (Atari ST)


Commit: 1a783101fb3a314614180617fab1e8bb84f66116
    https://github.com/scummvm/scummvm/commit/1a783101fb3a314614180617fab1e8bb84f66116
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:16+02:00

Commit Message:
FREESCAPE: fixed incorrect colors in hercurles support for eclipse (DOS)

Changed paths:
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/gfx.cpp
    engines/freescape/gfx.h
    engines/freescape/gfx_opengl.cpp
    engines/freescape/gfx_opengl.h
    engines/freescape/gfx_opengl_shaders.cpp
    engines/freescape/gfx_opengl_shaders.h
    engines/freescape/gfx_tinygl.cpp
    engines/freescape/gfx_tinygl.h


diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index f52a4e29ef6..efc5d2b81d2 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -522,6 +522,10 @@ void EclipseEngine::drawBackground() {
 		} else if (isDOS() && _renderMode == Common::kRenderCGA) {
 			color1 = 2;
 			color2 = 8;
+		} else if (isDOS() && _renderMode == Common::kRenderHercG) {
+			// A solid sun and a 50% moon, against the 75% sky
+			color1 = 2;
+			color2 = 12;
 		}
 
 		_gfx->drawEclipse(color1, color2, progress);
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index 80a8e68a0a9..171d88370cb 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -1330,6 +1330,9 @@ void Renderer::drawBackground(uint8 color) {
 
 	getRGBAt(color, 0, r1, g1, b1, r2, g2, b2, stipple);
 	clear(r1, g1, b1);
+	// Skies are often a dither of two colors, which clear() cannot express
+	if (stipple && (r1 != r2 || g1 != g2 || b1 != b2))
+		fillViewportStippled(r1, g1, b1, r2, g2, b2, stipple);
 }
 
 void Renderer::drawEclipse(byte color1, byte color2, float progress) {
diff --git a/engines/freescape/gfx.h b/engines/freescape/gfx.h
index 37432fb2ebd..cdf0bded4ad 100644
--- a/engines/freescape/gfx.h
+++ b/engines/freescape/gfx.h
@@ -118,6 +118,7 @@ public:
 	virtual void clearDepthBuffer(bool ignoreViewport = false) {}
 	virtual void drawFloor(uint8 color) = 0;
 	virtual void drawBackground(uint8 color);
+	virtual void fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) {}
 
 	void drawEclipse(uint8 color1, uint8 color2, float difference);
 	virtual void drawSkybox(Texture *texture, Math::Vector3d camera) {};
diff --git a/engines/freescape/gfx_opengl.cpp b/engines/freescape/gfx_opengl.cpp
index 7d3adfe0ea4..0e25861c0d8 100644
--- a/engines/freescape/gfx_opengl.cpp
+++ b/engines/freescape/gfx_opengl.cpp
@@ -673,6 +673,40 @@ void OpenGLRenderer::drawFloor(uint8 color) {
 	glDisableClientState(GL_VERTEX_ARRAY);
 }
 
+void OpenGLRenderer::fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) {
+	glMatrixMode(GL_PROJECTION);
+	glPushMatrix();
+	glLoadIdentity();
+	glOrtho(0, _screenW, _screenH, 0, 0, 1);
+	glMatrixMode(GL_MODELVIEW);
+	glPushMatrix();
+	glLoadIdentity();
+	glDepthMask(GL_FALSE);
+
+	// The unstippled color has to be set first, since that is the one the
+	// two-color stipple keeps for the pixels the pattern leaves out
+	useColor(r1, g1, b1);
+	setStippleData(stipple);
+	useStipple(true);
+	useColor(r2, g2, b2);
+
+	glEnableClientState(GL_VERTEX_ARRAY);
+	copyToVertexArray(0, Math::Vector3d(0, 0, 0));
+	copyToVertexArray(1, Math::Vector3d(_screenW, 0, 0));
+	copyToVertexArray(2, Math::Vector3d(_screenW, _screenH, 0));
+	copyToVertexArray(3, Math::Vector3d(0, _screenH, 0));
+	glVertexPointer(3, GL_FLOAT, 0, _verts);
+	glDrawArrays(GL_QUADS, 0, 4);
+	glDisableClientState(GL_VERTEX_ARRAY);
+
+	useStipple(false);
+	glDepthMask(GL_TRUE);
+	glMatrixMode(GL_PROJECTION);
+	glPopMatrix();
+	glMatrixMode(GL_MODELVIEW);
+	glPopMatrix();
+}
+
 void OpenGLRenderer::flipBuffer() {}
 
 Graphics::Surface *OpenGLRenderer::getScreenshot() {
diff --git a/engines/freescape/gfx_opengl.h b/engines/freescape/gfx_opengl.h
index eda2b72b43e..ce79327a8e9 100644
--- a/engines/freescape/gfx_opengl.h
+++ b/engines/freescape/gfx_opengl.h
@@ -94,6 +94,7 @@ public:
 
 	virtual void flipBuffer() override;
 	virtual void drawFloor(uint8 color) override;
+	virtual void fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) override;
 	void drawCelestialBody(Math::Vector3d position, float radius, uint8 color) override;
 	void drawSkybox(Texture *texture, Math::Vector3d camera) override;
 	void drawThunder(Texture *texture, Math::Vector3d camera, float size) override;
diff --git a/engines/freescape/gfx_opengl_shaders.cpp b/engines/freescape/gfx_opengl_shaders.cpp
index 579a9cc2b4e..a7606b1cb88 100644
--- a/engines/freescape/gfx_opengl_shaders.cpp
+++ b/engines/freescape/gfx_opengl_shaders.cpp
@@ -507,6 +507,7 @@ void OpenGLShaderRenderer::drawCelestialBody(const Math::Vector3d position, floa
 	uint8 r1, g1, b1, r2, g2, b2;
 	byte *stipple = nullptr;
 	getRGBAt(color, 0, r1, g1, b1, r2, g2, b2, stipple);
+	setStippleData(stipple);
 	useColor(r1, g1, b1);
 
 	// === Build circular vertex fan ===
@@ -881,6 +882,44 @@ void OpenGLShaderRenderer::drawFloor(uint8 color) {
 	glDisableClientState(GL_VERTEX_ARRAY);*/
 }
 
+void OpenGLShaderRenderer::fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) {
+	Math::Matrix4 identity;
+	identity(0, 0) = 1.0;
+	identity(1, 1) = 1.0;
+	identity(2, 2) = 1.0;
+	identity(3, 3) = 1.0;
+
+	_triangleShader->use();
+	_triangleShader->setUniform("mvpMatrix", identity);
+	_triangleShader->setUniform("shakeOffset", Math::Vector2d(0, 0));
+
+	glDepthMask(GL_FALSE);
+
+	useColor(r1, g1, b1);
+	setStippleData(stipple);
+	useStipple(true);
+	useColor(r2, g2, b2);
+
+	// Clockwise, since the renderer treats that as the front face
+	copyToVertexArray(0, Math::Vector3d(-1, 1, 0));
+	copyToVertexArray(1, Math::Vector3d(1, 1, 0));
+	copyToVertexArray(2, Math::Vector3d(1, -1, 0));
+	copyToVertexArray(3, Math::Vector3d(-1, -1, 0));
+
+	glBindBuffer(GL_ARRAY_BUFFER, _triangleVBO);
+	glBufferData(GL_ARRAY_BUFFER, 4 * 3 * sizeof(float), _verts, GL_DYNAMIC_DRAW);
+	glEnableVertexAttribArray(0);
+	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
+	glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
+	glDisableVertexAttribArray(0);
+
+	useStipple(false);
+	glDepthMask(GL_TRUE);
+	// The rest of the frame is still drawn with the camera set up by positionCamera()
+	_triangleShader->setUniform("shakeOffset",
+		Math::Vector2d(_shakeOffset.x * 0.025f, _shakeOffset.y * 0.025f));
+}
+
 void OpenGLShaderRenderer::flipBuffer() {}
 
 Graphics::Surface *OpenGLShaderRenderer::getScreenshot() {
diff --git a/engines/freescape/gfx_opengl_shaders.h b/engines/freescape/gfx_opengl_shaders.h
index 824f5672d00..dfff330529c 100644
--- a/engines/freescape/gfx_opengl_shaders.h
+++ b/engines/freescape/gfx_opengl_shaders.h
@@ -106,6 +106,7 @@ public:
 
 	virtual void flipBuffer() override;
 	virtual void drawFloor(uint8 color) override;
+	virtual void fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) override;
 
 	virtual Graphics::Surface *getScreenshot() override;
 };
diff --git a/engines/freescape/gfx_tinygl.cpp b/engines/freescape/gfx_tinygl.cpp
index 92bccf8df0b..8028759031b 100644
--- a/engines/freescape/gfx_tinygl.cpp
+++ b/engines/freescape/gfx_tinygl.cpp
@@ -554,6 +554,40 @@ void TinyGLRenderer::drawFloor(uint8 color) {
 	tglDisableClientState(TGL_VERTEX_ARRAY);
 }
 
+void TinyGLRenderer::fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) {
+	tglMatrixMode(TGL_PROJECTION);
+	tglPushMatrix();
+	tglLoadIdentity();
+	tglOrtho(0, _screenW, _screenH, 0, 0, 1);
+	tglMatrixMode(TGL_MODELVIEW);
+	tglPushMatrix();
+	tglLoadIdentity();
+	tglDepthMask(TGL_FALSE);
+
+	// The unstippled color has to be set first, since that is the one the
+	// two-color stipple keeps for the pixels the pattern leaves out
+	useColor(r1, g1, b1);
+	setStippleData(stipple);
+	useStipple(true);
+	useColor(r2, g2, b2);
+
+	tglEnableClientState(TGL_VERTEX_ARRAY);
+	copyToVertexArray(0, Math::Vector3d(0, 0, 0));
+	copyToVertexArray(1, Math::Vector3d(_screenW, 0, 0));
+	copyToVertexArray(2, Math::Vector3d(_screenW, _screenH, 0));
+	copyToVertexArray(3, Math::Vector3d(0, _screenH, 0));
+	tglVertexPointer(3, TGL_FLOAT, 0, _verts);
+	tglDrawArrays(TGL_QUADS, 0, 4);
+	tglDisableClientState(TGL_VERTEX_ARRAY);
+
+	useStipple(false);
+	tglDepthMask(TGL_TRUE);
+	tglMatrixMode(TGL_PROJECTION);
+	tglPopMatrix();
+	tglMatrixMode(TGL_MODELVIEW);
+	tglPopMatrix();
+}
+
 void TinyGLRenderer::flipBuffer() {
 	Common::List<Common::Rect> dirtyAreas;
 	TinyGL::presentBuffer(dirtyAreas);
diff --git a/engines/freescape/gfx_tinygl.h b/engines/freescape/gfx_tinygl.h
index cc4228018c5..3ac8e36d56f 100644
--- a/engines/freescape/gfx_tinygl.h
+++ b/engines/freescape/gfx_tinygl.h
@@ -97,6 +97,7 @@ public:
 
 	virtual void flipBuffer() override;
 	virtual void drawFloor(uint8 color) override;
+	virtual void fillViewportStippled(uint8 r1, uint8 g1, uint8 b1, uint8 r2, uint8 g2, uint8 b2, byte *stipple) override;
 	virtual Graphics::Surface *getScreenshot() override;
 
 	byte _defaultStippleArray[128] = {


Commit: 3714c7576f8ff41d23f12a671d8a50c02cbe1974
    https://github.com/scummvm/scummvm/commit/3714c7576f8ff41d23f12a671d8a50c02cbe1974
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: fixed incorrect colors in cga support for dark (DOS)

Changed paths:
    engines/freescape/games/dark/dark.cpp
    engines/freescape/games/dark/dark.h
    engines/freescape/games/dark/dos.cpp


diff --git a/engines/freescape/games/dark/dark.cpp b/engines/freescape/games/dark/dark.cpp
index c1eab0dc025..1fcdd0bd6a8 100644
--- a/engines/freescape/games/dark/dark.cpp
+++ b/engines/freescape/games/dark/dark.cpp
@@ -125,6 +125,10 @@ DarkEngine::~DarkEngine() {
 		frame->free();
 		delete frame;
 	}
+	for (auto &indicator : _indicatorsIndexed) {
+		indicator->free();
+		delete indicator;
+	}
 }
 
 void DarkEngine::addECDs(Area *area) {
@@ -733,6 +737,8 @@ void DarkEngine::gotoArea(uint16 areaID, int entranceID) {
 	_gfx->setColorRemaps(&_currentArea->_colorRemaps);
 
 	swapPalette(areaID);
+	if (isDOS() && _renderMode == Common::kRenderCGA)
+		updateIndicatorsCGA(_gfx->_palette);
 	if (isCPC()) {
 		// The CPC loader still uses the generic area header parser, but the
 		// original Driller code does not use the first header byte as split
@@ -929,6 +935,11 @@ void DarkEngine::drawHorizontalCompass(int x, int y, float angle, uint32 front,
 	} else if (isSpectrum()) {
 		// The ZX HUD uses a single ink color for all the text, including the compass.
 		green = front;
+	} else if (isDOS() && _renderMode == Common::kRenderCGA) {
+		// Use color 1 for labels; front (color 3) highlights the heading.
+		uint8 r, g, b;
+		_gfx->readFromPalette(1, r, g, b);
+		green = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 	}
 
 	int delta = (angle - 180) / 5.5;
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index 885d1e4ae36..bccfd2c3fad 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -96,6 +96,10 @@ public:
 	void drawBinaryClock(Graphics::Surface *surface, int xPosition, int yPosition, uint32 front, uint32 back);
 	void drawIndicator(Graphics::Surface *surface, int xPosition, int yPosition);
 
+	Common::Array<Graphics::ManagedSurface *> _indicatorsIndexed;
+	void loadIndicatorsCGA(Common::SeekableReadStream *file);
+	void updateIndicatorsCGA(const byte *palette);
+
 	void drawSensorShoot(Sensor *sensor) override;
 	void drawDOSUI(Graphics::Surface *surface) override;
 	void drawC64UI(Graphics::Surface *surface) override;
diff --git a/engines/freescape/games/dark/dos.cpp b/engines/freescape/games/dark/dos.cpp
index ffd8f3e68ea..2652004790b 100644
--- a/engines/freescape/games/dark/dos.cpp
+++ b/engines/freescape/games/dark/dos.cpp
@@ -39,6 +39,54 @@ void DarkEngine::initDOS() {
 	_maxShield = 79;
 }
 
+// DSIDEC.EXE stores the posture sprites as packed CGA bitmaps after a
+// height/byte-width header. Offset them four rows within the 24x24 HUD slot.
+void DarkEngine::loadIndicatorsCGA(Common::SeekableReadStream *file) {
+	const int offsets[4] = { 0x350a, 0x3490, 0x3416, 0x3395 }; // fallen, crouch, walk, jet
+
+	for (int i = 0; i < 4; i++) {
+		file->seek(offsets[i]);
+		int height = file->readByte();
+		int widthBytes = file->readByte();
+
+		auto *indexed = new Graphics::ManagedSurface();
+		indexed->create(widthBytes * 4, height + 4, Graphics::PixelFormat::createFormatCLUT8());
+		indexed->fillRect(Common::Rect(0, 0, widthBytes * 4, height + 4), 0);
+
+		for (int y = 0; y < height; y++)
+			for (int col = 0; col < widthBytes; col++) {
+				byte b = file->readByte();
+				for (int px = 0; px < 4; px++)
+					indexed->setPixel(col * 4 + px, y + 4, (b >> (6 - px * 2)) & 3);
+			}
+
+		_indicatorsIndexed.push_back(indexed);
+	}
+}
+
+void DarkEngine::updateIndicatorsCGA(const byte *palette) {
+	for (auto &it : _indicators) {
+		it->free();
+		delete it;
+	}
+	_indicators.clear();
+
+	uint32 colors[4];
+	for (int i = 0; i < 4; i++)
+		colors[i] = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[3 * i], palette[3 * i + 1], palette[3 * i + 2]);
+
+	for (auto &indexed : _indicatorsIndexed) {
+		Graphics::Surface *surface = new Graphics::Surface();
+		surface->create(indexed->w, indexed->h, _gfx->_texturePixelFormat);
+
+		for (int y = 0; y < surface->h; y++)
+			for (int x = 0; x < surface->w; x++)
+				surface->setPixel(x, y, colors[indexed->getPixel(x, y) & 3]);
+
+		_indicators.push_back(surface);
+	}
+}
+
 void DarkEngine::loadAssetsDOSDemo() {
 	Common::File file;
 	if (_renderMode == Common::kRenderEGA) {
@@ -145,8 +193,10 @@ void DarkEngine::loadAssetsDOSFullGame() {
 		load8bitBinary(&file, 0x8600, 16);
 		_border = load8bitBinImage(&file, 0x210);
 		_border->setPalette((byte *)&kCGAPalettePinkBlue, 0, 4);
+		loadIndicatorsCGA(&file);
 
 		swapPalette(1);
+		updateIndicatorsCGA(_gfx->_palette);
 	} else
 		error("Invalid or unsupported render mode %s for Dark Side", Common::getRenderModeDescription(_renderMode));
 }


Commit: 175a4558e4d45d1799d3cf9881a4da90aaa64e54
    https://github.com/scummvm/scummvm/commit/175a4558e4d45d1799d3cf9881a4da90aaa64e54
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: add hercules support for dark (DOS)

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/dark/dark.cpp
    engines/freescape/games/dark/dark.h
    engines/freescape/games/dark/dos.cpp
    engines/freescape/gfx.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 9648c01a82a..b3dfcfe972f 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -458,7 +458,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA)
+		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
 	},
 	{
 		"darkside",
@@ -473,7 +473,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA)
+		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
 	},
 	{
 		"darkside",
@@ -488,7 +488,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA)
+		GUIO4(GUIO_NOMIDI, GUIO_RENDEREGA, GUIO_RENDERCGA, GUIO_RENDERHERCGREEN)
 	},
 	{
 		"darkside",
diff --git a/engines/freescape/games/dark/dark.cpp b/engines/freescape/games/dark/dark.cpp
index 1fcdd0bd6a8..40cedf50c63 100644
--- a/engines/freescape/games/dark/dark.cpp
+++ b/engines/freescape/games/dark/dark.cpp
@@ -738,7 +738,7 @@ void DarkEngine::gotoArea(uint16 areaID, int entranceID) {
 
 	swapPalette(areaID);
 	if (isDOS() && _renderMode == Common::kRenderCGA)
-		updateIndicatorsCGA(_gfx->_palette);
+		updateIndicatorsDOS(_gfx->_palette);
 	if (isCPC()) {
 		// The CPC loader still uses the generic area header parser, but the
 		// original Driller code does not use the first header byte as split
@@ -891,14 +891,15 @@ void DarkEngine::drawBinaryClock(Graphics::Surface *surface, int xPosition, int
 
 	int maxBits = 14;
 	int bits = 0;
+	bool isHercules = _renderMode == Common::kRenderHercG;
 	while (bits <= maxBits) {
 		int y = 0;
 		if (isAmiga() || isAtariST()) {
 			y = yPosition - (3 * bits);
 			surface->fillRect(Common::Rect(xPosition, y - 2, xPosition + 4, y), number & 1 ? front : back);
 		} else {
-			y = yPosition - (7 * bits);
-			surface->drawLine(xPosition, y, xPosition + 3, y, number & 1 ? front : back);
+			y = yPosition - ((isHercules ? 10 : 7) * bits);
+			surface->drawLine(xPosition, y, xPosition + (isHercules ? 7 : 3), y, number & 1 ? front : back);
 		}
 		number = number >> 1;
 		bits++;
@@ -906,13 +907,15 @@ void DarkEngine::drawBinaryClock(Graphics::Surface *surface, int xPosition, int
 }
 
 void DarkEngine::drawVerticalCompass(Graphics::Surface *surface, int x, int y, float angle, uint32 color) {
-	int pitch = int(angle / 1.65);
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	int pitch = int(angle / (isHercules ? 1.25 : 1.65));
+	int width = isHercules ? 7 : 3;
 	Common::Array<int> xpoints;
 	Common::Array<int> ypoints;
 
 	xpoints.push_back(x);
-	xpoints.push_back(x + 3);
-	xpoints.push_back(x + 3);
+	xpoints.push_back(x + width);
+	xpoints.push_back(x + width);
 	xpoints.push_back(x);
 
 	ypoints.push_back(y - pitch);
@@ -927,6 +930,8 @@ void DarkEngine::drawHorizontalCompass(int x, int y, float angle, uint32 front,
 	// TODO implement different compass styles for C64, Amiga and Atari ST
 	uint32 transparent = _gfx->_texturePixelFormat.ARGBToColor(0x00, 0x00, 0x00, 0x00);
 
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	int scale = isHercules ? 2 : 1;
 	uint32 green = _gfx->_texturePixelFormat.ARGBToColor(0xff, 0x00, 0xaa, 0x00);
 	if (isCPC()) {
 		uint8 r, g, b;
@@ -940,24 +945,26 @@ void DarkEngine::drawHorizontalCompass(int x, int y, float angle, uint32 front,
 		uint8 r, g, b;
 		_gfx->readFromPalette(1, r, g, b);
 		green = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
+	} else if (isHercules) {
+		green = front;
 	}
 
-	int delta = (angle - 180) / 5.5;
+	int delta = int((angle - 180) / 5.5) * scale;
 	Common::String compass = "-N-E-S-W-N-E-S";
 
 	for (uint i = 0; i < compass.size(); i++) {
-	  int charX = delta + x + (i * 8);
+	  int charX = delta + x + (i * 8 * scale);
 	  uint32 color = green;
 
-		if (charX >= x + 52 && charX < x + 60) {
+		if (charX >= x + 52 * scale && charX < x + 60 * scale) {
 			color = front;
 		}
 
 		drawStringInSurface(Common::String(compass[i]), charX, y, color, back, surface);
 	}
 
-	surface->fillRect(Common::Rect(x - 20, y - 5, x + 40, y + 10), transparent);
-	surface->fillRect(Common::Rect(x + 80, y - 5, 320, y + 10), transparent);
+	surface->fillRect(Common::Rect(x - 20 * scale, y - 5, x + 40 * scale, y + 10), transparent);
+	surface->fillRect(Common::Rect(x + 80 * scale, y - 5, surface->w, y + 10), transparent);
 }
 
 void DarkEngine::drawCPCSprite(Graphics::Surface *surface, const Graphics::ManagedSurface *indicator, int xPosition, int yPosition) {
@@ -1064,6 +1071,7 @@ void DarkEngine::drawInfoMenu() {
 	uint32 color = 0;
 	switch (_renderMode) {
 		case Common::kRenderCGA:
+		case Common::kRenderHercG:
 			color = 1;
 			break;
 		case Common::kRenderZX:
@@ -1089,20 +1097,23 @@ void DarkEngine::drawInfoMenu() {
 		_gfx->readFromPalette(color, r, g, b);
 		uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 		uint32 black = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00);
+		bool isHercules = _renderMode == Common::kRenderHercG;
+		auto menuX = [isHercules](int x) { return isHercules ? 2 * x + 32 : x; };
+		int menuOffsetY = isHercules ? 76 : 0;
 
-		surface->fillRect(Common::Rect(88, 48, 231, 103), black);
-		surface->frameRect(Common::Rect(88, 48, 231, 103), front);
+		surface->fillRect(Common::Rect(menuX(88), 48 + menuOffsetY, menuX(231), 103 + menuOffsetY), black);
+		surface->frameRect(Common::Rect(menuX(88), 48 + menuOffsetY, menuX(231), 103 + menuOffsetY), front);
 
-		surface->frameRect(Common::Rect(90, 50, 229, 101), front);
+		surface->frameRect(Common::Rect(menuX(90), 50 + menuOffsetY, menuX(229), 101 + menuOffsetY), front);
 
-		drawStringInSurface("L-LOAD S-SAVE", 105, 56, front, black, surface);
+		drawStringInSurface("L-LOAD S-SAVE", menuX(105), 56 + menuOffsetY, front, black, surface);
 		if (isSpectrum())
-			drawStringInSurface("1-TERMINATE", 105, 64, front, black, surface);
+			drawStringInSurface("1-TERMINATE", menuX(105), 64 + menuOffsetY, front, black, surface);
 		else
-			drawStringInSurface("ESC-TERMINATE", 105, 64, front, black, surface);
+			drawStringInSurface("ESC-TERMINATE", menuX(105), 64 + menuOffsetY, front, black, surface);
 
-		drawStringInSurface("T-TOGGLE", 128, 81, front, black, surface);
-		drawStringInSurface("SOUND ON/OFF", 113, 88, front, black, surface);
+		drawStringInSurface("T-TOGGLE", menuX(128), 81 + menuOffsetY, front, black, surface);
+		drawStringInSurface("SOUND ON/OFF", menuX(113), 88 + menuOffsetY, front, black, surface);
 	}
 	menuTexture = _gfx->createTexture(surface);
 
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index bccfd2c3fad..a8771bc5e51 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -97,8 +97,8 @@ public:
 	void drawIndicator(Graphics::Surface *surface, int xPosition, int yPosition);
 
 	Common::Array<Graphics::ManagedSurface *> _indicatorsIndexed;
-	void loadIndicatorsCGA(Common::SeekableReadStream *file);
-	void updateIndicatorsCGA(const byte *palette);
+	void loadIndicatorsDOS(Common::SeekableReadStream *file);
+	void updateIndicatorsDOS(const byte *palette);
 
 	void drawSensorShoot(Sensor *sensor) override;
 	void drawDOSUI(Graphics::Surface *surface) override;
diff --git a/engines/freescape/games/dark/dos.cpp b/engines/freescape/games/dark/dos.cpp
index 2652004790b..e29a7d5dff7 100644
--- a/engines/freescape/games/dark/dos.cpp
+++ b/engines/freescape/games/dark/dos.cpp
@@ -32,6 +32,8 @@ void DarkEngine::initDOS() {
 		_viewArea = Common::Rect(40, 24, 280, 125);
 	else if (_renderMode == Common::kRenderCGA)
 		_viewArea = Common::Rect(40, 24, 280, 125);
+	else if (_renderMode == Common::kRenderHercG)
+		_viewArea = Common::Rect(112, 72, 592, 232);
 	else
 		error("Invalid or unknown render mode");
 
@@ -39,10 +41,15 @@ void DarkEngine::initDOS() {
 	_maxShield = 79;
 }
 
-// DSIDEC.EXE stores the posture sprites as packed CGA bitmaps after a
-// height/byte-width header. Offset them four rows within the 24x24 HUD slot.
-void DarkEngine::loadIndicatorsCGA(Common::SeekableReadStream *file) {
-	const int offsets[4] = { 0x350a, 0x3490, 0x3416, 0x3395 }; // fallen, crouch, walk, jet
+// The DOS executables store posture sprites as packed bitmaps after a
+// height/byte-width header. Offset them four rows within the HUD slot.
+void DarkEngine::loadIndicatorsDOS(Common::SeekableReadStream *file) {
+	const int cgaOffsets[4] = { 0x350a, 0x3490, 0x3416, 0x3395 };
+	const int herculesOffsets[4] = { 0x4448, 0x43ce, 0x4354, 0x42d3 };
+	const int *offsets = _renderMode == Common::kRenderHercG ? herculesOffsets : cgaOffsets;
+	const int bitsPerPixel = _renderMode == Common::kRenderHercG ? 1 : 2;
+	const int pixelsPerByte = 8 / bitsPerPixel;
+	const int colorMask = (1 << bitsPerPixel) - 1;
 
 	for (int i = 0; i < 4; i++) {
 		file->seek(offsets[i]);
@@ -50,21 +57,21 @@ void DarkEngine::loadIndicatorsCGA(Common::SeekableReadStream *file) {
 		int widthBytes = file->readByte();
 
 		auto *indexed = new Graphics::ManagedSurface();
-		indexed->create(widthBytes * 4, height + 4, Graphics::PixelFormat::createFormatCLUT8());
-		indexed->fillRect(Common::Rect(0, 0, widthBytes * 4, height + 4), 0);
+		indexed->create(widthBytes * pixelsPerByte, height + 4, Graphics::PixelFormat::createFormatCLUT8());
+		indexed->fillRect(Common::Rect(0, 0, indexed->w, indexed->h), 0);
 
 		for (int y = 0; y < height; y++)
 			for (int col = 0; col < widthBytes; col++) {
 				byte b = file->readByte();
-				for (int px = 0; px < 4; px++)
-					indexed->setPixel(col * 4 + px, y + 4, (b >> (6 - px * 2)) & 3);
+				for (int px = 0; px < pixelsPerByte; px++)
+					indexed->setPixel(col * pixelsPerByte + px, y + 4, (b >> (8 - (px + 1) * bitsPerPixel)) & colorMask);
 			}
 
 		_indicatorsIndexed.push_back(indexed);
 	}
 }
 
-void DarkEngine::updateIndicatorsCGA(const byte *palette) {
+void DarkEngine::updateIndicatorsDOS(const byte *palette) {
 	for (auto &it : _indicators) {
 		it->free();
 		delete it;
@@ -72,7 +79,8 @@ void DarkEngine::updateIndicatorsCGA(const byte *palette) {
 	_indicators.clear();
 
 	uint32 colors[4];
-	for (int i = 0; i < 4; i++)
+	int colorCount = _renderMode == Common::kRenderHercG ? 2 : 4;
+	for (int i = 0; i < colorCount; i++)
 		colors[i] = _gfx->_texturePixelFormat.ARGBToColor(0xFF, palette[3 * i], palette[3 * i + 1], palette[3 * i + 2]);
 
 	for (auto &indexed : _indicatorsIndexed) {
@@ -81,7 +89,7 @@ void DarkEngine::updateIndicatorsCGA(const byte *palette) {
 
 		for (int y = 0; y < surface->h; y++)
 			for (int x = 0; x < surface->w; x++)
-				surface->setPixel(x, y, colors[indexed->getPixel(x, y) & 3]);
+				surface->setPixel(x, y, colors[indexed->getPixel(x, y) & (colorCount - 1)]);
 
 		_indicators.push_back(surface);
 	}
@@ -193,23 +201,49 @@ void DarkEngine::loadAssetsDOSFullGame() {
 		load8bitBinary(&file, 0x8600, 16);
 		_border = load8bitBinImage(&file, 0x210);
 		_border->setPalette((byte *)&kCGAPalettePinkBlue, 0, 4);
-		loadIndicatorsCGA(&file);
+		loadIndicatorsDOS(&file);
 
 		swapPalette(1);
-		updateIndicatorsCGA(_gfx->_palette);
+		updateIndicatorsDOS(_gfx->_palette);
+	} else if (_renderMode == Common::kRenderHercG) {
+		file.open("SCN1H.DAT");
+		if (file.isOpen()) {
+			_title = load8bitBinImage(&file, 0x0);
+			_title->setPalette((byte *)&kHerculesPaletteGreen, 0, 2);
+		}
+		file.close();
+		file.open("DSIDEH.EXE");
+
+		if (!file.isOpen())
+			error("Failed to open DSIDEH.EXE");
+
+		_sound = loadSpeakerFxDOS(&file, 0x3fb5 + 0x200, 0x3e66 + 0x200, 20);
+		loadFonts(&file, 0x9328);
+		loadMessagesFixedSize(&file, 0x3ca3, 16, 27);
+		loadGlobalObjects(&file, 0x3364, 23);
+		load8bitBinary(&file, 0x9490, 4);
+		_border = load8bitBinImage(&file, 0x210);
+		_border->setPalette((byte *)&kHerculesPaletteGreen, 0, 2);
+		loadIndicatorsDOS(&file);
+		updateIndicatorsDOS((byte *)&kHerculesPaletteGreen);
 	} else
 		error("Invalid or unsupported render mode %s for Dark Side", Common::getRenderModeDescription(_renderMode));
 }
 
 void DarkEngine::drawDOSUI(Graphics::Surface *surface) {
-	uint32 color = _renderMode == Common::kRenderCGA ? 3 : 14;
+	bool isHercules = _renderMode == Common::kRenderHercG;
+	auto hudX = [isHercules](int x) { return isHercules ? 2 * x + 32 : x; };
+	int lowerHudOffsetY = isHercules ? 108 : 0;
+	int topHudOffsetY = isHercules ? 48 : 0;
+
+	uint32 color = _renderMode == Common::kRenderCGA ? 3 : (isHercules ? 1 : 14);
 	uint8 r, g, b;
 
 	_gfx->readFromPalette(color, r, g, b);
 	uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
-	color = _currentArea->_usualBackgroundColor;
-	if (_gfx->_colorRemaps && _gfx->_colorRemaps->contains(color)) {
+	color = isHercules ? 0 : _currentArea->_usualBackgroundColor;
+	if (!isHercules && _gfx->_colorRemaps && _gfx->_colorRemaps->contains(color)) {
 		color = (*_gfx->_colorRemaps)[color];
 	}
 
@@ -217,68 +251,65 @@ void DarkEngine::drawDOSUI(Graphics::Surface *surface) {
 	uint32 back = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
 	// Drawing the horizontal compass should be done first, so that the background is properly filled
-	drawHorizontalCompass(200, 143, _yaw, front, back, surface);
-	Common::Rect stepBackgroundRect = Common::Rect(69, 177, 98, 185);
+	drawHorizontalCompass(hudX(200), 143 + lowerHudOffsetY, _yaw, front, back, surface);
+	Common::Rect stepBackgroundRect = Common::Rect(hudX(69), 177 + lowerHudOffsetY, hudX(98), 185 + lowerHudOffsetY);
 	surface->fillRect(stepBackgroundRect, back);
 
-	Common::Rect positionBackgroundRect = Common::Rect(199, 135, 232, 160);
+	Common::Rect positionBackgroundRect = Common::Rect(hudX(199), 135 + lowerHudOffsetY, hudX(232), 160 + lowerHudOffsetY);
 	surface->fillRect(positionBackgroundRect, back);
 
 	int score = _gameStateVars[k8bitVariableScore];
 	int ecds = _gameStateVars[kVariableActiveECDs];
-	drawStringInSurface(Common::String::format("%04d", int(2 * _position.x())), 199, 137, front, back, surface);
-	drawStringInSurface(Common::String::format("%04d", int(2 * _position.z())), 199, 145, front, back, surface);
-	drawStringInSurface(Common::String::format("%04d", int(2 * _position.y())), 199, 153, front, back, surface);
-
-	drawStringInSurface(Common::String::format("%02d", int(_angleRotations[_angleRotationIndex])), 71, 168, front, back, surface);
-	drawStringInSurface(Common::String::format("%3d", _playerSteps[_playerStepIndex]), 71, 177, front, back, surface);
-	drawStringInSurface(Common::String::format("%07d", score), 95, 8, front, back, surface);
-	drawStringInSurface(Common::String::format("%3d%%", ecds), 192, 8, front, back, surface);
+	drawStringInSurface(Common::String::format("%04d", int(2 * _position.x())), hudX(199), 137 + lowerHudOffsetY, front, back, surface);
+	drawStringInSurface(Common::String::format("%04d", int(2 * _position.z())), hudX(199), 145 + lowerHudOffsetY, front, back, surface);
+	drawStringInSurface(Common::String::format("%04d", int(2 * _position.y())), hudX(199), 153 + lowerHudOffsetY, front, back, surface);
 
-	int seconds, minutes, hours;
-	getTimeFromCountdown(seconds, minutes, hours);
+	drawStringInSurface(Common::String::format("%02d", int(_angleRotations[_angleRotationIndex])), hudX(71), 168 + lowerHudOffsetY, front, back, surface);
+	drawStringInSurface(Common::String::format("%3d", _playerSteps[_playerStepIndex]), hudX(71), 177 + lowerHudOffsetY, front, back, surface);
+	drawStringInSurface(Common::String::format("%07d", score), hudX(95), 8 + topHudOffsetY, front, back, surface);
+	drawStringInSurface(Common::String::format("%3d%%", ecds), hudX(192), 8 + topHudOffsetY, front, back, surface);
 
 	Common::String message;
 	int deadline;
 	getLatestMessages(message, deadline);
 	if (deadline <= _countdown) {
-		drawStringInSurface(message, 112, 177, back, front, surface);
+		drawStringInSurface(message, hudX(112), 177 + lowerHudOffsetY, back, front, surface);
 		_temporaryMessages.push_back(message);
 		_temporaryMessageDeadlines.push_back(deadline);
 	} else
-		drawStringInSurface(_currentArea->_name, 112, 177, front, back, surface);
+		drawStringInSurface(_currentArea->_name, hudX(112), 177 + lowerHudOffsetY, front, back, surface);
 
 	int energy = _gameStateVars[k8bitVariableEnergy]; // called fuel in this game
 	int shield = _gameStateVars[k8bitVariableShield];
 
-	_gfx->readFromPalette(_renderMode == Common::kRenderCGA ? 1 : 9, r, g, b);
+	_gfx->readFromPalette(_renderMode == Common::kRenderEGA ? 9 : 1, r, g, b);
 	uint32 blue = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
 	if (shield >= 0) {
 		Common::Rect shieldBar;
-		shieldBar = Common::Rect(72, 140, 151 - (_maxShield - shield), 141); // Upper outer shieldBar
+		shieldBar = Common::Rect(hudX(72), 140 + lowerHudOffsetY, hudX(151 - (_maxShield - shield)), 141 + lowerHudOffsetY);
 		surface->fillRect(shieldBar, blue);
-		shieldBar = Common::Rect(72, 145, 151 - (_maxShield - shield), 146); // Lower outer shieldBar
+		shieldBar = Common::Rect(hudX(72), 145 + lowerHudOffsetY, hudX(151 - (_maxShield - shield)), 146 + lowerHudOffsetY);
 		surface->fillRect(shieldBar, blue);
 
-		shieldBar = Common::Rect(72, 142, 151 - (_maxShield - shield), 144); // Inner shieldBar
+		shieldBar = Common::Rect(hudX(72), 142 + lowerHudOffsetY, hudX(151 - (_maxShield - shield)), 144 + lowerHudOffsetY);
 		surface->fillRect(shieldBar, front);
 	}
 
 	if (energy >= 0) {
 		Common::Rect energyBar;
-		energyBar = Common::Rect(72, 148, 151 - (_maxEnergy - energy), 149); // Upper outer energyBar
+		energyBar = Common::Rect(hudX(72), 148 + lowerHudOffsetY, hudX(151 - (_maxEnergy - energy)), 149 + lowerHudOffsetY);
 		surface->fillRect(energyBar, blue);
-		energyBar = Common::Rect(72, 153, 151 - (_maxEnergy - energy), 154); // Lower outer energyBar
+		energyBar = Common::Rect(hudX(72), 153 + lowerHudOffsetY, hudX(151 - (_maxEnergy - energy)), 154 + lowerHudOffsetY);
 		surface->fillRect(energyBar, blue);
 
-		energyBar = Common::Rect(72, 150, 151 - (_maxEnergy - energy), 152); // Inner energyBar
+		energyBar = Common::Rect(hudX(72), 150 + lowerHudOffsetY, hudX(151 - (_maxEnergy - energy)), 152 + lowerHudOffsetY);
 		surface->fillRect(energyBar, front);
 	}
-	uint32 clockColor = _renderMode == Common::kRenderCGA ? front : _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0xFF, 0xFF, 0xFF);
-	drawBinaryClock(surface, 300, 124, clockColor, back);
-	drawIndicator(surface, 160, 136);
-	drawVerticalCompass(surface, 24, 76, _pitch, blue);
+	uint32 clockColor = _renderMode == Common::kRenderEGA ? _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0xFF, 0xFF, 0xFF) : front;
+	drawBinaryClock(surface, hudX(300), isHercules ? 220 : 124, clockColor, back);
+	drawIndicator(surface, hudX(160), 136 + lowerHudOffsetY);
+	drawVerticalCompass(surface, hudX(24), isHercules ? 152 : 76, _pitch, blue);
 }
 
 } // End of namespace Freescape
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index 171d88370cb..57a9b6669c9 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -1309,16 +1309,20 @@ void Renderer::drawBackground(uint8 color) {
 
 	if (_colorRemaps && _colorRemaps->contains(color)) {
 		int mappedColor = (*_colorRemaps)[color];
-		if (_renderMode == Common::kRenderAmiga || _renderMode == Common::kRenderAtariST)
-			_texturePixelFormat.colorToRGB(mappedColor, r1, g1, b1);
-		else {
+		if (_renderMode == Common::kRenderHercG) {
 			color = mappedColor;
-			if (_renderMode == Common::kRenderCPC && isEncodedCPCDirectColor(color))
-				color = decodeCPCDirectColor(color);
-			readFromPalette(color, r1, g1, b1);
+		} else {
+			if (_renderMode == Common::kRenderAmiga || _renderMode == Common::kRenderAtariST)
+				_texturePixelFormat.colorToRGB(mappedColor, r1, g1, b1);
+			else {
+				color = mappedColor;
+				if (_renderMode == Common::kRenderCPC && isEncodedCPCDirectColor(color))
+					color = decodeCPCDirectColor(color);
+				readFromPalette(color, r1, g1, b1);
+			}
+			clear(r1, g1, b1);
+			return;
 		}
-		clear(r1, g1, b1);
-		return;
 	}
 
 	if (color == 0) {


Commit: 7f5e00c05eb054058d578128d44d44f30f57b673
    https://github.com/scummvm/scummvm/commit/7f5e00c05eb054058d578128d44d44f30f57b673
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: fixed castle sounds for amiga

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


diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index 6ab4fa73526..f6cfd24dd68 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -531,7 +531,8 @@ public:
 	Sound *loadSpeakerFxZX(Common::SeekableReadStream *file, int sfxTable, int sfxData, int numberSounds);
 	Sound *loadSpeakerFxDrillerZX();
 	Sound *loadSoundsCPC(Common::SeekableReadStream *file, int offsetTone, int sizeTone, int offsetEnvelope, int sizeEnvelope, int offsetSoundDef, int sizeSoundDef);
-	Sound *loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset);
+	// modOffset points at the embedded module used for one extra sample, or -1
+	Sound *loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset);
 
 	int _soundIndexShoot;
 	int _soundIndexCollide;
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 64362e875cf..202f2903748 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -1402,9 +1402,9 @@ void CastleEngine::loadAssetsAmigaDemo() {
 		}
 	}
 
-	// Load synthesized sound effects from command table
-	// Table at file offset 0x1469E (memory 0x14682), 30 entries
-	_sound = loadSoundsAmigaDemo(&file, 0x1469E, 30, 0x3D5A6);
+	// Sound effect command table at file offset 0x1469E (memory 0x14682). This
+	// version takes its extra sample slot from the music module.
+	_sound = loadSoundsAmiga(&file, 0x1469E, 36, 0x3D5A6);
 
 	// Load embedded ProTracker module for background music
 	// Module is at file offset 0x3D5A6 (memory 0x3D58A), ~86260 bytes
@@ -1724,10 +1724,8 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 		}
 	}
 
-	// Sound effects command table (30 entries). Pass the full-game MOD
-	// offset so DMA sample extraction reads from the right place — the
-	// demo's 0x3D5A6 hardcoded default is wrong for the full binary.
-	_sound = loadSoundsAmigaDemo(&file, 0x13cf2, 30, 0x3cbfa);
+	// Sound effect command table at file offset 0x13cf2 (memory 0x13cd6)
+	_sound = loadSoundsAmiga(&file, 0x13cf2, 36, -1);
 
 	// Embedded ProTracker module for background music.
 	static const int kModOffset = 0x3cbfa;
diff --git a/engines/freescape/sound/amiga.cpp b/engines/freescape/sound/amiga.cpp
index ee104b6b27b..15518826e77 100644
--- a/engines/freescape/sound/amiga.cpp
+++ b/engines/freescape/sound/amiga.cpp
@@ -23,7 +23,10 @@
 #include "audio/mods/module.h"
 #include "audio/mods/paula.h"
 
+#include "common/file.h"
 #include "common/memstream.h"
+#include "common/mutex.h"
+#include "common/ptr.h"
 
 #include "freescape/freescape.h"
 
@@ -38,54 +41,46 @@ struct AmigaDmaSample {
 	Common::Array<int8> data;
 };
 
+// Priority of the sound currently playing (DAT_25E6). Shared with the stream,
+// which releases it from the mixer thread.
+struct AmigaSfxPriority {
+	AmigaSfxPriority() : value(0) {}
+	int value;
+};
+
 /**
- * Amiga Sound Effect Synthesizer
- *
- * Synthesizes sound effects from a command stream, emulating the Castle Master
- * Amiga demo's custom sound engine. All 4 Amiga audio channels play the same
- * 64-byte square wave buffer (alternating +64/-64 signed bytes).
- *
- * Command format: 16-bit big-endian words.
- *   Bits 15-12: command type
- *   Bits 11-0:  parameter
+ * Amiga (and Atari ST) Castle Master sound engine: a 50Hz command interpreter
+ * driving the four Paula channels.
  *
- * Period commands (set absolute frequency):
- *   0x0xxx: AUD1 period = xxx  (0 disables channel)
- *   0x1xxx: AUD2 period = xxx
- *   0x2xxx: AUD3 period = xxx
- *   0x3xxx: AUD0 period = xxx
+ * All channels start out playing a shared 64-byte square wave (+64/-64 bytes),
+ * so a bare period command gives a tone at 3546895 / (period * 2) Hz. AUD0
+ * doubles as the sample channel: 0x5NNN points it at a PCM sample of the
+ * external `cmsnds2` bank and the audio interrupt counts buffer repeats.
  *
- * Relative period commands (pitch bend):
- *   0x8xxx: AUD1 period += sign_extend_12(xxx)
- *   0x9xxx: AUD2 period += sign_extend_12(xxx)
- *   0xAxxx: AUD3 period += sign_extend_12(xxx)
- *   0xBxxx: AUD0 period += sign_extend_12(xxx)
+ * Commands are 16-bit big-endian words: type in bits 15-12, parameter below.
  *
- * Volume commands (channel select in bits 11-8):
- *   0x4Yxx: set volume = xx (Y=1: AUD1, Y=2: AUD2, else: AUD0+AUD3)
- *   0xCYxx: volume += sign_extend_8(xx) (same channel mapping)
- *
- * Control commands:
- *   0x5NNN: play note (reads 3 extra words; 3rd word = DMA repeat count)
- *   0x6xxx: delay xxx VBI ticks (50Hz)
- *   0x7000: full stop (silence all, end stream)
- *   0x7001: pause until DMA playback completes
- *   0x7002: loop (decrement counter, jump to saved position if > 0)
- *   0xDxxx: save loop position, set loop counter = xxx
- *   0xFxxx: end (stop interpreter)
- *
- * Tone frequency: 3,546,895 / (period * 2) Hz.
- * Paula plays bytes at 3,546,895 / period, and the 0x40/0xC0 waveform
- * alternates every byte (2 samples per cycle), adding a /2.
+ *   0x0xxx-0x3xxx  period of AUD1/AUD2/AUD3/AUD0 = xxx, and enable it
+ *                  (xxx = 0 only disables the channel)
+ *   0x8xxx-0xBxxx  same channels, period += sign_extend_12(xxx)
+ *   0x4Yxx         volume = xx (Y=1: AUD1, Y=2: AUD2, else AUD0+AUD3)
+ *   0xCYxx         volume += sign_extend_8(xx), same channel mapping
+ *   0x5NNN         play sample NNN on AUD0, consuming three more words:
+ *                  start offset, end trim and repeat count (NNN = 0: no-op)
+ *   0x6xxx         wait xxx ticks
+ *   0x7000         stop everything, 0x7001 wait for the sample to end,
+ *                  0x7002 loop back while the counter lasts
+ *   0xDxxx         mark loop position, loop counter = xxx
+ *   0xFxxx         end, leaving the channels running
  */
 class AmigaSfxStream : public Audio::Paula {
 public:
-	AmigaSfxStream(const uint16 *commands, int numCommands, const Common::Array<AmigaDmaSample> *dmaSamples, int rate = 44100)
+	AmigaSfxStream(const uint16 *commands, int numCommands, const Common::Array<AmigaDmaSample> *dmaSamples,
+				   const Common::SharedPtr<AmigaSfxPriority> &priority, int rate = 44100)
 		: Audio::Paula(false, rate, rate / 50),
-		  _dmaSamples(dmaSamples),
+		  _dmaSamples(dmaSamples), _priority(priority),
 		  _cmdPos(0), _delay(0), _paused(false),
-		  _dmaCounter(0), _dmaAud0Active(false), _loopPos(0), _loopCounter(0),
-		  _graceCounter(0) {
+		  _dmaActive(false), _dmaDone(true), _dmaStopPending(false), _dmaRepeats(0),
+		  _loopPos(0), _loopCounter(0), _graceCounter(0) {
 
 		_commands.resize(numCommands);
 		for (int i = 0; i < numCommands; i++)
@@ -96,7 +91,7 @@ public:
 			_squareWave[i + 1] = -64;
 		}
 
-		// FUN_2520 init values.
+		// Init periods, slightly detuned per channel
 		static const uint16 initPeriods[4] = { 0x1A1, 0x1AB, 0x1B5, 0x1BF };
 		for (int ch = 0; ch < 4; ch++) {
 			_periodShadow[ch] = initPeriods[ch];
@@ -107,305 +102,319 @@ public:
 			setChannelSampleLen(ch, 0x20); // 32 words = 64 bytes
 			setChannelPeriod(ch, _periodShadow[ch]);
 			setChannelVolume(ch, 0);
-			// FUN_2520 writes DMACON=0x000F (clear audio DMA bits), so channels
-			// are configured but disabled until command handlers enable them.
+			// Configured but silent until a command enables the channel
 			disableChannel(ch);
 		}
+		// Count AUD0 buffer repeats, as the audio interrupt does
+		setChannelInterrupt(0, true);
 		startPaula();
 	}
 
 private:
-	void interrupt() override {
-		tickUpdate();
-	}
+	// Tail given to a sound that ends with its channels still running: the
+	// original would keep them looping until the next sound is triggered.
+	static const int kEndGraceTicks = 25;
 
 	Common::Array<uint16> _commands;
 	const Common::Array<AmigaDmaSample> *_dmaSamples;
+	Common::SharedPtr<AmigaSfxPriority> _priority;
 	int _cmdPos;
-	int _delay;         // -1 = stopped, 0 = execute next, >0 = waiting
-	bool _paused;       // Waiting for DMA completion
-	int _dmaCounter;    // DMA ticks remaining (approximate)
-	bool _dmaAud0Active;
-	int _loopPos;       // Saved command position for looping
-	int _loopCounter;   // Loop iterations remaining
-	int _graceCounter;  // Ticks to keep playing after END before finishing
+	int _delay;            // -1 = interpreter stopped, 0 = execute next, >0 = waiting
+	bool _paused;          // Waiting for sample playback to complete (0x7001)
+	bool _dmaActive;       // AUD0 is playing a sample
+	bool _dmaDone;         // Sample playback finished
+	bool _dmaStopPending;  // Repeat counter expired inside the mixing loop
+	int _dmaRepeats;       // Times the sample buffer must be played
+	int _loopPos;
+	int _loopCounter;
+	int _graceCounter;     // Ticks left before winding down after END
 	uint16 _periodShadow[4];
 	int _volumeShadow[4];
-	bool _channelEnabled[4]; // Tracks DMA enable state per channel
+	bool _channelEnabled[4];
 	int8 _squareWave[64];
 
-	/**
-	 * Map period command nibble (0-3) to internal channel index.
-	 * Command 0 -> AUD1 (ch 1), 1 -> AUD2 (ch 2), 2 -> AUD3 (ch 3), 3 -> AUD0 (ch 0)
-	 */
+	// Period command 0 -> AUD1, 1 -> AUD2, 2 -> AUD3, 3 -> AUD0
 	static int periodCmdToChannel(int nibble) {
 		return (nibble + 1) & 3;
 	}
 
-	uint8 clampVolume(int value) const {
-		return (uint8)CLIP<int>(value, 0, 64);
+	// AUDxVOL only implements bits 6-0 and caps at 64, so a fade running past
+	// zero wraps around to full volume instead of going silent.
+	static byte hardwareVolume(int value) {
+		int vol = value & 0x7F;
+		return (byte)MIN(vol, 64);
+	}
+
+	void releasePriority() {
+		if (_priority)
+			_priority->value = 0;
 	}
 
 	void setAbsolutePeriod(int ch, uint16 period) {
 		_periodShadow[ch] = period;
 		setChannelPeriod(ch, period);
-		if (!_channelEnabled[ch]) {
-			// Channel was off -> enable DMA (like writing DMACON with SET bit).
-			// Restore square wave for AUD0 if not playing a DMA sample.
-			if (ch == 0 && !_dmaAud0Active) {
-				setChannelSampleStart(0, _squareWave);
-				setChannelSampleLen(0, 0x20);
-			}
-			enableChannel(ch);
-			_channelEnabled[ch] = true;
-		}
-		// If already enabled, just the period register update above is
-		// sufficient. On real hardware, writing AUDxPER only changes the
-		// DMA fetch rate without restarting the buffer position.
+		enableChannelDma(ch);
+	}
+
+	void enableChannelDma(int ch) {
+		// Setting the DMACON bit of a running channel is a no-op, so only
+		// restart the buffer when the channel was actually off.
+		if (_channelEnabled[ch])
+			return;
+		enableChannel(ch);
+		_channelEnabled[ch] = true;
+	}
+
+	void disableChannelDma(int ch) {
+		disableChannel(ch);
+		_channelEnabled[ch] = false;
 	}
 
 	void setRelativePeriod(int ch, int16 delta) {
-		// Original only writes to shadow + period register.
-		// Does NOT touch DMACON - channel retains its current enable state.
 		uint16 newPeriod = (uint16)(_periodShadow[ch] + delta);
-		_periodShadow[ch] = newPeriod;
 		if (newPeriod == 0) {
-			disableChannel(ch);
-			_channelEnabled[ch] = false;
+			// The shadow keeps its previous value here
+			disableChannelDma(ch);
 			return;
 		}
+		_periodShadow[ch] = newPeriod;
 		setChannelPeriod(ch, newPeriod);
+		enableChannelDma(ch);
 	}
 
-	void setAbsoluteVolume(int sel, uint8 vol) {
+	void setAbsoluteVolume(int sel, int vol) {
 		if (sel == 1) {
 			_volumeShadow[1] = vol;
-			setChannelVolume(1, clampVolume(_volumeShadow[1]));
+			setChannelVolume(1, hardwareVolume(_volumeShadow[1]));
 		} else if (sel == 2) {
 			_volumeShadow[2] = vol;
-			setChannelVolume(2, clampVolume(_volumeShadow[2]));
+			setChannelVolume(2, hardwareVolume(_volumeShadow[2]));
 		} else {
 			_volumeShadow[0] = vol;
 			_volumeShadow[3] = vol;
-			setChannelVolume(0, clampVolume(_volumeShadow[0]));
-			setChannelVolume(3, clampVolume(_volumeShadow[3]));
+			setChannelVolume(0, hardwareVolume(_volumeShadow[0]));
+			setChannelVolume(3, hardwareVolume(_volumeShadow[3]));
 		}
 	}
 
 	void addRelativeVolume(int sel, int8 delta) {
 		if (sel == 1) {
 			_volumeShadow[1] += delta;
-			setChannelVolume(1, clampVolume(_volumeShadow[1]));
+			setChannelVolume(1, hardwareVolume(_volumeShadow[1]));
 		} else if (sel == 2) {
 			_volumeShadow[2] += delta;
-			setChannelVolume(2, clampVolume(_volumeShadow[2]));
+			setChannelVolume(2, hardwareVolume(_volumeShadow[2]));
 		} else {
 			_volumeShadow[0] += delta;
 			_volumeShadow[3] += delta;
-			setChannelVolume(0, clampVolume(_volumeShadow[0]));
-			setChannelVolume(3, clampVolume(_volumeShadow[3]));
+			setChannelVolume(0, hardwareVolume(_volumeShadow[0]));
+			setChannelVolume(3, hardwareVolume(_volumeShadow[3]));
 		}
 	}
 
-	void tickUpdate() {
-		if (_dmaCounter > 0) {
-			_dmaCounter--;
-			if (_dmaCounter == 0 && _dmaAud0Active) {
-				disableChannel(0);
-				_channelEnabled[0] = false;
-				setChannelSampleStart(0, _squareWave);
-				setChannelSampleLen(0, 0x20);
-				setChannelOffset(0, Audio::Paula::Offset(0));
-				_dmaAud0Active = false;
-			}
-		}
+	// Start sample playback on AUD0. Paula interrupts once when DMA is switched
+	// on plus once per completed buffer, so the sample is heard exactly
+	// `repeats` times.
+	void triggerSample(int sampleNum, uint16 startOffset, uint16 endTrim, uint16 repeats) {
+		if (sampleNum <= 0 || repeats == 0)
+			return;
 
-		if (_paused) {
-			if (_dmaCounter <= 0)
-				_paused = false;
-			else
-				return;
-		}
+		if (!_dmaSamples || sampleNum >= (int)_dmaSamples->size())
+			return;
 
-		if (_delay < 0) {
-			// After END command, allow a grace period so short sounds
-			// remain audible (original hardware keeps channels playing
-			// until next sound trigger silences them).
-			if (_graceCounter > 0) {
-				_graceCounter--;
-				return;
-			}
-			_dmaAud0Active = false;
-			setChannelSampleStart(0, _squareWave);
-			setChannelSampleLen(0, 0x20);
-			setChannelOffset(0, Audio::Paula::Offset(0));
-			stopPaula();
+		const AmigaDmaSample &sample = (*_dmaSamples)[sampleNum];
+		if (sample.data.empty())
 			return;
-		}
 
-		if (_delay > 0) {
-			_delay--;
+		int size = sample.data.size();
+		int start = MIN<int>(startOffset, size);
+		int trim = MIN<int>(endTrim, size - start);
+		int playLen = (size - start - trim) & ~1; // AUD0LEN counts words
+		if (playLen <= 1)
 			return;
-		}
 
-		// _delay == 0: execute commands
-		executeCommands();
+		// The selected segment is reloaded on each buffer completion
+		const int8 *src = sample.data.data() + start;
+		setChannelData(0, src, src, playLen, playLen);
+		setChannelDmaCount(0, 0);
+		_dmaRepeats = repeats;
+		_dmaActive = true;
+		_dmaDone = false;
+		_dmaStopPending = false;
+		_channelEnabled[0] = true;
 	}
 
-	void executeCommands() {
-		// Process commands until we hit a delay, stop, or end
-		while (_cmdPos < (int)_commands.size()) {
-			uint16 cmd = _commands[_cmdPos++];
-			int nibble = (cmd >> 12) & 0xF;
-			int param = cmd & 0xFFF;
-
-			switch (nibble) {
-			case 0: case 1: case 2: case 3: {
-				// Set absolute period
-				int ch = periodCmdToChannel(nibble);
-				if (param == 0) {
-					_periodShadow[ch] = 0;
-					disableChannel(ch);
-					_channelEnabled[ch] = false;
-				} else {
-					setAbsolutePeriod(ch, (uint16)param);
-				}
-				break;
-			}
+	// AUD0 buffer wrap. Paula calls this from the middle of its mixing loop, so
+	// the channel can only be muted here: disabling it would leave the mixer
+	// dereferencing a null sample pointer for the rest of the buffer.
+	void interruptChannel(byte channel) override {
+		if (channel != 0 || !_dmaActive)
+			return;
+		if (getChannelDmaCount(0) < _dmaRepeats)
+			return;
+		setChannelVolume(0, 0);
+		_dmaStopPending = true;
+	}
 
-			case 4: {
-				// Set volume
-				int sel = (param >> 8) & 0xF;
-				int vol = param & 0xFF;
-				setAbsoluteVolume(sel, (uint8)vol);
-				break;
-			}
+	void finishDma() {
+		disableChannelDma(0);
+		setChannelVolume(0, hardwareVolume(_volumeShadow[0])); // Undo the mute
+		_dmaActive = false;
+		_dmaStopPending = false;
+		_dmaDone = true;
+		releasePriority();
+	}
 
-			case 5: {
-				// Play note: NNN selects a sample, 3 extra words follow.
-				// FUN_26C2 does SUBQ #1, D0 (D0=NNN). If D0<0 (NNN=0) -> NO-OP.
-				// NNN>0: triggers DMA playback of a sample buffer on AUD0.
-				// Extra words: D2=start offset, D4=end trim, D3=repeat count.
-				// DMA plays buffer (D3+1) times total (SUBQ #1 + BPL counting).
-				if (_cmdPos + 3 <= (int)_commands.size()) {
-					uint16 startOffset = _commands[_cmdPos++]; // D2
-					uint16 endTrim = _commands[_cmdPos++];     // D4
-					uint16 dmaCount = _commands[_cmdPos++];    // D3
-					if (param > 0 && dmaCount > 0 && _periodShadow[0] > 0) {
-						int bufSize = 256;
-						if (_dmaSamples && param < (int)_dmaSamples->size()) {
-							const AmigaDmaSample &sample = (*_dmaSamples)[param];
-							if (!sample.data.empty()) {
-								int start = MIN<int>(startOffset, sample.data.size());
-								int trim = MIN<int>(endTrim, sample.data.size() - start);
-									int playLen = sample.data.size() - start - trim;
-									if (playLen > 1) {
-										const int8 *src = sample.data.data() + start;
-										// AUD0LC/AUD0LEN are reloaded on each DMA completion,
-										// so the selected segment repeats in full.
-										setChannelData(0, src, src, playLen, playLen);
-										bufSize = playLen;
-									}
-								}
-							}
-
-						double durationSec = (double)(dmaCount + 1) * bufSize * _periodShadow[0] / Audio::Paula::kPalPaulaClock;
-						_dmaCounter = (int)(durationSec * 50.0) + 1;
-						_dmaAud0Active = true;
-						enableChannel(0);
-						_channelEnabled[0] = true;
-					}
+	// Interpreter tick, once per emulated VBI
+	void interrupt() override {
+		if (_dmaActive && (_dmaStopPending || getChannelDmaCount(0) >= _dmaRepeats))
+			finishDma();
+
+		// The original re-enters the whole routine after every command, so a
+		// delay of N is decremented once on the tick that sets it: the next
+		// command runs exactly N ticks later.
+		for (;;) {
+			if (_paused) {
+				if (!_dmaDone)
+					return;
+				_paused = false;
+			} else if (_delay < 0) {
+				// Stopped, but the channels keep running: let the sample finish
+				if (_dmaActive)
+					return;
+				if (_graceCounter < 0)
+					_graceCounter = channelsAudible() ? kEndGraceTicks : 0;
+				if (_graceCounter > 0) {
+					_graceCounter--;
+					return;
 				}
-				break;
+				stopPaula();
+				return;
+			} else if (_delay > 0) {
+				_delay--;
+				return;
 			}
 
-			case 6:
-				// Delay
-				_delay = param;
+			if (!executeCommand())
 				return;
+		}
+	}
 
-			case 7:
-				if (param == 0x000) {
-					// Full stop: silence all channels
-					for (int ch = 0; ch < 4; ch++) {
-						_volumeShadow[ch] = 0;
-						_channelEnabled[ch] = false;
-						setChannelVolume(ch, 0);
-						disableChannel(ch);
-					}
-					setChannelSampleStart(0, _squareWave);
-					setChannelSampleLen(0, 0x20);
-					setChannelOffset(0, Audio::Paula::Offset(0));
-					_dmaAud0Active = false;
-					_delay = -1;
-					stopPaula();
-					return;
-				} else if (param == 0x001) {
-					// Pause: wait for DMA completion
-					_paused = true;
-					return;
-				} else if (param == 0x002) {
-					// Loop: decrement counter, jump back if > 0
-					_loopCounter--;
-					if (_loopCounter > 0)
-						_cmdPos = _loopPos;
-					break;
-				}
-				break;
+	// Runs one command word, returning false when the tick is over
+	bool executeCommand() {
+		if (_cmdPos >= (int)_commands.size()) {
+			endInterpreter();
+			return false;
+		}
 
-			case 8: case 9: case 0xA: case 0xB: {
-				// Relative period (pitch bend)
-				int ch = periodCmdToChannel(nibble - 8);
-				// Sign-extend 12-bit parameter
-				int16 delta = (int16)(param << 4) >> 4;
-				setRelativePeriod(ch, delta);
-				break;
-			}
+		uint16 cmd = _commands[_cmdPos++];
+		int nibble = (cmd >> 12) & 0xF;
+		int param = cmd & 0xFFF;
+
+		switch (nibble) {
+		case 0:
+		case 1:
+		case 2:
+		case 3: {
+			int ch = periodCmdToChannel(nibble);
+			if (param == 0)
+				disableChannelDma(ch);
+			else
+				setAbsolutePeriod(ch, (uint16)param);
+			break;
+		}
 
-			case 0xC: {
-				// Relative volume
-				int sel = (param >> 8) & 0xF;
-				int8 delta = (int8)(param & 0xFF);
-				addRelativeVolume(sel, delta);
-				break;
+		case 4:
+			setAbsoluteVolume((param >> 8) & 0xF, param & 0xFF);
+			break;
+
+		case 5: {
+			if (_cmdPos + 3 > (int)_commands.size()) {
+				endInterpreter();
+				return false;
 			}
+			uint16 startOffset = _commands[_cmdPos++];
+			uint16 endTrim = _commands[_cmdPos++];
+			uint16 repeats = _commands[_cmdPos++];
+			triggerSample(param, startOffset, endTrim, repeats);
+			break;
+		}
 
-			case 0xD:
-				// Save loop position and set counter
-				_loopPos = _cmdPos;
-				_loopCounter = param;
-				break;
+		case 6:
+			_delay = param;
+			break;
 
-			case 0xF:
-				// End: stop interpreter but let channels keep playing.
-				// On real Amiga hardware, audio DMA channels loop their
-				// waveform buffer continuously until the next FUN_2652 call
-				// silences them. playSoundAmiga() calls stopHandle() before
-				// playing a new sound, matching this behavior.
-				// Grace period of 25 ticks (500ms) approximates the typical
-				// inter-sound gap during gameplay.
+		case 7:
+			if (param == 0x000) {
+				for (int ch = 0; ch < 4; ch++) {
+					_volumeShadow[ch] = 0;
+					setChannelVolume(ch, 0);
+					disableChannelDma(ch);
+				}
+				_dmaActive = false;
+				_dmaStopPending = false;
+				_dmaDone = true;
 				_delay = -1;
-				_graceCounter = 25;
-				return;
-
-			default:
-				break;
+				releasePriority();
+				stopPaula();
+				return false;
+			} else if (param == 0x001) {
+				_paused = true;
+			} else if (param == 0x002) {
+				_loopCounter--;
+				if (_loopCounter > 0)
+					_cmdPos = _loopPos;
 			}
+			break;
+
+		case 8:
+		case 9:
+		case 0xA:
+		case 0xB: {
+			int ch = periodCmdToChannel(nibble - 8);
+			int delta = (param & 0x800) ? param - 0x1000 : param; // Sign-extended
+			setRelativePeriod(ch, (int16)delta);
+			break;
 		}
 
-		// Ran out of commands
+		case 0xC:
+			addRelativeVolume((param >> 8) & 0xF, (int8)(param & 0xFF));
+			break;
+
+		case 0xD:
+			_loopPos = _cmdPos;
+			_loopCounter = param;
+			break;
+
+		case 0xF:
+			endInterpreter();
+			return false;
+
+		default:
+			// Unknown commands (0xE) are skipped by the original as well
+			break;
+		}
+
+		return true;
+	}
+
+	void endInterpreter() {
 		_delay = -1;
-		_dmaAud0Active = false;
-		setChannelSampleStart(0, _squareWave);
-		setChannelSampleLen(0, 0x20);
-		setChannelOffset(0, Audio::Paula::Offset(0));
-		stopPaula();
+		_graceCounter = -1; // Decided once the sample playback is over
+	}
+
+	bool channelsAudible() const {
+		for (int ch = 0; ch < 4; ch++) {
+			if (_channelEnabled[ch] && hardwareVolume(_volumeShadow[ch]) > 0)
+				return true;
+		}
+		return false;
 	}
 };
 
 class SoundAmigaDemo final : public Sound {
 public:
-	SoundAmigaDemo(Audio::Mixer *mixer) : _mixer(mixer) {}
+	SoundAmigaDemo(Audio::Mixer *mixer) : _priority(new AmigaSfxPriority()), _mixer(mixer) {}
 
 	void loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset);
 
@@ -413,15 +422,24 @@ public:
 
 	void stopSound(Type type) override {
 		_mixer->stopHandle(_soundFxHandle);
+		Common::StackLock lock(_mixer->mutex());
+		_priority->value = 0;
 	}
 
 	bool isPlayingSound(Type type) const override {
 		return _mixer->isSoundHandleActive(_soundFxHandle);
 	}
 
+	bool isSoundAvailable(int index) const override {
+		return index >= 0 && index < (int)_amigaSfxTable.size();
+	}
+
 private:
+	void loadDmaSamples(Common::SeekableReadStream *file, int modOffset);
+
 	Common::Array<AmigaSfxEntry> _amigaSfxTable;
 	Common::Array<AmigaDmaSample> _amigaDmaSamples;
+	Common::SharedPtr<AmigaSfxPriority> _priority;
 
 	Audio::Mixer *_mixer;
 	Audio::SoundHandle _soundFxHandle;
@@ -443,29 +461,62 @@ void SoundAmigaDemo::loadSounds(Common::SeekableReadStream *file, int offset, in
 	}
 	debugC(1, kFreescapeDebugParser, "Loaded %d Amiga sound effects", numSounds);
 
-	// Prepare DMA sample set for 0x5 commands from the embedded ProTracker module.
-	// Parameter N uses index N (1-based), so keep index 0 empty.
+	loadDmaSamples(file, modOffset);
+}
+
+// The samples played by 0x5NNN come from the `cmsnds2` bank, which the original
+// loads over the memory holding the ProTracker module (hence music and sound
+// effects being mutually exclusive there). Each of its 10 entries is a 4-byte
+// big endian length, a 2-byte sample rate and that many signed 8-bit samples.
+void SoundAmigaDemo::loadDmaSamples(Common::SeekableReadStream *file, int modOffset) {
+	// Parameter N uses index N, so index 0 stays empty; 11 is the extra slot
 	_amigaDmaSamples.clear();
 	_amigaDmaSamples.resize(12);
 
-	if (file->size() > modOffset + 1084) {
-		int modSize = file->size() - modOffset;
-		Common::Array<byte> modBytes;
-		modBytes.resize(modSize);
-		file->seek(modOffset);
-		file->read(modBytes.data(), modSize);
-
-		Common::MemoryReadStream modStream(modBytes.data(), modBytes.size());
-		Modules::Module module;
-		if (module.load(modStream, 0)) {
-			for (int i = 1; i <= 10; i++) {
-				const Modules::sample_t &sample = module.sample[i - 1];
-				if (sample.len > 0 && sample.data) {
-					_amigaDmaSamples[i].data.resize(sample.len);
-					memcpy(_amigaDmaSamples[i].data.data(), sample.data, sample.len);
-				}
-			}
+	Common::File bank;
+	if (bank.open("cmsnds2")) {
+		int index = 1;
+		while (index <= 10 && bank.pos() + 6 <= bank.size()) {
+			uint32 length = bank.readUint32BE();
+			bank.readUint16BE(); // Nominal rate, unused: 0x3xxx sets the period
+			if (length == 0 || bank.pos() + (int64)length > bank.size())
+				break;
+
+			_amigaDmaSamples[index].data.resize(length);
+			bank.read(_amigaDmaSamples[index].data.data(), length);
+			debugC(1, kFreescapeDebugParser, "Amiga DMA sample %d: %d bytes", index, length);
+			index++;
 		}
+		bank.close();
+	} else {
+		warning("Freescape: 'cmsnds2' is missing from the game data, so the sampled "
+				"part of the Amiga sound effects will not play");
+	}
+
+	if (modOffset < 0)
+		return;
+
+	// The demo points its extra sample slot at the third instrument of the
+	// music module; the full game leaves that pointer uninitialized
+	int64 fileSize = file->size();
+	if (fileSize <= modOffset + 1084)
+		return;
+
+	int modSize = fileSize - modOffset;
+	Common::Array<byte> modBytes;
+	modBytes.resize(modSize);
+	file->seek(modOffset);
+	file->read(modBytes.data(), modSize);
+
+	Common::MemoryReadStream modStream(modBytes.data(), modBytes.size());
+	Modules::Module module;
+	if (!module.load(modStream, 0))
+		return;
+
+	const Modules::sample_t &sample = module.sample[2];
+	if (sample.len > 0 && sample.data) {
+		_amigaDmaSamples[11].data.resize(sample.len);
+		memcpy(_amigaDmaSamples[11].data.data(), sample.data, sample.len);
 	}
 }
 
@@ -481,16 +532,32 @@ void SoundAmigaDemo::playSound(int index, Type type) {
 		return;
 	}
 
+	// A sound still holding the priority slot cannot be replaced by a lesser one
+	if (_mixer->isSoundHandleActive(_soundFxHandle)) {
+		Common::StackLock lock(_mixer->mutex());
+		if (entry.priority < _priority->value) {
+			debugC(1, kFreescapeDebugMedia, "Amiga sound %d skipped (priority %d < %d)",
+				index, entry.priority, _priority->value);
+			return;
+		}
+	}
+
 	debugC(1, kFreescapeDebugMedia, "Playing Amiga sound %d (priority=%d, commands=%d)",
 		index, entry.priority, (int)entry.commands.size());
 
-	AmigaSfxStream *stream = new AmigaSfxStream(entry.commands.data(), entry.commands.size(), &_amigaDmaSamples);
+	AmigaSfxStream *stream = new AmigaSfxStream(entry.commands.data(), entry.commands.size(), &_amigaDmaSamples, _priority);
+	// Claim the slot only once the previous stream is gone, so that its clean
+	// up cannot release the new claim
 	_mixer->stopHandle(_soundFxHandle);
+	{
+		Common::StackLock lock(_mixer->mutex());
+		_priority->value = entry.priority;
+	}
 	_mixer->playStream(Audio::Mixer::kSFXSoundType, &_soundFxHandle, stream, -1,
 		Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::YES);
 }
 
-Sound *FreescapeEngine::loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset) {
+Sound *FreescapeEngine::loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset) {
 	SoundAmigaDemo *sound = new SoundAmigaDemo(_mixer);
 	sound->loadSounds(file, offset, numSounds, modOffset);
 	return sound;


Commit: 2849df996c7056f1a6bd717bd9c8f9063025c1cc
    https://github.com/scummvm/scummvm/commit/2849df996c7056f1a6bd717bd9c8f9063025c1cc
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: no sounds of the castle amiga demo

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


diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index f6cfd24dd68..a6e31cd866b 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -531,8 +531,9 @@ public:
 	Sound *loadSpeakerFxZX(Common::SeekableReadStream *file, int sfxTable, int sfxData, int numberSounds);
 	Sound *loadSpeakerFxDrillerZX();
 	Sound *loadSoundsCPC(Common::SeekableReadStream *file, int offsetTone, int sizeTone, int offsetEnvelope, int sizeEnvelope, int offsetSoundDef, int sizeSoundDef);
-	// modOffset points at the embedded module used for one extra sample, or -1
-	Sound *loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset);
+	// sampleBank names the external PCM bank, modOffset points at the embedded
+	// module used for one extra sample; both are optional (empty path, -1)
+	Sound *loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, const Common::Path &sampleBank, int modOffset);
 
 	int _soundIndexShoot;
 	int _soundIndexCollide;
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 202f2903748..76cd61cafba 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -1402,9 +1402,10 @@ void CastleEngine::loadAssetsAmigaDemo() {
 		}
 	}
 
-	// Sound effect command table at file offset 0x1469E (memory 0x14682). This
-	// version takes its extra sample slot from the music module.
-	_sound = loadSoundsAmiga(&file, 0x1469E, 36, 0x3D5A6);
+	// Sound effect command table at file offset 0x1469E (memory 0x14682). The
+	// demo disk ships no sample bank, so only the extra slot taken from the
+	// music module is available and the sampled effects are square waves.
+	_sound = loadSoundsAmiga(&file, 0x1469E, 36, Common::Path(), 0x3D5A6);
 
 	// Load embedded ProTracker module for background music
 	// Module is at file offset 0x3D5A6 (memory 0x3D58A), ~86260 bytes
@@ -1725,7 +1726,7 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Sound effect command table at file offset 0x13cf2 (memory 0x13cd6)
-	_sound = loadSoundsAmiga(&file, 0x13cf2, 36, -1);
+	_sound = loadSoundsAmiga(&file, 0x13cf2, 36, "cmsnds2", -1);
 
 	// Embedded ProTracker module for background music.
 	static const int kModOffset = 0x3cbfa;
diff --git a/engines/freescape/sound/amiga.cpp b/engines/freescape/sound/amiga.cpp
index 15518826e77..a8be617a3c1 100644
--- a/engines/freescape/sound/amiga.cpp
+++ b/engines/freescape/sound/amiga.cpp
@@ -416,7 +416,7 @@ class SoundAmigaDemo final : public Sound {
 public:
 	SoundAmigaDemo(Audio::Mixer *mixer) : _priority(new AmigaSfxPriority()), _mixer(mixer) {}
 
-	void loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset);
+	void loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, const Common::Path &sampleBank, int modOffset);
 
 	void playSound(int index, Type type) override;
 
@@ -435,7 +435,7 @@ public:
 	}
 
 private:
-	void loadDmaSamples(Common::SeekableReadStream *file, int modOffset);
+	void loadDmaSamples(Common::SeekableReadStream *file, const Common::Path &sampleBank, int modOffset);
 
 	Common::Array<AmigaSfxEntry> _amigaSfxTable;
 	Common::Array<AmigaDmaSample> _amigaDmaSamples;
@@ -445,7 +445,7 @@ private:
 	Audio::SoundHandle _soundFxHandle;
 };
 
-void SoundAmigaDemo::loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset) {
+void SoundAmigaDemo::loadSounds(Common::SeekableReadStream *file, int offset, int numSounds, const Common::Path &sampleBank, int modOffset) {
 	file->seek(offset);
 	_amigaSfxTable.clear();
 	for (int i = 0; i < numSounds; i++) {
@@ -461,20 +461,22 @@ void SoundAmigaDemo::loadSounds(Common::SeekableReadStream *file, int offset, in
 	}
 	debugC(1, kFreescapeDebugParser, "Loaded %d Amiga sound effects", numSounds);
 
-	loadDmaSamples(file, modOffset);
+	loadDmaSamples(file, sampleBank, modOffset);
 }
 
-// The samples played by 0x5NNN come from the `cmsnds2` bank, which the original
-// loads over the memory holding the ProTracker module (hence music and sound
-// effects being mutually exclusive there). Each of its 10 entries is a 4-byte
-// big endian length, a 2-byte sample rate and that many signed 8-bit samples.
-void SoundAmigaDemo::loadDmaSamples(Common::SeekableReadStream *file, int modOffset) {
+// The samples played by 0x5NNN come from an external bank, which the original
+// loads over the memory holding the ProTracker module: that is why music and
+// sound effects are mutually exclusive there, and why the rolling demo, whose
+// disk carries no bank at all, is music only. Each of the 10 entries is a
+// 4-byte big endian length, a 2-byte sample rate and that many signed 8-bit
+// samples.
+void SoundAmigaDemo::loadDmaSamples(Common::SeekableReadStream *file, const Common::Path &sampleBank, int modOffset) {
 	// Parameter N uses index N, so index 0 stays empty; 11 is the extra slot
 	_amigaDmaSamples.clear();
 	_amigaDmaSamples.resize(12);
 
 	Common::File bank;
-	if (bank.open("cmsnds2")) {
+	if (!sampleBank.empty() && bank.open(sampleBank)) {
 		int index = 1;
 		while (index <= 10 && bank.pos() + 6 <= bank.size()) {
 			uint32 length = bank.readUint32BE();
@@ -488,9 +490,9 @@ void SoundAmigaDemo::loadDmaSamples(Common::SeekableReadStream *file, int modOff
 			index++;
 		}
 		bank.close();
-	} else {
-		warning("Freescape: 'cmsnds2' is missing from the game data, so the sampled "
-				"part of the Amiga sound effects will not play");
+	} else if (!sampleBank.empty()) {
+		warning("Freescape: '%s' is missing from the game data, so the sampled part "
+				"of the Amiga sound effects will not play", sampleBank.toString().c_str());
 	}
 
 	if (modOffset < 0)
@@ -557,9 +559,9 @@ void SoundAmigaDemo::playSound(int index, Type type) {
 		Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::YES);
 }
 
-Sound *FreescapeEngine::loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset) {
+Sound *FreescapeEngine::loadSoundsAmiga(Common::SeekableReadStream *file, int offset, int numSounds, const Common::Path &sampleBank, int modOffset) {
 	SoundAmigaDemo *sound = new SoundAmigaDemo(_mixer);
-	sound->loadSounds(file, offset, numSounds, modOffset);
+	sound->loadSounds(file, offset, numSounds, sampleBank, modOffset);
 	return sound;
 }
 


Commit: d137c33dca464c52b0f87346bba49ce53ecb1f5d
    https://github.com/scummvm/scummvm/commit/d137c33dca464c52b0f87346bba49ce53ecb1f5d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: fixed ui indicator in castle amiga

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


diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 76cd61cafba..4a877f73705 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -1173,18 +1173,19 @@ void CastleEngine::loadAssetsAmigaDemo() {
 	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(&file, 1, 10);
 	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	// Strength weight sprites (file 0x395F2, 1 word x 14 rows x 4 frames)
-	file.seek(0x395f2);
+	// Weight discs of the strength barbell (memory 0x395C6): 4 frames of
+	// 1 word x 15 rows, i.e. 120 bytes each.
+	file.seek(0x395e2);
 	for (int i = 0; i < 4; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 14);
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 15);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_strenghtWeightsFrames.push_back(frame);
 	}
 
-	// Strength background with bar (file 0x397B2, 5 words x 20 rows)
-	//file.seek(0x397b2);
-	//_strenghtBackgroundFrame = loadFrameFromPlanesInterleaved(&file, 5, 4);
-	//_strenghtBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+	// Barbell shaft (memory 0x397A6): 5 words x 3 rows.
+	file.seek(0x397c2);
+	_strenghtBarFrame = loadFrameFromPlanesInterleaved(&file, 5, 3);
+	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Eye icon sprites (memory 0x3C096, 12 frames, 16x7 each, interleaved 4-plane)
 	// Used for strength/compass display at screen (224, 164). Header at 0x3C08E.
@@ -1522,14 +1523,20 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(&file, 1, 10);
 	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	// Strength weight sprites: 4 frames × 1 word × 14 rows.
-	file.seek(0x38c46);
+	// Weight discs of the strength barbell (memory 0x38C1A): 4 frames of
+	// 1 word × 15 rows, i.e. 120 bytes each.
+	file.seek(0x38c36);
 	for (int i = 0; i < 4; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 14);
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 15);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_strenghtWeightsFrames.push_back(frame);
 	}
 
+	// Barbell shaft (memory 0x38DFA): 5 words × 3 rows.
+	file.seek(0x38e16);
+	_strenghtBarFrame = loadFrameFromPlanesInterleaved(&file, 5, 3);
+	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
 	// Eye icon sprites: 12 frames × 1 word × 7 rows. Header at 0x3b6fe.
 	file.seek(0x3b706);
 	for (int i = 0; i < 12; i++) {
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index b7c38e031a6..e950462730c 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -248,13 +248,19 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(file, 1, 10);
 	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	file->seek(0x569e0);
+	// Weight discs of the strength barbell: 4 frames of 1 word x 15 rows,
+	// followed by the 5 word x 3 row shaft.
+	file->seek(0x569d0);
 	for (int i = 0; i < 4; i++) {
-		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 14);
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 15);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 		_strenghtWeightsFrames.push_back(frame);
 	}
 
+	file->seek(0x56bb0);
+	_strenghtBarFrame = loadFrameFromPlanesInterleaved(file, 5, 3);
+	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
 	file->seek(0x594a0);
 	for (int i = 0; i < 12; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 7);
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index ec7cbcb304e..f2946a3b1bd 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -760,7 +760,8 @@ void CastleEngine::initGameState() {
 		}
 	}
 
-	_gameStateVars[k8bitVariableShield] = 16;
+	// The Amiga and Atari ST releases start with three weights per side
+	_gameStateVars[k8bitVariableShield] = (isAmiga() || isAtariST()) ? 12 : 16;
 	_gameStateVars[k8bitVariableEnergy] = 1;
 	_gameStateVars[8] = 128; // -1
 	_countdown = INT_MAX - 8;
@@ -1956,6 +1957,9 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 			barFrameOrigin += Common::Point(0, 6 + extraYOffset);
 		else if (isCPC())
 			barFrameOrigin += Common::Point(0, 6 + extraYOffset);
+		else if (isAmiga() || isAtariST())
+			// The shaft is 80 pixels wide and starts left of the discs
+			barFrameOrigin += Common::Point(-8, 6 + extraYOffset);
 
 		surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_strenghtBarFrame, barFrameOrigin.x, barFrameOrigin.y, Common::Rect(0, 0, _strenghtBarFrame->w, _strenghtBarFrame->h), black);
 	}
@@ -1984,8 +1988,8 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 		rightWeightPos = 63;
 	} else if (isAmiga() || isAtariST()) {
 		weightStep = 3;
-		weightOffset = 10;
-		rightWeightPos = 62;
+		weightOffset = 8;
+		rightWeightPos = 64;
 	} else if (_renderMode == Common::kRenderCGA) {
 		// The CGA discs are 4 pixels wide instead of 8
 		weightStep = 3;


Commit: 4a6c5730e92d1e159041f25f8aa52c38319e99ae
    https://github.com/scummvm/scummvm/commit/4a6c5730e92d1e159041f25f8aa52c38319e99ae
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: fixed ui indicator in castle zx

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


diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index f2946a3b1bd..657f70d1886 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -1933,15 +1933,26 @@ void CastleEngine::drawRiddleStringInSurface(const Common::String &str, int x, i
 	}
 }
 
+void CastleEngine::drawStrengthWeight(Graphics::Surface *surface, int frameIdx, const Common::Point &position, int width, uint32 back) {
+	Graphics::ManagedSurface *frame = _strenghtWeightsFrames[frameIdx];
+	Common::Rect src(0, 0, width, frame->h);
+
+	// A Spectrum disc is drawn opaquely, so the shaft stays hidden in the gap
+	// it leaves; the other versions key out the black around theirs
+	if (isSpectrum())
+		surface->copyRectToSurface((const Graphics::Surface)*frame, position.x, position.y, src);
+	else
+		surface->copyRectToSurfaceWithKey((const Graphics::Surface)*frame, position.x, position.y, src, back);
+}
+
 void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point origin) {
 	if (_strenghtBackgroundFrame)
 		surface->copyRectToSurface((const Graphics::Surface)*_strenghtBackgroundFrame, origin.x, origin.y, Common::Rect(0, 0, _strenghtBackgroundFrame->w, _strenghtBackgroundFrame->h));
 
 	uint32 black = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00);
-	uint32 back = 0;
-
-	if (isDOS() || isAmiga() || isAtariST())
-		back = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00);
+	// The CPC weights are converted with transparency, the others keep the
+	// black they were loaded with and rely on it being the key colour
+	uint32 back = isCPC() ? 0 : black;
 
 	int strength = _gameStateVars[k8bitVariableShield];
 
@@ -1971,6 +1982,8 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 		return;
 
 	int weightWidth = _strenghtWeightsFrames[0]->w;
+	if (isSpectrum())
+		weightWidth = 4; // The disc only fills the half of its sprite the frame header masks in
 
 	// Weight discs overlap: step is smaller than sprite width (3 pixels in original ZX assembly).
 	// Each disc is drawn at pixel-level precision, converging from outside toward center.
@@ -2010,18 +2023,12 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 
 	if (frameIdx != 0) {
 		frameIdx = 4 - frameIdx;
-		if (isSpectrum())
-			surface->copyRectToSurface((const Graphics::Surface)*_strenghtWeightsFrames[frameIdx], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[frameIdx]->h));
-		else
-			surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_strenghtWeightsFrames[frameIdx], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[frameIdx]->h), back);
+		drawStrengthWeight(surface, frameIdx, weightPoint, weightWidth, back);
 		weightPoint += Common::Point(weightStep, 0);
 	}
 
 	for (int i = 0; i < strength / 4; i++) {
-		if (isSpectrum())
-			surface->copyRectToSurface((const Graphics::Surface)*_strenghtWeightsFrames[0], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[0]->h));
-		else
-			surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_strenghtWeightsFrames[0], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[0]->h), back);
+		drawStrengthWeight(surface, 0, weightPoint, weightWidth, back);
 		weightPoint += Common::Point(weightStep, 0);
 	}
 
@@ -2035,19 +2042,13 @@ void CastleEngine::drawEnergyMeter(Graphics::Surface *surface, Common::Point ori
 	weightPoint = Common::Point(origin.x + rightWeightPos - (totalRight - 1) * weightStep, weightY);
 
 	for (int i = 0; i < numFullRight; i++) {
-		if (isSpectrum())
-			surface->copyRectToSurface((const Graphics::Surface)*_strenghtWeightsFrames[0], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[0]->h));
-		else
-			surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_strenghtWeightsFrames[0], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[0]->h), back);
+		drawStrengthWeight(surface, 0, weightPoint, weightWidth, back);
 		weightPoint += Common::Point(weightStep, 0);
 	}
 
 	if (hasPartial) {
 		frameIdx = 4 - (strength % 4);
-		if (isSpectrum())
-			surface->copyRectToSurface((const Graphics::Surface)*_strenghtWeightsFrames[frameIdx], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[frameIdx]->h));
-		else
-			surface->copyRectToSurfaceWithKey((const Graphics::Surface)*_strenghtWeightsFrames[frameIdx], weightPoint.x, weightPoint.y, Common::Rect(0, 0, weightWidth, _strenghtWeightsFrames[frameIdx]->h), back);
+		drawStrengthWeight(surface, frameIdx, weightPoint, weightWidth, back);
 	}
 }
 
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 2c28628a7b0..71dcfa4736b 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -93,6 +93,7 @@ public:
 	void drawCPCUI(Graphics::Surface *surface) override;
 	void drawAmigaAtariSTUI(Graphics::Surface *surface) override;
 	void drawEnergyMeter(Graphics::Surface *surface, Common::Point origin);
+	void drawStrengthWeight(Graphics::Surface *surface, int frameIdx, const Common::Point &position, int width, uint32 back);
 	void drawLiftingGate(Graphics::Surface *surface);
 	void drawDroppingGate(Graphics::Surface *surface);
 	void pressedKey(const int keycode) override;


Commit: fc8bbd0131b80bbeb86d24a33e4fbe2bd898a5d0
    https://github.com/scummvm/scummvm/commit/fc8bbd0131b80bbeb86d24a33e4fbe2bd898a5d0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: adding thunder frames for castle amiga

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


diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 4a877f73705..e3eed680f1d 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -1082,6 +1082,49 @@ Graphics::ManagedSurface *CastleEngine::loadFrameFromPlanesInterleaved(Common::S
 	return surface;
 }
 
+// Two lightning bolts of 2 words x 85 rows, one picked at random per strike. The
+// original stencils them onto the sky, keeping only the pixels where the screen
+// already holds color 15, so the color 15 of the bolt is its transparency.
+void CastleEngine::loadThunderFramesAmiga(Common::SeekableReadStream *file, int offset) {
+	file->seek(offset);
+	for (int i = 0; i < 2; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 2, 85);
+		for (int y = 0; y < frame->h; y++) {
+			for (int x = 0; x < frame->w; x++) {
+				if (frame->getPixel(x, y) == 15)
+					frame->setPixel(x, y, 0);
+			}
+		}
+		_thunderCLUT8Frames.push_back(frame);
+	}
+}
+
+// The bolt is drawn into the viewport, so it takes the colors of the current area
+void CastleEngine::updateThunderFramesPalette() {
+	if (_thunderCLUT8Frames.empty() || !_gfx->_palette)
+		return;
+
+	while (_thunderFrames.size() < _thunderCLUT8Frames.size())
+		_thunderFrames.push_back(nullptr);
+
+	for (uint i = 0; i < _thunderCLUT8Frames.size(); i++) {
+		if (_thunderFrames[i]) {
+			_thunderFrames[i]->free();
+			delete _thunderFrames[i];
+		}
+
+		Graphics::ManagedSurface *frame = new Graphics::ManagedSurface();
+		frame->copyFrom(*_thunderCLUT8Frames[i]);
+		frame->convertToInPlace(_gfx->_texturePixelFormat, _gfx->_palette, 16);
+		_thunderFrames[i] = frame;
+	}
+
+	// Uploaded once, so drop them to have them rebuilt recolored
+	for (uint i = 0; i < _thunderTextures.size(); i++)
+		delete _thunderTextures[i];
+	_thunderTextures.clear();
+}
+
 void CastleEngine::loadAssetsAmigaDemo() {
 	Common::File file;
 	file.open("x");
@@ -1187,6 +1230,8 @@ void CastleEngine::loadAssetsAmigaDemo() {
 	_strenghtBarFrame = loadFrameFromPlanesInterleaved(&file, 5, 3);
 	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
+	loadThunderFramesAmiga(&file, 0x38b32); // Memory 0x38B16
+
 	// Eye icon sprites (memory 0x3C096, 12 frames, 16x7 each, interleaved 4-plane)
 	// Used for strength/compass display at screen (224, 164). Header at 0x3C08E.
 	// TODO: load as separate eye icon member, not _keysBorderFrames
@@ -1537,6 +1582,8 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	_strenghtBarFrame = loadFrameFromPlanesInterleaved(&file, 5, 3);
 	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
+	loadThunderFramesAmiga(&file, 0x38186); // Memory 0x3816A
+
 	// Eye icon sprites: 12 frames × 1 word × 7 rows. Header at 0x3b6fe.
 	file.seek(0x3b706);
 	for (int i = 0; i < 12; i++) {
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index e950462730c..d9cf94474f8 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -261,6 +261,8 @@ void CastleEngine::loadAssetsAtariFullGame() {
 	_strenghtBarFrame = loadFrameFromPlanesInterleaved(file, 5, 3);
 	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
+	loadThunderFramesAmiga(file, 0x55f20);
+
 	file->seek(0x594a0);
 	for (int i = 0; i < 12; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(file, 1, 7);
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 657f70d1886..c87ee52caae 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -194,6 +194,13 @@ CastleEngine::~CastleEngine() {
 		}
 	}
 
+	for (int i = 0; i < int(_thunderCLUT8Frames.size()); i++) {
+		if (_thunderCLUT8Frames[i]) {
+			_thunderCLUT8Frames[i]->free();
+			delete _thunderCLUT8Frames[i];
+		}
+	}
+
 	for (int i = 0; i < int(_thunderTextures.size()); i++) {
 		if (_thunderTextures[i]) {
 			delete _thunderTextures[i];
@@ -707,6 +714,8 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 		(*palette)[5][0] = 0xcc;
 		(*palette)[5][1] = 0xcc;
 		(*palette)[5][2] = 0xcc;
+
+		updateThunderFramesPalette();
 	}
 
 	if (isSpectrum())
@@ -2578,9 +2587,13 @@ void CastleEngine::updateThunder() {
 		if (_thunderFrameDuration == 5)
 			_gfx->clear(255, 255, 255);
 
-		if (_thunderFrameDuration == 0)
-			if (isSpectrum() || isCPC() || isDOS())
+		if (_thunderFrameDuration == 0) {
+			// The Amiga and Atari ST end the strike with sound 7
+			if (isAmiga() || isAtariST())
+				playSound(7, false);
+			else if (isSpectrum() || isCPC() || isDOS())
 				playSound(8, false);
+		}
 		return;
 	}
 
@@ -2588,7 +2601,7 @@ void CastleEngine::updateThunder() {
 		//debug("Thunder ticks: %d", _thunderTicks);
 		_thunderTicks--;
 		if (_thunderTicks <= 0) {
-			if (isDOS())
+			if (isDOS() || isAmiga() || isAtariST())
 				_thunderFrameIndex = int(_rnd->getRandomNumber(_thunderTextures.size() - 1));
 			_thunderFrameDuration = 10;
 			_thunderOffset = Math::Vector3d();
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 71dcfa4736b..6876d715b0a 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -141,6 +141,8 @@ public:
 	Graphics::ManagedSurface *loadFrameFromPlanesVertical(Common::SeekableReadStream *file, int widthInBytes, int height);
 	Graphics::ManagedSurface *loadFrameFromPlanesInternalVertical(Common::SeekableReadStream *file, Graphics::ManagedSurface *surface, int width, int height, int plane);
 	Graphics::ManagedSurface *loadFrameFromPlanesInterleaved(Common::SeekableReadStream *file, int widthInWords, int height);
+	void loadThunderFramesAmiga(Common::SeekableReadStream *file, int offset);
+	void updateThunderFramesPalette();
 
 	Common::Array<Graphics::ManagedSurface *>_keysBorderFrames;
 	Common::Array<Graphics::ManagedSurface *>_keysMenuFrames;
@@ -231,6 +233,8 @@ private:
 	int _thunderFrameIndex;
 	Math::Vector3d _thunderOffset;
 	Common::Array<Texture *>_thunderTextures;
+	// Amiga and Atari ST: kept indexed, to be recolored per area
+	Common::Array<Graphics::ManagedSurface *> _thunderCLUT8Frames;
 };
 
 }


Commit: d8c7d157657624680824dc2cfaec35fb8abf4b40
    https://github.com/scummvm/scummvm/commit/d8c7d157657624680824dc2cfaec35fb8abf4b40
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: added support to decrypt copylock directly from files

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


diff --git a/engines/freescape/copylock.cpp b/engines/freescape/copylock.cpp
new file mode 100644
index 00000000000..6f2e1d13545
--- /dev/null
+++ b/engines/freescape/copylock.cpp
@@ -0,0 +1,312 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/md5.h"
+#include "common/memstream.h"
+#include "common/stream.h"
+
+#include "freescape/copylock.h"
+#include "freescape/freescape.h"
+
+namespace Freescape {
+
+// Cipher used to wrap the program, i.e. the shape of the decoding loop found
+// in the protection. Both come from Copylock series 2 (1989).
+enum CopylockCipher {
+	// sub.w d0,(a6)+ ; add.w d1,(a6)+
+	// add.l d2,d0    ; rol.l d0,d0
+	// add.l d3,d1    ; ror.l d1,d1
+	// The key is d0..d3 in key[0..3].
+	kCipherRotate = 0,
+
+	// roxl.l #1,d0 ... roxl.l #1,d5 ; eor.l d0,(a6)+
+	// The six registers and the X flag they rotate through form a 193 bit ring
+	// which turns one bit per longword, so the key stream is the ring itself:
+	// key[0..6] holds it, from the value of d0 on, one bit per iteration.
+	kCipherShift = 1
+};
+
+// The registers the protection derives from the disk serial. They are constant
+// for a release, see the class comment. The md5 is that of the whole file, not
+// the partial one detection matches on.
+struct CopylockKey {
+	const char *md5;
+	CopylockCipher cipher;
+	uint32 key[7];
+};
+
+static const CopylockKey kCopylockKeys[] = {
+	// Castle Master, Atari ST (Virtual Worlds compilation), M.PRG
+	{ "2e9f0b3fe88e961851b50af9f7c77002", kCipherRotate,
+	  { 0x276bd21e, 0x00000000, 0xffffe829, 0xeeefd7cb, 0, 0, 0 } },
+	// Castle Master, Atari ST (Castle Master & The Crypt, Incentive), M.PRG
+	{ "f580b8658e622588298d1d6ad87437fb", kCipherShift,
+	  { 0x4ed7a43c, 0x0010c85c, 0x400b051c, 0x5bd5a219, 0x0900ff04, 0x00000000, 0x00000000 } },
+	{ nullptr, kCipherRotate, { 0, 0, 0, 0, 0, 0, 0 } }
+};
+
+// Layout of the protection, recovered from the file itself.
+struct CopylockLayout {
+	uint32 magic;      // key of the Trace Vector Decoder
+	uint32 progOffset; // start of the wrapped program, from the TEXT segment
+	uint32 progSize;   // number of encrypted bytes
+	CopylockCipher cipher;
+};
+
+static uint32 readUint32(const byte *buf, uint32 offset) {
+	return READ_BE_UINT32(buf + offset);
+}
+
+// Series 2 encrypts its own loops by xor-ing each instruction with the
+// preceding encrypted longword plus a magic value, which needs no execution.
+static uint32 tvdKey(const byte *text, uint32 offset, uint32 magic) {
+	return readUint32(text, offset - 4) + magic;
+}
+
+static void tvdDecode(const byte *text, uint32 offset, uint32 magic, uint32 *out) {
+	uint32 key = tvdKey(text, offset, magic);
+	out[0] = readUint32(text, offset) ^ key;
+	out[1] = readUint32(text, offset + 4) ^ key;
+}
+
+// Signature of the protection: lea pc+$12,a6 / move.l -4(a6),d6 / add.l $8.l,d6
+static bool findProtection(const byte *text, uint32 size, uint32 &start, uint32 &magic) {
+	uint32 instr[2];
+
+	for (uint32 i = 4; i + 20 < size; i += 2) {
+		uint32 candidate = (readUint32(text, i) ^ 0x4dfa0010) - readUint32(text, i - 4);
+
+		tvdDecode(text, i + 4, candidate, instr);
+		if (instr[0] != 0x2c2efffc)
+			continue;
+		tvdDecode(text, i + 8, candidate, instr);
+		if (instr[0] != 0xdcb90000)
+			continue;
+
+		start = i;
+		magic = candidate;
+		return true;
+	}
+	return false;
+}
+
+// The decoding routine starts with lea here(pc),a6 / adda.l #offset,a6 /
+// move.l #size,d6, which gives the location and the length of the program.
+static bool findDecoder(const byte *text, uint32 limit, uint32 magic, CopylockLayout &layout) {
+	uint32 instr[2];
+
+	for (uint32 i = 4; i < limit; i += 2) {
+		tvdDecode(text, i, magic, instr);
+		if (instr[0] != 0x4dfafffe)
+			continue;
+
+		tvdDecode(text, i + 4, magic, instr);
+		if ((instr[0] >> 16) != 0xddfc)
+			continue;
+		uint32 offset = ((instr[0] & 0xffff) << 16) | (instr[1] >> 16);
+
+		tvdDecode(text, i + 10, magic, instr);
+		if ((instr[0] >> 16) != 0x2c3c)
+			continue;
+		uint32 count = ((instr[0] & 0xffff) << 16) | (instr[1] >> 16);
+
+		// Then the decoding loop itself, which has to be one we implement
+		static const uint16 rotateLoop[] = {
+			0x915e, 0xd35e, 0xd082, 0xe1b8, 0xd283, 0xe2b9, 0x5986, 0x66f0
+		};
+		static const uint16 shiftLoop[] = {
+			0xdc8e, 0xe390, 0xe391, 0xe392, 0xe393, 0xe394, 0xe395, 0xb19e, 0xbdc6, 0x66ee
+		};
+
+		for (int variant = 0; variant < 2; variant++) {
+			const uint16 *loop = variant ? shiftLoop : rotateLoop;
+			int length = variant ? ARRAYSIZE(shiftLoop) : ARRAYSIZE(rotateLoop);
+
+			uint32 at = i + 16;
+			bool matched = true;
+			for (int j = 0; j < length && matched; j++, at += 2) {
+				tvdDecode(text, at, magic, instr);
+				matched = (instr[0] >> 16) == loop[j];
+			}
+
+			if (matched) {
+				layout.magic = magic;
+				layout.progOffset = i + offset;
+				layout.progSize = count;
+				layout.cipher = variant ? kCipherShift : kCipherRotate;
+				return true;
+			}
+		}
+		return false;
+	}
+	return false;
+}
+
+static uint32 rol32(uint32 value, uint32 count) {
+	count &= 31;
+	return count ? ((value << count) | (value >> (32 - count))) : value;
+}
+
+static uint32 ror32(uint32 value, uint32 count) {
+	count &= 31;
+	return count ? ((value >> count) | (value << (32 - count))) : value;
+}
+
+static void decodeRotate(byte *prog, uint32 size, const CopylockKey &key) {
+	uint32 d0 = key.key[0], d1 = key.key[1], d2 = key.key[2], d3 = key.key[3];
+
+	for (uint32 i = 0; i + 4 <= size; i += 4) {
+		WRITE_BE_UINT16(prog + i, READ_BE_UINT16(prog + i) - (uint16)d0);
+		WRITE_BE_UINT16(prog + i + 2, READ_BE_UINT16(prog + i + 2) + (uint16)d1);
+
+		d0 += d2;
+		d0 = rol32(d0, d0);
+		d1 += d3;
+		d1 = ror32(d1, d1);
+	}
+}
+
+// The ring turns by one bit per longword, so the key of iteration i is made of
+// the 32 bits it holds from position i on.
+static const int kRingBits = 193;
+
+static void decodeShift(byte *prog, uint32 size, const CopylockKey &key) {
+	for (uint32 i = 0; i + 4 <= size; i += 4) {
+		uint32 k = 0;
+		for (int b = 0; b < 32; b++) {
+			int pos = ((i / 4) + b) % kRingBits;
+			k = (k << 1) | ((key.key[pos / 32] >> (31 - (pos % 32))) & 1);
+		}
+		WRITE_BE_UINT32(prog + i, READ_BE_UINT32(prog + i) ^ k);
+	}
+}
+
+static void decodeProgram(byte *prog, uint32 size, const CopylockKey &key) {
+	if (key.cipher == kCipherShift)
+		decodeShift(prog, size, key);
+	else
+		decodeRotate(prog, size, key);
+}
+
+// Size of the unwrapped GEMDOS program, so that the tail of the protection is
+// not carried over.
+static uint32 programSize(const byte *prog, uint32 available) {
+	uint32 size = 0x1c + readUint32(prog, 2) + readUint32(prog, 6) + readUint32(prog, 14);
+	if (size + 4 > available)
+		return 0;
+
+	if (READ_BE_UINT16(prog + 26) != 0) // absflag: no relocation table
+		return size;
+
+	bool empty = readUint32(prog, size) == 0;
+	size += 4;
+	if (!empty) {
+		while (size < available && prog[size] != 0)
+			size++;
+		size++;
+	}
+	return size <= available ? size : 0;
+}
+
+static const CopylockKey *findKey(Common::SeekableReadStream *file) {
+	Common::String md5 = Common::computeStreamMD5AsString(*file, file->size());
+	file->seek(0);
+
+	for (const CopylockKey *key = kCopylockKeys; key->md5; key++) {
+		if (md5 == key->md5)
+			return key;
+	}
+
+	debugC(1, kFreescapeDebugParser, "Copylock: no key for md5 %s", md5.c_str());
+	return nullptr;
+}
+
+static bool readLayout(Common::SeekableReadStream *file, Common::Array<byte> &data, CopylockLayout &layout) {
+	file->seek(0);
+	data.resize(file->size());
+	if (file->read(data.data(), data.size()) != data.size())
+		return false;
+	file->seek(0);
+
+	if (data.size() < 0x1c || READ_BE_UINT16(data.data()) != 0x601a)
+		return false;
+
+	uint32 textSize = readUint32(data.data(), 2);
+	if (textSize < 0x100 || 0x1c + textSize > data.size())
+		return false;
+
+	const byte *text = data.data() + 0x1c;
+	uint32 start;
+	if (!findProtection(text, textSize, start, layout.magic))
+		return false;
+
+	return findDecoder(text, start, layout.magic, layout);
+}
+
+bool Copylock::isProtected(Common::SeekableReadStream *file) {
+	Common::Array<byte> data;
+	CopylockLayout layout;
+	return readLayout(file, data, layout);
+}
+
+Common::SeekableReadStream *Copylock::unwrap(Common::SeekableReadStream *file) {
+	Common::Array<byte> data;
+	CopylockLayout layout;
+
+	if (!readLayout(file, data, layout))
+		return nullptr;
+
+	const CopylockKey *key = findKey(file);
+	if (!key)
+		return nullptr;
+
+	if (key->cipher != layout.cipher) {
+		warning("Copylock: the key does not match the cipher of the file");
+		return nullptr;
+	}
+
+	uint32 progOffset = 0x1c + layout.progOffset;
+	if (progOffset + layout.progSize > data.size()) {
+		warning("Copylock: the wrapped program does not fit in the file");
+		return nullptr;
+	}
+
+	debugC(1, kFreescapeDebugParser, "Copylock: program at 0x%x, %d bytes encrypted",
+		progOffset, layout.progSize);
+
+	uint32 available = data.size() - progOffset;
+	byte *prog = (byte *)malloc(available);
+	memcpy(prog, data.data() + progOffset, available);
+	decodeProgram(prog, layout.progSize, *key);
+
+	uint32 size = READ_BE_UINT16(prog) == 0x601a ? programSize(prog, available) : 0;
+	if (!size) {
+		warning("Copylock: decryption did not yield a GEMDOS program");
+		free(prog);
+		return nullptr;
+	}
+
+	return new Common::MemoryReadStream(prog, size, DisposeAfterUse::YES);
+}
+
+} // End of namespace Freescape
diff --git a/engines/freescape/copylock.h b/engines/freescape/copylock.h
new file mode 100644
index 00000000000..7d96579c0af
--- /dev/null
+++ b/engines/freescape/copylock.h
@@ -0,0 +1,67 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_COPYLOCK_H
+#define FREESCAPE_COPYLOCK_H
+
+#include "common/scummsys.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Freescape {
+
+/**
+ * Removal of the Rob Northen Copylock wrapper found on several Amiga and
+ * Atari ST releases.
+ *
+ * The protection reads a serial from a specially formatted track of the
+ * original disk and decrypts the wrapped program with it. The ciphertext is
+ * however produced once, when the release is mastered, so the key is a
+ * constant of that release: every copy carries the same encrypted program and
+ * the key disk only lets the protection *check* the serial at run time. That
+ * is why the wrapper can be removed here without the disk, and without
+ * executing any 68000 code.
+ *
+ * Everything but the key is read from the file: the protection is located by
+ * its signature, its own code is decoded (the Trace Vector Decoder used for
+ * the loops is static), and the decoding routine found in there gives the
+ * offset and the length of the wrapped program.
+ */
+class Copylock {
+public:
+	/**
+	 * Unwrap a Copylock protected program.
+	 *
+	 * Returns the wrapped program on success, or nullptr when the file is not
+	 * protected (it can then be used as is) or when the release is unknown.
+	 * The caller owns the returned stream; the input stream is left untouched.
+	 */
+	static Common::SeekableReadStream *unwrap(Common::SeekableReadStream *file);
+
+	/** Whether the file carries a wrapper this class recognizes. */
+	static bool isProtected(Common::SeekableReadStream *file);
+};
+
+} // End of namespace Freescape
+
+#endif // FREESCAPE_COPYLOCK_H
diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index b3dfcfe972f..3cdf14f4e08 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -899,10 +899,41 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_UNSTABLE,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
-	// Full Castle Master, Atari ST.
-	// The player must provide the Copylock-decrypted game executable as "M.PRG"
-	// and the intro program as "J.PRG" (both Huffman-packed; the engine
-	// decompresses them at load time).
+	// Full Castle Master, Atari ST, as found on the original disk: "M.PRG" is
+	// wrapped in a Copylock protection, which the engine removes at load time,
+	// and both it and the intro program "J.PRG" are Huffman-packed.
+	{
+		"castlemaster",
+		"",
+		{
+			{"M.PRG", 0, "6e6e1b68b311a60e7885377fc67b1a93", 269432},
+			{"J.PRG", 0, "4934cf2f304b8ae5327e92b773acd35c", 58514},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAtariST,
+		ADGF_UNSTABLE,
+		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
+	},
+	// Full Castle Master, Atari ST, from the "Castle Master & The Crypt"
+	// release by Incentive. Same game program, wrapped with another Copylock
+	// key and cipher.
+	{
+		"castlemaster",
+		"",
+		{
+			{"M.PRG", 0, "ced428ad4c59ebdeb778fcd4bed4be08", 269478},
+			{"J.PRG", 0, "fd61e4eed3b1a965fa53f5560eb066c0", 58430},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAtariST,
+		ADGF_UNSTABLE,
+		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERATARIST, GAMEOPTION_WASD_CONTROLS)
+	},
+	// The same, with "M.PRG" already decrypted by hand (dec0de and a real or
+	// emulated Atari ST), as was required before the protection was removable
+	// from the engine.
 	{
 		"castlemaster",
 		"",
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index d9cf94474f8..72438d84479 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -22,6 +22,7 @@
 #include "common/memstream.h"
 #include "common/endian.h"
 
+#include "freescape/copylock.h"
 #include "freescape/freescape.h"
 #include "freescape/games/castle/castle.h"
 #include "freescape/language/8bitDetokeniser.h"
@@ -50,8 +51,7 @@ static void emitByteAtari(Common::MemoryWriteStreamDynamic &out, int &rep, int &
 
 // Decompress a Castle Master (Atari ST) self-extracting GEMDOS executable.
 //
-// The player is expected to provide the Copylock-decrypted file (named
-// "M.PRG"): a GEMDOS executable (magic 0x601A) whose DATA segment holds a
+// M.PRG is a GEMDOS executable (magic 0x601A) whose DATA segment holds a
 // Huffman-tree + RLE packed stream that, when expanded, yields the actual
 // Castle Master game executable (also a GEMDOS PRG).
 //
@@ -71,14 +71,20 @@ Common::SeekableReadStream *CastleEngine::decompressAtari(const Common::Path &fi
 	if (!file.open(filename))
 		error("Failed to open '%s'", filename.toString().c_str());
 
-	int fileSize = file.size();
+	// The original file is wrapped in a Copylock protection, which is removed
+	// here; a file that was already decrypted by hand is taken as it is
+	Common::SeekableReadStream *unwrapped = Copylock::unwrap(&file);
+	Common::SeekableReadStream *source = unwrapped ? unwrapped : (Common::SeekableReadStream *)&file;
+
+	int fileSize = source->size();
 	byte *buffer = (byte *)malloc(fileSize);
-	file.read(buffer, fileSize);
+	source->read(buffer, fileSize);
+	delete unwrapped;
 	file.close();
 
 	if (READ_BE_UINT16(buffer) != 0x601a) {
 		free(buffer);
-		error("'%s' is not a GEMDOS executable (expected Copylock-decrypted M.PRG)", filename.toString().c_str());
+		error("'%s' is not a GEMDOS executable", filename.toString().c_str());
 	}
 
 	uint32 textSize = READ_BE_UINT32(buffer + 2);
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index b93980b11c6..2ad965f3155 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/freescape
 MODULE_OBJS := \
 	area.o \
 	assets.o \
+	copylock.o \
 	debugger.o \
 	demo.o \
 	doodle.o \


Commit: 4b4a4d545e1036b323abfc1efa5bd55553050853
    https://github.com/scummvm/scummvm/commit/4b4a4d545e1036b323abfc1efa5bd55553050853
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: support for more castle amiga variants

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


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 3cdf14f4e08..44cea8e309d 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -899,6 +899,38 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_UNSTABLE,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
+	// Full Castle Master, Amiga, by Domark: another build of the same game, with
+	// its data at different offsets
+	{
+		"castlemaster",
+		"",
+		{
+			{"cm", 0, "cbfc54c0e79c30dd64a0b2d72236d56c", 1184},
+			{"w", 0, "63c770f1008a641c5fd5d0b9df2bcbb6", 32000},
+			{"x", 0, "bdf95c6c97dfc35f3d7b07d7f66fc417", 353774},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAmiga,
+		ADGF_UNSTABLE,
+		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
+	},
+	// Full Castle Master, Amiga, from "Castle Master & The Crypt" by Incentive,
+	// where the game is packed into "cmstr.com"
+	{
+		"castlemaster",
+		"",
+		{
+			{"cm", 0, "07d6cabd9d2acdc762956eb3e189cde3", 1188},
+			{"w", 0, "63c770f1008a641c5fd5d0b9df2bcbb6", 32000},
+			{"cmstr.com", 0, "ad4cf87a37561b8f08d888b7a82d6c82", 216950},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAmiga,
+		ADGF_UNSTABLE,
+		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
+	},
 	// Full Castle Master, Atari ST, as found on the original disk: "M.PRG" is
 	// wrapped in a Copylock protection, which the engine removes at load time,
 	// and both it and the intro program "J.PRG" are Huffman-packed.
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index e3eed680f1d..f7bd1944f81 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -96,6 +96,33 @@ const CastleIntroLayout kAmigaIntroLayout = {
 	0
 };
 
+// The other two Amiga releases hold the same intro, moved by a constant: 0x9b6
+// for the Domark build, 0x308 for the one packed with The Crypt. The language
+// tables were checked as well, their pointers move by the same amount.
+const CastleIntroLayout kAmigaDomarkIntroLayout = {
+	0x2e32, 0x371a, 0x501a,
+	0x655a, 0x65c2,
+	0x159aa, 0x1b62a,
+	0x79c2, 0xde62, 0x11262, 0x137a2, 0x14092,
+	0x27dc, 0x286e, 0x2900,
+	0x2a0a, 0x2c0c, 0x2b66,
+	0x277c, 0x279c, 0x27bc,
+	0x26a8, 0x26e4, 0x2720,
+	0
+};
+
+const CastleIntroLayout kAmigaIncentiveIntroLayout = {
+	0x2784, 0x306c, 0x496c,
+	0x5eac, 0x5f14,
+	0x152fc, 0x1af7c,
+	0x7314, 0xd7b4, 0x10bb4, 0x130f4, 0x139e4,
+	0x212e, 0x21c0, 0x2252,
+	0x235c, 0x255e, 0x24b8,
+	0x20ce, 0x20ee, 0x210e,
+	0x1ffa, 0x2036, 0x2072,
+	0
+};
+
 const CastleIntroLayout kAtariIntroLayout = {
 	0x22, 0x90a, 0x220a,
 	0x374a, 0x37b2,
@@ -108,6 +135,63 @@ const CastleIntroLayout kAtariIntroLayout = {
 	1
 };
 
+// Where each asset sits in the game image. The three Amiga releases share the
+// same data, but the text moves independently of the rest, so every offset is
+// listed rather than derived from a single delta.
+const CastleAmigaLayout kAmigaLayout = {
+	0x99ac, 0xa476, 0x998e, 0x11540, 0x147fa, 0x13cf2, 0x158fa, 0x2b4ea,
+	0x2c5ca, 0x49c8, 0x3473a, 0x3620a, 0x37fa6, 0x38136, 0x379fa, 0x38186,
+	0x38c36, 0x38e16, 0x3b706, 0x3b9b0, 0x3bd4a, 0x3bd6a, 0x39136, 0x3a156,
+	0x3cbfa
+};
+
+// "Castle Master" by Domark, which ships the game as a plain "x"
+const CastleAmigaLayout kAmigaDomarkLayout = {
+	0xa22a, 0xad18, 0xa20c, 0x11ff2, 0x152ac, 0x147a4, 0x163ac, 0x2bf9c,
+	0x2d07c, 0x40aa, 0x351ec, 0x36cbc, 0x38a58, 0x38be8, 0x384ac, 0x38c38,
+	0x396e8, 0x398c8, 0x3c1b8, 0x3c462, 0x3c7fc, 0x3c81c, 0x39be8, 0x3ac08,
+	0x3d6ac
+};
+
+// "Castle Master & The Crypt" by Incentive, which packs the game into
+// "cmstr.com" with the same packer the Atari ST release uses
+const CastleAmigaLayout kAmigaIncentiveLayout = {
+	0xa1f6, 0xace4, 0xa1d8, 0x11fca, 0x15284, 0x1477c, 0x16384, 0x2bf74,
+	0x2d054, 0x4086, 0x351c4, 0x36c94, 0x38a30, 0x38bc0, 0x38484, 0x38c10,
+	0x396c0, 0x398a0, 0x3c190, 0x3c43a, 0x3c7d4, 0x3c7f4, 0x39bc0, 0x3abe0,
+	0x3d684
+};
+
+// The game image, either read as is or unpacked, with the matching layout
+Common::SeekableReadStream *CastleEngine::openAmigaGameFile(const CastleAmigaLayout *&layout) {
+	Common::File file;
+	Common::SeekableReadStream *stream = nullptr;
+
+	if (file.open("x"))
+		stream = file.readStream(file.size());
+	else if (file.open("cmstr.com"))
+		stream = decompressCastle(&file, 0);
+	else
+		error("Failed to open 'x' or 'cmstr.com'");
+
+	switch (stream->size()) {
+	case 349975:
+		layout = &kAmigaLayout;
+		break;
+	case 353774:
+		layout = &kAmigaDomarkLayout;
+		break;
+	case 353735:
+		layout = &kAmigaIncentiveLayout;
+		break;
+	default:
+		delete stream;
+		error("Unknown Castle Master (Amiga) build");
+	}
+
+	return stream;
+}
+
 class CastleAmigaIntroPlayer {
 public:
 	CastleAmigaIntroPlayer(CastleEngine *engine, const Common::Array<byte> &introText,
@@ -118,9 +202,32 @@ public:
 		reset();
 	}
 
+	// Refuse to play rather than read past the data when the layout does not
+	// belong to this build of the intro
+	bool layoutFits() const {
+		const int offsets[] = {
+			_l.scrollPlaneA, _l.scrollPlaneB, _l.staticPlane, _l.fillBands, _l.overlay,
+			_l.logo, _l.foreground, _l.sprite1, _l.sprite2, _l.selSprite, _l.objSprite,
+			_l.objArrow, _l.char1Slot, _l.char2Slot, _l.motionSlot, _l.motionStatic1,
+			_l.motionSelect, _l.motionLoop, _l.palBlack, _l.palMain, _l.palSelect,
+			_l.langTableEN, _l.langTableFR, _l.langTableDE
+		};
+
+		for (int i = 0; i < ARRAYSIZE(offsets); i++) {
+			if (offsets[i] < 0 || (uint32)offsets[i] + 64 > _data.size())
+				return false;
+		}
+		return true;
+	}
+
 	bool run(bool &selectedPrincess) {
 		selectedPrincess = false;
 
+		if (!layoutFits()) {
+			warning("Castle Master: the intro data does not match any known layout");
+			return false;
+		}
+
 		clearDrawBuffer();
 		drawBaseScreen();
 		drawStaticLogo();
@@ -884,6 +991,9 @@ private:
 		if (_renderMode == 1 && _frameCounter <= 10)
 			return;
 
+		if (_motionPtr < 0 || (uint32)_motionPtr + 6 > _data.size())
+			_motionPtr = _l.motionLoop;
+
 		if (READ_BE_UINT32(_data.data() + _motionPtr) == 0xffffffff) {
 			_phaseDone = 1;
 			_motionPtr = _l.motionLoop;
@@ -1473,16 +1583,15 @@ void CastleEngine::loadAssetsAmigaDemo() {
 }
 
 void CastleEngine::loadAssetsAmigaFullGame() {
-	Common::File file;
-	file.open("x");
-	if (!file.isOpen())
-		error("Failed to open 'x' file");
+	const CastleAmigaLayout *layout = nullptr;
+	Common::SeekableReadStream *stream = openAmigaGameFile(layout);
+	Common::SeekableReadStream &file = *stream;
 
 	_viewArea = Common::Rect(40, 29, 280, 154);
-	loadMessagesVariableSize(&file, 0x99ac, 178);
-	loadRiddles(&file, 0xa476, 19);
+	loadMessagesVariableSize(&file, layout->messages, 178);
+	loadRiddles(&file, layout->riddles, 19);
 
-	file.seek(0x11540);
+	file.seek(layout->fonts);
 	Common::Array<Graphics::ManagedSurface *> chars;
 	Common::Array<Graphics::ManagedSurface *> charsRiddle;
 	for (int i = 0; i < 90; i++) {
@@ -1503,7 +1612,7 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	_fontRiddle = Font(charsRiddle);
 	_fontRiddle.setCharWidth(9);
 
-	load8bitBinary(&file, 0x158fa, 16);
+	load8bitBinary(&file, layout->areaDB, 16);
 	for (int i = 0; i < 3; i++) {
 		debugC(1, kFreescapeDebugParser, "Continue to parse area index %d at offset %x", _areaMap.size() + i + 1, (int)file.pos());
 		Area *newArea = load8bitArea(&file, 16);
@@ -1517,21 +1626,21 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 		}
 	}
 
-	loadPalettes(&file, 0x147fa);
+	loadPalettes(&file, layout->palettes);
 
 	// COLOR15 cycling table: same format as the demo, terminated by 0xFFFF.
-	file.seek(0x998e);
+	file.seek(layout->colorCycling);
 	while (true) {
 		uint16 val = file.readUint16BE();
 		if (val == 0xFFFF) break;
 		_gfx->_colorCyclingTable.push_back(val);
 	}
 
-	file.seek(0x2b4ea); // Area 255
+	file.seek(layout->area255);
 	_areaMap[255] = load8bitArea(&file, 16);
 
 	// Border NEO image (demo loaded at 0x2cf28 + 0x28 - 0x2 + 0x28 = 0x2cf76)
-	file.seek(0x2c5ca);
+	file.seek(layout->border);
 	_border = loadFrameFromPlanesVertical(&file, 160, 200);
 	_border->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
@@ -1546,31 +1655,31 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Mountains panorama (63 words × 22 rows × 4 planes, interleaved).
-	file.seek(0x49c8);
+	file.seek(layout->mountains);
 	_background = loadFrameFromPlanesInterleaved(&file, 63, 22);
 	_background->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Info menu image (14 words × 116 rows).
-	file.seek(0x3473a);
+	file.seek(layout->menu);
 	_menu = loadFrameFromPlanesInterleaved(&file, 14, 116);
 	_menu->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Additional 224×54 menu-related block.
-	file.seek(0x3620a);
+	file.seek(layout->menuButtons);
 	_menuButtons = loadFrameFromPlanesInterleaved(&file, 14, 54);
 	_menuButtons->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	file.seek(0x37fa6); // Spirit meter indicator background
+	file.seek(layout->spiritMeterBg);
 	_spiritsMeterIndicatorBackgroundFrame = loadFrameFromPlanesInterleaved(&file, 5, 10);
 	_spiritsMeterIndicatorBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	file.seek(0x38136); // Spirit meter indicator
+	file.seek(layout->spiritMeter);
 	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(&file, 1, 10);
 	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
 	// Weight discs of the strength barbell (memory 0x38C1A): 4 frames of
 	// 1 word × 15 rows, i.e. 120 bytes each.
-	file.seek(0x38c36);
+	file.seek(layout->weights);
 	for (int i = 0; i < 4; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 15);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -1578,14 +1687,14 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Barbell shaft (memory 0x38DFA): 5 words × 3 rows.
-	file.seek(0x38e16);
+	file.seek(layout->bar);
 	_strenghtBarFrame = loadFrameFromPlanesInterleaved(&file, 5, 3);
 	_strenghtBarFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
-	loadThunderFramesAmiga(&file, 0x38186); // Memory 0x3816A
+	loadThunderFramesAmiga(&file, layout->thunder);
 
 	// Eye icon sprites: 12 frames × 1 word × 7 rows. Header at 0x3b6fe.
-	file.seek(0x3b706);
+	file.seek(layout->eyeIcons);
 	for (int i = 0; i < 12; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 7);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -1594,7 +1703,7 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 
 	// Crawl/Walk/Run + Sound indicators: 5 frames × 3 words × 12 rows,
 	// preceded by a 6-byte header and a 6-byte mask (skipped here).
-	file.seek(0x379fa + 6 + 6);
+	file.seek(layout->indicators + 6 + 6);
 	{
 		_menuCrawlIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
 		_menuCrawlIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -1659,7 +1768,7 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Flag animation: 5 frames × 2 words × 11 rows.
-	file.seek(0x3b9b0);
+	file.seek(layout->flag);
 	for (int i = 0; i < 5; i++) {
 		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 2, 11);
 		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
@@ -1667,12 +1776,12 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Riddle mask + frames (see demo loader for layout details).
-	file.seek(0x3bd4a);
+	file.seek(layout->riddleMask);
 	uint16 riddleMask[16];
 	for (int i = 0; i < 16; i++)
 		riddleMask[i] = file.readUint16BE();
 
-	file.seek(0x3bd6a);
+	file.seek(layout->riddleTop);
 	_riddleTopFrame = loadFrameFromPlanesInterleaved(&file, 16, 20);
 	_riddleBackgroundFrame = loadFrameFromPlanesInterleaved(&file, 16, 1);
 	_riddleBottomFrame = loadFrameFromPlanesInterleaved(&file, 16, 8);
@@ -1708,9 +1817,9 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 		byte pixelData[kTotalSrcRows * kPixelBytesPerRow];
 		byte maskData[kTotalSrcRows * kMaskBytesPerRow];
 
-		file.seek(0x39136);
+		file.seek(layout->gatePixels);
 		file.read(pixelData, sizeof(pixelData));
-		file.seek(0x3a156);
+		file.seek(layout->gateMask);
 		file.read(maskData, sizeof(maskData));
 
 		uint32 keyColor = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x24, 0xA5);
@@ -1780,10 +1889,10 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Sound effect command table at file offset 0x13cf2 (memory 0x13cd6)
-	_sound = loadSoundsAmiga(&file, 0x13cf2, 36, "cmsnds2", -1);
+	_sound = loadSoundsAmiga(&file, layout->soundTable, 36, "cmsnds2", -1);
 
 	// Embedded ProTracker module for background music.
-	static const int kModOffset = 0x3cbfa;
+	const int kModOffset = layout->mod;
 	file.seek(0, SEEK_END);
 	int fileSize = file.pos();
 	int modSize = fileSize - kModOffset;
@@ -1793,7 +1902,7 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 		file.read(_modData.data(), modSize);
 	}
 
-	file.close();
+	delete stream;
 
 	_areaMap[2]->_groundColor = 1;
 	for (auto &it : _areaMap)
@@ -1854,8 +1963,15 @@ bool CastleEngine::playAmigaIntro() {
 			_mixer->playStream(Audio::Mixer::kMusicSoundType, &introMusicHandle, musicStream);
 	}
 
+	// Each release holds the intro at its own offsets
+	const CastleIntroLayout *introLayout = &kAmigaIntroLayout;
+	if (introText.size() == 0x1cfd0)
+		introLayout = &kAmigaDomarkIntroLayout;
+	else if (introText.size() == 0x1c920)
+		introLayout = &kAmigaIncentiveIntroLayout;
+
 	bool selectedPrincess = false;
-	CastleAmigaIntroPlayer player(this, introText);
+	CastleAmigaIntroPlayer player(this, introText, *introLayout);
 	bool played = player.run(selectedPrincess);
 	if (played)
 		_selectedPrincess = selectedPrincess;
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index 72438d84479..049268e3383 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -66,29 +66,12 @@ static void emitByteAtari(Common::MemoryWriteStreamDynamic &out, int &rep, int &
 // child word `v`: 0 <= v <= 0x201 is an internal node (continue at node v);
 // otherwise it is a leaf whose high byte (unless 0xFF) and low byte are fed to
 // the RLE stage, after which the walk resets to the root.
-Common::SeekableReadStream *CastleEngine::decompressAtari(const Common::Path &filename) {
-	Common::File file;
-	if (!file.open(filename))
-		error("Failed to open '%s'", filename.toString().c_str());
-
-	// The original file is wrapped in a Copylock protection, which is removed
-	// here; a file that was already decrypted by hand is taken as it is
-	Common::SeekableReadStream *unwrapped = Copylock::unwrap(&file);
-	Common::SeekableReadStream *source = unwrapped ? unwrapped : (Common::SeekableReadStream *)&file;
-
+// The Atari ST release wraps the packed stream in a GEMDOS executable, while the
+// Amiga compilation stores it on its own, hence the offset.
+Common::SeekableReadStream *CastleEngine::decompressCastle(Common::SeekableReadStream *source, uint32 packedOffset) {
 	int fileSize = source->size();
 	byte *buffer = (byte *)malloc(fileSize);
 	source->read(buffer, fileSize);
-	delete unwrapped;
-	file.close();
-
-	if (READ_BE_UINT16(buffer) != 0x601a) {
-		free(buffer);
-		error("'%s' is not a GEMDOS executable", filename.toString().c_str());
-	}
-
-	uint32 textSize = READ_BE_UINT32(buffer + 2);
-	uint32 packedOffset = 0x1c + textSize; // start of the DATA segment
 
 	uint32 count = READ_BE_UINT32(buffer + packedOffset);
 	uint16 nodeTableSize = READ_BE_UINT16(buffer + packedOffset + 4);
@@ -130,6 +113,28 @@ Common::SeekableReadStream *CastleEngine::decompressAtari(const Common::Path &fi
 	return new Common::MemoryReadStream(out.getData(), out.size(), DisposeAfterUse::YES);
 }
 
+Common::SeekableReadStream *CastleEngine::decompressAtari(const Common::Path &filename) {
+	Common::File file;
+	if (!file.open(filename))
+		error("Failed to open '%s'", filename.toString().c_str());
+
+	// The original file is wrapped in a Copylock protection, which is removed
+	// here; a file that was already decrypted by hand is taken as it is
+	Common::SeekableReadStream *unwrapped = Copylock::unwrap(&file);
+	Common::SeekableReadStream *source = unwrapped ? unwrapped : (Common::SeekableReadStream *)&file;
+
+	byte header[6];
+	source->read(header, sizeof(header));
+	source->seek(0);
+	if (READ_BE_UINT16(header) != 0x601a)
+		error("'%s' is not a GEMDOS executable", filename.toString().c_str());
+
+	// the packed stream follows the TEXT segment, i.e. it is the DATA segment
+	Common::SeekableReadStream *result = decompressCastle(source, 0x1c + READ_BE_UINT32(header + 2));
+	delete unwrapped;
+	return result;
+}
+
 static uint32 getProTrackerModuleSize(Common::SeekableReadStream *file, uint32 offset) {
 	int64 oldPos = file->pos();
 	uint32 result = 0;
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 6876d715b0a..c722ba7f8a7 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -21,6 +21,14 @@
 
 namespace Freescape {
 
+// Offsets of the assets inside a Castle Master Amiga game image
+struct CastleAmigaLayout {
+	int messages, riddles, colorCycling, fonts, palettes, soundTable, areaDB;
+	int area255, border, mountains, menu, menuButtons, spiritMeterBg, spiritMeter;
+	int indicators, thunder, weights, bar, eyeIcons, flag, riddleMask, riddleTop;
+	int gatePixels, gateMask, mod;
+};
+
 class MusicPlayer;
 
 struct RiddleText {
@@ -141,6 +149,8 @@ public:
 	Graphics::ManagedSurface *loadFrameFromPlanesVertical(Common::SeekableReadStream *file, int widthInBytes, int height);
 	Graphics::ManagedSurface *loadFrameFromPlanesInternalVertical(Common::SeekableReadStream *file, Graphics::ManagedSurface *surface, int width, int height, int plane);
 	Graphics::ManagedSurface *loadFrameFromPlanesInterleaved(Common::SeekableReadStream *file, int widthInWords, int height);
+	Common::SeekableReadStream *openAmigaGameFile(const struct CastleAmigaLayout *&layout);
+	Common::SeekableReadStream *decompressCastle(Common::SeekableReadStream *file, uint32 packedOffset);
 	void loadThunderFramesAmiga(Common::SeekableReadStream *file, int offset);
 	void updateThunderFramesPalette();
 


Commit: b633ef2daed5874c9894d5b9a74a7350d5a44ccb
    https://github.com/scummvm/scummvm/commit/b633ef2daed5874c9894d5b9a74a7350d5a44ccb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: support for more dark amiga variants

Changed paths:
    engines/freescape/copylock.cpp
    engines/freescape/copylock.h
    engines/freescape/detection.cpp
    engines/freescape/freescape.h
    engines/freescape/games/dark/amiga.cpp
    engines/freescape/games/dark/atari.cpp
    engines/freescape/games/dark/dark.h
    engines/freescape/loaders/8bitBinaryLoader.cpp


diff --git a/engines/freescape/copylock.cpp b/engines/freescape/copylock.cpp
index 6f2e1d13545..f6bf3cb96fc 100644
--- a/engines/freescape/copylock.cpp
+++ b/engines/freescape/copylock.cpp
@@ -62,17 +62,34 @@ static const CopylockKey kCopylockKeys[] = {
 	// Castle Master, Atari ST (Castle Master & The Crypt, Incentive), M.PRG
 	{ "f580b8658e622588298d1d6ad87437fb", kCipherShift,
 	  { 0x4ed7a43c, 0x0010c85c, 0x400b051c, 0x5bd5a219, 0x0900ff04, 0x00000000, 0x00000000 } },
+	// Dark Side, Amiga (Stampede cover disk, issue 1), DARKSIDE
+	{ "87310b48b108374a45709885e45a7c2a", kCipherShift,
+	  { 0x00000000, 0x3688589d, 0x003ffe40, 0x1fffc015, 0x735067a3, 0x4281be60, 0x00000000 } },
 	{ nullptr, kCipherRotate, { 0, 0, 0, 0, 0, 0, 0 } }
 };
 
+// The executable the wrapper sits in: a GEMDOS program on the Atari ST, a hunk
+// one on the Amiga.
+enum CopylockContainer {
+	kContainerGemdos = 0,
+	kContainerHunk = 1
+};
+
 // Layout of the protection, recovered from the file itself.
 struct CopylockLayout {
+	CopylockContainer container;
+	uint32 textOffset; // the segment the protection lives in
+	uint32 textSize;
 	uint32 magic;      // key of the Trace Vector Decoder
 	uint32 progOffset; // start of the wrapped program, from the TEXT segment
 	uint32 progSize;   // number of encrypted bytes
 	CopylockCipher cipher;
 };
 
+const uint32 kHunkHeader = 0x000003f3;
+const uint32 kHunkCode = 0x000003e9;
+const uint32 kHunkHeaderSize = 6 * 4; // one hunk, no resident libraries
+
 static uint32 readUint32(const byte *buf, uint32 offset) {
 	return READ_BE_UINT32(buf + offset);
 }
@@ -241,6 +258,29 @@ static const CopylockKey *findKey(Common::SeekableReadStream *file) {
 	return nullptr;
 }
 
+// The TEXT of a GEMDOS program, or the single code hunk of an Amiga executable.
+bool findTextSegment(const Common::Array<byte> &data, CopylockLayout &layout) {
+	if (data.size() >= 0x1c && READ_BE_UINT16(data.data()) == 0x601a) {
+		layout.container = kContainerGemdos;
+		layout.textOffset = 0x1c;
+		layout.textSize = readUint32(data.data(), 2);
+		return layout.textSize >= 0x100 && layout.textOffset + layout.textSize <= data.size();
+	}
+
+	// A hunk header of one code hunk, then that hunk: 0x3E9 and its length in
+	// longwords, this one without the memory flags the header carries.
+	if (data.size() >= kHunkHeaderSize + 8 && readUint32(data.data(), 0) == kHunkHeader &&
+			readUint32(data.data(), 4) == 0 && readUint32(data.data(), 8) == 1 &&
+			readUint32(data.data(), kHunkHeaderSize) == kHunkCode) {
+		layout.container = kContainerHunk;
+		layout.textOffset = kHunkHeaderSize + 8;
+		layout.textSize = 4 * readUint32(data.data(), kHunkHeaderSize + 4);
+		return layout.textSize >= 0x100 && layout.textOffset + layout.textSize <= data.size();
+	}
+
+	return false;
+}
+
 static bool readLayout(Common::SeekableReadStream *file, Common::Array<byte> &data, CopylockLayout &layout) {
 	file->seek(0);
 	data.resize(file->size());
@@ -248,16 +288,12 @@ static bool readLayout(Common::SeekableReadStream *file, Common::Array<byte> &da
 		return false;
 	file->seek(0);
 
-	if (data.size() < 0x1c || READ_BE_UINT16(data.data()) != 0x601a)
+	if (!findTextSegment(data, layout))
 		return false;
 
-	uint32 textSize = readUint32(data.data(), 2);
-	if (textSize < 0x100 || 0x1c + textSize > data.size())
-		return false;
-
-	const byte *text = data.data() + 0x1c;
+	const byte *text = data.data() + layout.textOffset;
 	uint32 start;
-	if (!findProtection(text, textSize, start, layout.magic))
+	if (!findProtection(text, layout.textSize, start, layout.magic))
 		return false;
 
 	return findDecoder(text, start, layout.magic, layout);
@@ -269,23 +305,9 @@ bool Copylock::isProtected(Common::SeekableReadStream *file) {
 	return readLayout(file, data, layout);
 }
 
-Common::SeekableReadStream *Copylock::unwrap(Common::SeekableReadStream *file) {
-	Common::Array<byte> data;
-	CopylockLayout layout;
-
-	if (!readLayout(file, data, layout))
-		return nullptr;
-
-	const CopylockKey *key = findKey(file);
-	if (!key)
-		return nullptr;
-
-	if (key->cipher != layout.cipher) {
-		warning("Copylock: the key does not match the cipher of the file");
-		return nullptr;
-	}
-
-	uint32 progOffset = 0x1c + layout.progOffset;
+Common::SeekableReadStream *unwrapGemdos(const Common::Array<byte> &data,
+		const CopylockLayout &layout, const CopylockKey &key) {
+	uint32 progOffset = layout.textOffset + layout.progOffset;
 	if (progOffset + layout.progSize > data.size()) {
 		warning("Copylock: the wrapped program does not fit in the file");
 		return nullptr;
@@ -297,7 +319,7 @@ Common::SeekableReadStream *Copylock::unwrap(Common::SeekableReadStream *file) {
 	uint32 available = data.size() - progOffset;
 	byte *prog = (byte *)malloc(available);
 	memcpy(prog, data.data() + progOffset, available);
-	decodeProgram(prog, layout.progSize, *key);
+	decodeProgram(prog, layout.progSize, key);
 
 	uint32 size = READ_BE_UINT16(prog) == 0x601a ? programSize(prog, available) : 0;
 	if (!size) {
@@ -309,4 +331,72 @@ Common::SeekableReadStream *Copylock::unwrap(Common::SeekableReadStream *file) {
 	return new Common::MemoryReadStream(prog, size, DisposeAfterUse::YES);
 }
 
+// Here the displacement of the decoding loop is relative to where the protection
+// runs, not to the file, so it cannot locate the program. The key stream is a
+// ring anchored at the start of the code hunk though, so the hunk is decoded as
+// a whole and the program found by the hunk header it carries.
+Common::SeekableReadStream *unwrapHunk(const Common::Array<byte> &data,
+		const CopylockLayout &layout, const CopylockKey &key) {
+	byte *code = (byte *)malloc(layout.textSize);
+	memcpy(code, data.data() + layout.textOffset, layout.textSize);
+	decodeProgram(code, layout.textSize, key);
+
+	uint32 offset = 0;
+	uint32 hunkSize = 0;
+	for (; offset + 8 <= layout.textSize; offset += 4) {
+		if (readUint32(code, offset) != kHunkCode)
+			continue;
+		hunkSize = 4 * readUint32(code, offset + 4);
+		if (hunkSize && offset + 8 + hunkSize <= layout.textSize)
+			break;
+		hunkSize = 0;
+	}
+
+	if (!hunkSize) {
+		warning("Copylock: decryption did not yield a hunk executable");
+		free(code);
+		return nullptr;
+	}
+
+	debugC(1, kFreescapeDebugParser, "Copylock: program at 0x%x, %d bytes of code",
+		layout.textOffset + offset, hunkSize);
+
+	// The wrapper holds the hunks but not the header of the executable they came
+	// from, which is rebuilt so the result reads like the unprotected release.
+	uint32 imageSize = layout.textSize - offset;
+	byte *prog = (byte *)malloc(kHunkHeaderSize + imageSize);
+	WRITE_BE_UINT32(prog, kHunkHeader);
+	WRITE_BE_UINT32(prog + 4, 0);  // no resident library
+	WRITE_BE_UINT32(prog + 8, 1);  // one hunk, first and last
+	WRITE_BE_UINT32(prog + 12, 0);
+	WRITE_BE_UINT32(prog + 16, 0);
+	WRITE_BE_UINT32(prog + 20, hunkSize / 4);
+	memcpy(prog + kHunkHeaderSize, code + offset, imageSize);
+	free(code);
+
+	return new Common::MemoryReadStream(prog, kHunkHeaderSize + imageSize, DisposeAfterUse::YES);
+}
+
+Common::SeekableReadStream *Copylock::unwrap(Common::SeekableReadStream *file) {
+	Common::Array<byte> data;
+	CopylockLayout layout;
+
+	if (!readLayout(file, data, layout))
+		return nullptr;
+
+	const CopylockKey *key = findKey(file);
+	if (!key)
+		return nullptr;
+
+	if (key->cipher != layout.cipher) {
+		warning("Copylock: the key does not match the cipher of the file");
+		return nullptr;
+	}
+
+	if (layout.container == kContainerHunk)
+		return unwrapHunk(data, layout, *key);
+
+	return unwrapGemdos(data, layout, *key);
+}
+
 } // End of namespace Freescape
diff --git a/engines/freescape/copylock.h b/engines/freescape/copylock.h
index 7d96579c0af..2c44b3def73 100644
--- a/engines/freescape/copylock.h
+++ b/engines/freescape/copylock.h
@@ -44,8 +44,11 @@ namespace Freescape {
  *
  * Everything but the key is read from the file: the protection is located by
  * its signature, its own code is decoded (the Trace Vector Decoder used for
- * the loops is static), and the decoding routine found in there gives the
- * offset and the length of the wrapped program.
+ * the loops is static), and the decoding routine found in there tells which
+ * cipher wraps the program, and where it is.
+ *
+ * Both the GEMDOS programs of the Atari ST releases and the hunk executables
+ * of the Amiga ones are handled.
  */
 class Copylock {
 public:
diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 44cea8e309d..99c6109d800 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -442,7 +442,7 @@ const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSUPPORTED,
+		ADGF_NO_FLAGS,
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERAMIGA)
 	},
 	{
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index a6e31cd866b..edd64114b7b 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -341,6 +341,7 @@ public:
 
 	void parseAmigaAtariHeader(Common::SeekableReadStream *file);
 	Common::SeekableReadStream *decryptFileAmigaAtari(const Common::Path &packed, const Common::Path &unpacker, uint32 unpackArrayOffset);
+	Common::SeekableReadStream *decryptFileAmigaAtari(const Common::Path &packed, Common::SeekableReadStream *unpacker, uint32 unpackArrayOffset);
 	Common::SeekableReadStream *decryptFileAtariVirtualWorlds(const Common::Path &filename);
 
 	// Areas
diff --git a/engines/freescape/games/dark/amiga.cpp b/engines/freescape/games/dark/amiga.cpp
index 44f7c266e54..1eb38530560 100644
--- a/engines/freescape/games/dark/amiga.cpp
+++ b/engines/freescape/games/dark/amiga.cpp
@@ -23,6 +23,7 @@
 
 #include "graphics/palette.h"
 
+#include "freescape/copylock.h"
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
 #include "freescape/language/8bitDetokeniser.h"
@@ -107,16 +108,33 @@ void decodeMaskedAmigaSprite(Common::SeekableReadStream *file, Graphics::Managed
 	}
 }
 
+// The executable is 0.drk in every release but the Stampede cover disk, where it
+// is DARKSIDE and Copylock protected. Unwrapped it is the very same executable.
+Common::SeekableReadStream *DarkEngine::openAmigaExecutable() {
+	Common::File *file = new Common::File();
+	if (file->open("0.drk"))
+		return file;
+
+	if (!file->open("DARKSIDE"))
+		error("Failed to open 0.drk or DARKSIDE");
+
+	Common::SeekableReadStream *unwrapped = Copylock::unwrap(file);
+	delete file;
+	if (!unwrapped)
+		error("Failed to remove the Copylock protection of DARKSIDE");
+
+	return unwrapped;
+}
+
 void DarkEngine::loadAssetsAmigaFullGame() {
-	Common::File file;
-	file.open("0.drk");
+	Common::SeekableReadStream *executable = openAmigaExecutable();
 	// Load title image: Amiga non-interleaved bitplanes with Atari ST palette
 	// Palette: 16 words at file offset 0x9934, Atari ST 3-bit $0RGB format
-	file.seek(0x9934);
+	executable->seek(0x9934);
 	Graphics::Palette pal(16);
 	for (int i = 0; i < 16; i++) {
-		byte v1 = file.readByte();
-		byte v2 = file.readByte();
+		byte v1 = executable->readByte();
+		byte v2 = executable->readByte();
 		byte r = floor((v1 & 0x07) * 255.0 / 7.0);
 		byte g = floor((v2 & 0x70) * 255.0 / 7.0 / 16.0);
 		byte b = floor((v2 & 0x07) * 255.0 / 7.0);
@@ -124,14 +142,14 @@ void DarkEngine::loadAssetsAmigaFullGame() {
 	}
 
 	// Bitplanes: 4 planes x 8000 bytes at file offset 0x99B0, non-interleaved
-	file.seek(0x99B0);
+	executable->seek(0x99B0);
 	Graphics::ManagedSurface *titleSurface = new Graphics::ManagedSurface();
 	titleSurface->create(320, 200, Graphics::PixelFormat::createFormatCLUT8());
 	titleSurface->fillRect(Common::Rect(0, 0, 320, 200), 0);
 	for (int plane = 0; plane < 4; plane++) {
 		for (int y = 0; y < 200; y++) {
 			for (int x = 0; x < 40; x++) {
-				byte b = file.readByte();
+				byte b = executable->readByte();
 				for (int n = 0; n < 8; n++) {
 					int px = x * 8 + (7 - n);
 					int bit = ((b >> n) & 0x01) << plane;
@@ -160,9 +178,9 @@ void DarkEngine::loadAssetsAmigaFullGame() {
 	_gfx->_colorCyclingSpeed = 1;
 	_gfx->_colorCyclingTimer = 0; // always active
 
-	file.close();
-
-	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.drk", "0.drk", 798);
+	// the unpack array ends at program address $1320, i.e. 0x1340 in the file
+	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.drk", executable, 830);
+	delete executable;
 	parseAmigaAtariHeader(stream);
 
 	_border = loadAndConvertNeoImage(stream, 0x1b762);
diff --git a/engines/freescape/games/dark/atari.cpp b/engines/freescape/games/dark/atari.cpp
index f2f00c2d1b2..cbad5e37431 100644
--- a/engines/freescape/games/dark/atari.cpp
+++ b/engines/freescape/games/dark/atari.cpp
@@ -48,6 +48,7 @@ void DarkEngine::loadAssetsAtariFullGame() {
 
 	file.close();
 
+	// same array, ending at program address $132E, i.e. 0x134A in the file
 	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.drk", "0.drk", 840);
 	parseAmigaAtariHeader(stream);
 
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index a8771bc5e51..28599f72060 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -81,6 +81,7 @@ public:
 	void loadAssetsDOSDemo() override;
 	void loadAssetsC64FullGame() override;
 	void loadAssetsAmigaFullGame() override;
+	Common::SeekableReadStream *openAmigaExecutable();
 	void loadAssetsAtariFullGame() override;
 
 	void loadAssetsCPCFullGame() override;
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index def662d3651..0691378ed21 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -1189,6 +1189,44 @@ void FreescapeEngine::parseAmigaAtariHeader(Common::SeekableReadStream *stream)
 }
 
 Common::SeekableReadStream *FreescapeEngine::decryptFileAmigaAtari(const Common::Path &packed, const Common::Path &unpacker, uint32 unpackArrayOffset) {
+	Common::File executable;
+	if (!executable.open(unpacker))
+		error("Failed to open %s", unpacker.toString().c_str());
+
+	return decryptFileAmigaAtari(packed, &executable, unpackArrayOffset);
+}
+
+// moveq #0,d1 ; move.w -(a5),d1 ; move.w -(a5),d0 ; add.l d1,d1 ;
+// move.w d0,(a6,d1.l) ; dbra d7,...
+const byte kUnpackLoop[] = {
+	0x72, 0x00, 0x32, 0x25, 0x30, 0x25, 0xd2, 0x81,
+	0x3d, 0x80, 0x18, 0x00, 0x51, 0xcf, 0xff, 0xf2
+};
+
+// The unpack array is a table of 1024 (word offset, value) pairs holding the
+// words the encryption took out of the data file. It lives in the executable,
+// which walks it back to front, so the lea that precedes that loop gives its
+// end, as an offset within the code segment. Every release is laid out
+// differently, hence the search; the value the caller passes is only used when
+// the loop cannot be found.
+uint32 findUnpackArrayOffset(const byte *data, uint32 size, uint32 fallback) {
+	// the code follows the header of the executable, hunk or GEMDOS
+	uint32 header = (size >= 4 && READ_BE_UINT32(data) == 0x000003f3) ? 0x20 : 0x1c;
+
+	for (uint32 i = 6; i + sizeof(kUnpackLoop) <= size; i += 2) {
+		if (READ_BE_UINT16(data + i - 6) != 0x4bf9) // lea $xxxxxxxx.l,a5
+			continue;
+		if (memcmp(data + i, kUnpackLoop, sizeof(kUnpackLoop)))
+			continue;
+
+		return READ_BE_UINT32(data + i - 4) + header - 0x1002; // 0x1000 long, read from its last word
+	}
+
+	debugC(1, kFreescapeDebugParser, "Unpack array not found, using offset %d", fallback);
+	return fallback;
+}
+
+Common::SeekableReadStream *FreescapeEngine::decryptFileAmigaAtari(const Common::Path &packed, Common::SeekableReadStream *unpacker, uint32 unpackArrayOffset) {
 	Common::File file;
 	file.open(packed);
 	if (!file.isOpen())
@@ -1230,23 +1268,21 @@ Common::SeekableReadStream *FreescapeEngine::decryptFileAmigaAtari(const Common:
 		a6 += 4;
 	}
 
-	file.open(unpacker);
-	if (!file.isOpen())
-		error("Failed to open %s", unpacker.toString().c_str());
-
 	int originalSize = size;
-	size = file.size();
+	size = unpacker->size();
 	byte *unpackArray = (byte *)malloc(size);
-	file.read(unpackArray, size);
-	file.close();
+	unpacker->seek(0);
+	unpacker->read(unpackArray, size);
+
+	uint32 offset = findUnpackArrayOffset(unpackArray, size, unpackArrayOffset);
+	if (offset + 4098 > uint32(size))
+		error("The unpack array of the executable used to decrypt %s is out of bounds", packed.toString().c_str());
 
-	byte *unpackArrayPtr = unpackArray + unpackArrayOffset;
+	byte *unpackArrayPtr = unpackArray + offset;
 	uint32 i = 2 * 1024;
 	do {
 		uint8 ptr0 = unpackArrayPtr[2 * i];
-		//debug("%x -> %x", unpackArrayOffset + 2 * i, ptr0);
 		uint8 ptr1 = unpackArrayPtr[2 * i + 1];
-		//debug("%x -> %x", unpackArrayOffset + 2 * i + 1, ptr1);
 		uint8 val0 = unpackArrayPtr[2 * (i - 1)];
 		uint8 val1 = unpackArrayPtr[2 * (i - 1) + 1];
 
@@ -1256,6 +1292,7 @@ Common::SeekableReadStream *FreescapeEngine::decryptFileAmigaAtari(const Common:
 		i = i - 2;
 	} while (i > 0);
 
+	free(unpackArray);
 	return (new Common::MemoryReadStream(encryptedBuffer, originalSize));
 }
 


Commit: 719b757e0d0d120023f4c7835634d52a0e96db2e
    https://github.com/scummvm/scummvm/commit/719b757e0d0d120023f4c7835634d52a0e96db2e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-07-29T17:13:17+02:00

Commit Message:
FREESCAPE: support for more dark atari variants

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


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 99c6109d800..deb59b02077 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -418,7 +418,7 @@ const ADGameDescription gameDescriptions[] = {
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERATARIST)
 	},
 	{
-		// Stampede AtariST, Issue 1
+		// Stampede AtariST, Issue 1, where 0.DRK ships packed
 		"darkside",
 		"",
 		{
diff --git a/engines/freescape/games/dark/atari.cpp b/engines/freescape/games/dark/atari.cpp
index cbad5e37431..88a0f2ff136 100644
--- a/engines/freescape/games/dark/atari.cpp
+++ b/engines/freescape/games/dark/atari.cpp
@@ -18,7 +18,9 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  *
  */
+#include "common/endian.h"
 #include "common/file.h"
+#include "common/memstream.h"
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
@@ -26,10 +28,158 @@
 
 namespace Freescape {
 
+// Code lengths and their bases, for the three escape coded fields of the packer
+// below. A field is read again with the next, shorter code whenever it comes
+// out with every bit set.
+const byte kAtariPackLiteralBits[4] = { 0x0a, 0x03, 0x02, 0x02 };
+const byte kAtariPackLiteralBase[4] = { 0x0e, 0x07, 0x04, 0x01 };
+const byte kAtariPackLengthBits[5] = { 0x0a, 0x02, 0x01, 0x00, 0x00 };
+const byte kAtariPackLengthBase[5] = { 0x0a, 0x06, 0x04, 0x03, 0x02 };
+const byte kAtariPackOffsetBits[3] = { 0x0b, 0x04, 0x07 };
+const uint16 kAtariPackOffsetBase[3] = { 0x0120, 0x0000, 0x0020 };
+
+// The bit stream is read backwards, one byte at a time, each byte carrying a
+// set bit below its data to mark where it ends.
+struct AtariPackReader {
+	const byte *data;
+	uint32 pos;
+	byte bits;
+
+	byte getBit() {
+		byte bit = (bits >> 7) & 1;
+		bits = (bits << 1) & 0xff;
+		if (bits == 0) {
+			byte next = data[--pos];
+			bits = ((next << 1) | bit) & 0xff;
+			bit = (next >> 7) & 1;
+		}
+		return bit;
+	}
+
+	uint16 readBits(int count) {
+		uint16 value = 0;
+		while (count-- > 0)
+			value = (value << 1) | getBit();
+		return value;
+	}
+
+	// Several codes are introduced by the number of set bits before them
+	int countOnes(int start) {
+		while (getBit()) {
+			if (--start < 0)
+				break;
+		}
+		return start + 1;
+	}
+};
+
+// Expand an Atari ST executable packed with the "****" packer, which some
+// releases ship instead of the plain program: its TEXT segment holds a small
+// loader and its DATA segment the packed stream. Ported from that loader; the
+// stream expands backwards, from its end towards its start, and so does the
+// output. Returns nullptr when the file is not packed, so it can be used as is.
+Common::SeekableReadStream *depackAtariExecutable(Common::SeekableReadStream *file) {
+	uint32 size = file->size();
+	byte *data = (byte *)malloc(size);
+	file->seek(0);
+	file->read(data, size);
+
+	uint32 stream = 0x1c + READ_BE_UINT32(data + 2);
+	if (size < 0x1c || stream + 12 > size || READ_BE_UINT32(data + stream) != 0x2a2a2a2a) {
+		free(data);
+		return nullptr;
+	}
+
+	uint32 base = stream + 4;
+	uint32 unpackedSize = READ_BE_UINT32(data + base);
+	uint32 packedSize = READ_BE_UINT32(data + base + 4);
+	if (!unpackedSize || base + 4 + packedSize > size || packedSize < 12) {
+		free(data);
+		return nullptr;
+	}
+
+	AtariPackReader in;
+	in.data = data;
+	in.pos = base + 4 + packedSize - 6;
+	if ((int16)READ_BE_UINT16(data + in.pos) < 0)
+		in.pos--;
+	in.bits = data[--in.pos];
+
+	byte *out = (byte *)malloc(unpackedSize);
+	uint32 dst = unpackedSize;
+
+	while (in.pos > base && dst > 0) {
+		if (in.getBit()) {
+			uint16 count = 0;
+			if (in.getBit()) {
+				int i = 3;
+				while (true) {
+					count = in.readBits(kAtariPackLiteralBits[i]);
+					if (i == 0 || count != (1 << kAtariPackLiteralBits[i]) - 1)
+						break;
+					i--;
+				}
+				count += kAtariPackLiteralBase[i];
+			}
+			if (count + 1 > dst || in.pos < count + 1)
+				break;
+			for (uint16 i = 0; i <= count; i++)
+				out[--dst] = data[--in.pos];
+		}
+
+		if (in.pos <= base + 8)
+			break;
+
+		int i = in.countOnes(3);
+		uint16 length = kAtariPackLengthBase[i];
+		if (kAtariPackLengthBits[i])
+			length += in.readBits(kAtariPackLengthBits[i]);
+
+		uint16 offset;
+		if (length == 2) {
+			// short matches carry their offset in a code of their own
+			offset = in.getBit() ? in.readBits(9) + 0x40 : in.readBits(6);
+		} else {
+			int j = in.countOnes(1);
+			offset = in.readBits(kAtariPackOffsetBits[j] + 1) + kAtariPackOffsetBase[j];
+		}
+
+		uint32 src = dst + offset + length;
+		if (length > dst || src > unpackedSize)
+			break;
+		for (uint16 n = 0; n < length; n++)
+			out[--dst] = out[--src];
+	}
+
+	free(data);
+	if (dst != 0) {
+		warning("The packed Atari executable expanded to %d bytes short of %d",
+			dst, unpackedSize);
+		free(out);
+		return nullptr;
+	}
+
+	return new Common::MemoryReadStream(out, unpackedSize, DisposeAfterUse::YES);
+}
+
+// The Stampede cover disk ships 0.DRK packed; every other release ships it as
+// the plain program, and expanding it gives back the same executable.
+Common::SeekableReadStream *DarkEngine::openAtariExecutable() {
+	Common::File *file = new Common::File();
+	if (!file->open("0.drk"))
+		error("Failed to open 0.drk");
+
+	Common::SeekableReadStream *depacked = depackAtariExecutable(file);
+	if (!depacked)
+		return file;
+
+	delete file;
+	return depacked;
+}
+
 void DarkEngine::loadAssetsAtariFullGame() {
-	Common::File file;
-	file.open("0.drk");
-	_title = loadAndConvertNeoImage(&file, 0x13ec);
+	Common::SeekableReadStream *executable = openAtariExecutable();
+	_title = loadAndConvertNeoImage(executable, 0x13ec);
 
 	// Atari ST Dark Side: same COLOR5 cycling as Amiga.
 	{
@@ -46,10 +196,9 @@ void DarkEngine::loadAssetsAtariFullGame() {
 	_gfx->_colorCyclingSpeed = 1;
 	_gfx->_colorCyclingTimer = 0;
 
-	file.close();
-
 	// same array, ending at program address $132E, i.e. 0x134A in the file
-	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.drk", "0.drk", 840);
+	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.drk", executable, 840);
+	delete executable;
 	parseAmigaAtariHeader(stream);
 
 	_border = loadAndConvertNeoImage(stream, 0xd710);
diff --git a/engines/freescape/games/dark/dark.h b/engines/freescape/games/dark/dark.h
index 28599f72060..a39ef191f60 100644
--- a/engines/freescape/games/dark/dark.h
+++ b/engines/freescape/games/dark/dark.h
@@ -83,6 +83,7 @@ public:
 	void loadAssetsAmigaFullGame() override;
 	Common::SeekableReadStream *openAmigaExecutable();
 	void loadAssetsAtariFullGame() override;
+	Common::SeekableReadStream *openAtariExecutable();
 
 	void loadAssetsCPCFullGame() override;
 




More information about the Scummvm-git-logs mailing list