[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