[Scummvm-git-logs] scummvm master -> 1e2b3bb0410d2c0606ecee75b72a0d74421361b6

neuromancer noreply at scummvm.org
Fri Apr 10 19:13:58 UTC 2026


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

Summary:
a0547351ab COLONY: improve sounds generation for the intro in dos
d273f9f302 COLONY: improve intro text for the intro in dos
aedad94f5a COLONY: more fixes for the intro in dos
bc63b4c9c4 COLONY: make sure the compass spins in dos
9abc59008b FREESCAPE: changed AY8912Stream by YM2149Emu to improve music playblack in eclipse atari
0cb47035c0 FREESCAPE: properly render eclipse UI for atari
4294c03aa8 FREESCAPE: render water jar in eclipse atari
d7ace101f7 FREESCAPE: dark rooms implemented in eclipse atari
e566f73b5b FREESCAPE: limited lantern batery in eclipse atari
d0da715423 FREESCAPE: better handling of palettes for eclipse atari
26fe85ba26 FREESCAPE: make sure dark areas have reasonable palettes in eclipse atarist
444ca7e18f FREESCAPE: refactored palette handling and light effect in eclipse atarist
956cbac5f1 FREESCAPE: simplify brightness handling in eclipse atarist
24bcddbf11 FREESCAPE: update brightness in real time in eclipse atarist
4ae107a7a7 FREESCAPE: use correct frames for brightness UI updates in eclipse atarist
8b44c75060 FREESCAPE: support more eclipse atarist releases
adf891981d FREESCAPE: initial support for eclipse amiga releases
0f28460d4a FREESCAPE: initial support music in eclipse amiga
1e2b3bb041 FREESCAPE: decay fix in wb music module


Commit: a0547351ab0a79896971b565a444f7835d72aa24
    https://github.com/scummvm/scummvm/commit/a0547351ab0a79896971b565a444f7835d72aa24
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
COLONY: improve sounds generation for the intro in dos

Changed paths:
    engines/colony/intro.cpp
    engines/colony/sound.cpp


diff --git a/engines/colony/intro.cpp b/engines/colony/intro.cpp
index 9115bde96c4..6d5bcfd1ad0 100644
--- a/engines/colony/intro.cpp
+++ b/engines/colony/intro.cpp
@@ -395,28 +395,21 @@ void ColonyEngine::playIntro() {
 			_gfx->clear(_gfx->black());
 		}
 
-		// Final crash: DOS IBM_INTR.C lines 119-131
-		// Original DOS: while(!SoundDone()) { EraseRect; PaintRect; } — flashes
-		// for the full Explode2 sound (~7s). On Mac the digitized sample is shorter.
+		// Final crash: DOS IBM_INTR.C lines 119-131.
+		// Keep this much shorter than the original long PC speaker strobe.
 		_sound->stop();
-		if (getPlatform() == Common::kPlatformMacintosh) {
-			// Mac: play short digitized crash sample with brief flash
-			_sound->play(Sound::kExplode);
-			uint32 crashStart = _system->getMillis();
-			int frame = 0;
-			while (!shouldQuit() && _sound->isPlaying() && _system->getMillis() - crashStart < 2000) {
-				_gfx->clear(frame % 2 ? _gfx->black() : _gfx->white());
-				_gfx->copyToScreen();
-				_system->delayMillis(125);
-				frame++;
-			}
-			_sound->stop();
-		} else {
-			// DOS: skip the harsh 7-second PC speaker explode
-			_gfx->clear(_gfx->black());
+		_sound->play(Sound::kExplode);
+		const uint32 crashFlashDurationMs = 700;
+		const uint32 crashFlashStepMs = 200;
+		const uint32 crashStart = _system->getMillis();
+		int frame = 0;
+		while (!shouldQuit() && _system->getMillis() - crashStart < crashFlashDurationMs) {
+			_gfx->clear(frame % 2 ? _gfx->black() : _gfx->white());
 			_gfx->copyToScreen();
-			_system->delayMillis(1000);
+			_system->delayMillis(crashFlashStepMs);
+			frame++;
 		}
+		_sound->stop();
 		_gfx->clear(_gfx->black());
 		_gfx->copyToScreen();
 
@@ -452,7 +445,8 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 	};
 	const int storyLength = ARRAYSIZE(story);
 
-	if (getPlatform() == Common::kPlatformMacintosh)
+	bool qt = checkSkipRequested();
+	if (!qt)
 		_sound->play(Sound::kBeamMe);
 
 	_gfx->clear(_gfx->black());
@@ -487,7 +481,6 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 	// moves up by inc=4 each frame until text is visible at its correct position.
 	// We simulate by drawing text with a y-offset that starts at _height and decreases to 0.
 	int inc = 4;
-	bool qt = false;
 
 	for (int scrollOff = _height; scrollOff > 0 && !qt; scrollOff -= inc) {
 		if (checkSkipRequested()) {
@@ -525,10 +518,10 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 	if (!qt) {
 		for (int scrollOff = 0; scrollOff > -_height && !qt; scrollOff -= inc) {
 			if (checkSkipRequested()) {
-			qt = true;
-			_sound->stop();
-			break;
-		}
+				qt = true;
+				_sound->stop();
+				break;
+			}
 
 			_gfx->clear(_gfx->black());
 			for (int i = 0; i < storyLength; i++) {
@@ -541,9 +534,9 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 		}
 	}
 
-	// Original does NOT stop the sound here  BeamMe continues playing
-	// and intro() waits for it with while(!SoundDone()) after ScrollInfo returns.
-	// Only stop if skipping (qt already stops in the modifier+click handlers above).
+	// DOS stops BeamMe here; Mac lets it continue and waits in playIntro().
+	if (!qt && !macFont)
+		_sound->stop();
 	_gfx->clear(_gfx->black());
 	_gfx->copyToScreen();
 	return qt;
@@ -937,6 +930,7 @@ bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *m
 	// Mac original: blue starts at 0xFFFF and decreases by 4096 per line pair
 	// B&W Mac: white gradient instead of blue
 	const bool bwMac = (macFont && !_hasMacColors);
+	const bool macStyle = (macFont != nullptr);
 	for (int i = 0; i < 16; i++) {
 		int val = 255 - i * 16; // 255, 239, 223, ... 15
 		if (val < 0)
@@ -972,33 +966,43 @@ bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *m
 		_system->delayMillis(8);
 	}
 
-	// Phase 2: Klaxon flash — original intro.c lines 312-322:
-	// EndCSound(); for 6 iterations: wait for sound, stop, play klaxon,
-	// InvertRect. The klaxon is short (~200ms). Inversions happen rapidly
-	// at the start of each klaxon, creating a fast strobe effect.
+	// Phase 2: Klaxon flash — original intro.c lines 312-322.
+	// DOS does 4 full klaxon cycles here; Mac uses the longer 6-flash variant.
 	_sound->stop();
 	_gfx->setXorMode(true);
-	for (int i = 0; i < 6; i++) {
+	const int klaxonCount = macStyle ? 6 : 4;
+	const uint32 dosKlaxonFlashMs = 12 * 1000 / 60;
+	for (int i = 0; i < klaxonCount; i++) {
 		if (checkSkipRequested()) {
 			_gfx->setXorMode(false);
 			return true;
 		}
 
-		_sound->play(Sound::kKlaxon);
 		// InvertRect(&invrt) — XOR the text band
 		_gfx->fillRect(Common::Rect(0, centery + 1, _width, centery + 16), 0xFFFFFFFF);
 		_gfx->copyToScreen();
-		// Brief pause matching klaxon duration (~200ms)
-		_system->delayMillis(200);
+
+		_sound->play(Sound::kKlaxon);
+		if (macStyle) {
+			// Keep the snappier Mac timing.
+			_system->delayMillis(200);
+		} else {
+			// At modern frame rates, waiting for the synthesized klaxon to end
+			// drags these warning cards out too long. Keep a short fixed flash.
+			_system->delayMillis(dosKlaxonFlashMs);
+		}
 	}
 	_gfx->setXorMode(false);
-	// Wait for last klaxon to finish
-	while (_sound->isPlaying() && !shouldQuit())
-		_system->delayMillis(10);
+	if (macStyle) {
+		// Wait for last klaxon to finish
+		while (_sound->isPlaying() && !shouldQuit())
+			_system->delayMillis(10);
+	}
 	_sound->stop();
 
-	// Phase 3: PlayMars(), scroll text off to the left
-	_sound->play(Sound::kMars);
+	// Phase 3: Mac resumes Mars here; DOS scrolls out silently.
+	if (macStyle)
+		_sound->play(Sound::kMars);
 	for (int x = targetX; x > -swidth; x -= 2) {
 		_gfx->fillRect(Common::Rect(0, centery + 1, _width, centery + 16), 0);
 		_gfx->drawString(font, str, x, centery + 2, 176, Graphics::kTextAlignLeft);
diff --git a/engines/colony/sound.cpp b/engines/colony/sound.cpp
index 1870bf6c615..3dea5fcc281 100644
--- a/engines/colony/sound.cpp
+++ b/engines/colony/sound.cpp
@@ -34,6 +34,73 @@
 
 namespace Colony {
 
+namespace {
+
+struct MelodyStep {
+	uint32 divider;
+	uint8 ticks;
+};
+
+
+void queueMelody(Audio::PCSpeaker *speaker, const MelodyStep *steps, uint count, uint repeats, uint32 tickUs) {
+	if (!speaker || !steps || !count || !repeats)
+		return;
+
+	for (uint repeat = 0; repeat < repeats; ++repeat) {
+		for (uint i = 0; i < count; ++i) {
+			const MelodyStep &step = steps[i];
+			if (step.divider == 0)
+				speaker->playQueue(Audio::PCSpeaker::kWaveFormSilence, 0, step.ticks * tickUs);
+			else
+				speaker->playQueue(Audio::PCSpeaker::kWaveFormSquare, 1193180.0f / step.divider, step.ticks * tickUs);
+		}
+	}
+}
+
+const uint32 kRestDivider = 0;
+
+// Ambient DOS intro phrases from COLDAT.ASM.
+// These are sparse note patterns with long rests; exact VSP note decoding
+// is not available here, so we map them to stable PC speaker dividers.
+static const MelodyStep kStars1Phrase[] = {
+	{ 4831, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 },
+	{ 4063, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 },
+	{ 1811, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 },
+	{ 1715, 3 }, { kRestDivider, 3 }
+};
+
+static const MelodyStep kStars2Phrase[] = {
+	{ 4831, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { 2712, 3 },
+	{ 4063, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { 2032, 3 },
+	{ 1811, 3 }, { kRestDivider, 3 }, { kRestDivider, 3 }, { 9121, 3 },
+	{ 1715, 3 }, { kRestDivider, 3 }
+};
+
+static const MelodyStep kStars3Phrase[] = {
+	{ 4831, 3 }, { kRestDivider, 3 }, { 9121, 3 }, { 2712, 3 },
+	{ 4063, 3 }, { kRestDivider, 3 }, { 7670, 3 }, { 2032, 3 },
+	{ 1811, 3 }, { kRestDivider, 3 }, { 4560, 3 }, { 9121, 3 },
+	{ 1715, 3 }, { kRestDivider, 3 }
+};
+
+static const MelodyStep kStars4Phrase[] = {
+	{ 4831, 3 }, { 1524, 3 }, { 9121, 3 }, { 2712, 3 },
+	{ 4063, 3 }, { 4831, 3 }, { 7670, 3 }, { 2032, 3 },
+	{ 1811, 3 }, { 3044, 3 }, { 4560, 3 }, { 9121, 3 },
+	{ 1715, 3 }, { 2032, 3 }
+};
+
+
+// The original DOS VSP data loops these ambience patterns until killed.
+// In ScummVM that becomes grating, so keep them long enough for the intro
+// section but do not let them drone for many seconds.
+const uint kIntroStarsRepeats = 2;
+const int kBeamMeRamp1Steps = 20;
+const int kBeamMeRamp2Steps = 20;
+const int kBeamMeRamp3Steps = 80;
+
+} // namespace
+
 Sound::Sound(ColonyEngine *vm) : _vm(vm), _resMan(nullptr), _appResMan(nullptr) {
 	_speaker = new Audio::PCSpeaker();
 	_speaker->init();
@@ -231,14 +298,31 @@ void Sound::playPCSpeaker(int soundID) {
 		queueTick(1000, 2);
 		queueTick(40000, 3);
 		break;
+	case kBeamMe:
+		queueTick(65535, 1);
+		for (int i = 0; i < kBeamMeRamp1Steps; ++i) {
+			queueTick(65535 - i * 20, 1);
+		}
+		queueTick(65531, 1);
+		for (int i = 0; i < kBeamMeRamp2Steps; ++i) {
+			queueTick(65531 - i * 20, 1);
+		}
+		queueTick(65535, 1);
+		for (int i = 0; i < kBeamMeRamp3Steps; ++i) {
+			queueTick(65535 - i * 20, 1);
+		}
+		break;
 	case kStars1:
+		queueMelody(_speaker, kStars1Phrase, ARRAYSIZE(kStars1Phrase), kIntroStarsRepeats, tickUs);
+		break;
 	case kStars2:
+		queueMelody(_speaker, kStars2Phrase, ARRAYSIZE(kStars2Phrase), kIntroStarsRepeats, tickUs);
+		break;
 	case kStars3:
+		queueMelody(_speaker, kStars3Phrase, ARRAYSIZE(kStars3Phrase), kIntroStarsRepeats, tickUs);
+		break;
 	case kStars4:
-		queueTick(4000, 2);
-		queueTick(8000, 2);
-		queueTick(2000, 2);
-		queueTick(6000, 2);
+		queueMelody(_speaker, kStars4Phrase, ARRAYSIZE(kStars4Phrase), kIntroStarsRepeats, tickUs);
 		break;
 	case kToilet: // "Sailor's Hornpipe"
 		queueTick(2651, 4); // G


Commit: d273f9f302ce44c99bf3d76d4b461680e7109af7
    https://github.com/scummvm/scummvm/commit/d273f9f302ce44c99bf3d76d4b461680e7109af7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
COLONY: improve intro text for the intro in dos

Changed paths:
    engines/colony/intro.cpp


diff --git a/engines/colony/intro.cpp b/engines/colony/intro.cpp
index 6d5bcfd1ad0..c243201bcf7 100644
--- a/engines/colony/intro.cpp
+++ b/engines/colony/intro.cpp
@@ -427,7 +427,22 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 	// waits for click, then scrolls it off the top.
 	// Mac original: TextFont(190 = Commando); TextSize(12);
 	// Text blue starts at 0xFFFF and fades by -4096 per visible line.
-	const char *story[] = {
+	static const char *const kDosStory[] = {
+		"Mankind has left the",
+		"cradle of earth and",
+		"is beginning to eye",
+		"the galaxy. He has",
+		"begun to colonize",
+		"distant planets but has",
+		"yet to meet any alien",
+		"life forms.",
+		"****",
+		"Until now...",
+		"****",
+		"Press any key to begin",
+		"the Adventure..."
+	};
+	static const char *const kMacStory[] = {
 		"",
 		"Mankind has left the",
 		"cradle of earth and",
@@ -437,13 +452,14 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 		"distant planets but has",
 		"yet to meet any alien",
 		"life forms.",
-		"",      // null separator in original
+		"",
 		"Until now...",
-		"",      // null separator in original
+		"",
 		"Click to begin",
 		"the Adventure..."
 	};
-	const int storyLength = ARRAYSIZE(story);
+	const char *const *story = macFont ? kMacStory : kDosStory;
+	const int storyLength = macFont ? ARRAYSIZE(kMacStory) : ARRAYSIZE(kDosStory);
 
 	bool qt = checkSkipRequested();
 	if (!qt)
@@ -454,9 +470,10 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 
 	Graphics::DosFont dosFont;
 	const Graphics::Font *font = macFont ? macFont : (const Graphics::Font *)&dosFont;
+	const bool macStyle = (macFont != nullptr);
 
-	// Original uses 19px line height, centers vertically within height
-	int lineHeight = 19;
+	// Mac and DOS use different title layouts here.
+	int lineHeight = macStyle ? 19 : 15;
 	int totalHeight = lineHeight * storyLength;
 	int ht = (_height - totalHeight) / 2;
 
@@ -476,31 +493,38 @@ bool ColonyEngine::scrollInfo(const Graphics::Font *macFont) {
 	}
 	_gfx->setPalette(pal, 200, storyLength);
 
-	// Phase 1: Scroll text up from below screen
-	// Original: scrollRect starts at bottom (stayRect.bottom..stayRect.bottom*2),
-	// moves up by inc=4 each frame until text is visible at its correct position.
-	// We simulate by drawing text with a y-offset that starts at _height and decreases to 0.
-	int inc = 4;
+	int inc = macStyle ? 4 : 8;
+	if (macStyle) {
+		// Mac: scroll the text in from below.
+		for (int scrollOff = _height; scrollOff > 0 && !qt; scrollOff -= inc) {
+			if (checkSkipRequested()) {
+				qt = true;
+				_sound->stop();
+				break;
+			}
 
-	for (int scrollOff = _height; scrollOff > 0 && !qt; scrollOff -= inc) {
-		if (checkSkipRequested()) {
-			qt = true;
-			_sound->stop();
-			break;
+			_gfx->clear(_gfx->black());
+			for (int i = 0; i < storyLength; i++) {
+				int drawY = ht + lineHeight * i + scrollOff;
+				if (strlen(story[i]) > 0 && drawY >= 0 && drawY < _height)
+					_gfx->drawString(font, story[i], _width / 2, drawY, 200 + i, Graphics::kTextAlignCenter);
+			}
+			_gfx->copyToScreen();
+			_system->delayMillis(16);
 		}
 
-		_gfx->clear(_gfx->black());
-		for (int i = 0; i < storyLength; i++) {
-			int drawY = ht + lineHeight * i + scrollOff;
-			if (strlen(story[i]) > 0 && drawY >= 0 && drawY < _height)
-				_gfx->drawString(font, story[i], _width / 2, drawY, 200 + i, Graphics::kTextAlignCenter);
+		// Draw final position (scrollOff = 0)
+		if (!qt) {
+			_gfx->clear(_gfx->black());
+			for (int i = 0; i < storyLength; i++) {
+				if (strlen(story[i]) > 0)
+					_gfx->drawString(font, story[i], _width / 2, ht + lineHeight * i, 200 + i, Graphics::kTextAlignCenter);
+			}
+			_gfx->copyToScreen();
 		}
-		_gfx->copyToScreen();
-		_system->delayMillis(16);
-	}
-
-	// Draw final position (scrollOff = 0)
-	if (!qt) {
+	} else {
+		// DOS: draw the whole story immediately, then wait for input before
+		// scrolling it off the top.
 		_gfx->clear(_gfx->black());
 		for (int i = 0; i < storyLength; i++) {
 			if (strlen(story[i]) > 0)


Commit: aedad94f5ac7a172620ffe87a5c1d69299a2c82e
    https://github.com/scummvm/scummvm/commit/aedad94f5ac7a172620ffe87a5c1d69299a2c82e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
COLONY: more fixes for the intro in dos

Changed paths:
    engines/colony/intro.cpp


diff --git a/engines/colony/intro.cpp b/engines/colony/intro.cpp
index c243201bcf7..be70a7aff33 100644
--- a/engines/colony/intro.cpp
+++ b/engines/colony/intro.cpp
@@ -934,13 +934,9 @@ bool ColonyEngine::makePlanet() {
 
 bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *macFont) {
 	// Original: TimeSquare() in intro.c
-	// 1. Draw horizontal blue gradient lines above/below center
-	// 2. Scroll red text from right to center
-	// 3. Flash klaxon 6 times with inverted rect
-	// 4. Play Mars again, scroll text off to the left
-	//
-	// Mac original: fcolor starts at (0,0,0xFFFF) and subtracts 4096 per pair of lines.
-	// Text is drawn in red (0xFFFF,0,0) on black background.
+	// Mac and DOS use different presentation here. DOS is a monochrome/gray
+	// warning band with 16-pixel blits and white text; Mac uses the colorful
+	// gradient treatment.
 
 	_gfx->clear(_gfx->black());
 
@@ -950,44 +946,67 @@ bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *m
 
 	int centery = _height / 2 - 10;
 
-	// Set up gradient palette entries (160-175) for the gradient lines
-	// Mac original: blue starts at 0xFFFF and decreases by 4096 per line pair
-	// B&W Mac: white gradient instead of blue
 	const bool bwMac = (macFont && !_hasMacColors);
 	const bool macStyle = (macFont != nullptr);
-	for (int i = 0; i < 16; i++) {
-		int val = 255 - i * 16; // 255, 239, 223, ... 15
-		if (val < 0)
-			val = 0;
-		byte pal[3] = { (byte)(bwMac ? val : 0), (byte)(bwMac ? val : 0), (byte)val };
-		_gfx->setPalette(pal, 160 + i, 1);
-	}
-	// Set palette entry 176 for text (red in color, white in B&W)
-	{
-		byte pal[3] = { 255, (byte)(bwMac ? 255 : 0), (byte)(bwMac ? 255 : 0) };
-		_gfx->setPalette(pal, 176, 1);
-	}
+	const uint32 grayIndex = 160;
+	const uint32 textIndex = 176;
+	const Common::Rect textBand(0, centery + 1, _width, centery + 16);
+
+	if (macStyle) {
+		// Mac original: blue starts at 0xFFFF and decreases by 4096 per line pair.
+		for (int i = 0; i < 16; i++) {
+			int val = 255 - i * 16; // 255, 239, 223, ... 15
+			if (val < 0)
+				val = 0;
+			byte pal[3] = { (byte)(bwMac ? val : 0), (byte)(bwMac ? val : 0), (byte)val };
+			_gfx->setPalette(pal, 160 + i, 1);
+		}
+
+		// Text is red in color, white in B&W.
+		{
+			byte pal[3] = { 255, (byte)(bwMac ? 255 : 0), (byte)(bwMac ? 255 : 0) };
+			_gfx->setPalette(pal, textIndex, 1);
+		}
 
-	// Draw blue gradient lines above/below center band
-	for (int i = 0; i < 16; i++) {
-		_gfx->drawLine(0, centery - 2 - i * 2, _width, centery - 2 - i * 2, 160 + i);
-		_gfx->drawLine(0, centery - 2 - (i * 2 + 1), _width, centery - 2 - (i * 2 + 1), 160 + i);
-		_gfx->drawLine(0, centery + 16 + i * 2, _width, centery + 16 + i * 2, 160 + i);
-		_gfx->drawLine(0, centery + 16 + i * 2 + 1, _width, centery + 16 + i * 2 + 1, 160 + i);
+		// Draw the blue gradient lines above/below the center band.
+		for (int i = 0; i < 16; i++) {
+			_gfx->drawLine(0, centery - 2 - i * 2, _width, centery - 2 - i * 2, 160 + i);
+			_gfx->drawLine(0, centery - 2 - (i * 2 + 1), _width, centery - 2 - (i * 2 + 1), 160 + i);
+			_gfx->drawLine(0, centery + 16 + i * 2, _width, centery + 16 + i * 2, 160 + i);
+			_gfx->drawLine(0, centery + 16 + i * 2 + 1, _width, centery + 16 + i * 2 + 1, 160 + i);
+		}
+	} else {
+		// DOS warning band: white outer lines, gray inner lines, white text.
+		const byte grayPal[3] = { 170, 170, 170 };
+		const byte whitePal[3] = { 255, 255, 255 };
+		_gfx->setPalette(grayPal, grayIndex, 1);
+		_gfx->setPalette(whitePal, textIndex, 1);
+
+		_gfx->drawLine(0, centery - 3, _width, centery - 3, textIndex);
+		_gfx->drawLine(0, centery - 2, _width, centery - 2, grayIndex);
+		_gfx->drawLine(0, centery - 1, _width, centery - 1, textIndex);
+		_gfx->drawLine(0, centery + 17, _width, centery + 17, textIndex);
+		_gfx->drawLine(0, centery + 18, _width, centery + 18, grayIndex);
+		_gfx->drawLine(0, centery + 19, _width, centery + 19, textIndex);
 	}
 	_gfx->copyToScreen();
 
-	// Phase 1: Scroll text in from the right to center
-	// Original: if(Button()) if(qt=OptionKey()) break;
+	// Phase 1: Scroll text in from the right to center.
+	// DOS uses 16-pixel blits of a black text box; Mac scrolls smoothly.
 	int targetX = (_width - swidth) / 2;
-	for (int x = _width; x > targetX; x -= 2) {
-		_gfx->fillRect(Common::Rect(0, centery + 1, _width, centery + 16), 0);
-		_gfx->drawString(font, str, x, centery + 2, 176, Graphics::kTextAlignLeft);
+	const int startX = macStyle ? _width : (_width + 16);
+	const int stepX = macStyle ? 2 : 16;
+	const int endX = macStyle ? -swidth : (-swidth - 16);
+	const uint32 scrollDelayMs = macStyle ? 8 : (1000 / 60);
+
+	for (int x = startX; x > targetX; x -= stepX) {
+		_gfx->fillRect(textBand, 0);
+		_gfx->drawString(font, str, x, centery + 2, textIndex, Graphics::kTextAlignLeft);
 		_gfx->copyToScreen();
 
 		if (checkSkipRequested())
 			return true;
-		_system->delayMillis(8);
+		_system->delayMillis(scrollDelayMs);
 	}
 
 	// Phase 2: Klaxon flash — original intro.c lines 312-322.
@@ -1003,7 +1022,7 @@ bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *m
 		}
 
 		// InvertRect(&invrt) — XOR the text band
-		_gfx->fillRect(Common::Rect(0, centery + 1, _width, centery + 16), 0xFFFFFFFF);
+		_gfx->fillRect(textBand, 0xFFFFFFFF);
 		_gfx->copyToScreen();
 
 		_sound->play(Sound::kKlaxon);
@@ -1027,14 +1046,14 @@ bool ColonyEngine::timeSquare(const Common::String &str, const Graphics::Font *m
 	// Phase 3: Mac resumes Mars here; DOS scrolls out silently.
 	if (macStyle)
 		_sound->play(Sound::kMars);
-	for (int x = targetX; x > -swidth; x -= 2) {
-		_gfx->fillRect(Common::Rect(0, centery + 1, _width, centery + 16), 0);
-		_gfx->drawString(font, str, x, centery + 2, 176, Graphics::kTextAlignLeft);
+	for (int x = targetX; x > endX; x -= stepX) {
+		_gfx->fillRect(textBand, 0);
+		_gfx->drawString(font, str, x, centery + 2, textIndex, Graphics::kTextAlignLeft);
 		_gfx->copyToScreen();
 
 		if (checkSkipRequested())
 			return true;
-		_system->delayMillis(8);
+		_system->delayMillis(scrollDelayMs);
 	}
 
 	return false;


Commit: bc63b4c9c410ad44ab164153a4d986938ad2751f
    https://github.com/scummvm/scummvm/commit/bc63b4c9c410ad44ab164153a4d986938ad2751f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
COLONY: make sure the compass spins in dos

Changed paths:
    engines/colony/ui.cpp


diff --git a/engines/colony/ui.cpp b/engines/colony/ui.cpp
index 22fb2d60b7b..a40a589018a 100644
--- a/engines/colony/ui.cpp
+++ b/engines/colony/ui.cpp
@@ -471,8 +471,8 @@ void ColonyEngine::drawDashboardStep1() {
 	// RCompass(): draw compass oval
 	// _compassRect stores the post-shrink compOval (inner erasable area)
 	if (_compassRect.width() > 2 && _compassRect.height() > 2) {
-		// Original draws FillOval on the pre-shrink rect, then EraseOval on the shrunk rect.
-		// Pre-shrink = _compassRect expanded by 2 on each side
+		// Original DOS draws a solid black outer oval, shrinks the rect by 2px on
+		// each side, then erases the inner oval to white, leaving a black annulus.
 		const int cx = (_compassRect.left + _compassRect.right) >> 1;
 		const int cy = (_compassRect.top + _compassRect.bottom) >> 1;
 		const int outerRx = (_compassRect.width() + 4) >> 1;
@@ -480,16 +480,14 @@ void ColonyEngine::drawDashboardStep1() {
 		const int innerRx = _compassRect.width() >> 1;
 		const int innerRy = _compassRect.height() >> 1;
 
-		// FillOval: filled oval (vINTWHITE background)
-		_gfx->fillEllipse(cx, cy, outerRx, outerRy, 15);
-		// EraseOval: clear interior (also vINTWHITE)
+		_gfx->fillEllipse(cx, cy, outerRx, outerRy, 0);
 		_gfx->fillEllipse(cx, cy, innerRx, innerRy, 15);
-		// Frame the outer oval
-		_gfx->drawEllipse(cx, cy, outerRx, outerRy, 0);
 
-		// Compass needle: updateDashBoard() uses Me.ang
-		const int ex = cx + ((_cost[_me.ang] * innerRx) >> 8);
-		const int ey = cy - ((_sint[_me.ang] * innerRy) >> 8);
+		// In the current ScummVM colony controls, the rendered camera follows
+		// _me.look. Using _me.ang here leaves the DOS compass static under
+		// mouse/camera rotation even though the scene is turning.
+		const int ex = cx + ((_cost[_me.look] * _compassRect.width()) >> 8);
+		const int ey = cy - ((_sint[_me.look] * _compassRect.height()) >> 8);
 		_gfx->drawLine(cx, cy, ex, ey, 0); // vBLACK needle
 	}
 
@@ -741,8 +739,9 @@ void ColonyEngine::drawMiniMap(uint32 lineColor) {
 		ccenterx = (_headsUpRect.left + _headsUpRect.right) >> 1;
 		ccentery = (_headsUpRect.top + _headsUpRect.bottom) >> 1;
 	}
-	const int tsin = _sint[_me.look];
-	const int tcos = _cost[_me.look];
+	const uint8 mapAngle = _me.look;
+	const int tsin = _sint[mapAngle];
+	const int tcos = _cost[mapAngle];
 
 	int xcorner[6];
 	int ycorner[6];
@@ -962,8 +961,9 @@ void ColonyEngine::drawAutomap() {
 	const int yloc = (lExt * ((_me.yindex << 8) - _me.yloc)) >> 8;
 	const int ccx = (vp.left + vp.right) >> 1;
 	const int ccy = (vp.top + vp.bottom) >> 1;
-	const int tsin = _sint[_me.look];
-	const int tcos = _cost[_me.look];
+	const uint8 mapAngle = _me.look;
+	const int tsin = _sint[mapAngle];
+	const int tcos = _cost[mapAngle];
 	const uint32 lineColor = 0;
 
 	const int radius = (int)(sqrtf((float)(vpW * vpW + vpH * vpH)) / (2.0f * lExt)) + 2;


Commit: 9abc59008b26d32ba9c792c6a15b82d2c2e850f5
    https://github.com/scummvm/scummvm/commit/9abc59008b26d32ba9c792c6a15b82d2c2e850f5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: changed AY8912Stream by YM2149Emu to improve music playblack in eclipse atari

Changed paths:
    audio/softsynth/ym2149.h
    engines/freescape/games/eclipse/atari.music.cpp


diff --git a/audio/softsynth/ym2149.h b/audio/softsynth/ym2149.h
index 086d5179e74..953466c7dbb 100644
--- a/audio/softsynth/ym2149.h
+++ b/audio/softsynth/ym2149.h
@@ -26,7 +26,7 @@
 
 namespace Audio {
 
-class YM2149Emu final : public YM2149::YM2149, public EmulatedChip {
+class YM2149Emu : public YM2149::YM2149, public EmulatedChip {
 public:
 	YM2149Emu();
 	~YM2149Emu() override;
diff --git a/engines/freescape/games/eclipse/atari.music.cpp b/engines/freescape/games/eclipse/atari.music.cpp
index e4760ce4138..29a056f5991 100644
--- a/engines/freescape/games/eclipse/atari.music.cpp
+++ b/engines/freescape/games/eclipse/atari.music.cpp
@@ -35,7 +35,7 @@
  *   $0DCC  Pattern pointer table (up to 31 x uint32 BE)
  */
 
-#include "audio/softsynth/ay8912.h"
+#include "audio/softsynth/ym2149.h"
 
 #include "freescape/freescape.h"
 #include "freescape/wb.h"
@@ -58,7 +58,7 @@ static const int kTENumPeriods     = 96;
 static const int kTENumInstruments = 12;
 static const int kTEMaxPatterns    = 31;
 
-class EclipseAtariMusicStream : public Audio::AY8912Stream {
+class EclipseAtariMusicStream : public Audio::YM2149Emu {
 public:
 	EclipseAtariMusicStream(const byte *data, uint32 dataSize, int songNum, int rate = 44100);
 	~EclipseAtariMusicStream() override {}
@@ -66,6 +66,7 @@ public:
 	int readBuffer(int16 *buffer, const int numSamples) override;
 	bool endOfData() const override { return !_musicActive; }
 	bool endOfStream() const override { return !_musicActive; }
+	Audio::AudioStream *toAudioStream() { return this; }
 
 private:
 	// --- Data tables ---
@@ -194,6 +195,7 @@ private:
 	void buildArpeggioTable(ChannelState &c, byte mask);
 	void tickUpdate();
 	void writeYMRegisters();
+	void setReg(int reg, byte value) { writeReg(reg, value); }
 
 	uint16 getPeriod(int note) const {
 		if (note < 0 || note >= kTENumPeriods)
@@ -225,9 +227,8 @@ private:
 // ---------------------------------------------------------------------------
 
 EclipseAtariMusicStream::EclipseAtariMusicStream(const byte *data, uint32 dataSize,
-                                                   int songNum, int rate)
-	: AY8912Stream(rate, 2000000), // YM2149 at 2 MHz on Atari ST
-	  _data(data), _dataSize(dataSize),
+                                                   int songNum, int)
+	: _data(data), _dataSize(dataSize),
 	  _musicActive(false), _tickSpeed(6), _tickCounter(0),
 	  _tickSampleCount(0), _hwEnvelopeDirty(false), _hwEnvelopePeriod(0), _hwEnvelopeShape(0),
 	  _numPatterns(0) {
@@ -239,11 +240,7 @@ EclipseAtariMusicStream::EclipseAtariMusicStream(const byte *data, uint32 dataSi
 	memset(_arpeggioIntervals, 0, sizeof(_arpeggioIntervals));
 	memset(_channels, 0, sizeof(_channels));
 
-	// Reset all YM registers
-	for (int r = 0; r < 14; r++)
-		setReg(r, 0);
-	// Mixer: all channels disabled initially
-	setReg(7, 0x3F);
+	init();
 
 	loadTables();
 	startSong(songNum);
@@ -1035,8 +1032,7 @@ int EclipseAtariMusicStream::readBuffer(int16 *buffer, const int numSamples) {
 		return 0;
 
 	int samplesGenerated = 0;
-	// AY8912Stream is stereo: 2 int16 values per frame
-	int samplesPerTick = (getRate() / 50) * 2;
+	int samplesPerTick = MAX(1, getRate() / 50);
 
 	while (samplesGenerated < numSamples && _musicActive) {
 		int remaining = samplesPerTick - _tickSampleCount;


Commit: 0cb47035c02a4954646014642d7ea5b119ea8978
    https://github.com/scummvm/scummvm/commit/0cb47035c02a4954646014642d7ea5b119ea8978
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: properly render eclipse UI for atari

Changed paths:
    engines/freescape/freescape.cpp
    engines/freescape/freescape.h
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index d6cf35df8f1..fd02470e18b 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -1148,6 +1148,7 @@ void FreescapeEngine::rotate(float xoffset, float yoffset, float zoffset) {
 	if (_roll < 0.0f)
 		_roll += 360.0f;
 
+	onRotate(xoffset, yoffset, zoffset);
 	updateCamera();
 }
 
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index e51774ec742..f35e0d1501b 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -427,6 +427,7 @@ public:
 
 	Math::Vector3d directionToVector(float pitch, float heading, bool useTable);
 	void updateCamera();
+	virtual void onRotate(float xoffset, float yoffset, float zoffset) {}
 
 	// Camera options
 	Common::Point _crossairPosition;
diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 376a2ec865a..b7dccfb08d8 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -29,6 +29,23 @@
 
 namespace Freescape {
 
+const int kAtariCompassPhaseCount = 72;
+const int kAtariCompassBaseFrames = 19;
+const int kAtariCompassTotalFrames = 37;
+const int kAtariClockCenterX = 106;
+const int kAtariClockCenterY = 159;
+const int kAtariCompassX = 176;
+const int kAtariCompassY = 151;
+
+// Repaired ST phase-to-frame table from $1542. Six corrupt bytes in the dumped
+// binary break the intended 37-frame sweep built from $20B36 and $22B46.
+const int8 kAtariCompassPhaseToFrame[kAtariCompassPhaseCount] = {
+	0, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
+	36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19,
+	0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 17, 16,
+	15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
+};
+
 void EclipseEngine::initAmigaAtari() {
 	_viewArea = Common::Rect(32, 16, 288, 118);
 
@@ -231,7 +248,162 @@ static Graphics::ManagedSurface *loadAtariSTRawSprite(Common::SeekableReadStream
 	return surface;
 }
 
-static Graphics::ManagedSurface *loadAtariSTSprite(Common::SeekableReadStream *stream,
+float atariCompassAbs(float value) {
+	return value < 0.0f ? -value : value;
+}
+
+int atariCompassRoundToNearestInt(float value) {
+	return value >= 0.0f ? (int)(value + 0.5f) : (int)(value - 0.5f);
+}
+
+int wrapAtariCompassPhase(int phase) {
+	phase %= kAtariCompassPhaseCount;
+	if (phase < 0)
+		phase += kAtariCompassPhaseCount;
+	return phase;
+}
+
+float normalizeAtariCompassYaw(float yaw) {
+	while (yaw < 0.0f)
+		yaw += 360.0f;
+	while (yaw >= 360.0f)
+		yaw -= 360.0f;
+	return yaw;
+}
+
+int atariCompassPhaseToYaw(int phase) {
+	int rotationY = wrapAtariCompassPhase(phase) * 5;
+	int yaw = 0;
+	if (rotationY < 90) {
+		yaw = 90 - rotationY;
+	} else if (rotationY <= 180) {
+		yaw = 450 - rotationY;
+	} else if (rotationY <= 225) {
+		yaw = rotationY;
+	} else if (rotationY < 270) {
+		yaw = rotationY - 90;
+	} else {
+		yaw = 450 - rotationY;
+	}
+
+	while (yaw < 0)
+		yaw += 360;
+	while (yaw >= 360)
+		yaw -= 360;
+	return yaw;
+}
+
+float atariCompassAngularDistance(float a, float b) {
+	float diff = atariCompassAbs(a - b);
+	if (diff > 180.0f)
+		diff = 360.0f - diff;
+	return diff;
+}
+
+int stepAtariCompassPhaseTowardTarget(int currentPhase, int targetPhase) {
+	if (currentPhase == targetPhase)
+		return 0;
+
+	int forwardDistance = wrapAtariCompassPhase(targetPhase - currentPhase);
+	int backwardDistance = wrapAtariCompassPhase(currentPhase - targetPhase);
+	return (forwardDistance < backwardDistance) ? 1 : -1;
+}
+
+bool atariCompassPhaseUsesVerticalFlip(int phase) {
+	int wrappedPhase = wrapAtariCompassPhase(phase);
+	return wrappedPhase < 18 || wrappedPhase > 54;
+}
+
+void atariCompassRoxl(uint32 &value, uint32 &extend) {
+	uint32 nextExtend = (value >> 31) & 1;
+	value = ((value << 1) | extend) & 0xFFFFFFFF;
+	extend = nextExtend;
+}
+
+void atariCompassRoxr(uint32 &value, uint32 &extend) {
+	uint32 nextExtend = value & 1;
+	value = ((extend << 31) | (value >> 1)) & 0xFFFFFFFF;
+	extend = nextExtend;
+}
+
+void decodeAtariSTNeedleRow(Graphics::ManagedSurface *surface, int row, const uint16 *rowWords,
+		const Graphics::PixelFormat &pixelFormat) {
+	for (int half = 0; half < 2; half++) {
+		int offset = half * 4;
+		for (int bit = 15; bit >= 0; bit--) {
+			byte colorIndex = ((rowWords[offset + 0] >> bit) & 1)
+			                | (((rowWords[offset + 1] >> bit) & 1) << 1)
+			                | (((rowWords[offset + 2] >> bit) & 1) << 2)
+			                | (((rowWords[offset + 3] >> bit) & 1) << 3);
+			if (colorIndex == 0)
+				continue;
+
+			int x = half * 16 + (15 - bit);
+			uint32 color = pixelFormat.ARGBToColor(0xFF,
+				kBorderPalette[colorIndex * 3],
+				kBorderPalette[colorIndex * 3 + 1],
+				kBorderPalette[colorIndex * 3 + 2]);
+			surface->setPixel(x, row, color);
+		}
+	}
+}
+
+Graphics::ManagedSurface *loadAtariSTNeedleSprite(Common::SeekableReadStream *stream,
+		int pixelOffset, const Graphics::PixelFormat &pixelFormat) {
+	stream->seek(pixelOffset);
+
+	uint32 transparent = pixelFormat.ARGBToColor(0x00, 0, 0, 0);
+	Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+	surface->create(32, 27, pixelFormat);
+	surface->fillRect(Common::Rect(0, 0, surface->w, surface->h), transparent);
+
+	for (int row = 0; row < 27; row++) {
+		uint16 sourceWords[8];
+		for (int word = 0; word < 8; word++)
+			sourceWords[word] = stream->readUint16BE();
+		decodeAtariSTNeedleRow(surface, row, sourceWords, pixelFormat);
+	}
+
+	return surface;
+}
+
+void buildAtariSTCompassMirrorCache(Common::SeekableReadStream *stream, int pixelOffset,
+		Common::Array<Graphics::ManagedSurface *> &sprites, const Graphics::PixelFormat &pixelFormat) {
+	stream->seek(pixelOffset + 432);
+
+	uint32 extend = 0;
+	uint32 transformed = 0;
+
+	for (int frame = 1; frame < kAtariCompassBaseFrames; frame++) {
+		uint32 transparent = pixelFormat.ARGBToColor(0x00, 0, 0, 0);
+		Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
+		surface->create(32, 27, pixelFormat);
+		surface->fillRect(Common::Rect(0, 0, surface->w, surface->h), transparent);
+
+		for (int row = 0; row < 27; row++) {
+			uint16 sourceWords[8];
+			uint16 rowWords[8];
+			for (int word = 0; word < 8; word++)
+				sourceWords[word] = stream->readUint16BE();
+
+			for (int plane = 0; plane < 4; plane++) {
+				uint32 value = ((uint32)sourceWords[plane] << 16) | sourceWords[plane + 4];
+				for (int bit = 0; bit <= 0x20; bit++) {
+					atariCompassRoxl(value, extend);
+					atariCompassRoxr(transformed, extend);
+				}
+				rowWords[plane] = transformed >> 16;
+				rowWords[plane + 4] = transformed & 0xFFFF;
+			}
+
+			decodeAtariSTNeedleRow(surface, row, rowWords, pixelFormat);
+		}
+
+		sprites[18 + frame] = surface;
+	}
+}
+
+Graphics::ManagedSurface *loadAtariSTSprite(Common::SeekableReadStream *stream,
 		int maskOffset, int pixelOffset, int cols, int rows) {
 	// Read per-column mask (1 word per column, same for all rows)
 	stream->seek(maskOffset);
@@ -262,6 +434,44 @@ static Graphics::ManagedSurface *loadAtariSTSprite(Common::SeekableReadStream *s
 	return surface;
 }
 
+void drawAtariCompassNeedle(Graphics::Surface *surface, const Graphics::ManagedSurface *needle,
+		int x, int y, bool flipVertically, uint32 transparent) {
+	for (int destY = 0; destY < needle->h; destY++) {
+		int srcY = flipVertically ? (needle->h - 1 - destY) : destY;
+		for (int srcX = 0; srcX < needle->w; srcX++) {
+			uint32 pixel = needle->getPixel(srcX, srcY);
+			if (pixel != transparent)
+				surface->setPixel(x + srcX, y + destY, pixel);
+		}
+	}
+}
+
+int EclipseEngine::atariCompassPhaseFromRotationY(float rotationY) const {
+	return wrapAtariCompassPhase(atariCompassRoundToNearestInt(rotationY / 5.0f));
+}
+
+int EclipseEngine::atariCompassTargetPhaseFromYaw(float yaw, int referencePhase) const {
+	float normalizedYaw = normalizeAtariCompassYaw(yaw);
+	int wrappedReferencePhase = wrapAtariCompassPhase(referencePhase);
+	int bestPhase = wrappedReferencePhase;
+	float bestYawError = 361.0f;
+	int bestPhaseDistance = kAtariCompassPhaseCount + 1;
+
+	for (int phase = 0; phase < kAtariCompassPhaseCount; phase++) {
+		float yawError = atariCompassAngularDistance(normalizedYaw, (float)atariCompassPhaseToYaw(phase));
+		int phaseDistance = MIN(wrapAtariCompassPhase(phase - wrappedReferencePhase),
+			wrapAtariCompassPhase(wrappedReferencePhase - phase));
+		if (yawError + 0.001f < bestYawError ||
+				(atariCompassAbs(yawError - bestYawError) <= 0.001f && phaseDistance < bestPhaseDistance)) {
+			bestPhase = phase;
+			bestYawError = yawError;
+			bestPhaseDistance = phaseDistance;
+		}
+	}
+
+	return bestPhase;
+}
+
 void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	// Border palette colors for the 4-plane font (from CONSOLE.NEO).
 	// The Atari ST uses raster interrupts to switch palettes between
@@ -367,15 +577,34 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 		}
 	}
 
-	// Compass at x=$B0(176), y=$97(151) — from sprite header at prog $2097C/$2097E
-	if (_compassSprites.size() >= 37) {
-		// Normalize yaw to 0-359 first (C++ modulo can return negative), then map to table index
-		int deg = ((int)_yaw % 360 + 360) % 360;
-		int lookupIdx = deg / 5;
-		int needleFrame = _compassLookup[lookupIdx];
-		if (needleFrame < (int)_compassSprites.size()) {
-			surface->copyRectToSurface(*_compassSprites[needleFrame], 176, 151,
-				Common::Rect(_compassSprites[needleFrame]->w, _compassSprites[needleFrame]->h));
+	// Compass at x=$B0(176), y=$97(151): restore the ST background, then overlay
+	// the animated needle.
+	if (_compassBackground) {
+		surface->copyRectToSurface(*_compassBackground, kAtariCompassX, kAtariCompassY,
+			Common::Rect(_compassBackground->w, _compassBackground->h));
+	}
+
+	if (_compassSprites.size() >= kAtariCompassTotalFrames) {
+		uint32 transparent = _gfx->_texturePixelFormat.ARGBToColor(0x00, 0, 0, 0);
+		if (!_atariCompassPhaseInitialized) {
+			int targetPhase = atariCompassTargetPhaseFromYaw(_yaw, 0);
+			_atariCompassPhase = targetPhase;
+			_atariCompassTargetPhase = targetPhase;
+			_atariCompassLastUpdateTick = _ticks;
+			_atariCompassPhaseInitialized = true;
+		}
+
+		if (_atariCompassLastUpdateTick != _ticks) {
+			_atariCompassPhase = wrapAtariCompassPhase(_atariCompassPhase +
+				stepAtariCompassPhaseTowardTarget(_atariCompassPhase, _atariCompassTargetPhase));
+			_atariCompassLastUpdateTick = _ticks;
+		}
+
+		int wrappedPhase = wrapAtariCompassPhase(_atariCompassPhase);
+		int needleFrame = _compassLookup[wrappedPhase];
+		if (needleFrame >= 0 && needleFrame < (int)_compassSprites.size()) {
+			drawAtariCompassNeedle(surface, _compassSprites[needleFrame], kAtariCompassX, kAtariCompassY,
+				atariCompassPhaseUsesVerticalFlip(wrappedPhase), transparent);
 		}
 	}
 
@@ -399,7 +628,8 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 			Common::Rect(_shootSprites[shootFrame]->w, _shootSprites[shootFrame]->h));
 	}
 
-	// Analog clock — kept from existing implementation
+	// The Atari border art uses a different clock pivot than the CPC-style UI.
+	// These coordinates are the center of the bezel drawn in CONSOLE.NEO.
 	uint8 r, g, b;
 	uint32 color = _currentArea->_underFireBackgroundColor;
 	_gfx->readFromPalette(color, r, g, b);
@@ -415,7 +645,7 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	_gfx->readFromPalette(color, r, g, b);
 	uint32 other = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
-	drawAnalogClock(surface, 90, 172, back, other, front);
+	drawAnalogClock(surface, kAtariClockCenterX, kAtariClockCenterY, back, other, front);
 }
 
 void EclipseEngine::loadAssetsAtariFullGame() {
@@ -483,47 +713,21 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 			const_cast<byte *>(kBorderPalette), 16);
 	}
 
-	// Compass background at prog $20986 (32x27, raw 4-plane) and needle at prog $20B36
-	// (37 frames, 32x27 each, stride 432 bytes). Pre-composite background + needle.
+	// Compass background at $20986 and needle bank at $20B36.
+	_compassBackground = loadAtariSTRawSprite(stream, 0x20986 + kHdr, 2, 27);
+	_compassBackground->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	// The ST init code expands frames 1-18 into the second 18-frame bank in RAM.
 	{
-		Graphics::ManagedSurface *compassBG = loadAtariSTRawSprite(stream, 0x20986 + kHdr, 2, 27);
-
-		// Load compass direction lookup table (72 entries at prog $1542)
-		stream->seek(0x1542 + kHdr);
-		stream->read(_compassLookup, 72);
-
-		// Find max needle frame index
-		int maxFrame = 0;
-		for (int i = 0; i < 72; i++)
-			if (_compassLookup[i] < 200 && _compassLookup[i] > maxFrame)
-				maxFrame = _compassLookup[i];
-
-		int numFrames = maxFrame + 1;
-		_compassSprites.resize(numFrames);
-		for (int f = 0; f < numFrames; f++) {
-			// Load needle frame (raw 4-plane, no mask)
-			Graphics::ManagedSurface *needle = loadAtariSTRawSprite(stream,
-				0x20B36 + kHdr + f * 432, 2, 27);
-
-			// Composite: copy background, then overlay needle where non-zero
-			Graphics::ManagedSurface *composite = new Graphics::ManagedSurface();
-			composite->create(32, 27, Graphics::PixelFormat::createFormatCLUT8());
-			composite->copyFrom(*compassBG);
-			for (int y = 0; y < 27; y++) {
-				for (int x = 0; x < 32; x++) {
-					byte needlePixel = *(const byte *)needle->getBasePtr(x, y);
-					if (needlePixel != 0)
-						composite->setPixel(x, y, needlePixel);
-				}
-			}
-			delete needle;
+		Common::copy(kAtariCompassPhaseToFrame, kAtariCompassPhaseToFrame + kAtariCompassPhaseCount, _compassLookup);
 
-			// Convert to target format
-			composite->convertToInPlace(_gfx->_texturePixelFormat,
-				const_cast<byte *>(kBorderPalette), 16);
-			_compassSprites[f] = composite;
+		_compassSprites.resize(kAtariCompassTotalFrames);
+		for (int frame = 0; frame < kAtariCompassBaseFrames; frame++) {
+			_compassSprites[frame] = loadAtariSTNeedleSprite(stream,
+				0x20B36 + kHdr + frame * 432, _gfx->_texturePixelFormat);
 		}
-		delete compassBG;
+		buildAtariSTCompassMirrorCache(stream, 0x20B36 + kHdr, _compassSprites, _gfx->_texturePixelFormat);
 	}
 
 	// Lantern light animation: 6 frames, 32x6, at prog $2026A, stride 96 bytes
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index a219a81f5ca..4389ce7d4a8 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -35,6 +35,7 @@
 #include "freescape/games/eclipse/ay.music.h"
 #include "freescape/games/eclipse/opl.music.h"
 #include "freescape/games/eclipse/eclipse.h"
+#include "freescape/objects/entrance.h"
 #include "freescape/language/8bitDetokeniser.h"
 
 namespace Freescape {
@@ -105,6 +106,12 @@ EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : Frees
 	_lastHeartbeatSoundTick = -1;
 	_lastHeartIndicatorFrame = 1;
 	_lastSecond = -1;
+	_compassBackground = nullptr;
+	_atariCompassPhase = 0;
+	_atariCompassTargetPhase = 0;
+	_atariCompassTargetRemainder = 0.0f;
+	_atariCompassLastUpdateTick = -1;
+	_atariCompassPhaseInitialized = false;
 	_resting = false;
 	_flashlightOn = false;
 }
@@ -143,6 +150,10 @@ void EclipseEngine::restartBackgroundMusic() {
 
 EclipseEngine::~EclipseEngine() {
 	stopBackgroundMusic();
+	if (_compassBackground) {
+		_compassBackground->free();
+		delete _compassBackground;
+	}
 	delete _playerOPLMusic;
 	delete _playerAYMusic;
 	delete _playerC64Music;
@@ -163,6 +174,11 @@ void EclipseEngine::initGameState() {
 	_lastFiveSeconds = seconds / 5;
 	_lastHeartbeatSoundTick = -1;
 	_lastHeartIndicatorFrame = 1;
+	_atariCompassPhase = 0;
+	_atariCompassTargetPhase = 0;
+	_atariCompassTargetRemainder = 0.0f;
+	_atariCompassLastUpdateTick = -1;
+	_atariCompassPhaseInitialized = false;
 	_resting = false;
 	_flashlightOn = false;
 	restartBackgroundMusic();
@@ -420,6 +436,18 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	if (isAmiga() || isAtariST())
 		_currentArea->_skyColor = 15;
 
+	if (isAtariST() && entranceID > 0) {
+		Entrance *entrance = (Entrance *)_currentArea->entranceWithID(entranceID);
+		if (entrance) {
+			int phase = atariCompassPhaseFromRotationY(entrance->getRotation().y());
+			_atariCompassPhase = phase;
+			_atariCompassTargetPhase = phase;
+			_atariCompassTargetRemainder = 0.0f;
+			_atariCompassLastUpdateTick = _ticks;
+			_atariCompassPhaseInitialized = true;
+		}
+	}
+
 	// Start background music (Atari ST)
 	if (isAtariST() && !_musicData.empty() && !_mixer->isSoundHandleActive(_musicHandle)) {
 		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
@@ -627,6 +655,58 @@ void EclipseEngine::pressedKey(const int keycode) {
 	}
 }
 
+void EclipseEngine::onRotate(float xoffset, float yoffset, float zoffset) {
+	(void)yoffset;
+	(void)zoffset;
+
+	if (!isAtariST() || xoffset == 0.0f)
+		return;
+
+	if (!_atariCompassPhaseInitialized) {
+		int phase = atariCompassTargetPhaseFromYaw(_yaw, 0);
+		_atariCompassPhase = phase;
+		_atariCompassTargetPhase = phase;
+		_atariCompassTargetRemainder = 0.0f;
+		_atariCompassLastUpdateTick = _ticks;
+		_atariCompassPhaseInitialized = true;
+	}
+
+	_atariCompassTargetRemainder += xoffset / 5.0f;
+	int phaseDelta = 0;
+	while (_atariCompassTargetRemainder >= 1.0f) {
+		_atariCompassTargetRemainder -= 1.0f;
+		phaseDelta++;
+	}
+	while (_atariCompassTargetRemainder <= -1.0f) {
+		_atariCompassTargetRemainder += 1.0f;
+		phaseDelta--;
+	}
+
+	if (phaseDelta == 0)
+		return;
+
+	_atariCompassTargetPhase = _atariCompassTargetPhase + phaseDelta;
+
+	// The original ST draw routine at $1CC0 always moves by the shortest path
+	// to a stored target phase. Clamp the target so that queued ScummVM input
+	// cannot place it more than half a turn away, which would otherwise trigger
+	// a wraparound reversal the original transition-driven code never presents.
+	while (_atariCompassTargetPhase < 0)
+		_atariCompassTargetPhase += 72;
+	while (_atariCompassTargetPhase >= 72)
+		_atariCompassTargetPhase -= 72;
+
+	if (phaseDelta > 0) {
+		int forwardDistance = (_atariCompassTargetPhase - _atariCompassPhase + 72) % 72;
+		if (forwardDistance > 35)
+			_atariCompassTargetPhase = (_atariCompassPhase + 35) % 72;
+	} else {
+		int backwardDistance = (_atariCompassPhase - _atariCompassTargetPhase + 72) % 72;
+		if (backwardDistance > 35)
+			_atariCompassTargetPhase = (_atariCompassPhase + 72 - 35) % 72;
+	}
+}
+
 bool EclipseEngine::onScreenControls(Common::Point mouse) {
 	if (!isAmiga() && !isAtariST())
 		return false;
@@ -645,8 +725,7 @@ bool EclipseEngine::onScreenControls(Common::Point mouse) {
 		rotate(5, 0, 0);
 		return true;
 	} else if (_uTurnArea.contains(mouse)) {
-		_yaw += 180;
-		updateCamera();
+		rotate(180, 0, 0);
 		return true;
 	} else if (_faceForwardArea.contains(mouse)) {
 		pressedKey(kActionFaceForward);
@@ -1091,6 +1170,11 @@ Common::Error EclipseEngine::saveGameStreamExtended(Common::WriteStream *stream,
 Common::Error EclipseEngine::loadGameStreamExtended(Common::SeekableReadStream *stream) {
 	_lastHeartbeatSoundTick = -1;
 	_lastHeartIndicatorFrame = 1;
+	_atariCompassPhase = 0;
+	_atariCompassTargetPhase = 0;
+	_atariCompassTargetRemainder = 0.0f;
+	_atariCompassLastUpdateTick = -1;
+	_atariCompassPhaseInitialized = false;
 	return Common::kNoError;
 }
 
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 9ba254de665..d82e155ae97 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -101,6 +101,9 @@ public:
 	void drawAnalogClock(Graphics::Surface *surface, int x, int y, uint32 colorHand1, uint32 colorHand2, uint32 colorBack);
 	void drawAnalogClockHand(Graphics::Surface *surface, int x, int y, double degrees, double magnitude, uint32 color);
 	void drawCompass(Graphics::Surface *surface, int x, int y, double degrees, double magnitude, uint32 color);
+	int atariCompassPhaseFromRotationY(float rotationY) const;
+	int atariCompassTargetPhaseFromYaw(float yaw, int referencePhase) const;
+	void onRotate(float xoffset, float yoffset, float zoffset) override;
 	void drawEclipseIndicator(Graphics::Surface *surface, int x, int y, uint32 color1, uint32 color2, uint32 color3 = 0);
 	Common::String getScoreString(int score);
 	void drawScoreString(int score, int x, int y, uint32 front, uint32 back, Graphics::Surface *surface);
@@ -132,14 +135,20 @@ public:
 	Font _fontScore; // Font B (10 score digit glyphs, 4-plane at $249BE)
 	Common::Array<Graphics::ManagedSurface *> _eclipseSprites; // 2 eclipse animation frames (16x13)
 	Common::Array<Graphics::ManagedSurface *> _eclipseProgressSprites;  // 16 eclipse animation frames (16x16)
-	Common::Array<Graphics::ManagedSurface *> _compassSprites; // 37 pre-composited compass frames (32x27)
+	Graphics::ManagedSurface *_compassBackground; // Atari ST compass background at $20986
+	Common::Array<Graphics::ManagedSurface *> _compassSprites; // signed Atari compass needle bank addressed by the $1542 lookup table
 	Common::Array<Graphics::ManagedSurface *> _lanternLightSprites;  // 6 lantern light animation frames (32x6)
 	Common::Array<Graphics::ManagedSurface *> _lanternSwitchSprites; // 2 lantern on/off frames (32x23)
 	Common::Array<Graphics::ManagedSurface *> _shootSprites;         // 2 shooting crosshair frames (32x25, 48x25)
 	Common::Array<Graphics::ManagedSurface *> _ankhSprites;          // 5 ankh fade-in frames (16x15)
 	Common::Array<Graphics::ManagedSurface *> _waterSprites;         // 9 water ripple frames (32x9)
 	Common::Array<Graphics::ManagedSurface *> _soundToggleSprites;   // 5 sound on/off toggle frames (16x11)
-	byte _compassLookup[72];  // direction-to-needle-frame lookup table
+	int8 _compassLookup[72];  // signed Atari ST phase-to-frame lookup table
+	int _atariCompassPhase;
+	int _atariCompassTargetPhase;
+	float _atariCompassTargetRemainder;
+	int _atariCompassLastUpdateTick;
+	bool _atariCompassPhaseInitialized;
 
 	// Atari ST on-screen control hotspots (from binary hotspot table at prog $869A)
 	bool onScreenControls(Common::Point mouse) override;


Commit: 4294c03aa8cfeb54b1b6116e8dadf47396946dd3
    https://github.com/scummvm/scummvm/commit/4294c03aa8cfeb54b1b6116e8dadf47396946dd3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: render water jar in eclipse atari

Changed paths:
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index b7dccfb08d8..5615aa59723 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -36,6 +36,9 @@ const int kAtariClockCenterX = 106;
 const int kAtariClockCenterY = 159;
 const int kAtariCompassX = 176;
 const int kAtariCompassY = 151;
+const int kAtariWaterIndicatorX = 224;
+const int kAtariWaterIndicatorY = 154;
+const int kAtariWaterIndicatorMaxLevel = 29;
 
 // Repaired ST phase-to-frame table from $1542. Six corrupt bytes in the dumped
 // binary break the intended 37-frame sweep built from $20B36 and $22B46.
@@ -446,6 +449,36 @@ void drawAtariCompassNeedle(Graphics::Surface *surface, const Graphics::ManagedS
 	}
 }
 
+uint32 getAtariBorderColor(const Graphics::PixelFormat &pixelFormat, byte colorIndex) {
+	return pixelFormat.ARGBToColor(0xFF,
+		kBorderPalette[colorIndex * 3],
+		kBorderPalette[colorIndex * 3 + 1],
+		kBorderPalette[colorIndex * 3 + 2]);
+}
+
+void drawAtariWaterSurfaceRow(Graphics::Surface *surface, int x, int y,
+		const Common::Array<uint16> &maskWords, const Common::Array<uint16> &pixelWords,
+		const Graphics::PixelFormat &pixelFormat) {
+	if (maskWords.size() != 2 || pixelWords.size() != 8)
+		return;
+
+	for (uint col = 0; col < maskWords.size(); col++) {
+		uint16 mask = maskWords[col];
+		int offset = col * 4;
+		for (int bit = 15; bit >= 0; bit--) {
+			if ((mask >> bit) & 1)
+				continue;
+
+			byte colorIndex = ((pixelWords[offset + 0] >> bit) & 1)
+			                | (((pixelWords[offset + 1] >> bit) & 1) << 1)
+			                | (((pixelWords[offset + 2] >> bit) & 1) << 2)
+			                | (((pixelWords[offset + 3] >> bit) & 1) << 3);
+			surface->setPixel(x + col * 16 + (15 - bit), y,
+				getAtariBorderColor(pixelFormat, colorIndex));
+		}
+	}
+}
+
 int EclipseEngine::atariCompassPhaseFromRotationY(float rotationY) const {
 	return wrapAtariCompassPhase(atariCompassRoundToNearestInt(rotationY / 5.0f));
 }
@@ -502,6 +535,26 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	} else if (!_currentAreaMessages.empty())
 		drawStringInSurface(_currentArea->_name, 85, 119, pal[1], pal[2], pal[0], surface);
 
+	// Atari ST water indicator from TeDrawWaterIndicator at $1EF0:
+	// a fixed 32x31 body bitmap at $2003C and a highlighted top row at
+	// $2024C/$2025C.
+	int waterLevel = _gameStateVars[k8bitVariableEnergy];
+	if (waterLevel < 0)
+		waterLevel = 0;
+	if (waterLevel > kAtariWaterIndicatorMaxLevel)
+		waterLevel = kAtariWaterIndicatorMaxLevel;
+
+	if (_atariWaterBody) {
+		surface->copyRectToSurface(*_atariWaterBody, kAtariWaterIndicatorX, kAtariWaterIndicatorY,
+			Common::Rect(_atariWaterBody->w, _atariWaterBody->h));
+	}
+
+	if (waterLevel > 0) {
+		int topY = 183 - waterLevel;
+		drawAtariWaterSurfaceRow(surface, kAtariWaterIndicatorX, topY,
+			_atariWaterSurfaceMask, _atariWaterSurfacePixels, _gfx->_texturePixelFormat);
+	}
+
 	// Step indicator: $CDC at x=$4C(76), y=$77(119)
 	// d7 = $42 + (2 - _playerStepIndex) * 2, drawChar chr = d7 + 31
 	{
@@ -756,14 +809,22 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 			const_cast<byte *>(kBorderPalette), 16);
 	}
 
-	// Water ripple animation: 9 frames, 32x9, at prog $27714, stride 144 bytes
-	// Mask at prog $27710. Drawn at (0, 28) in left border strip.
-	_waterSprites.resize(9);
-	for (int i = 0; i < 9; i++) {
-		_waterSprites[i] = loadAtariSTSprite(stream, 0x27710 + kHdr, 0x27714 + kHdr + i * 144, 2, 9);
-		_waterSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
-			const_cast<byte *>(kBorderPalette), 16);
-	}
+	// Water indicator data from TeDrawWaterIndicator at $1EF0:
+	// a 32x31 body bitmap at $2003C and a highlighted 32-pixel cap row at
+	// $2024C/$2025C.
+	_atariWaterBody = loadAtariSTRawSprite(stream, 0x2003C + kHdr, 2, 31);
+	_atariWaterBody->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	_atariWaterSurfacePixels.resize(8);
+	stream->seek(0x2024C + kHdr);
+	for (uint i = 0; i < _atariWaterSurfacePixels.size(); i++)
+		_atariWaterSurfacePixels[i] = stream->readUint16BE();
+
+	_atariWaterSurfaceMask.resize(2);
+	stream->seek(0x2025C + kHdr);
+	for (uint i = 0; i < _atariWaterSurfaceMask.size(); i++)
+		_atariWaterSurfaceMask[i] = stream->readUint16BE();
 
 	// Shooting crosshair sprites: 2 frames with mask, at prog $1CC26 and $1CDC0
 	// Frame 0: 32x25 (2 cols), frame 1: 48x25 (3 cols)
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 4389ce7d4a8..769320f81ea 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -107,6 +107,7 @@ EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : Frees
 	_lastHeartIndicatorFrame = 1;
 	_lastSecond = -1;
 	_compassBackground = nullptr;
+	_atariWaterBody = nullptr;
 	_atariCompassPhase = 0;
 	_atariCompassTargetPhase = 0;
 	_atariCompassTargetRemainder = 0.0f;
@@ -150,6 +151,10 @@ void EclipseEngine::restartBackgroundMusic() {
 
 EclipseEngine::~EclipseEngine() {
 	stopBackgroundMusic();
+	if (_atariWaterBody) {
+		_atariWaterBody->free();
+		delete _atariWaterBody;
+	}
 	if (_compassBackground) {
 		_compassBackground->free();
 		delete _compassBackground;
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index d82e155ae97..9cce3fb029f 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -136,12 +136,14 @@ public:
 	Common::Array<Graphics::ManagedSurface *> _eclipseSprites; // 2 eclipse animation frames (16x13)
 	Common::Array<Graphics::ManagedSurface *> _eclipseProgressSprites;  // 16 eclipse animation frames (16x16)
 	Graphics::ManagedSurface *_compassBackground; // Atari ST compass background at $20986
+	Graphics::ManagedSurface *_atariWaterBody; // Atari ST water body bitmap at $2003C
 	Common::Array<Graphics::ManagedSurface *> _compassSprites; // signed Atari compass needle bank addressed by the $1542 lookup table
 	Common::Array<Graphics::ManagedSurface *> _lanternLightSprites;  // 6 lantern light animation frames (32x6)
 	Common::Array<Graphics::ManagedSurface *> _lanternSwitchSprites; // 2 lantern on/off frames (32x23)
 	Common::Array<Graphics::ManagedSurface *> _shootSprites;         // 2 shooting crosshair frames (32x25, 48x25)
 	Common::Array<Graphics::ManagedSurface *> _ankhSprites;          // 5 ankh fade-in frames (16x15)
-	Common::Array<Graphics::ManagedSurface *> _waterSprites;         // 9 water ripple frames (32x9)
+	Common::Array<uint16> _atariWaterSurfacePixels;                  // Atari ST water surface row words at $2024C
+	Common::Array<uint16> _atariWaterSurfaceMask;                    // Atari ST water surface mask words at $2025C
 	Common::Array<Graphics::ManagedSurface *> _soundToggleSprites;   // 5 sound on/off toggle frames (16x11)
 	int8 _compassLookup[72];  // signed Atari ST phase-to-frame lookup table
 	int _atariCompassPhase;


Commit: d7ace101f770f1dc6506b4d7e4b2d5a961839e7d
    https://github.com/scummvm/scummvm/commit/d7ace101f770f1dc6506b4d7e4b2d5a961839e7d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: dark rooms implemented in eclipse atari

Changed paths:
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 5615aa59723..d88626b5a7a 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -39,6 +39,14 @@ const int kAtariCompassY = 151;
 const int kAtariWaterIndicatorX = 224;
 const int kAtariWaterIndicatorY = 154;
 const int kAtariWaterIndicatorMaxLevel = 29;
+const int kAtariDarkLightRadius = 34;
+const int kAtariDarkLightRadiusStep = 4;
+const uint32 kAtariAreaRecordBase = 0x2A520;
+const uint32 kAtariAreaIndexBase = 0x2A6B0;
+const int kAtariAreaIndexCount = 64;
+const uint16 kAtariDarkAreaFlag = 0x8000;
+
+void fillCircle(Graphics::Surface *surface, int x, int y, int radius, int color);
 
 // Repaired ST phase-to-frame table from $1542. Six corrupt bytes in the dumped
 // binary break the intended 37-frame sweep built from $20B36 and $22B46.
@@ -456,6 +464,65 @@ uint32 getAtariBorderColor(const Graphics::PixelFormat &pixelFormat, byte colorI
 		kBorderPalette[colorIndex * 3 + 2]);
 }
 
+bool containsAtariAreaID(const Common::Array<uint16> &areaIDs, uint16 areaID) {
+	for (uint i = 0; i < areaIDs.size(); i++) {
+		if (areaIDs[i] == areaID)
+			return true;
+	}
+	return false;
+}
+
+int getAtariLanternHoleRadius(int lanternFrame) {
+	if (lanternFrame < 0)
+		return 0;
+
+	int radius = kAtariDarkLightRadius - lanternFrame * kAtariDarkLightRadiusStep;
+	if (radius < 0)
+		radius = 0;
+	return radius;
+}
+
+void advanceAtariLanternAnimation(EclipseEngine *engine) {
+	if (engine->_atariLanternAnimationDirection == 0 || engine->_atariLanternLastUpdateTick == engine->_ticks)
+		return;
+
+	if (engine->_atariLanternAnimationDirection < 0) {
+		if (engine->_atariLanternLightFrame > 0)
+			engine->_atariLanternLightFrame--;
+		else
+			engine->_atariLanternAnimationDirection = 0;
+	} else if (engine->_atariLanternLightFrame < 5) {
+		engine->_atariLanternLightFrame++;
+	} else {
+		engine->_atariLanternLightFrame = -1;
+		engine->_atariLanternAnimationDirection = 0;
+	}
+
+	engine->_atariLanternLastUpdateTick = engine->_ticks;
+}
+
+void drawAtariDarknessMask(Graphics::Surface *surface, const Common::Rect &viewArea,
+		int holeX, int holeY, int holeRadius, const Graphics::PixelFormat &pixelFormat) {
+	uint32 blackout = pixelFormat.ARGBToColor(0xFF, 0, 0, 0);
+	int transparent = pixelFormat.ARGBToColor(0x00, 0, 0, 0);
+
+	surface->fillRect(viewArea, blackout);
+	if (holeRadius <= 0)
+		return;
+
+	if (holeX < viewArea.left)
+		holeX = viewArea.left;
+	else if (holeX >= viewArea.right)
+		holeX = viewArea.right - 1;
+
+	if (holeY < viewArea.top)
+		holeY = viewArea.top;
+	else if (holeY >= viewArea.bottom)
+		holeY = viewArea.bottom - 1;
+
+	fillCircle(surface, holeX, holeY, holeRadius, transparent);
+}
+
 void drawAtariWaterSurfaceRow(Graphics::Surface *surface, int x, int y,
 		const Common::Array<uint16> &maskWords, const Common::Array<uint16> &pixelWords,
 		const Graphics::PixelFormat &pixelFormat) {
@@ -506,6 +573,13 @@ int EclipseEngine::atariCompassTargetPhaseFromYaw(float yaw, int referencePhase)
 }
 
 void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
+	int lanternFrame = _atariLanternLightFrame;
+	int lanternRadius = getAtariLanternHoleRadius(lanternFrame);
+
+	if (_atariAreaDark)
+		drawAtariDarknessMask(surface, _viewArea, _crossairPosition.x, _crossairPosition.y,
+			lanternRadius, _gfx->_texturePixelFormat);
+
 	// Border palette colors for the 4-plane font (from CONSOLE.NEO).
 	// The Atari ST uses raster interrupts to switch palettes between
 	// the 3D viewport (area palette) and the border/UI area (border palette).
@@ -661,17 +735,20 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 		}
 	}
 
-	// Lantern switch at x=$30(48), y=$91(145) — only drawn when lantern is ON
-	if (_flashlightOn && _lanternSwitchSprites.size() >= 2) {
-		surface->copyRectToSurface(*_lanternSwitchSprites[0], 48, 145,
-			Common::Rect(_lanternSwitchSprites[0]->w, _lanternSwitchSprites[0]->h));
+	// Lantern switch at x=$30(48), y=$91(145). The ST code always draws the
+	// switch and picks frame 0/1 from the persistent lantern state.
+	if (_lanternSwitchSprites.size() >= 2) {
+		int switchFrame = _flashlightOn ? 0 : 1;
+		surface->copyRectToSurface(*_lanternSwitchSprites[switchFrame], 48, 145,
+			Common::Rect(_lanternSwitchSprites[switchFrame]->w, _lanternSwitchSprites[switchFrame]->h));
 	}
 
-	// Lantern light animation overlay at (48, 139) — 6 frames, 32x6, toggled with 'T' key
-	if (_flashlightOn && _lanternLightSprites.size() >= 6) {
-		int lightFrame = (_ticks / 8) % 6;
-		surface->copyRectToSurface(*_lanternLightSprites[lightFrame], 48, 139,
-			Common::Rect(_lanternLightSprites[lightFrame]->w, _lanternLightSprites[lightFrame]->h));
+	// Lantern light strip at (48, 139). The ST code uses a finite frame index,
+	// not a looping idle animation, so keep drawing the settled frame instead
+	// of cycling on _ticks.
+	if (lanternFrame >= 0 && lanternFrame < 6 && _lanternLightSprites.size() >= 6) {
+		surface->copyRectToSurface(*_lanternLightSprites[lanternFrame], 48, 139,
+			Common::Rect(_lanternLightSprites[lanternFrame]->w, _lanternLightSprites[lanternFrame]->h));
 	}
 
 	// Shooting crosshair overlay at x=$80(128), y=$9F(159)
@@ -699,6 +776,7 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	uint32 other = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 
 	drawAnalogClock(surface, kAtariClockCenterX, kAtariClockCenterY, back, other, front);
+	advanceAtariLanternAnimation(this);
 }
 
 void EclipseEngine::loadAssetsAtariFullGame() {
@@ -743,6 +821,34 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	// convert program addresses to stream offsets.
 	static const int kHdr = 0x1C;
 
+	_atariDarkAreas.clear();
+	Common::Array<uint16> parsedAreas;
+	uint32 streamSize = stream->size();
+	for (int i = 0; i < kAtariAreaIndexCount; i++) {
+		uint32 indexOffset = kAtariAreaIndexBase + kHdr + i * 4;
+		if (indexOffset + 4 > streamSize)
+			break;
+
+		stream->seek(indexOffset);
+		uint16 lowWord = stream->readUint16BE();
+		uint16 highWord = stream->readUint16BE();
+		uint32 recordWordOffset = lowWord | ((uint32)highWord << 8);
+		uint32 recordOffset = kAtariAreaRecordBase + kHdr + recordWordOffset * 2;
+		if (recordOffset + 6 > streamSize)
+			continue;
+
+		stream->seek(recordOffset);
+		uint16 flags = stream->readUint16BE();
+		stream->readUint16BE();
+		uint16 areaID = stream->readUint16BE();
+		if (!_areaMap.contains(areaID) || containsAtariAreaID(parsedAreas, areaID))
+			continue;
+
+		parsedAreas.push_back(areaID);
+		if ((flags & kAtariDarkAreaFlag) != 0)
+			_atariDarkAreas.push_back(areaID);
+	}
+
 	// Heart indicator sprites: 2 frames, 16x13 pixels
 	// Descriptor at prog $1D2B8 (1 col × 13 rows), mask at +6, pixels at +8
 	// Frame 0 = heart visible, Frame 1 = heart hidden/dimmed
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 769320f81ea..1f06f8c7cd2 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -113,6 +113,10 @@ EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : Frees
 	_atariCompassTargetRemainder = 0.0f;
 	_atariCompassLastUpdateTick = -1;
 	_atariCompassPhaseInitialized = false;
+	_atariLanternLightFrame = -1;
+	_atariLanternAnimationDirection = 0;
+	_atariLanternLastUpdateTick = -1;
+	_atariAreaDark = false;
 	_resting = false;
 	_flashlightOn = false;
 }
@@ -184,6 +188,10 @@ void EclipseEngine::initGameState() {
 	_atariCompassTargetRemainder = 0.0f;
 	_atariCompassLastUpdateTick = -1;
 	_atariCompassPhaseInitialized = false;
+	_atariLanternLightFrame = -1;
+	_atariLanternAnimationDirection = 0;
+	_atariLanternLastUpdateTick = -1;
+	_atariAreaDark = false;
 	_resting = false;
 	_flashlightOn = false;
 	restartBackgroundMusic();
@@ -402,6 +410,7 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	assert(_areaMap.contains(areaID));
 	_currentArea = _areaMap[areaID];
 	_currentArea->show();
+	_atariAreaDark = isAtariST() && isAtariDarkArea(areaID);
 
 	_currentAreaMessages.clear();
 	_currentAreaMessages.push_back(_currentArea->_name);
@@ -466,6 +475,14 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	resetInput();
 }
 
+bool EclipseEngine::isAtariDarkArea(uint16 areaID) const {
+	for (uint i = 0; i < _atariDarkAreas.size(); i++) {
+		if (_atariDarkAreas[i] == areaID)
+			return true;
+	}
+	return false;
+}
+
 void EclipseEngine::drawBackground() {
 	clearBackground();
 	_gfx->drawBackground(_currentArea->_skyColor);
@@ -650,7 +667,22 @@ void EclipseEngine::pressedKey(const int keycode) {
 		_pitch = 0;
 		updateCamera();
 	} else if (keycode == kActionToggleFlashlight) {
-		_flashlightOn = !_flashlightOn;
+		if (isAtariST()) {
+			if (_flashlightOn) {
+				_flashlightOn = false;
+				if (_atariLanternLightFrame < 0)
+					_atariLanternLightFrame = 0;
+				_atariLanternAnimationDirection = 1;
+			} else {
+				_flashlightOn = true;
+				if (_atariLanternLightFrame < 0 || _atariLanternLightFrame > 5)
+					_atariLanternLightFrame = 5;
+				_atariLanternAnimationDirection = -1;
+			}
+			_atariLanternLastUpdateTick = -1;
+		} else {
+			_flashlightOn = !_flashlightOn;
+		}
 	} else if (keycode == kActionRunModifier) {
 		// Shift-to-sprint: save current step, switch to max while held
 		if (_savedPlayerStepIndex < 0) {
@@ -1173,6 +1205,7 @@ Common::Error EclipseEngine::saveGameStreamExtended(Common::WriteStream *stream,
 }
 
 Common::Error EclipseEngine::loadGameStreamExtended(Common::SeekableReadStream *stream) {
+	(void)stream;
 	_lastHeartbeatSoundTick = -1;
 	_lastHeartIndicatorFrame = 1;
 	_atariCompassPhase = 0;
@@ -1180,6 +1213,10 @@ Common::Error EclipseEngine::loadGameStreamExtended(Common::SeekableReadStream *
 	_atariCompassTargetRemainder = 0.0f;
 	_atariCompassLastUpdateTick = -1;
 	_atariCompassPhaseInitialized = false;
+	_atariLanternAnimationDirection = 0;
+	_atariLanternLightFrame = _flashlightOn ? 0 : -1;
+	_atariLanternLastUpdateTick = -1;
+	_atariAreaDark = isAtariST() && _currentArea && isAtariDarkArea(_currentArea->getAreaID());
 	return Common::kNoError;
 }
 
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 9cce3fb029f..3e3ab57cc01 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -67,6 +67,7 @@ public:
 
 	bool _resting;
 	bool _flashlightOn;
+	bool _atariAreaDark;
 	int _lastThirtySeconds;
 	int _lastFiveSeconds;
 	int _lastHeartbeatSoundTick;
@@ -103,6 +104,7 @@ public:
 	void drawCompass(Graphics::Surface *surface, int x, int y, double degrees, double magnitude, uint32 color);
 	int atariCompassPhaseFromRotationY(float rotationY) const;
 	int atariCompassTargetPhaseFromYaw(float yaw, int referencePhase) const;
+	bool isAtariDarkArea(uint16 areaID) const;
 	void onRotate(float xoffset, float yoffset, float zoffset) override;
 	void drawEclipseIndicator(Graphics::Surface *surface, int x, int y, uint32 color1, uint32 color2, uint32 color3 = 0);
 	Common::String getScoreString(int score);
@@ -142,6 +144,7 @@ public:
 	Common::Array<Graphics::ManagedSurface *> _lanternSwitchSprites; // 2 lantern on/off frames (32x23)
 	Common::Array<Graphics::ManagedSurface *> _shootSprites;         // 2 shooting crosshair frames (32x25, 48x25)
 	Common::Array<Graphics::ManagedSurface *> _ankhSprites;          // 5 ankh fade-in frames (16x15)
+	Common::Array<uint16> _atariDarkAreas;                           // Atari ST dark areas from the $2A520 runtime table
 	Common::Array<uint16> _atariWaterSurfacePixels;                  // Atari ST water surface row words at $2024C
 	Common::Array<uint16> _atariWaterSurfaceMask;                    // Atari ST water surface mask words at $2025C
 	Common::Array<Graphics::ManagedSurface *> _soundToggleSprites;   // 5 sound on/off toggle frames (16x11)
@@ -151,6 +154,9 @@ public:
 	float _atariCompassTargetRemainder;
 	int _atariCompassLastUpdateTick;
 	bool _atariCompassPhaseInitialized;
+	int _atariLanternLightFrame;
+	int _atariLanternAnimationDirection;
+	int _atariLanternLastUpdateTick;
 
 	// Atari ST on-screen control hotspots (from binary hotspot table at prog $869A)
 	bool onScreenControls(Common::Point mouse) override;


Commit: e566f73b5b48691448728819d8657f91533a4050
    https://github.com/scummvm/scummvm/commit/e566f73b5b48691448728819d8657f91533a4050
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: limited lantern batery in eclipse atari

Changed paths:
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index d88626b5a7a..4d805da826d 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -574,7 +574,16 @@ int EclipseEngine::atariCompassTargetPhaseFromYaw(float yaw, int referencePhase)
 
 void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	int lanternFrame = _atariLanternLightFrame;
-	int lanternRadius = getAtariLanternHoleRadius(lanternFrame);
+
+	// Darkness radius based on battery level (5=full/bright → 0=dim → -1=dead).
+	// ROM uses TeLanternBrightnessFrame ($7f6c) directly: frame 5 = largest radius,
+	// frame 0 = smallest, frame -1 = no light.  ScummVM's getAtariLanternHoleRadius
+	// maps frame 0→34px and frame 5→14px (inverted), so we pass (5 - battery).
+	int lanternRadius = 0;
+	if (_flashlightOn && _lanternBatteryLevel >= 0) {
+		int effectiveFrame = 5 - _lanternBatteryLevel;
+		lanternRadius = getAtariLanternHoleRadius(effectiveFrame);
+	}
 
 	if (_atariAreaDark)
 		drawAtariDarknessMask(surface, _viewArea, _crossairPosition.x, _crossairPosition.y,
@@ -743,12 +752,17 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 			Common::Rect(_lanternSwitchSprites[switchFrame]->w, _lanternSwitchSprites[switchFrame]->h));
 	}
 
-	// Lantern light strip at (48, 139). The ST code uses a finite frame index,
-	// not a looping idle animation, so keep drawing the settled frame instead
-	// of cycling on _ticks.
-	if (lanternFrame >= 0 && lanternFrame < 6 && _lanternLightSprites.size() >= 6) {
-		surface->copyRectToSurface(*_lanternLightSprites[lanternFrame], 48, 139,
-			Common::Rect(_lanternLightSprites[lanternFrame]->w, _lanternLightSprites[lanternFrame]->h));
+	// Lantern light strip at (48, 139). During on/off animation use the animation
+	// frame; once settled, show the battery level (5=full → 0=dim, mapped to
+	// sprite index 0=bright → 5=dim via inversion).
+	{
+		int hudLanternFrame = lanternFrame;
+		if (_flashlightOn && _atariLanternAnimationDirection == 0 && _lanternBatteryLevel >= 0)
+			hudLanternFrame = 5 - _lanternBatteryLevel;
+		if (hudLanternFrame >= 0 && hudLanternFrame < 6 && _lanternLightSprites.size() >= 6) {
+			surface->copyRectToSurface(*_lanternLightSprites[hudLanternFrame], 48, 139,
+				Common::Rect(_lanternLightSprites[hudLanternFrame]->w, _lanternLightSprites[hudLanternFrame]->h));
+		}
 	}
 
 	// Shooting crosshair overlay at x=$80(128), y=$9F(159)
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 1f06f8c7cd2..8b0ea7d82b7 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -116,6 +116,7 @@ EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : Frees
 	_atariLanternLightFrame = -1;
 	_atariLanternAnimationDirection = 0;
 	_atariLanternLastUpdateTick = -1;
+	_lanternBatteryLevel = 5;
 	_atariAreaDark = false;
 	_resting = false;
 	_flashlightOn = false;
@@ -191,6 +192,7 @@ void EclipseEngine::initGameState() {
 	_atariLanternLightFrame = -1;
 	_atariLanternAnimationDirection = 0;
 	_atariLanternLastUpdateTick = -1;
+	_lanternBatteryLevel = 5;
 	_atariAreaDark = false;
 	_resting = false;
 	_flashlightOn = false;
@@ -673,7 +675,7 @@ void EclipseEngine::pressedKey(const int keycode) {
 				if (_atariLanternLightFrame < 0)
 					_atariLanternLightFrame = 0;
 				_atariLanternAnimationDirection = 1;
-			} else {
+			} else if (_lanternBatteryLevel >= 0) {
 				_flashlightOn = true;
 				if (_atariLanternLightFrame < 0 || _atariLanternLightFrame > 5)
 					_atariLanternLightFrame = 5;
@@ -1162,6 +1164,18 @@ void EclipseEngine::updateTimeVariables() {
 		if (_gameStateVars[k8bitVariableShield] < _maxShield) {
 			_gameStateVars[k8bitVariableShield] += 1;
 		}
+
+		// Lantern battery drain: non-rechargeable, one level per 30-second tick
+		// while the flashlight is on. ROM drains TeLanternBrightnessFrame ($7f6c)
+		// from 5 (brightest) down to -1 (dead). 6 levels total.
+		if (isAtariST() && _flashlightOn && _lanternBatteryLevel >= 0) {
+			_lanternBatteryLevel--;
+			if (_lanternBatteryLevel < 0) {
+				_flashlightOn = false;
+				_atariLanternLightFrame = -1;
+				_atariLanternAnimationDirection = 0;
+			}
+		}
 	}
 
 	if (isEclipse() && isSpectrum() && _currentArea->getAreaID() == 42) {
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 3e3ab57cc01..6a050a1a8db 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -157,6 +157,7 @@ public:
 	int _atariLanternLightFrame;
 	int _atariLanternAnimationDirection;
 	int _atariLanternLastUpdateTick;
+	int _lanternBatteryLevel; // 5=full, 0=nearly dead, -1=dead (non-rechargeable)
 
 	// Atari ST on-screen control hotspots (from binary hotspot table at prog $869A)
 	bool onScreenControls(Common::Point mouse) override;


Commit: d0da7154230d1e1e217044c8f4184c396c78a0a7
    https://github.com/scummvm/scummvm/commit/d0da7154230d1e1e217044c8f4184c396c78a0a7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: better handling of palettes for eclipse atari

Changed paths:
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/palettes.cpp


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 4d805da826d..561fad49428 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -806,7 +806,21 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	load8bitBinary(stream, 0x2a53c, 16);
 
 	_border = loadAndConvertNeoImage(stream, 0x139c8);
-	loadPalettes(stream, 0x2a0fa);
+	// The palette table is split across two regions in the binary: areas 32-51
+	// at prog $29E36, then areas 25-31, 127, 1-24 at prog $2A0DE. Load from
+	// the start of the first region so all areas get palettes.
+	loadPalettes(stream, 0x29e52);
+
+	// The original Atari ST game uses a Timer-B raster interrupt to split the
+	// hardware palette mid-screen: colors 0-5 always come from the border
+	// (CONSOLE.NEO) palette, while only colors 6-15 are swapped per area.
+	// Objects using indices 0-5 (e.g. ankhs at color 4 = bright gold) must
+	// therefore show the border palette values, not the area-specific ones.
+	for (auto &entry : _paletteByArea) {
+		byte *pal = entry._value;
+		memcpy(pal, kBorderPalette, 6 * 3);
+	}
+
 	loadSoundsFx(stream, 0x3030c, 6);
 
 	// Load TEMUSIC.ST (GEMDOS executable at file offset $11F5A, skip $1C header, TEXT size $11E8)
diff --git a/engines/freescape/games/palettes.cpp b/engines/freescape/games/palettes.cpp
index 933dea17616..e9a2a0dc42b 100644
--- a/engines/freescape/games/palettes.cpp
+++ b/engines/freescape/games/palettes.cpp
@@ -195,6 +195,8 @@ void FreescapeEngine::loadPalettes(Common::SeekableReadStream *file, int offset)
 		numberOfAreas += 5;
 	else if (isCastle())
 		numberOfAreas += 20;
+	else if (isEclipse())
+		numberOfAreas += 2;
 
 	for (uint i = 0; i < numberOfAreas; i++) {
 		int label = readField(file, 8);
@@ -215,7 +217,12 @@ void FreescapeEngine::loadPalettes(Common::SeekableReadStream *file, int offset)
 			palette[c][2] = b & 0xff;
 			debugC(1, kFreescapeDebugParser, "Color %d: (%04x) %02x %02x %02x", c, v, palette[c][0], palette[c][1], palette[c][2]);
 		}
-		assert(!_paletteByArea.contains(label));
+		if (_paletteByArea.contains(label)) {
+			// Eclipse Atari ST has a duplicate palette entry for area 42
+			assert(isEclipse() && isAtariST());
+			delete[] palette;
+			continue;
+		}
 		_paletteByArea[label] = (byte *)palette;
 	}
 }


Commit: 26fe85ba26a15cc2f3670ca3ec299bd0d4f05844
    https://github.com/scummvm/scummvm/commit/26fe85ba26a15cc2f3670ca3ec299bd0d4f05844
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: make sure dark areas have reasonable palettes in eclipse atarist

Changed paths:
    engines/freescape/games/eclipse/atari.cpp


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 561fad49428..f611f499d6c 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -811,11 +811,9 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	// the start of the first region so all areas get palettes.
 	loadPalettes(stream, 0x29e52);
 
-	// The original Atari ST game uses a Timer-B raster interrupt to split the
-	// hardware palette mid-screen: colors 0-5 always come from the border
-	// (CONSOLE.NEO) palette, while only colors 6-15 are swapped per area.
-	// Objects using indices 0-5 (e.g. ankhs at color 4 = bright gold) must
-	// therefore show the border palette values, not the area-specific ones.
+	// The original game uses a Timer-B raster interrupt to split the hardware
+	// palette mid-screen: colors 0-5 always come from the border (CONSOLE.NEO)
+	// palette, while only colors 6-15 are swapped per area.
 	for (auto &entry : _paletteByArea) {
 		byte *pal = entry._value;
 		memcpy(pal, kBorderPalette, 6 * 3);
@@ -877,6 +875,52 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 			_atariDarkAreas.push_back(areaID);
 	}
 
+	// Eclipse/dark areas use a fade palette table at prog $10EB6 instead of
+	// the per-area palette table. The table has 6 brightness levels (0=black,
+	// 5=brightest), 32 bytes each. The 68K copy routine at $10FDC applies
+	// LSR.L #1 to each packed longword pair (values are pre-doubled).
+	// For now we use level 5 (brightest, matching full lantern).
+	{
+		static const uint32 kEclipseFadePalette = 0x10EB6;
+		static const int kBrightest = 5;
+		static const int kHdrSize = 0x1C;
+		stream->seek(kEclipseFadePalette + kHdrSize + kBrightest * 32);
+		uint16 rawWords[16];
+		for (int w = 0; w < 16; w++)
+			rawWords[w] = stream->readUint16BE();
+
+		// Simulate the 68K LSR.L #1 on packed longword pairs, then mask to $0777
+		byte darkPal[16 * 3];
+		for (int i = 0; i < 16; i += 2) {
+			uint32 packed = ((uint32)rawWords[i] << 16) | rawWords[i + 1];
+			packed >>= 1;
+			uint16 w1 = (packed >> 16) & 0x0777;
+			uint16 w2 = packed & 0x0777;
+			for (int j = 0; j < 2; j++) {
+				uint16 v = (j == 0) ? w1 : w2;
+				int idx = i + j;
+				int r = (v >> 8) & 0xf; 
+				r = r << 4 | r;
+				int g = (v >> 4) & 0xf; 
+				g = g << 4 | g;
+				int b = v & 0xf;
+				b = b << 4 | b;
+				darkPal[idx * 3 + 0] = r;
+				darkPal[idx * 3 + 1] = g;
+				darkPal[idx * 3 + 2] = b;
+			}
+		}
+
+		// Overwrite dark area palettes: colors 0-5 from border, 6-15 from dark palette
+		for (uint i = 0; i < _atariDarkAreas.size(); i++) {
+			uint16 areaID = _atariDarkAreas[i];
+			if (_paletteByArea.contains(areaID)) {
+				byte *pal = _paletteByArea[areaID];
+				memcpy(pal + 6 * 3, darkPal + 6 * 3, 10 * 3);
+			}
+		}
+	}
+
 	// Heart indicator sprites: 2 frames, 16x13 pixels
 	// Descriptor at prog $1D2B8 (1 col × 13 rows), mask at +6, pixels at +8
 	// Frame 0 = heart visible, Frame 1 = heart hidden/dimmed


Commit: 444ca7e18f755ad3556ca60f7d5c261a7ad14260
    https://github.com/scummvm/scummvm/commit/444ca7e18f755ad3556ca60f7d5c261a7ad14260
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: refactored palette handling and light effect in eclipse atarist

Changed paths:
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index f611f499d6c..679455a1589 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -39,8 +39,15 @@ const int kAtariCompassY = 151;
 const int kAtariWaterIndicatorX = 224;
 const int kAtariWaterIndicatorY = 154;
 const int kAtariWaterIndicatorMaxLevel = 29;
-const int kAtariDarkLightRadius = 34;
-const int kAtariDarkLightRadiusStep = 4;
+// The original 68K code at $07BA renders the dark room light hole using a
+// pre-built bitplane mask. The fully-visible center spans 6 word-groups
+// (96px) with 2 masked border groups (32px each) on either side, for a
+// total visible width of ~160px in the 256px-wide viewport. The vertical
+// extent is similarly masked per-scanline. This corresponds to a circle
+// radius of approximately 80 pixels. The lantern battery (6 levels)
+// scales this down proportionally.
+const int kAtariDarkLightRadius = 80;
+const int kAtariDarkLightRadiusStep = 10;
 const uint32 kAtariAreaRecordBase = 0x2A520;
 const uint32 kAtariAreaIndexBase = 0x2A6B0;
 const int kAtariAreaIndexCount = 64;
@@ -57,6 +64,15 @@ const int8 kAtariCompassPhaseToFrame[kAtariCompassPhaseCount] = {
 	15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
 };
 
+void EclipseEngine::applyEclipseFadePalette(uint16 areaID, int brightnessLevel) {
+	if (!_paletteByArea.contains(areaID))
+		return;
+	brightnessLevel = CLIP(brightnessLevel, 0, 5);
+	byte *pal = _paletteByArea[areaID];
+	// Colors 0-5 stay as the border palette; overwrite 6-15 from the fade table
+	memcpy(pal + 6 * 3, _eclipseFadePalettes[brightnessLevel] + 6 * 3, 10 * 3);
+}
+
 void EclipseEngine::initAmigaAtari() {
 	_viewArea = Common::Rect(32, 16, 288, 118);
 
@@ -879,18 +895,13 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	// the per-area palette table. The table has 6 brightness levels (0=black,
 	// 5=brightest), 32 bytes each. The 68K copy routine at $10FDC applies
 	// LSR.L #1 to each packed longword pair (values are pre-doubled).
-	// For now we use level 5 (brightest, matching full lantern).
-	{
-		static const uint32 kEclipseFadePalette = 0x10EB6;
-		static const int kBrightest = 5;
-		static const int kHdrSize = 0x1C;
-		stream->seek(kEclipseFadePalette + kHdrSize + kBrightest * 32);
+	// Load all 6 levels into _eclipseFadePalettes for runtime use.
+	for (int level = 0; level < 6; level++) {
+		stream->seek(0x10EB6 + 0x1C + level * 32);
 		uint16 rawWords[16];
 		for (int w = 0; w < 16; w++)
 			rawWords[w] = stream->readUint16BE();
 
-		// Simulate the 68K LSR.L #1 on packed longword pairs, then mask to $0777
-		byte darkPal[16 * 3];
 		for (int i = 0; i < 16; i += 2) {
 			uint32 packed = ((uint32)rawWords[i] << 16) | rawWords[i + 1];
 			packed >>= 1;
@@ -899,28 +910,20 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 			for (int j = 0; j < 2; j++) {
 				uint16 v = (j == 0) ? w1 : w2;
 				int idx = i + j;
-				int r = (v >> 8) & 0xf; 
-				r = r << 4 | r;
-				int g = (v >> 4) & 0xf; 
-				g = g << 4 | g;
-				int b = v & 0xf;
-				b = b << 4 | b;
-				darkPal[idx * 3 + 0] = r;
-				darkPal[idx * 3 + 1] = g;
-				darkPal[idx * 3 + 2] = b;
-			}
-		}
-
-		// Overwrite dark area palettes: colors 0-5 from border, 6-15 from dark palette
-		for (uint i = 0; i < _atariDarkAreas.size(); i++) {
-			uint16 areaID = _atariDarkAreas[i];
-			if (_paletteByArea.contains(areaID)) {
-				byte *pal = _paletteByArea[areaID];
-				memcpy(pal + 6 * 3, darkPal + 6 * 3, 10 * 3);
+				int r = (v >> 8) & 0xf; r = r << 4 | r;
+				int g = (v >> 4) & 0xf; g = g << 4 | g;
+				int b = v & 0xf;         b = b << 4 | b;
+				_eclipseFadePalettes[level][idx * 3 + 0] = r;
+				_eclipseFadePalettes[level][idx * 3 + 1] = g;
+				_eclipseFadePalettes[level][idx * 3 + 2] = b;
 			}
 		}
 	}
 
+	// Apply brightest fade palette to all dark areas at load time
+	for (uint i = 0; i < _atariDarkAreas.size(); i++)
+		applyEclipseFadePalette(_atariDarkAreas[i], _lanternBatteryLevel);
+
 	// Heart indicator sprites: 2 frames, 16x13 pixels
 	// Descriptor at prog $1D2B8 (1 col × 13 rows), mask at +6, pixels at +8
 	// Frame 0 = heart visible, Frame 1 = heart hidden/dimmed
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 8b0ea7d82b7..0586dde8c8d 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -446,6 +446,8 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	}
 
 	_gfx->_keyColor = 0;
+	if (isAtariST() && isAtariDarkArea(areaID))
+		applyEclipseFadePalette(areaID, _lanternBatteryLevel);
 	swapPalette(areaID);
 	if (isCPC())
 		updateHeartFramesCPC();
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 6a050a1a8db..1d30ba4883b 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -145,6 +145,8 @@ public:
 	Common::Array<Graphics::ManagedSurface *> _shootSprites;         // 2 shooting crosshair frames (32x25, 48x25)
 	Common::Array<Graphics::ManagedSurface *> _ankhSprites;          // 5 ankh fade-in frames (16x15)
 	Common::Array<uint16> _atariDarkAreas;                           // Atari ST dark areas from the $2A520 runtime table
+	byte _eclipseFadePalettes[6][16 * 3];                            // 6 brightness levels from prog $10EB6 (post LSR.L #1)
+	void applyEclipseFadePalette(uint16 areaID, int brightnessLevel);
 	Common::Array<uint16> _atariWaterSurfacePixels;                  // Atari ST water surface row words at $2024C
 	Common::Array<uint16> _atariWaterSurfaceMask;                    // Atari ST water surface mask words at $2025C
 	Common::Array<Graphics::ManagedSurface *> _soundToggleSprites;   // 5 sound on/off toggle frames (16x11)


Commit: 956cbac5f1d4f58d41de931122d51239732b2033
    https://github.com/scummvm/scummvm/commit/956cbac5f1d4f58d41de931122d51239732b2033
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: simplify brightness handling in eclipse atarist

Changed paths:
    engines/freescape/games/eclipse/atari.cpp


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 679455a1589..eec4d627636 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -892,32 +892,16 @@ void EclipseEngine::loadAssetsAtariFullGame() {
 	}
 
 	// Eclipse/dark areas use a fade palette table at prog $10EB6 instead of
-	// the per-area palette table. The table has 6 brightness levels (0=black,
-	// 5=brightest), 32 bytes each. The 68K copy routine at $10FDC applies
-	// LSR.L #1 to each packed longword pair (values are pre-doubled).
-	// Load all 6 levels into _eclipseFadePalettes for runtime use.
+	// the per-area palette table. 6 brightness levels (0=black, 5=brightest),
+	// 16 words each. The 68K code at $10FDC applies LSR.L #1 before writing
+	// to the raster interrupt buffer, but ScummVM reads normal area palettes
+	// from the same table format without that shift (and they look correct),
+	// so we load these the same way — via loadPalette, no shift.
 	for (int level = 0; level < 6; level++) {
 		stream->seek(0x10EB6 + 0x1C + level * 32);
-		uint16 rawWords[16];
-		for (int w = 0; w < 16; w++)
-			rawWords[w] = stream->readUint16BE();
-
-		for (int i = 0; i < 16; i += 2) {
-			uint32 packed = ((uint32)rawWords[i] << 16) | rawWords[i + 1];
-			packed >>= 1;
-			uint16 w1 = (packed >> 16) & 0x0777;
-			uint16 w2 = packed & 0x0777;
-			for (int j = 0; j < 2; j++) {
-				uint16 v = (j == 0) ? w1 : w2;
-				int idx = i + j;
-				int r = (v >> 8) & 0xf; r = r << 4 | r;
-				int g = (v >> 4) & 0xf; g = g << 4 | g;
-				int b = v & 0xf;         b = b << 4 | b;
-				_eclipseFadePalettes[level][idx * 3 + 0] = r;
-				_eclipseFadePalettes[level][idx * 3 + 1] = g;
-				_eclipseFadePalettes[level][idx * 3 + 2] = b;
-			}
-		}
+		byte *pal = loadPalette(stream);
+		memcpy(_eclipseFadePalettes[level], pal, 16 * 3);
+		delete[] pal;
 	}
 
 	// Apply brightest fade palette to all dark areas at load time


Commit: 24bcddbf11571dfd9d5ea0380fbd68e20b974727
    https://github.com/scummvm/scummvm/commit/24bcddbf11571dfd9d5ea0380fbd68e20b974727
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: update brightness in real time in eclipse atarist

Changed paths:
    engines/freescape/games/eclipse/eclipse.cpp


diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 0586dde8c8d..0906ef80767 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -1177,6 +1177,10 @@ void EclipseEngine::updateTimeVariables() {
 				_atariLanternLightFrame = -1;
 				_atariLanternAnimationDirection = 0;
 			}
+			if (_atariAreaDark && _currentArea) {
+				applyEclipseFadePalette(_currentArea->getAreaID(), _lanternBatteryLevel);
+				swapPalette(_currentArea->getAreaID());
+			}
 		}
 	}
 


Commit: 4ae107a7a722fd5defe35451d0672894376296e7
    https://github.com/scummvm/scummvm/commit/4ae107a7a722fd5defe35451d0672894376296e7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: use correct frames for brightness UI updates in eclipse atarist

Changed paths:
    engines/freescape/games/eclipse/atari.cpp


diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index eec4d627636..e8a88208f6d 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -769,12 +769,13 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	}
 
 	// Lantern light strip at (48, 139). During on/off animation use the animation
-	// frame; once settled, show the battery level (5=full → 0=dim, mapped to
-	// sprite index 0=bright → 5=dim via inversion).
+	// frame; once settled, show the battery level. The original 68K code at
+	// $1C1C uses eclipse_brightness_level ($7F6C) directly as the sprite index:
+	// level 5 = sprite 5 (brightest), level 0 = sprite 0 (dimmest).
 	{
 		int hudLanternFrame = lanternFrame;
 		if (_flashlightOn && _atariLanternAnimationDirection == 0 && _lanternBatteryLevel >= 0)
-			hudLanternFrame = 5 - _lanternBatteryLevel;
+			hudLanternFrame = _lanternBatteryLevel;
 		if (hudLanternFrame >= 0 && hudLanternFrame < 6 && _lanternLightSprites.size() >= 6) {
 			surface->copyRectToSurface(*_lanternLightSprites[hudLanternFrame], 48, 139,
 				Common::Rect(_lanternLightSprites[hudLanternFrame]->w, _lanternLightSprites[hudLanternFrame]->h));


Commit: 8b44c75060d95ee8b012e19b93d42ce24c3a8b68
    https://github.com/scummvm/scummvm/commit/8b44c75060d95ee8b012e19b93d42ce24c3a8b68
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: support more eclipse atarist releases

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/freescape.h
    engines/freescape/games/driller/atari.cpp
    engines/freescape/games/driller/driller.h
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/loaders/8bitBinaryLoader.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index d69807c680d..5e6011b0196 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -766,6 +766,20 @@ static const ADGameDescription gameDescriptions[] = {
 		ADGF_UNSUPPORTED,
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERAMIGA)
 	},
+	{
+		// Virtual Worlds release
+		"totaleclipse",
+		"",
+		{
+			{"TEC.ALL", 0, "e71e07b7d20ea9888dd0b35d66d30ec3", 48128},
+  			{"1.TEC", 0, "7bc83a8293f3a7e09a827db1c278c8bc", 122620},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAtariST,
+		ADGF_TESTING,
+		GUIO4(GUIO_NOMIDI, GUIO_RENDERATARIST, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS)
+	},
 	{
 		"totaleclipse",
 		"",
@@ -776,8 +790,8 @@ static const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSTABLE,
-		GUIO2(GUIO_NOMIDI, GUIO_RENDERATARIST)
+		ADGF_TESTING,
+		GUIO4(GUIO_NOMIDI, GUIO_RENDERATARIST, GAMEOPTION_MODERN_MOVEMENT, GAMEOPTION_WASD_CONTROLS)
 	},
 	{
 		// Stampede Atari, Issue 7
@@ -790,7 +804,7 @@ static const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAtariST,
-		ADGF_UNSUPPORTED,
+		ADGF_UNSTABLE,
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERATARIST)
 	},
 
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index f35e0d1501b..f63766a0255 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -339,6 +339,7 @@ public:
 
 	void parseAmigaAtariHeader(Common::SeekableReadStream *file);
 	Common::SeekableReadStream *decryptFileAmigaAtari(const Common::Path &packed, const Common::Path &unpacker, uint32 unpackArrayOffset);
+	Common::SeekableReadStream *decryptFileAtariVirtualWorlds(const Common::Path &filename);
 
 	// Areas
 	uint16 _startArea;
diff --git a/engines/freescape/games/driller/atari.cpp b/engines/freescape/games/driller/atari.cpp
index 1819bd9a5f3..6d8e8c9345b 100644
--- a/engines/freescape/games/driller/atari.cpp
+++ b/engines/freescape/games/driller/atari.cpp
@@ -28,168 +28,6 @@
 
 namespace Freescape {
 
-namespace {
-// A simple implementation of memmem, which is a non-standard GNU extension.
-const void *local_memmem(const void *haystack, size_t haystack_len, const void *needle, size_t needle_len) {
-	if (needle_len == 0) {
-		return haystack;
-	}
-	if (haystack_len < needle_len) {
-		return nullptr;
-	}
-	const char *h = (const char *)haystack;
-	for (size_t i = 0; i <= haystack_len - needle_len; ++i) {
-		if (memcmp(h + i, needle, needle_len) == 0) {
-			return h + i;
-		}
-	}
-	return nullptr;
-}
-} // namespace
-
-Common::SeekableReadStream *DrillerEngine::decryptFileAtariVirtualWorlds(const Common::Path &filename) {
-	Common::File file;
-	if (!file.open(filename)) {
-		error("Failed to open %s", filename.toString().c_str());
-	}
-	const int size = file.size();
-	byte *data = (byte *)malloc(size);
-	file.read(data, size);
-
-	int start = 0;
-	int valid_offset = -1;
-	int chunk_size = 0;
-
-	while (true) {
-		const byte *found = (const byte *)local_memmem(data + start, size - start, "CBCP", 4);
-		if (!found) break;
-
-		int idx = found - data;
-		if (idx + 8 <= size) {
-			int sz = READ_BE_UINT32(data + idx + 4);
-			if (sz > 0 && sz < size + 0x20000) {
-				valid_offset = idx;
-				chunk_size = sz;
-			}
-		}
-		start = idx + 1;
-	}
-
-	if (valid_offset == -1) {
-		error("No valid CBCP chunk found in %s", filename.toString().c_str());
-	}
-
-	const byte *payload = data + valid_offset + 8;
-	const int payload_size = chunk_size;
-
-	if (payload_size < 12) {
-		error("Payload too short in %s", filename.toString().c_str());
-	}
-
-	uint32 bit_buf_init = READ_BE_UINT32(payload + payload_size - 12);
-	uint32 checksum_init = READ_BE_UINT32(payload + payload_size - 8);
-	uint32 decoded_size = READ_BE_UINT32(payload + payload_size - 4);
-
-	byte *out_buffer = (byte *)malloc(decoded_size);
-	int dst_idx = decoded_size;
-
-	struct BitStream {
-		const byte *_src_data;
-		int _src_idx;
-		uint32 _bit_buffer;
-		uint32 _checksum;
-		int _refill_carry;
-
-		BitStream(const byte *src_data, int start_idx, uint32 bit_buffer, uint32 checksum) :
-			_src_data(src_data), _src_idx(start_idx), _bit_buffer(bit_buffer), _checksum(checksum), _refill_carry(0) {}
-
-		void refill() {
-			if (_src_idx < 0) {
-				_refill_carry = 0;
-				_bit_buffer = 0x80000000;
-				return;
-			}
-			uint32 val = READ_BE_UINT32(_src_data + _src_idx);
-			_src_idx -= 4;
-			_checksum ^= val;
-			_refill_carry = val & 1;
-			_bit_buffer = (val >> 1) | 0x80000000;
-		}
-
-		int getBits(int count) {
-			uint32 result = 0;
-			for (int i = 0; i < count; ++i) {
-				int carry = _bit_buffer & 1;
-				_bit_buffer >>= 1;
-				if (_bit_buffer == 0) {
-					refill();
-					carry = _refill_carry;
-				}
-				result = (result << 1) | carry;
-			}
-			return result;
-		}
-	};
-
-	int src_idx = payload_size - 16;
-	uint32 checksum = checksum_init ^ bit_buf_init;
-	BitStream bs(payload, src_idx, bit_buf_init, checksum);
-
-	while (dst_idx > 0) {
-		if (bs.getBits(1) == 0) {
-			if (bs.getBits(1) == 1) {
-				int offset = bs.getBits(8);
-				for (int i = 0; i < 2; ++i) {
-					dst_idx--;
-					if (dst_idx >= 0) {
-						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
-					}
-				}
-			} else {
-				int count = bs.getBits(3) + 1;
-				for (int i = 0; i < count; ++i) {
-					dst_idx--;
-					if (dst_idx >= 0) {
-						out_buffer[dst_idx] = bs.getBits(8);
-					}
-				}
-			}
-		} else {
-			int tag = bs.getBits(2);
-			if (tag == 3) {
-				int count = bs.getBits(8) + 9;
-				for (int i = 0; i < count; ++i) {
-					dst_idx--;
-					if (dst_idx >= 0) {
-						out_buffer[dst_idx] = bs.getBits(8);
-					}
-				}
-			} else if (tag == 2) {
-				int length = bs.getBits(8) + 1;
-				int offset = bs.getBits(12);
-				for (int i = 0; i < length; ++i) {
-					dst_idx--;
-					if (dst_idx >= 0) {
-						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
-					}
-				}
-			} else {
-				int bits_offset = 9 + tag;
-				int length = 3 + tag;
-				int offset = bs.getBits(bits_offset);
-				for (int i = 0; i < length; ++i) {
-					dst_idx--;
-					if (dst_idx >= 0) {
-						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
-					}
-				}
-			}
-		}
-	}
-	free(data);
-	return new Common::MemoryReadStream(out_buffer, decoded_size);
-}
-
 Common::SeekableReadStream *DrillerEngine::decryptFileAtari(const Common::Path &filename) {
 	Common::File file;
 	file.open(filename);
diff --git a/engines/freescape/games/driller/driller.h b/engines/freescape/games/driller/driller.h
index 059b0bc1ea2..e427f1ab3d5 100644
--- a/engines/freescape/games/driller/driller.h
+++ b/engines/freescape/games/driller/driller.h
@@ -155,7 +155,6 @@ private:
 	Texture *_borderExtraTexture;
 
 	Common::SeekableReadStream *decryptFileAtari(const Common::Path &filename);
-	Common::SeekableReadStream *decryptFileAtariVirtualWorlds(const Common::Path &filename);
 };
 
 enum DrillerReleaseFlags {
diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index e8a88208f6d..9466bbe6758 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -813,10 +813,15 @@ void EclipseEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 void EclipseEngine::loadAssetsAtariFullGame() {
 	Common::File file;
 	file.open("0.tec");
-	_title = loadAndConvertNeoImage(&file, 0x17ac);
-	file.close();
+	Common::SeekableReadStream *stream = nullptr;
+	if (!file.isOpen()) {
+		stream = decryptFileAtariVirtualWorlds("1.tec");
+	} else {
+		_title = loadAndConvertNeoImage(&file, 0x17ac);
+		file.close();
 
-	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.tec", "0.tec", 0x1774 - 4 * 1024);
+		stream = decryptFileAmigaAtari("1.tec", "0.tec", 0x1774 - 4 * 1024);
+	}
 	parseAmigaAtariHeader(stream);
 
 	loadMessagesVariableSize(stream, 0x87a6, 28);
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index a69e1804c72..b63c6b4e231 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -1244,4 +1244,166 @@ Common::SeekableReadStream *FreescapeEngine::decryptFileAmigaAtari(const Common:
 }
 
 
+namespace {
+// A simple implementation of memmem, which is a non-standard GNU extension.
+const void *local_memmem(const void *haystack, size_t haystack_len, const void *needle, size_t needle_len) {
+	if (needle_len == 0) {
+		return haystack;
+	}
+	if (haystack_len < needle_len) {
+		return nullptr;
+	}
+	const char *h = (const char *)haystack;
+	for (size_t i = 0; i <= haystack_len - needle_len; ++i) {
+		if (memcmp(h + i, needle, needle_len) == 0) {
+			return h + i;
+		}
+	}
+	return nullptr;
+}
+} // namespace
+
+Common::SeekableReadStream *FreescapeEngine::decryptFileAtariVirtualWorlds(const Common::Path &filename) {
+	Common::File file;
+	if (!file.open(filename)) {
+		error("Failed to open %s", filename.toString().c_str());
+	}
+	const int size = file.size();
+	byte *data = (byte *)malloc(size);
+	file.read(data, size);
+
+	int start = 0;
+	int valid_offset = -1;
+	int chunk_size = 0;
+
+	while (true) {
+		const byte *found = (const byte *)local_memmem(data + start, size - start, "CBCP", 4);
+		if (!found) break;
+
+		int idx = found - data;
+		if (idx + 8 <= size) {
+			int sz = READ_BE_UINT32(data + idx + 4);
+			if (sz > 0 && sz < size + 0x20000) {
+				valid_offset = idx;
+				chunk_size = sz;
+			}
+		}
+		start = idx + 1;
+	}
+
+	if (valid_offset == -1) {
+		error("No valid CBCP chunk found in %s", filename.toString().c_str());
+	}
+
+	const byte *payload = data + valid_offset + 8;
+	const int payload_size = chunk_size;
+
+	if (payload_size < 12) {
+		error("Payload too short in %s", filename.toString().c_str());
+	}
+
+	uint32 bit_buf_init = READ_BE_UINT32(payload + payload_size - 12);
+	uint32 checksum_init = READ_BE_UINT32(payload + payload_size - 8);
+	uint32 decoded_size = READ_BE_UINT32(payload + payload_size - 4);
+
+	byte *out_buffer = (byte *)malloc(decoded_size);
+	int dst_idx = decoded_size;
+
+	struct BitStream {
+		const byte *_src_data;
+		int _src_idx;
+		uint32 _bit_buffer;
+		uint32 _checksum;
+		int _refill_carry;
+
+		BitStream(const byte *src_data, int start_idx, uint32 bit_buffer, uint32 checksum) :
+			_src_data(src_data), _src_idx(start_idx), _bit_buffer(bit_buffer), _checksum(checksum), _refill_carry(0) {}
+
+		void refill() {
+			if (_src_idx < 0) {
+				_refill_carry = 0;
+				_bit_buffer = 0x80000000;
+				return;
+			}
+			uint32 val = READ_BE_UINT32(_src_data + _src_idx);
+			_src_idx -= 4;
+			_checksum ^= val;
+			_refill_carry = val & 1;
+			_bit_buffer = (val >> 1) | 0x80000000;
+		}
+
+		int getBits(int count) {
+			uint32 result = 0;
+			for (int i = 0; i < count; ++i) {
+				int carry = _bit_buffer & 1;
+				_bit_buffer >>= 1;
+				if (_bit_buffer == 0) {
+					refill();
+					carry = _refill_carry;
+				}
+				result = (result << 1) | carry;
+			}
+			return result;
+		}
+	};
+
+	int src_idx = payload_size - 16;
+	uint32 checksum = checksum_init ^ bit_buf_init;
+	BitStream bs(payload, src_idx, bit_buf_init, checksum);
+
+	while (dst_idx > 0) {
+		if (bs.getBits(1) == 0) {
+			if (bs.getBits(1) == 1) {
+				int offset = bs.getBits(8);
+				for (int i = 0; i < 2; ++i) {
+					dst_idx--;
+					if (dst_idx >= 0) {
+						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
+					}
+				}
+			} else {
+				int count = bs.getBits(3) + 1;
+				for (int i = 0; i < count; ++i) {
+					dst_idx--;
+					if (dst_idx >= 0) {
+						out_buffer[dst_idx] = bs.getBits(8);
+					}
+				}
+			}
+		} else {
+			int tag = bs.getBits(2);
+			if (tag == 3) {
+				int count = bs.getBits(8) + 9;
+				for (int i = 0; i < count; ++i) {
+					dst_idx--;
+					if (dst_idx >= 0) {
+						out_buffer[dst_idx] = bs.getBits(8);
+					}
+				}
+			} else if (tag == 2) {
+				int length = bs.getBits(8) + 1;
+				int offset = bs.getBits(12);
+				for (int i = 0; i < length; ++i) {
+					dst_idx--;
+					if (dst_idx >= 0) {
+						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
+					}
+				}
+			} else {
+				int bits_offset = 9 + tag;
+				int length = 3 + tag;
+				int offset = bs.getBits(bits_offset);
+				for (int i = 0; i < length; ++i) {
+					dst_idx--;
+					if (dst_idx >= 0) {
+						out_buffer[dst_idx] = out_buffer[dst_idx + offset];
+					}
+				}
+			}
+		}
+	}
+	free(data);
+	return new Common::MemoryReadStream(out_buffer, decoded_size);
+}
+
 } // namespace Freescape


Commit: adf891981d7c26f8d1e5dbd71e25e8d3426d9186
    https://github.com/scummvm/scummvm/commit/adf891981d7c26f8d1e5dbd71e25e8d3426d9186
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: initial support for eclipse amiga releases

Changed paths:
  A engines/freescape/games/eclipse/amiga.cpp
    engines/freescape/detection.cpp
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/eclipse.h
    engines/freescape/games/palettes.cpp
    engines/freescape/module.mk


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 5e6011b0196..23d98d3b388 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -749,7 +749,7 @@ static const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSUPPORTED,
+		ADGF_UNSTABLE,
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERAMIGA)
 	},
 	{
@@ -763,7 +763,7 @@ static const ADGameDescription gameDescriptions[] = {
 		},
 		Common::EN_ANY,
 		Common::kPlatformAmiga,
-		ADGF_UNSUPPORTED,
+		ADGF_UNSTABLE,
 		GUIO2(GUIO_NOMIDI, GUIO_RENDERAMIGA)
 	},
 	{
diff --git a/engines/freescape/games/eclipse/amiga.cpp b/engines/freescape/games/eclipse/amiga.cpp
new file mode 100644
index 00000000000..ade5fbf9088
--- /dev/null
+++ b/engines/freescape/games/eclipse/amiga.cpp
@@ -0,0 +1,221 @@
+
+/* 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/file.h"
+#include "common/memstream.h"
+
+#include "freescape/freescape.h"
+#include "freescape/games/eclipse/eclipse.h"
+#include "freescape/language/8bitDetokeniser.h"
+
+namespace Freescape {
+
+extern const byte kBorderPalette[16 * 3];
+extern const int8 kAtariCompassPhaseToFrame[];
+extern const int kAtariCompassPhaseCount;
+extern const int kAtariCompassBaseFrames;
+extern const int kAtariCompassTotalFrames;
+extern const uint32 kAtariAreaRecordBase;
+extern const uint32 kAtariAreaIndexBase;
+extern const int kAtariAreaIndexCount;
+extern const uint16 kAtariDarkAreaFlag;
+
+Graphics::ManagedSurface *loadAtariSTRawSprite(Common::SeekableReadStream *stream,
+		int pixelOffset, int cols, int rows);
+Graphics::ManagedSurface *loadAtariSTSprite(Common::SeekableReadStream *stream,
+		int maskOffset, int pixelOffset, int cols, int rows);
+Graphics::ManagedSurface *loadAtariSTNeedleSprite(Common::SeekableReadStream *stream,
+		int pixelOffset, const Graphics::PixelFormat &format);
+void buildAtariSTCompassMirrorCache(Common::SeekableReadStream *stream, int pixelOffset,
+		Common::Array<Graphics::ManagedSurface *> &sprites, const Graphics::PixelFormat &format);
+bool containsAtariAreaID(const Common::Array<uint16> &areaIDs, uint16 areaID);
+
+void EclipseEngine::loadAssetsAmigaFullGame() {
+	// Decrypt 1.tec using 0.tec as the unpacker (unpack array at file offset $454).
+	Common::SeekableReadStream *stream = decryptFileAmigaAtari("1.tec", "0.tec", 0x454);
+
+	parseAmigaAtariHeader(stream);
+
+	// The Amiga decrypted stream shares identical game data with the Atari ST
+	// binary, but at a consistent offset delta of +0xC87A for most resources.
+	// Messages and the eclipse fade palette are at different locations.
+	static const int kAmigaDelta = 0xC87A;
+
+	loadMessagesVariableSize(stream, 0x9864, 28);
+	load8bitBinary(stream, 0x2a53c + kAmigaDelta, 16);
+
+	_border = loadAndConvertNeoImage(stream, 0x139c8 + kAmigaDelta);
+	loadPalettes(stream, 0x29e52 + kAmigaDelta);
+
+	// Colors 0-5 from border palette (Copper list palette split, same as Atari ST Timer-B)
+	for (auto &entry : _paletteByArea) {
+		byte *pal = entry._value;
+		memcpy(pal, kBorderPalette, 6 * 3);
+	}
+
+	loadSoundsFx(stream, 0x3030c + kAmigaDelta, 6);
+
+	// UI font (Font A): same 4-plane format as Atari ST
+	Common::Array<Graphics::ManagedSurface *> chars;
+	chars = getChars4Plane(stream, 0x24C5A + kAmigaDelta, 85);
+	_font = Font(chars);
+
+	// Score font (Font B)
+	Common::Array<Graphics::ManagedSurface *> scoreChars;
+	scoreChars = getChars4Plane(stream, 0x249DA + kAmigaDelta, 11);
+	_fontScore = Font(scoreChars);
+
+	// Sprite offsets: Atari prog addr + 0x1C (GEMDOS hdr) + kAmigaDelta
+	static const int kHdr = 0x1C + kAmigaDelta;
+
+	_atariDarkAreas.clear();
+	Common::Array<uint16> parsedAreas;
+	uint32 streamSize = stream->size();
+	for (int i = 0; i < kAtariAreaIndexCount; i++) {
+		uint32 indexOffset = kAtariAreaIndexBase + kHdr + i * 4;
+		if (indexOffset + 4 > streamSize)
+			break;
+
+		stream->seek(indexOffset);
+		uint16 lowWord = stream->readUint16BE();
+		uint16 highWord = stream->readUint16BE();
+		uint32 recordWordOffset = lowWord | ((uint32)highWord << 8);
+		uint32 recordOffset = kAtariAreaRecordBase + kHdr + recordWordOffset * 2;
+		if (recordOffset + 6 > streamSize)
+			continue;
+
+		stream->seek(recordOffset);
+		uint16 flags = stream->readUint16BE();
+		stream->readUint16BE();
+		uint16 areaID = stream->readUint16BE();
+		if (!_areaMap.contains(areaID) || containsAtariAreaID(parsedAreas, areaID))
+			continue;
+
+		parsedAreas.push_back(areaID);
+		if ((flags & kAtariDarkAreaFlag) != 0)
+			_atariDarkAreas.push_back(areaID);
+	}
+
+	// Eclipse fade palettes for dark areas (at a different offset than the main delta)
+	for (int level = 0; level < 6; level++) {
+		stream->seek(0xFE66 + level * 32);
+		byte *pal = loadPalette(stream);
+		memcpy(_eclipseFadePalettes[level], pal, 16 * 3);
+		delete[] pal;
+	}
+
+	for (uint i = 0; i < _atariDarkAreas.size(); i++)
+		applyEclipseFadePalette(_atariDarkAreas[i], _lanternBatteryLevel);
+
+	// Heart indicator sprites
+	_eclipseSprites.resize(2);
+	_eclipseSprites[0] = loadAtariSTSprite(stream, 0x1D2BE + kHdr, 0x1D2C0 + kHdr, 1, 13);
+	_eclipseSprites[0]->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+	_eclipseSprites[1] = loadAtariSTSprite(stream, 0x1D2BE + kHdr, 0x1D2C0 + 104 + kHdr, 1, 13);
+	_eclipseSprites[1]->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	// Eclipse progress indicator: 16 frames
+	_eclipseProgressSprites.resize(16);
+	for (int i = 0; i < 16; i++) {
+		_eclipseProgressSprites[i] = loadAtariSTSprite(stream, 0x1DA96 + kHdr, 0x1DA98 + kHdr + i * 128, 1, 16);
+		_eclipseProgressSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
+			const_cast<byte *>(kBorderPalette), 16);
+	}
+
+	// Ankh indicator: 5 fade-in frames
+	_ankhSprites.resize(5);
+	for (int i = 0; i < 5; i++) {
+		_ankhSprites[i] = loadAtariSTSprite(stream, 0x1B732 + kHdr, 0x1B734 + kHdr + i * 120, 1, 15);
+		_ankhSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
+			const_cast<byte *>(kBorderPalette), 16);
+	}
+
+	// Compass
+	_compassBackground = loadAtariSTRawSprite(stream, 0x20986 + kHdr, 2, 27);
+	_compassBackground->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	{
+		Common::copy(kAtariCompassPhaseToFrame, kAtariCompassPhaseToFrame + kAtariCompassPhaseCount, _compassLookup);
+
+		_compassSprites.resize(kAtariCompassTotalFrames);
+		for (int frame = 0; frame < kAtariCompassBaseFrames; frame++) {
+			_compassSprites[frame] = loadAtariSTNeedleSprite(stream,
+				0x20B36 + kHdr + frame * 432, _gfx->_texturePixelFormat);
+		}
+		buildAtariSTCompassMirrorCache(stream, 0x20B36 + kHdr, _compassSprites, _gfx->_texturePixelFormat);
+	}
+
+	// Lantern light animation: 6 frames
+	_lanternLightSprites.resize(6);
+	for (int i = 0; i < 6; i++) {
+		_lanternLightSprites[i] = loadAtariSTRawSprite(stream, 0x2026A + kHdr + i * 96, 2, 6);
+		_lanternLightSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
+			const_cast<byte *>(kBorderPalette), 16);
+	}
+
+	// Lantern switch: 2 frames
+	_lanternSwitchSprites.resize(2);
+	for (int i = 0; i < 2; i++) {
+		_lanternSwitchSprites[i] = loadAtariSTRawSprite(stream, 0x204B4 + kHdr + i * 0x170, 2, 23);
+		_lanternSwitchSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
+			const_cast<byte *>(kBorderPalette), 16);
+	}
+
+	// Sound toggle: 5 frames
+	_soundToggleSprites.resize(5);
+	for (int i = 0; i < 5; i++) {
+		_soundToggleSprites[i] = loadAtariSTRawSprite(stream, 0x20794 + kHdr + i * 96, 1, 11);
+		_soundToggleSprites[i]->convertToInPlace(_gfx->_texturePixelFormat,
+			const_cast<byte *>(kBorderPalette), 16);
+	}
+
+	// Water body bitmap
+	_atariWaterBody = loadAtariSTRawSprite(stream, 0x2003C + kHdr, 2, 31);
+	_atariWaterBody->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	stream->seek(0x2024C + kHdr);
+	_atariWaterSurfacePixels.resize(8);
+	for (int w = 0; w < 8; w++)
+		_atariWaterSurfacePixels[w] = stream->readUint16BE();
+
+	_atariWaterSurfaceMask.resize(2);
+	stream->seek(0x2025C + kHdr);
+	for (int w = 0; w < 2; w++)
+		_atariWaterSurfaceMask[w] = stream->readUint16BE();
+
+	// Shooting crosshair: 2 frames
+	_shootSprites.resize(2);
+	_shootSprites[0] = loadAtariSTSprite(stream, 0x1CC2C + kHdr, 0x1CC30 + kHdr, 2, 25);
+	_shootSprites[0]->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+	_shootSprites[1] = loadAtariSTSprite(stream, 0x1CDC6 + kHdr, 0x1CDCC + kHdr, 3, 25);
+	_shootSprites[1]->convertToInPlace(_gfx->_texturePixelFormat,
+		const_cast<byte *>(kBorderPalette), 16);
+
+	delete stream;
+}
+
+} // End of namespace Freescape
diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index 9466bbe6758..0b33e3668fc 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -29,9 +29,9 @@
 
 namespace Freescape {
 
-const int kAtariCompassPhaseCount = 72;
-const int kAtariCompassBaseFrames = 19;
-const int kAtariCompassTotalFrames = 37;
+extern const int kAtariCompassPhaseCount = 72;
+extern const int kAtariCompassBaseFrames = 19;
+extern const int kAtariCompassTotalFrames = 37;
 const int kAtariClockCenterX = 106;
 const int kAtariClockCenterY = 159;
 const int kAtariCompassX = 176;
@@ -48,16 +48,16 @@ const int kAtariWaterIndicatorMaxLevel = 29;
 // scales this down proportionally.
 const int kAtariDarkLightRadius = 80;
 const int kAtariDarkLightRadiusStep = 10;
-const uint32 kAtariAreaRecordBase = 0x2A520;
-const uint32 kAtariAreaIndexBase = 0x2A6B0;
-const int kAtariAreaIndexCount = 64;
-const uint16 kAtariDarkAreaFlag = 0x8000;
+extern const uint32 kAtariAreaRecordBase = 0x2A520;
+extern const uint32 kAtariAreaIndexBase = 0x2A6B0;
+extern const int kAtariAreaIndexCount = 64;
+extern const uint16 kAtariDarkAreaFlag = 0x8000;
 
 void fillCircle(Graphics::Surface *surface, int x, int y, int radius, int color);
 
 // Repaired ST phase-to-frame table from $1542. Six corrupt bytes in the dumped
 // binary break the intended 37-frame sweep built from $20B36 and $22B46.
-const int8 kAtariCompassPhaseToFrame[kAtariCompassPhaseCount] = {
+extern const int8 kAtariCompassPhaseToFrame[kAtariCompassPhaseCount] = {
 	0, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
 	36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19,
 	0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 17, 16,
@@ -231,7 +231,7 @@ void EclipseEngine::drawCPCUI(Graphics::Surface *surface) {
 
 // Border palette from CONSOLE.NEO (Atari ST 9-bit $0RGB, scaled to 8-bit).
 // Used for font rendering and sprite conversion.
-static const byte kBorderPalette[16 * 3] = {
+extern const byte kBorderPalette[16 * 3] = {
 	0, 0, 0,        // 0: $000 black
 	145, 72, 0,     // 1: $420 dark brown
 	182, 109, 36,   // 2: $531 medium brown
@@ -251,7 +251,7 @@ static const byte kBorderPalette[16 * 3] = {
 };
 
 // Load raw 4-plane pixel data (no mask) from stream into a CLUT8 surface.
-static Graphics::ManagedSurface *loadAtariSTRawSprite(Common::SeekableReadStream *stream,
+Graphics::ManagedSurface *loadAtariSTRawSprite(Common::SeekableReadStream *stream,
 		int pixelOffset, int cols, int rows) {
 	stream->seek(pixelOffset);
 	Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 0906ef80767..4af6509e1e3 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -140,7 +140,7 @@ void EclipseEngine::restartBackgroundMusic() {
 		_playerAYMusic->startMusic();
 	} else if (isDOS() && _playerOPLMusic) {
 		_playerOPLMusic->startMusic();
-	} else if (isAtariST() && !_musicData.empty()) {
+	} else if ((isAtariST() || isAmiga()) && !_musicData.empty()) {
 		if (_mixer)
 			_mixer->stopHandle(_musicHandle);
 		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
@@ -412,7 +412,7 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	assert(_areaMap.contains(areaID));
 	_currentArea = _areaMap[areaID];
 	_currentArea->show();
-	_atariAreaDark = isAtariST() && isAtariDarkArea(areaID);
+	_atariAreaDark = (isAtariST() || isAmiga()) && isAtariDarkArea(areaID);
 
 	_currentAreaMessages.clear();
 	_currentAreaMessages.push_back(_currentArea->_name);
@@ -446,7 +446,7 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	}
 
 	_gfx->_keyColor = 0;
-	if (isAtariST() && isAtariDarkArea(areaID))
+	if ((isAtariST() || isAmiga()) && isAtariDarkArea(areaID))
 		applyEclipseFadePalette(areaID, _lanternBatteryLevel);
 	swapPalette(areaID);
 	if (isCPC())
@@ -454,7 +454,7 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 	if (isAmiga() || isAtariST())
 		_currentArea->_skyColor = 15;
 
-	if (isAtariST() && entranceID > 0) {
+	if ((isAtariST() || isAmiga()) && entranceID > 0) {
 		Entrance *entrance = (Entrance *)_currentArea->entranceWithID(entranceID);
 		if (entrance) {
 			int phase = atariCompassPhaseFromRotationY(entrance->getRotation().y());
@@ -466,8 +466,8 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 		}
 	}
 
-	// Start background music (Atari ST)
-	if (isAtariST() && !_musicData.empty() && !_mixer->isSoundHandleActive(_musicHandle)) {
+	// Start background music (Atari ST / Amiga)
+	if ((isAtariST() || isAmiga()) && !_musicData.empty() && !_mixer->isSoundHandleActive(_musicHandle)) {
 		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
 			_musicData.data(), _musicData.size(), 1);
 		if (musicStream) {
@@ -671,7 +671,7 @@ void EclipseEngine::pressedKey(const int keycode) {
 		_pitch = 0;
 		updateCamera();
 	} else if (keycode == kActionToggleFlashlight) {
-		if (isAtariST()) {
+		if (isAtariST() || isAmiga()) {
 			if (_flashlightOn) {
 				_flashlightOn = false;
 				if (_atariLanternLightFrame < 0)
@@ -700,7 +700,7 @@ void EclipseEngine::onRotate(float xoffset, float yoffset, float zoffset) {
 	(void)yoffset;
 	(void)zoffset;
 
-	if (!isAtariST() || xoffset == 0.0f)
+	if ((!isAtariST() && !isAmiga()) || xoffset == 0.0f)
 		return;
 
 	if (!_atariCompassPhaseInitialized) {
@@ -1100,7 +1100,7 @@ void EclipseEngine::drawScoreString(int score, int x, int y, uint32 front, uint3
 	// Font B has 10 glyphs (0-9) for digits. In the original, the score bytes
 	// have $2F subtracted to map '0'→glyph 0, '1'→glyph 1, etc.
 	// For drawChar: chr = glyph_index + 32, so digit '0' → chr 32, '9' → chr 41.
-	if (isAtariST()) {
+	if (isAtariST() || isAmiga()) {
 		_fontScore.setBackground(back);
 		_fontScore.setSecondaryColor(front);
 		// Font B uses palette indices 1-4 like Font A
@@ -1170,7 +1170,7 @@ void EclipseEngine::updateTimeVariables() {
 		// Lantern battery drain: non-rechargeable, one level per 30-second tick
 		// while the flashlight is on. ROM drains TeLanternBrightnessFrame ($7f6c)
 		// from 5 (brightest) down to -1 (dead). 6 levels total.
-		if (isAtariST() && _flashlightOn && _lanternBatteryLevel >= 0) {
+		if ((isAtariST() || isAmiga()) && _flashlightOn && _lanternBatteryLevel >= 0) {
 			_lanternBatteryLevel--;
 			if (_lanternBatteryLevel < 0) {
 				_flashlightOn = false;
@@ -1236,7 +1236,7 @@ Common::Error EclipseEngine::loadGameStreamExtended(Common::SeekableReadStream *
 	_atariLanternAnimationDirection = 0;
 	_atariLanternLightFrame = _flashlightOn ? 0 : -1;
 	_atariLanternLastUpdateTick = -1;
-	_atariAreaDark = isAtariST() && _currentArea && isAtariDarkArea(_currentArea->getAreaID());
+	_atariAreaDark = (isAtariST() || isAmiga()) && _currentArea && isAtariDarkArea(_currentArea->getAreaID());
 	return Common::kNoError;
 }
 
diff --git a/engines/freescape/games/eclipse/eclipse.h b/engines/freescape/games/eclipse/eclipse.h
index 1d30ba4883b..2ae3b43b1ba 100644
--- a/engines/freescape/games/eclipse/eclipse.h
+++ b/engines/freescape/games/eclipse/eclipse.h
@@ -85,6 +85,7 @@ public:
 	void loadAssetsZXFullGame() override;
 	void loadAssetsCPCFullGame() override;
 	void loadAssetsC64FullGame() override;
+	void loadAssetsAmigaFullGame() override;
 	void loadAssetsAtariFullGame() override;
 	void loadAssetsCPCDemo() override;
 	void loadAssetsZXDemo() override;
diff --git a/engines/freescape/games/palettes.cpp b/engines/freescape/games/palettes.cpp
index e9a2a0dc42b..bffb9800962 100644
--- a/engines/freescape/games/palettes.cpp
+++ b/engines/freescape/games/palettes.cpp
@@ -218,8 +218,8 @@ void FreescapeEngine::loadPalettes(Common::SeekableReadStream *file, int offset)
 			debugC(1, kFreescapeDebugParser, "Color %d: (%04x) %02x %02x %02x", c, v, palette[c][0], palette[c][1], palette[c][2]);
 		}
 		if (_paletteByArea.contains(label)) {
-			// Eclipse Atari ST has a duplicate palette entry for area 42
-			assert(isEclipse() && isAtariST());
+			// Eclipse Atari ST / Amiga has a duplicate palette entry for area 42
+			assert(isEclipse() && (isAtariST() || isAmiga()));
 			delete[] palette;
 			continue;
 		}
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index de18151c54d..363ddc47f30 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -34,6 +34,7 @@ MODULE_OBJS := \
 	games/driller/driller.o \
 	games/driller/sounds.o \
 	games/driller/zx.o \
+	games/eclipse/amiga.o \
 	games/eclipse/atari.o \
 	games/eclipse/atari.music.o \
 	games/eclipse/c64.o \


Commit: 0f28460d4a7310edbc58a4d536ac9f88e41e7b14
    https://github.com/scummvm/scummvm/commit/0f28460d4a7310edbc58a4d536ac9f88e41e7b14
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: initial support music in eclipse amiga

Changed paths:
    engines/freescape/games/eclipse/amiga.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/wb.cpp
    engines/freescape/wb.h


diff --git a/engines/freescape/games/eclipse/amiga.cpp b/engines/freescape/games/eclipse/amiga.cpp
index ade5fbf9088..f8bb9708554 100644
--- a/engines/freescape/games/eclipse/amiga.cpp
+++ b/engines/freescape/games/eclipse/amiga.cpp
@@ -74,6 +74,18 @@ void EclipseEngine::loadAssetsAmigaFullGame() {
 
 	loadSoundsFx(stream, 0x3030c + kAmigaDelta, 6);
 
+	// Load TEMUSIC.AM — Wally Beben custom Paula engine (same family as Dark Side)
+	// GEMDOS executable embedded at stream offset 0x10F5E, TEXT size 0xEB20
+	{
+		static const uint32 kTEMusicAmigaOffset = 0x10F5E;
+		static const uint32 kGemdosHeaderSize = 0x1C;
+		static const uint32 kTEMusicAmigaTextSize = 0xEB20;
+		stream->seek(kTEMusicAmigaOffset + kGemdosHeaderSize);
+		_musicData.resize(kTEMusicAmigaTextSize);
+		stream->read(_musicData.data(), kTEMusicAmigaTextSize);
+		debug(3, "TE-Amiga: Loaded TEMUSIC.AM TEXT segment (%d bytes)", kTEMusicAmigaTextSize);
+	}
+
 	// UI font (Font A): same 4-plane format as Atari ST
 	Common::Array<Graphics::ManagedSurface *> chars;
 	chars = getChars4Plane(stream, 0x24C5A + kAmigaDelta, 85);
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index 4af6509e1e3..23c9c54eb0b 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -30,6 +30,7 @@
 #include "common/translation.h"
 
 #include "freescape/freescape.h"
+#include "freescape/wb.h"
 #include "freescape/games/eclipse/c64.music.h"
 #include "freescape/games/eclipse/c64.sfx.h"
 #include "freescape/games/eclipse/ay.music.h"
@@ -44,6 +45,18 @@ namespace Freescape {
 Audio::AudioStream *makeEclipseAtariMusicStream(const byte *data, uint32 dataSize,
                                                   int songNum = 1, int rate = 44100);
 
+// Wally Beben table offsets for Total Eclipse Amiga TEMUSIC.AM
+static const WBTableOffsets kEclipseAmigaMusicOffsets = {
+	0x0ACA, // periodTable
+	0x0C5E, // samplePtrTable
+	0x0CA6, // instrumentTable
+	0x0D16, // arpeggioIntervals
+	0x0D1E, // envelopeTable
+	0x0D8E, // songTable
+	0x0D9E, // patternPtrTable (songTable + 16, overlaps Song 2 like Dark Side)
+	14, 14, 14 // numSamples, numInstruments, numEnvelopes
+};
+
 EclipseEngine::EclipseEngine(OSystem *syst, const ADGameDescription *gd) : FreescapeEngine(syst, gd) {
 	_playerC64Music = nullptr;
 	_playerC64Sfx = nullptr;
@@ -143,8 +156,14 @@ void EclipseEngine::restartBackgroundMusic() {
 	} else if ((isAtariST() || isAmiga()) && !_musicData.empty()) {
 		if (_mixer)
 			_mixer->stopHandle(_musicHandle);
-		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
-			_musicData.data(), _musicData.size(), 1);
+		Audio::AudioStream *musicStream = nullptr;
+		if (isAmiga())
+			musicStream = makeWallyBebenStream(
+				_musicData.data(), _musicData.size(), 1, 44100, true,
+				&kEclipseAmigaMusicOffsets);
+		else
+			musicStream = makeEclipseAtariMusicStream(
+				_musicData.data(), _musicData.size(), 1);
 		if (musicStream) {
 			_mixer->playStream(Audio::Mixer::kMusicSoundType,
 				&_musicHandle, musicStream);
@@ -468,8 +487,14 @@ void EclipseEngine::gotoArea(uint16 areaID, int entranceID) {
 
 	// Start background music (Atari ST / Amiga)
 	if ((isAtariST() || isAmiga()) && !_musicData.empty() && !_mixer->isSoundHandleActive(_musicHandle)) {
-		Audio::AudioStream *musicStream = makeEclipseAtariMusicStream(
-			_musicData.data(), _musicData.size(), 1);
+		Audio::AudioStream *musicStream = nullptr;
+		if (isAmiga())
+			musicStream = makeWallyBebenStream(
+				_musicData.data(), _musicData.size(), 1, 44100, true,
+				&kEclipseAmigaMusicOffsets);
+		else
+			musicStream = makeEclipseAtariMusicStream(
+				_musicData.data(), _musicData.size(), 1);
 		if (musicStream) {
 			_mixer->playStream(Audio::Mixer::kMusicSoundType,
 				&_musicHandle, musicStream);
diff --git a/engines/freescape/wb.cpp b/engines/freescape/wb.cpp
index dfa2ce8d429..3083b68d98d 100644
--- a/engines/freescape/wb.cpp
+++ b/engines/freescape/wb.cpp
@@ -87,32 +87,42 @@ byte buildArpeggioTable(const byte intervals[8], byte mask, byte *outTable, byte
 
 } // End of namespace WBCommon
 
-// TEXT-relative offsets for data tables within HDSMUSIC.AM
+// Default TEXT-relative offsets for Dark Side HDSMUSIC.AM
 // All addresses verified against disassembly of the 68000 code.
-static const uint32 kPeriodTableOffset    = 0x0AAE; // 48 x uint16 BE (note 0=silence, 1-47=C-1..B-3)
-static const uint32 kSamplePtrTableOffset = 0x0C42; // 16 x uint32 BE (TEXT-relative PCM offsets)
-static const uint32 kInstrumentTableOffset = 0x0C82; // 16 x 8 bytes (sample#, loopFlag, len, loopOff, loopLen)
-static const uint32 kArpeggioIntervalsOffset = 0x0D02; // 8 bytes (semitone offsets for arpeggio bitmask)
-static const uint32 kEnvelopeTableOffset  = 0x0D0A; // 10 x 8 bytes (atk, dec, fadeRate, rel, mod, vib, arp, flags)
-static const uint32 kSongTableOffset      = 0x0DBA; // 2 songs x 4 channels x uint32 BE order-list pointers
-static const uint32 kPatternPtrTableOffset = 0x0DCA; // up to 128 x uint32 BE (overlaps Song 2 order ptrs)
-static const uint32 kMaxPatternEntries    = 128;
+static const WBTableOffsets kDarkSideOffsets = {
+	0x0AAE, // periodTable: 48 x uint16 BE
+	0x0C42, // samplePtrTable: 16 x uint32 BE
+	0x0C82, // instrumentTable: 16 x 8 bytes
+	0x0D02, // arpeggioIntervals: 8 bytes
+	0x0D0A, // envelopeTable: 10 x 8 bytes
+	0x0DBA, // songTable: 2 songs x 4 channels
+	0x0DCA, // patternPtrTable: up to 128 x uint32 BE
+	16, 16, 10 // numSamples, numInstruments, numEnvelopes
+};
+
+static const uint32 kMaxPatternEntries = 128;
 
 class WallyBebenStream : public Audio::Paula {
 public:
-	WallyBebenStream(const byte *data, uint32 dataSize, int songNum, int rate, bool stereo);
+	WallyBebenStream(const byte *data, uint32 dataSize, int songNum, int rate, bool stereo,
+	                 const WBTableOffsets &offsets);
 	~WallyBebenStream() override;
 
 private:
 	void interrupt() override;
 
-	// --- Data tables (parsed from HDSMUSIC.AM TEXT segment) ---
+	// --- Data tables (parsed from TEXT segment) ---
 
 	const byte *_data;
 	uint32 _dataSize;
+	WBTableOffsets _offsets;
 
 	uint16 _periods[48];
 
+	static const int kMaxSamples = 16;
+	static const int kMaxInstruments = 16;
+	static const int kMaxEnvelopes = 16;
+
 	struct InstrumentDesc {
 		byte sampleIndex;
 		byte loopFlag;
@@ -120,7 +130,7 @@ private:
 		uint16 loopOffset;
 		uint16 loopLength;
 	};
-	InstrumentDesc _instruments[16];
+	InstrumentDesc _instruments[kMaxInstruments];
 
 	struct EnvelopeDesc {
 		byte attackLevel;
@@ -132,10 +142,10 @@ private:
 		byte arpeggioMask;
 		byte flags;
 	};
-	EnvelopeDesc _envelopes[10];
+	EnvelopeDesc _envelopes[kMaxEnvelopes];
 
 	// Sample offsets within the data buffer
-	uint32 _sampleOffsets[16];
+	uint32 _sampleOffsets[kMaxSamples];
 
 	// Song order list pointers (TEXT-relative)
 	uint32 _songOrderPtrs[2][4];
@@ -253,9 +263,10 @@ private:
 // ---------------------------------------------------------------------------
 
 WallyBebenStream::WallyBebenStream(const byte *data, uint32 dataSize,
-                                   int songNum, int rate, bool stereo)
+                                   int songNum, int rate, bool stereo,
+                                   const WBTableOffsets &offsets)
 	: Paula(stereo, rate, rate / 50), // 50 Hz PAL VBI interrupt rate
-	  _data(data), _dataSize(dataSize),
+	  _data(data), _dataSize(dataSize), _offsets(offsets),
 	  _musicActive(false), _tickSpeed(6), _tickCounter(0),
 	  _pendingSongCommand(-1), _numPatterns(0), _firstSampleOffset(0) {
 
@@ -282,20 +293,19 @@ WallyBebenStream::~WallyBebenStream() {
 }
 
 void WallyBebenStream::loadTables() {
-	// Period table: 48 x uint16 BE at TEXT+$AAE
+	// Period table: 48 x uint16 BE
 	for (int i = 0; i < 48; i++) {
-		_periods[i] = readDataWord(kPeriodTableOffset + i * 2);
+		_periods[i] = readDataWord(_offsets.periodTable + i * 2);
 	}
 
-	// Sample pointer table: 16 x uint32 BE at TEXT+$C42
-	// These are TEXT-relative offsets to PCM sample data
-	for (int i = 0; i < 16; i++) {
-		_sampleOffsets[i] = readDataLong(kSamplePtrTableOffset + i * 4);
+	// Sample pointer table: TEXT-relative offsets to PCM sample data
+	for (int i = 0; i < _offsets.numSamples; i++) {
+		_sampleOffsets[i] = readDataLong(_offsets.samplePtrTable + i * 4);
 	}
 
-	// Instrument table: 16 x 8 bytes at TEXT+$C82
-	for (int i = 0; i < 16; i++) {
-		uint32 off = kInstrumentTableOffset + i * 8;
+	// Instrument table
+	for (int i = 0; i < _offsets.numInstruments; i++) {
+		uint32 off = _offsets.instrumentTable + i * 8;
 		_instruments[i].sampleIndex  = readDataByte(off + 0);
 		_instruments[i].loopFlag     = readDataByte(off + 1);
 		_instruments[i].totalLength  = readDataWord(off + 2);
@@ -303,14 +313,14 @@ void WallyBebenStream::loadTables() {
 		_instruments[i].loopLength   = readDataWord(off + 6);
 	}
 
-	// Arpeggio interval table: 8 bytes at TEXT+$D02
+	// Arpeggio interval table: 8 bytes
 	for (int i = 0; i < 8; i++) {
-		_arpeggioIntervals[i] = readDataByte(kArpeggioIntervalsOffset + i);
+		_arpeggioIntervals[i] = readDataByte(_offsets.arpeggioIntervals + i);
 	}
 
-	// Envelope table: 10 x 8 bytes at TEXT+$D0A
-	for (int i = 0; i < 10; i++) {
-		uint32 off = kEnvelopeTableOffset + i * 8;
+	// Envelope table
+	for (int i = 0; i < _offsets.numEnvelopes; i++) {
+		uint32 off = _offsets.envelopeTable + i * 8;
 		_envelopes[i].attackLevel  = readDataByte(off + 0);
 		_envelopes[i].decayTarget  = readDataByte(off + 1);
 		_envelopes[i].sustainLevel = readDataByte(off + 2);
@@ -321,47 +331,47 @@ void WallyBebenStream::loadTables() {
 		_envelopes[i].flags        = readDataByte(off + 7);
 	}
 
-	// Song table: 2 songs x 4 channels x uint32 BE at TEXT+$DBA
+	// Song table: 2 songs x 4 channels x uint32 BE
 	for (int s = 0; s < 2; s++) {
 		for (int ch = 0; ch < 4; ch++) {
-			_songOrderPtrs[s][ch] = readDataLong(kSongTableOffset + s * 16 + ch * 4);
+			_songOrderPtrs[s][ch] = readDataLong(_offsets.songTable + s * 16 + ch * 4);
 		}
 	}
 
 	// Compute the first sample offset — this is the upper bound for valid
 	// pattern/order-list data. Anything at or above this is PCM sample data.
 	_firstSampleOffset = _dataSize;
-	for (int i = 0; i < 16; i++) {
+	for (int i = 0; i < _offsets.numSamples; i++) {
 		if (_sampleOffsets[i] > 0 && _sampleOffsets[i] < _firstSampleOffset)
 			_firstSampleOffset = _sampleOffsets[i];
 	}
 
-	// Pattern pointer table at TEXT+$DCA.
+	// Pattern pointer table.
 	// Valid pattern pointers must be > 0 and < _firstSampleOffset (i.e., they
 	// point into the song data area between the tables and the PCM samples).
 	// Entries at or beyond _firstSampleOffset are stale data, not real patterns.
 	_numPatterns = 0;
 	for (uint32 i = 0; i < kMaxPatternEntries; i++) {
-		uint32 ptr = readDataLong(kPatternPtrTableOffset + i * 4);
+		uint32 ptr = readDataLong(_offsets.patternPtrTable + i * 4);
 		_patternPtrs[i] = ptr;
 		if (ptr > 0 && ptr < _firstSampleOffset)
 			_numPatterns = i + 1;
 	}
 
 	// Debug: dump loaded data tables for verification
-	debug(3, "WB: Data loaded from HDSMUSIC.AM TEXT segment (%u bytes)", _dataSize);
+	debug(3, "WB: Data loaded from TEXT segment (%u bytes)", _dataSize);
 
 	debug(3, "WB: Period table (first 12): %d %d %d %d %d %d %d %d %d %d %d %d",
 		_periods[0], _periods[1], _periods[2], _periods[3],
 		_periods[4], _periods[5], _periods[6], _periods[7],
 		_periods[8], _periods[9], _periods[10], _periods[11]);
 
-	for (int i = 0; i < 16; i++) {
+	for (int i = 0; i < _offsets.numSamples; i++) {
 		if (_sampleOffsets[i])
 			debug(3, "WB: Sample %d: offset=$%X", i, _sampleOffsets[i]);
 	}
 
-	for (int i = 0; i < 16; i++) {
+	for (int i = 0; i < _offsets.numInstruments; i++) {
 		const InstrumentDesc &inst = _instruments[i];
 		if (inst.totalLength > 0)
 			debug(3, "WB: Inst %d: sample=%d loop=%d len=%d loopOff=%d loopLen=%d",
@@ -369,7 +379,7 @@ void WallyBebenStream::loadTables() {
 				inst.loopOffset, inst.loopLength);
 	}
 
-	for (int i = 0; i < 10; i++) {
+	for (int i = 0; i < _offsets.numEnvelopes; i++) {
 		const EnvelopeDesc &env = _envelopes[i];
 		debug(3, "WB: Env %d: atk=%d dec=%d sus=%d rel=%d mod=%d arp=$%02X",
 			i, env.attackLevel, env.decayTarget, env.sustainLevel,
@@ -545,12 +555,12 @@ void WallyBebenStream::readPatternCommands(int ch) {
 		}
 
 		if (cmd >= 0xC0) {
-			// Set envelope: low 5 bits (0-31, but only 1-9 valid)
+			// Set envelope: low 5 bits (0-31)
 			// Asm ref: TEXT+$2C8 — envelope command handler
 			// $C0 (index 0) is a no-op: Env 0 is all zeros (sentinel entry).
 			// The original engine treats index 0 as "no envelope change".
 			byte envIdx = cmd & 0x1F;
-			if (envIdx == 0 || envIdx > 9) {
+			if (envIdx == 0 || envIdx >= _offsets.numEnvelopes) {
 				// Index 0 or out-of-range: skip, keep current envelope params
 				continue;
 			}
@@ -720,7 +730,7 @@ void WallyBebenStream::triggerNote(int ch) {
 	// Load instrument
 	const InstrumentDesc &inst = _instruments[c.instrumentIdx];
 	byte sampleIdx = inst.sampleIndex;
-	if (sampleIdx >= 16)
+	if (sampleIdx >= _offsets.numSamples)
 		sampleIdx = 0;
 
 	uint32 sampleOffset = _sampleOffsets[sampleIdx];
@@ -898,13 +908,18 @@ void WallyBebenStream::processEnvelope(int ch) {
 		if (c.volume > c.decayTarget) {
 			c.volume--;
 		} else {
-			c.volume = c.sustainLevel;
 			c.envelopePhase = 2;
 		}
 		break;
 
-	case 2: // Sustain — hold sustain level until note-off.
-		c.volume = c.sustainLevel;
+	case 2: // Sustain — hold at decay target; sustainLevel is the fade rate
+		// (0 = hold forever, >0 = fade by sustainLevel per tick toward 0).
+		if (c.sustainLevel > 0) {
+			if (c.volume > c.sustainLevel)
+				c.volume -= c.sustainLevel;
+			else
+				c.volume = 0;
+		}
 		break;
 
 	case 3: // Release — decrease volume on note-off
@@ -1034,13 +1049,15 @@ void WallyBebenStream::interrupt() {
 // ---------------------------------------------------------------------------
 
 Audio::AudioStream *makeWallyBebenStream(const byte *data, uint32 dataSize,
-                                         int songNum, int rate, bool stereo) {
-	if (!data || dataSize < 0xF000) {
+                                         int songNum, int rate, bool stereo,
+                                         const WBTableOffsets *offsets) {
+	if (!data || dataSize < 0x1000) {
 		warning("WallyBeben: invalid data (size %u)", dataSize);
 		return nullptr;
 	}
 
-	return new WallyBebenStream(data, dataSize, songNum, rate, stereo);
+	const WBTableOffsets &tbl = offsets ? *offsets : kDarkSideOffsets;
+	return new WallyBebenStream(data, dataSize, songNum, rate, stereo, tbl);
 }
 
 } // End of namespace Freescape
diff --git a/engines/freescape/wb.h b/engines/freescape/wb.h
index f39ee5c6be6..8dfc10ffa02 100644
--- a/engines/freescape/wb.h
+++ b/engines/freescape/wb.h
@@ -59,20 +59,36 @@ byte buildArpeggioTable(const byte intervals[8], byte mask, byte *outTable, byte
 
 } // End of namespace WBCommon
 
+/** Table offsets and sizes for a Wally Beben music module. */
+struct WBTableOffsets {
+	uint32 periodTable;       // 48 x uint16 BE
+	uint32 samplePtrTable;    // numSamples x uint32 BE
+	uint32 instrumentTable;   // numInstruments x 8 bytes
+	uint32 arpeggioIntervals; // 8 bytes
+	uint32 envelopeTable;     // numEnvelopes x 8 bytes
+	uint32 songTable;         // 2 songs x 4 channels x uint32 BE
+	uint32 patternPtrTable;   // up to 128 x uint32 BE
+	int numSamples;
+	int numInstruments;
+	int numEnvelopes;
+};
+
 /**
  * Create a music stream for the Wally Beben custom music engine
- * used in the Amiga version of Dark Side.
+ * used in the Amiga versions of Dark Side and Total Eclipse.
  *
- * @param data     Raw TEXT segment data from HDSMUSIC.AM (after 0x1C GEMDOS header)
- * @param dataSize Size of the TEXT segment (0xF4BC for Dark Side)
+ * @param data     Raw TEXT segment data (after 0x1C GEMDOS header)
+ * @param dataSize Size of the TEXT segment
  * @param songNum  Song number to play (1 or 2)
  * @param rate     Output sample rate
  * @param stereo   Whether to produce stereo output
+ * @param offsets  Table offsets (nullptr = Dark Side defaults)
  * @return A new AudioStream, or nullptr on error
  */
 Audio::AudioStream *makeWallyBebenStream(const byte *data, uint32 dataSize,
                                          int songNum = 1, int rate = 44100,
-                                         bool stereo = true);
+                                         bool stereo = true,
+                                         const WBTableOffsets *offsets = nullptr);
 
 } // End of namespace Freescape
 


Commit: 1e2b3bb0410d2c0606ecee75b72a0d74421361b6
    https://github.com/scummvm/scummvm/commit/1e2b3bb0410d2c0606ecee75b72a0d74421361b6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-04-10T21:12:35+02:00

Commit Message:
FREESCAPE: decay fix in wb music module

Changed paths:
    engines/freescape/wb.cpp


diff --git a/engines/freescape/wb.cpp b/engines/freescape/wb.cpp
index 3083b68d98d..2e7d8dc8469 100644
--- a/engines/freescape/wb.cpp
+++ b/engines/freescape/wb.cpp
@@ -905,10 +905,15 @@ void WallyBebenStream::processEnvelope(int ch) {
 		break;
 
 	case 1: // Decay — decrease toward decay target.
+		// The 68K code decreases volume each tick; when it reaches exactly
+		// decayTarget it enters sustain. If attack == decay, the volume
+		// never changes and the engine stays in this phase (holds forever).
 		if (c.volume > c.decayTarget) {
 			c.volume--;
-		} else {
-			c.envelopePhase = 2;
+			if (c.volume <= c.decayTarget) {
+				c.volume = c.decayTarget;
+				c.envelopePhase = 2;
+			}
 		}
 		break;
 




More information about the Scummvm-git-logs mailing list