[Scummvm-git-logs] scummvm master -> 04795e7633ec19cf515870c8eb69f55116cbd3d8

neuromancer noreply at scummvm.org
Sat May 23 19:15:36 UTC 2026


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

Summary:
e9e31ca2d4 FREESCAPE: improve AABB with a case where BB overlaps the geometry
f2293e631a FREESCAPE: initial support for castle master (amiga)
2e7f792636 FREESCAPE: improve castle amiga endgame
6cef8e0f34 FREESCAPE: experimental support red/blue stereo 3d images for the zx render
05707392b9 FREESCAPE: removed buggy collision workaround
4c18772a98 FREESCAPE: sensor fixes, in particular for driller amiga/atari
04795e7633 FREESCAPE: make sure saving/loading keep the game state consistent specially when the player dies


Commit: e9e31ca2d469c9053ae9071027ad167b3f96a3a6
    https://github.com/scummvm/scummvm/commit/e9e31ca2d469c9053ae9071027ad167b3f96a3a6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:50+02:00

Commit Message:
FREESCAPE: improve AABB with a case where BB overlaps the geometry

Changed paths:
    engines/freescape/sweepAABB.cpp


diff --git a/engines/freescape/sweepAABB.cpp b/engines/freescape/sweepAABB.cpp
index 426c0e15c38..0055fd3b1e8 100644
--- a/engines/freescape/sweepAABB.cpp
+++ b/engines/freescape/sweepAABB.cpp
@@ -44,6 +44,37 @@ float sweepAABB(Math::AABB const &a, Math::AABB const &b, Math::Vector3d const &
 	Math::Vector3d m = b.getMin() - a.getMax();
 	Math::Vector3d mh = a.getSize() + b.getSize();
 
+	// Overlap-at-start: the AABBs already intersect on every axis. The
+	// plane sweep below requires s >= 0 for every test, so it would
+	// incorrectly return "no collision" and the caller would let the
+	// player walk straight through the geometry they are already inside.
+	// Detect that case here, pick the axis of smallest overlap, and emit
+	// a t=0 collision with a unit normal pointing out along that axis.
+	// The caller's epsilon * normal push then pries the player out of
+	// the overlap incrementally over a few frames.
+	if (m.x() < 0 && m.x() + mh.x() > 0 &&
+	    m.y() < 0 && m.y() + mh.y() > 0 &&
+	    m.z() < 0 && m.z() + mh.z() > 0) {
+		// Overlap depth on the "push -axis" side vs the "push +axis" side
+		// (both values are positive while we are overlapping).
+		float ovNegX = -m.x();
+		float ovPosX = m.x() + mh.x();
+		float ovNegY = -m.y();
+		float ovPosY = m.y() + mh.y();
+		float ovNegZ = -m.z();
+		float ovPosZ = m.z() + mh.z();
+		float minX = MIN(ovNegX, ovPosX);
+		float minY = MIN(ovNegY, ovPosY);
+		float minZ = MIN(ovNegZ, ovPosZ);
+		if (minX <= minY && minX <= minZ)
+			normal = Math::Vector3d(ovNegX < ovPosX ? -1.0f : 1.0f, 0, 0);
+		else if (minY <= minZ)
+			normal = Math::Vector3d(0, ovNegY < ovPosY ? -1.0f : 1.0f, 0);
+		else
+			normal = Math::Vector3d(0, 0, ovNegZ < ovPosZ ? -1.0f : 1.0f);
+		return 0.0f;
+	}
+
 	float h = 1.0;
 	float s = 0.0;
 	Math::Vector3d zero;


Commit: f2293e631a10d5a8e3bd19d1e19cfe26c8adb783
    https://github.com/scummvm/scummvm/commit/f2293e631a10d5a8e3bd19d1e19cfe26c8adb783
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:50+02:00

Commit Message:
FREESCAPE: initial support for castle master (amiga)

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/freescape.h
    engines/freescape/games/castle/amiga.cpp
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/castle.h
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/sound/amiga.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 23d98d3b388..798e4376b59 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -880,6 +880,20 @@ static const ADGameDescription gameDescriptions[] = {
 		ADGF_DEMO,
 		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
 	},
+	// Full Castle Master, Amiga
+	{
+		"castlemaster",
+		"",
+		{
+			{"cm", 0, "67dcb3f62fe15f18ecc31380ffaf5c4e", 1112},
+			{"x", 0, "afc245de66ef8eb1b5fd061c6bbd602e", 349975},
+			AD_LISTEND
+		},
+		Common::EN_ANY,
+		Common::kPlatformAmiga,
+		ADGF_UNSTABLE,
+		GUIO4(GUIO_NOMIDI, GAMEOPTION_TRAVEL_ROCK, GUIO_RENDERAMIGA, GAMEOPTION_WASD_CONTROLS)
+	},
 	{
 		"castlemaster",
 		"",
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index f63766a0255..a7ada28eba7 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -534,7 +534,7 @@ public:
 	Common::Array<byte> _soundsCPCEnvelopeTable;
 	Common::Array<byte> _soundsCPCSoundDefTable;
 
-	void loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds);
+	void loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset = 0x3D5A6);
 	void playSoundAmiga(int index, Audio::SoundHandle &handle);
 	Common::Array<AmigaSfxEntry> _amigaSfxTable;
 	Common::Array<AmigaDmaSample> _amigaDmaSamples;
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 6e6795138a4..6ef8248c9e1 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -69,6 +69,914 @@ byte kAmigaCastleRiddlePalette[16][3] = {
 	{0xee, 0xcc, 0x66},
 };
 
+class CastleAmigaIntroPlayer {
+public:
+	CastleAmigaIntroPlayer(CastleEngine *engine, const Common::Array<byte> &introText)
+	    : _engine(engine), _data(introText) {
+		_screen[0].resize(kScreenBytes);
+		_screen[1].resize(kScreenBytes);
+		reset();
+	}
+
+	bool run(bool &selectedPrincess) {
+		selectedPrincess = false;
+
+		clearDrawBuffer();
+		drawBaseScreen();
+		drawStaticLogo();
+		drawStaticForeground();
+		fadePalette(0x1dc6);
+		pageFlip();
+		displayFrame(false, false);
+		fadePalette(0x1de6);
+
+		int key = 0;
+		while (!_aborted) {
+			clearDrawBuffer();
+			drawBaseScreen();
+			drawStaticForeground();
+			drawAnimatedObject();
+			drawStaticLogo();
+			key = displayFrame(false, true);
+			if (key || _phaseDone)
+				break;
+			pageFlip();
+			displayFrame(false, false);
+			updateAnimation();
+			_frameCounter++;
+		}
+
+		if (_aborted)
+			return false;
+
+		_phaseDone = 0;
+		_renderMode = 2;
+		_motionPtr = 0x2054;
+		updateAnimation();
+
+		clearDrawBuffer();
+		drawBaseScreen();
+		drawMovingCharacters();
+		drawOverlay();
+		drawLanguageText();
+		fadePalette(0x1dc6);
+		pageFlip();
+		displayFrame(false, false);
+		displayFrame(false, false);
+		fadePalette(0x1e06);
+
+		setSelectionMouseEnabled(true);
+		while (!_aborted) {
+			clearDrawBuffer();
+			drawBaseScreen();
+			drawMovingCharacters();
+			drawOverlay();
+			if (_choiceState == 0)
+				drawLanguageText();
+
+			key = displayFrame(true, false);
+			if (key == 1 || key == 2)
+				acceptSelection(key);
+
+			if (_phaseDone == 2 && _frameCounter > 5)
+				break;
+
+			pageFlip();
+			displayFrame(false, false);
+			updateAnimation();
+			_frameCounter++;
+		}
+		setSelectionMouseEnabled(false);
+
+		if (_aborted)
+			return false;
+
+		selectedPrincess = (_choice == 2);
+
+		_renderMode = 3;
+		_choiceState = 0;
+		_phaseDone = 0;
+		clearDrawBuffer();
+		_motionPtr = 0x2256;
+		updateAnimation();
+		drawBaseScreen();
+		drawStaticForeground();
+		drawAnimatedObject();
+		drawStaticLogo();
+		fadePalette(0x1dc6);
+		pageFlip();
+		displayFrame(false, false);
+		displayFrame(false, false);
+		fadePalette(0x1de6);
+
+		int guard = 0;
+		while (!_aborted && !_phaseDone && guard++ < 600) {
+			clearDrawBuffer();
+			drawBaseScreen();
+			drawStaticForeground();
+			drawAnimatedObject();
+			drawStaticLogo();
+			displayFrame(false, false);
+			pageFlip();
+			displayFrame(false, false);
+			updateAnimation();
+			_frameCounter++;
+		}
+
+		return !_aborted;
+	}
+
+private:
+	static const int kWidth = 320;
+	static const int kHeight = 200;
+	static const int kPlaneBytes = 0x1f40;
+	static const int kRowBytes = 40;
+	static const int kScreenBytes = 0x9c40;
+	static const int kVbiDelayMillis = 20;
+
+	CastleEngine *_engine;
+	const Common::Array<byte> &_data;
+	Common::Array<byte> _screen[2];
+	uint16 _palette[32];
+	int _displayBuffer;
+	int _drawBuffer;
+	bool _pageFlag;
+	bool _aborted;
+
+	int32 _scrollA;
+	int32 _scrollB;
+	int16 _sprite1Frame;
+	int16 _sprite1X;
+	int16 _sprite1Y;
+	int16 _sprite2Frame;
+	int16 _sprite2X;
+	int16 _sprite2Y;
+	int16 _choiceState;
+	int16 _frameCounter;
+	int16 _selectionLift;
+	int16 _selectionX;
+	int16 _selectionY;
+	int16 _objectFrame;
+	int16 _objectX;
+	int16 _objectY;
+	int16 _phaseDone;
+	int16 _renderMode;
+	int16 _arrowFrame;
+	int16 _choice;
+	uint32 _character1Ptr;
+	uint32 _character2Ptr;
+	uint32 _motionPtr;
+
+	void reset() {
+		memset(_screen[0].data(), 0, kScreenBytes);
+		memset(_screen[1].data(), 0, kScreenBytes);
+		memset(_palette, 0, sizeof(_palette));
+
+		_displayBuffer = 0;
+		_drawBuffer = 1;
+		_pageFlag = false;
+		_aborted = false;
+		_scrollA = 0;
+		_scrollB = 0;
+		_sprite1Frame = 0;
+		_sprite1X = 40;
+		_sprite1Y = 75;
+		_sprite2Frame = 0;
+		_sprite2X = 124;
+		_sprite2Y = 86;
+		_choiceState = 0;
+		_frameCounter = 0;
+		_selectionLift = 0;
+		_selectionX = 0;
+		_selectionY = -250;
+		_objectFrame = 0;
+		_objectX = 160;
+		_objectY = 100;
+		_phaseDone = 0;
+		_renderMode = 1;
+		_arrowFrame = 0;
+		_choice = 1;
+		_character1Ptr = READ_BE_UINT32(_data.data() + 0x1e26);
+		_character2Ptr = READ_BE_UINT32(_data.data() + 0x1eb8);
+		_motionPtr = READ_BE_UINT32(_data.data() + 0x1f4a);
+	}
+
+	static int16 highWord(int32 value) {
+		return (int16)((uint32)value >> 16);
+	}
+
+	uint16 screenWord(int offset) const {
+		if (offset < 0 || offset + 1 >= kScreenBytes)
+			return 0;
+		const Common::Array<byte> &buf = _screen[_drawBuffer];
+		return (buf[offset] << 8) | buf[offset + 1];
+	}
+
+	void setScreenWord(int offset, uint16 value) {
+		if (offset < 0 || offset + 1 >= kScreenBytes)
+			return;
+		Common::Array<byte> &buf = _screen[_drawBuffer];
+		buf[offset] = value >> 8;
+		buf[offset + 1] = value & 0xff;
+	}
+
+	void setScreenLong(int offset, uint32 value) {
+		if (offset < 0 || offset + 3 >= kScreenBytes)
+			return;
+		Common::Array<byte> &buf = _screen[_drawBuffer];
+		WRITE_BE_UINT32(&buf[offset], value);
+	}
+
+	void andScreenWord(int offset, uint16 value) {
+		setScreenWord(offset, screenWord(offset) & value);
+	}
+
+	void orScreenWord(int offset, uint16 value) {
+		setScreenWord(offset, screenWord(offset) | value);
+	}
+
+	static uint16 shiftedWord(uint32 value, int shift) {
+		return (uint16)((value << shift) >> 16);
+	}
+
+	void clearDrawBuffer() {
+		memset(_screen[_drawBuffer].data(), 0, kScreenBytes);
+	}
+
+	void drawBaseScreen() {
+		int dst = 38 * kRowBytes;
+		dst = drawScrollingPlane(0x247c, _scrollA, 57, dst);
+		dst = drawScrollingFourPlane(0x2d64, _scrollB, 40, dst);
+		dst = drawStaticFourPlane(0x4664, 33, dst);
+		drawFillBands(dst);
+	}
+
+	int drawScrollingPlane(int src, int32 scroll, int rows, int dst) {
+		int d7 = highWord(scroll);
+		int srcOffset = ((d7 >> 3) & 0x3e);
+		int shift = d7 & 0xf;
+		int skip = -(((srcOffset >> 1) - 0x12));
+		src += srcOffset;
+
+		for (int row = 0; row < rows; row++) {
+			int rowSrc = src + 0x28;
+			int run = skip;
+			int curSrc = src;
+			for (int col = 0; col < 20; col++) {
+				uint32 val;
+				if (run >= 0) {
+					val = READ_BE_UINT32(_data.data() + curSrc);
+					run--;
+				} else {
+					run = 0xff;
+					val = ((uint32)READ_BE_UINT16(_data.data() + curSrc) << 16) | READ_BE_UINT16(_data.data() + curSrc - 0x26);
+					curSrc -= 0x28;
+				}
+				uint16 word = shiftedWord(val, shift);
+				setScreenWord(dst + kPlaneBytes, 0);
+				setScreenWord(dst + kPlaneBytes * 2, 0);
+				setScreenWord(dst + kPlaneBytes * 3, 0);
+				setScreenWord(dst + kPlaneBytes * 4, 0);
+				setScreenWord(dst, word);
+				curSrc += 2;
+				dst += 2;
+			}
+			src = rowSrc;
+		}
+		return dst;
+	}
+
+	int drawScrollingFourPlane(int src, int32 scroll, int rows, int dst) {
+		int d7 = highWord(scroll);
+		int srcOffset = ((d7 >> 3) & 0x3e);
+		int shift = d7 & 0xf;
+		int skip = -(((srcOffset >> 1) - 0x12));
+		src += srcOffset;
+
+		for (int row = 0; row < rows; row++) {
+			int rowSrc = src + 0xa0;
+			int run = skip;
+			int curSrc = src;
+			for (int col = 0; col < 20; col++) {
+				uint32 p0, p1, p2, p3;
+				if (run >= 0) {
+					p0 = READ_BE_UINT32(_data.data() + curSrc);
+					p1 = READ_BE_UINT32(_data.data() + curSrc + 0x28);
+					p2 = READ_BE_UINT32(_data.data() + curSrc + 0x50);
+					p3 = READ_BE_UINT32(_data.data() + curSrc + 0x78);
+					run--;
+				} else {
+					run = 0xff;
+					p0 = ((uint32)READ_BE_UINT16(_data.data() + curSrc) << 16) | READ_BE_UINT16(_data.data() + curSrc - 0x26);
+					p1 = ((uint32)READ_BE_UINT16(_data.data() + curSrc + 0x28) << 16) | READ_BE_UINT16(_data.data() + curSrc + 0x02);
+					p2 = ((uint32)READ_BE_UINT16(_data.data() + curSrc + 0x50) << 16) | READ_BE_UINT16(_data.data() + curSrc + 0x2a);
+					p3 = ((uint32)READ_BE_UINT16(_data.data() + curSrc + 0x78) << 16) | READ_BE_UINT16(_data.data() + curSrc + 0x52);
+					curSrc -= 0x28;
+				}
+				setScreenLong(dst, p0 << shift);
+				setScreenLong(dst + kPlaneBytes, p1 << shift);
+				setScreenLong(dst + kPlaneBytes * 2, p2 << shift);
+				setScreenLong(dst + kPlaneBytes * 3, p3 << shift);
+				curSrc += 2;
+				dst += 2;
+			}
+			src = rowSrc;
+		}
+		return dst;
+	}
+
+	int drawStaticFourPlane(int src, int rows, int dst) {
+		for (int row = 0; row < rows; row++) {
+			for (int col = 0; col < 20; col++) {
+				setScreenWord(dst, READ_BE_UINT16(_data.data() + src));
+				setScreenWord(dst + kPlaneBytes, READ_BE_UINT16(_data.data() + src + 0x28));
+				setScreenWord(dst + kPlaneBytes * 2, READ_BE_UINT16(_data.data() + src + 0x50));
+				setScreenWord(dst + kPlaneBytes * 3, READ_BE_UINT16(_data.data() + src + 0x78));
+				src += 2;
+				dst += 2;
+			}
+			src += 0x78;
+		}
+		return dst;
+	}
+
+	void drawFillBands(int dst) {
+		int src = 0x5ba4;
+		for (int band = 0; band < 13; band++) {
+			int count = READ_BE_UINT16(_data.data() + src);
+			src += 2;
+			uint16 p0 = READ_BE_UINT16(_data.data() + src);
+			uint16 p1 = READ_BE_UINT16(_data.data() + src + 2);
+			uint16 p2 = READ_BE_UINT16(_data.data() + src + 4);
+			src += 6;
+			for (int repeat = 0; repeat <= count; repeat++) {
+				for (int col = 0; col < 20; col++) {
+					setScreenWord(dst, p0);
+					setScreenWord(dst + kPlaneBytes, p1);
+					setScreenWord(dst + kPlaneBytes * 2, p2);
+					setScreenWord(dst + kPlaneBytes * 3, 0);
+					dst += 2;
+				}
+			}
+		}
+	}
+
+	void drawOverlay() {
+		int src = 0x5c0c;
+		int dst = 0x1a18;
+		for (int row = 0; row < 30; row++) {
+			for (int col = 0; col < 20; col++) {
+				uint16 p0 = READ_BE_UINT16(_data.data() + src);
+				uint16 p1 = READ_BE_UINT16(_data.data() + src + 0x28);
+				uint16 p2 = READ_BE_UINT16(_data.data() + src + 0x50);
+				uint16 p3 = READ_BE_UINT16(_data.data() + src + 0x78);
+				uint16 mask = ~(p0 | p1 | p2 | p3);
+				andScreenWord(dst, mask);
+				orScreenWord(dst, p0);
+				andScreenWord(dst + kPlaneBytes, mask);
+				orScreenWord(dst + kPlaneBytes, p1);
+				andScreenWord(dst + kPlaneBytes * 2, mask);
+				orScreenWord(dst + kPlaneBytes * 2, p2);
+				andScreenWord(dst + kPlaneBytes * 3, mask);
+				orScreenWord(dst + kPlaneBytes * 3, p3);
+				src += 2;
+				dst += 2;
+			}
+			src += 0x78;
+		}
+		for (int i = 0; i < 60; i++) {
+			setScreenWord(dst, 0);
+			setScreenWord(dst + kPlaneBytes, 0);
+			setScreenWord(dst + kPlaneBytes * 2, 0);
+			setScreenWord(dst + kPlaneBytes * 3, 0);
+			dst += 2;
+		}
+	}
+
+	void drawMaskedBlock4(int src, int dst, int rows, int words) {
+		for (int row = 0; row < rows; row++) {
+			for (int col = 0; col < words; col++) {
+				uint16 p0 = READ_BE_UINT16(_data.data() + src);
+				uint16 p1 = READ_BE_UINT16(_data.data() + src + 2);
+				uint16 p2 = READ_BE_UINT16(_data.data() + src + 4);
+				uint16 p3 = READ_BE_UINT16(_data.data() + src + 6);
+				uint16 mask = ~(p0 | p1 | p2 | p3);
+				andScreenWord(dst, mask);
+				orScreenWord(dst, p0);
+				andScreenWord(dst + kPlaneBytes, mask);
+				orScreenWord(dst + kPlaneBytes, p1);
+				andScreenWord(dst + kPlaneBytes * 2, mask);
+				orScreenWord(dst + kPlaneBytes * 2, p2);
+				andScreenWord(dst + kPlaneBytes * 3, mask);
+				orScreenWord(dst + kPlaneBytes * 3, p3);
+				src += 8;
+				dst += 2;
+			}
+		}
+	}
+
+	void drawStaticLogo() {
+		drawMaskedBlock4(0x14ff4, 0x820, 0x94, 20);
+	}
+
+	void drawStaticForeground() {
+		drawMaskedBlock4(0x1ac74, 0, 0x27, 20);
+	}
+
+	void drawMovingCharacters() {
+		drawLargeSprite4(0x700c, _sprite1Frame, 0xe60, _sprite1X, _sprite1Y, 0x73, 0x20, 0x08, 4);
+		drawLargeSprite4(0xd4ac, _sprite2Frame, 0xd00, _sprite2X, _sprite2Y, 0x68, 0x20, 0x08, 4);
+		if (_choiceState != 0 && _selectionLift == 0) {
+			drawLargeSprite4(0x108ac, 0, 0, _selectionX - 0x20, _selectionY - 0x76, 0x95, 0x40, 0x10, 8);
+			return;
+		}
+		drawAnimatedObject();
+	}
+
+	void drawAnimatedObject() {
+		bool objectVisible = true;
+		if (_objectY < 0 && -_objectY >= 13) {
+			_selectionLift = 0;
+			objectVisible = false;
+		} else {
+			drawSprite1(0x12dec + _objectFrame * 0x34, _objectX, _objectY, 13);
+		}
+		if (objectVisible && _renderMode == 3) {
+			int src = 0x136dc;
+			if (_choice != 2)
+				src += 0x36;
+			src += _arrowFrame * 18;
+			_arrowFrame++;
+			if (_arrowFrame > 2)
+				_arrowFrame = 0;
+			drawSmallSamePlane(src, _objectX + 8, _objectY + 8);
+		}
+	}
+
+	void drawLargeSprite4(int base, int frame, int frameSize, int x, int y, int rows, int rowStride, int planeStride, int maxWords) {
+		int src = base + frame * frameSize;
+		int rowCount = rows;
+		int dstY = y;
+		if (dstY < 0) {
+			int skip = -dstY;
+			if (skip >= rowCount)
+				return;
+			rowCount -= skip;
+			src += skip * rowStride;
+			dstY = 0;
+		}
+		if (dstY >= kHeight)
+			return;
+		if (dstY + rowCount > kHeight)
+			rowCount = kHeight - dstY;
+		drawSprite4(src, x, dstY, rowCount, rowStride, planeStride, maxWords);
+	}
+
+	void drawSprite4(int src, int x, int y, int rows, int rowStride, int planeStride, int maxWords) {
+		if (x >= kWidth)
+			return;
+		int dstBase = y * kRowBytes + ((x >> 3) & 0xfffe);
+		int visible = kWidth - x;
+		if (visible <= 0)
+			return;
+		int d0 = visible >> 4;
+		if (d0 > maxWords)
+			d0 = maxWords;
+		int shift = (16 - (x & 0xf)) & 0xf;
+		if (shift == 0)
+			d0--;
+		if (d0 < 0)
+			return;
+
+		for (int row = 0; row < rows; row++) {
+			int dst = dstBase + row * kRowBytes;
+			for (int col = 0; col <= d0; col++) {
+				uint32 values[4];
+				for (int plane = 0; plane < 4; plane++) {
+					int planeSrc = src + plane * planeStride;
+					if (shift && col == 0)
+						values[plane] = READ_BE_UINT16(_data.data() + planeSrc);
+					else {
+						values[plane] = READ_BE_UINT32(_data.data() + planeSrc + (shift ? (col - 1) * 2 : col * 2));
+						if (col == d0)
+							values[plane] &= 0xffff0000;
+					}
+				}
+
+				uint16 p0 = shiftedWord(values[0], shift);
+				uint16 p1 = shiftedWord(values[1], shift);
+				uint16 p2 = shiftedWord(values[2], shift);
+				uint16 p3 = shiftedWord(values[3], shift);
+				uint16 mask = ~(p0 | p1 | p2 | p3);
+				andScreenWord(dst, mask);
+				orScreenWord(dst, p0);
+				andScreenWord(dst + kPlaneBytes, mask);
+				orScreenWord(dst + kPlaneBytes, p1);
+				andScreenWord(dst + kPlaneBytes * 2, mask);
+				orScreenWord(dst + kPlaneBytes * 2, p2);
+				andScreenWord(dst + kPlaneBytes * 3, mask);
+				orScreenWord(dst + kPlaneBytes * 3, p3);
+				dst += 2;
+			}
+			src += rowStride;
+		}
+	}
+
+	void drawSprite1(int src, int x, int y, int rows) {
+		if (x >= kWidth || y >= kHeight)
+			return;
+		if (y < 0) {
+			int skip = -y;
+			if (skip >= rows)
+				return;
+			rows -= skip;
+			src += skip * 4;
+			y = 0;
+		}
+		if (y + rows > kHeight)
+			rows = kHeight - y;
+
+		int visible = kWidth - x;
+		if (visible <= 0)
+			return;
+		int d0 = visible >> 4;
+		if (d0 > 2)
+			d0 = 2;
+		int shift = (16 - (x & 0xf)) & 0xf;
+		if (shift == 0)
+			d0--;
+		if (d0 < 0)
+			return;
+
+		int dstBase = y * kRowBytes + ((x >> 3) & 0xfffe);
+		for (int row = 0; row < rows; row++) {
+			int dst = dstBase + row * kRowBytes;
+			for (int col = 0; col <= d0; col++) {
+				uint32 value;
+				if (shift && col == 0)
+					value = READ_BE_UINT16(_data.data() + src);
+				else {
+					value = READ_BE_UINT32(_data.data() + src + (shift ? (col - 1) * 2 : col * 2));
+					if (col == d0 && x <= 0x120)
+						value &= 0xffff0000;
+				}
+				uint16 p = shiftedWord(value, shift);
+				uint16 mask = ~p;
+				if (_renderMode == 2) {
+					andScreenWord(dst + kPlaneBytes, mask);
+					andScreenWord(dst + kPlaneBytes * 2, mask);
+					andScreenWord(dst + kPlaneBytes * 4, mask);
+					orScreenWord(dst + kPlaneBytes * 4, p);
+					andScreenWord(dst + kPlaneBytes * 3, mask);
+					orScreenWord(dst + kPlaneBytes * 3, p);
+					andScreenWord(dst, mask);
+					orScreenWord(dst, p);
+				} else {
+					andScreenWord(dst, mask);
+					orScreenWord(dst, p);
+					andScreenWord(dst + kPlaneBytes, mask);
+					orScreenWord(dst + kPlaneBytes, p);
+					andScreenWord(dst + kPlaneBytes * 2, mask);
+					orScreenWord(dst + kPlaneBytes * 2, p);
+					andScreenWord(dst + kPlaneBytes * 3, mask);
+					orScreenWord(dst + kPlaneBytes * 3, p);
+				}
+				dst += 2;
+			}
+			src += 4;
+		}
+	}
+
+	void drawSmallSamePlane(int src, int x, int y) {
+		int rows = 9;
+		if (y < 0) {
+			int skip = -y;
+			if (skip >= rows)
+				return;
+			rows -= skip;
+			src += skip * 2;
+			y = 0;
+		}
+		if (y >= kHeight || x >= kWidth)
+			return;
+		if (y + rows > kHeight)
+			rows = kHeight - y;
+
+		int dstBase = y * kRowBytes + ((x >> 3) & 0xfffe);
+		int shift = (16 - (x & 0xf)) & 0x1f;
+		int words = (kWidth - x) >> 4;
+		if (words > 1)
+			words = 1;
+		if (words < 0)
+			return;
+
+		for (int row = 0; row < rows; row++) {
+			uint32 value = (uint32)READ_BE_UINT16(_data.data() + src) << shift;
+			uint16 right = value & 0xffff;
+			uint16 left = (value >> 16) & 0xffff;
+			int dst = dstBase + row * kRowBytes;
+			if (words != 0)
+				orSamePlanes(dst + 2, right);
+			orSamePlanes(dst, left);
+			src += 2;
+		}
+	}
+
+	void orSamePlanes(int dst, uint16 bits) {
+		uint16 mask = ~bits;
+		for (int plane = 0; plane < 4; plane++) {
+			andScreenWord(dst + plane * kPlaneBytes, mask);
+			orScreenWord(dst + plane * kPlaneBytes, bits);
+		}
+	}
+
+	void drawLanguageText() {
+		int table = currentLanguageTextTable();
+
+		for (int i = 0; i < 5; i++) {
+			int src = READ_BE_UINT32(_data.data() + table);
+			int x = (int16)READ_BE_UINT16(_data.data() + table + 4);
+			int y = (int16)READ_BE_UINT16(_data.data() + table + 6);
+			int width = (int16)READ_BE_UINT16(_data.data() + table + 8) + 1;
+			int rows = (int16)READ_BE_UINT16(_data.data() + table + 10) + 1;
+			drawLanguageBlock(src, x, y, width, rows);
+			table += 12;
+		}
+	}
+
+	int currentLanguageTextTable() const {
+		if (_engine->_language == Common::FR_FRA)
+			return 0x1d2e;
+		if (_engine->_language == Common::DE_DEU)
+			return 0x1d6a;
+		return 0x1cf2;
+	}
+
+	int selectionSplitX() const {
+		int table = currentLanguageTextTable();
+		int princeEntry = table + 3 * 12;
+		int princessEntry = table + 4 * 12;
+		int princeX = (int16)READ_BE_UINT16(_data.data() + princeEntry + 4);
+		int princeWords = (int16)READ_BE_UINT16(_data.data() + princeEntry + 8) + 2;
+		int princessX = (int16)READ_BE_UINT16(_data.data() + princessEntry + 4);
+		if (princeWords <= 0 || princessX <= princeX)
+			return kWidth / 2;
+		return (princeX + princeWords * 16 + princessX) / 2;
+	}
+
+	void drawLanguageBlock(int src, int x, int y, int sourceWords, int rows) {
+		if (y >= kHeight || x >= kWidth)
+			return;
+		int dstBase = y * kRowBytes + ((x >> 3) & 0xfffe);
+		int shift = (16 - (x & 0xf)) & 0xf;
+		for (int row = 0; row < rows; row++) {
+			int dst = dstBase + row * kRowBytes;
+			uint16 prev0 = 0;
+			uint16 prev1 = 0;
+			uint16 prev2 = 0;
+			uint16 prev3 = 0;
+			for (int col = 0; col <= sourceWords; col++) {
+				uint32 p0;
+				uint32 p1;
+				uint32 p2;
+				uint32 p3;
+				if (col < sourceWords) {
+					uint16 cur0 = READ_BE_UINT16(_data.data() + src);
+					uint16 cur1 = READ_BE_UINT16(_data.data() + src + 2);
+					uint16 cur2 = READ_BE_UINT16(_data.data() + src + 4);
+					uint16 cur3 = READ_BE_UINT16(_data.data() + src + 6);
+					if (col == 0) {
+						p0 = cur0;
+						p1 = cur1;
+						p2 = cur2;
+						p3 = cur3;
+					} else {
+						p0 = ((uint32)prev0 << 16) | cur0;
+						p1 = ((uint32)prev1 << 16) | cur1;
+						p2 = ((uint32)prev2 << 16) | cur2;
+						p3 = ((uint32)prev3 << 16) | cur3;
+					}
+					prev0 = cur0;
+					prev1 = cur1;
+					prev2 = cur2;
+					prev3 = cur3;
+					src += 8;
+				} else {
+					p0 = 0;
+					p1 = 0;
+					p2 = 0;
+					p3 = 0;
+				}
+				uint16 w0 = shiftedWord(p0, shift);
+				uint16 w1 = shiftedWord(p1, shift);
+				uint16 w2 = shiftedWord(p2, shift);
+				uint16 w3 = shiftedWord(p3, shift);
+				uint16 mask = ~(w0 | w1 | w2 | w3);
+				andScreenWord(dst, mask);
+				orScreenWord(dst, w0);
+				andScreenWord(dst + kPlaneBytes, mask);
+				orScreenWord(dst + kPlaneBytes, w1);
+				andScreenWord(dst + kPlaneBytes * 2, mask);
+				orScreenWord(dst + kPlaneBytes * 2, w2);
+				andScreenWord(dst + kPlaneBytes * 3, mask);
+				orScreenWord(dst + kPlaneBytes * 3, w3);
+				dst += 2;
+			}
+		}
+	}
+
+	void fadePalette(int target) {
+		for (int threshold = 15; threshold > 0 && !_aborted; threshold--) {
+			for (int i = 0; i < 16; i++) {
+				uint16 dst = READ_BE_UINT16(_data.data() + target + i * 2);
+				uint16 cur = _palette[i];
+				int channels[3] = {cur & 0xf, (cur >> 4) & 0xf, (cur >> 8) & 0xf};
+				int targets[3] = {dst & 0xf, (dst >> 4) & 0xf, (dst >> 8) & 0xf};
+				for (int c = 0; c < 3; c++) {
+					int diff = targets[c] - channels[c];
+					if (ABS(diff) >= threshold && diff != 0)
+						channels[c] += diff > 0 ? 1 : -1;
+				}
+				_palette[i] = (channels[2] << 8) | (channels[1] << 4) | channels[0];
+			}
+			displayFrame(false, false, 4);
+		}
+	}
+
+	void pageFlip() {
+		_pageFlag = !_pageFlag;
+		if (_pageFlag) {
+			_displayBuffer = 0;
+			_drawBuffer = 1;
+		} else {
+			_displayBuffer = 1;
+			_drawBuffer = 0;
+		}
+	}
+
+	void updateAnimation() {
+		_scrollA += 0x8000;
+		if (highWord(_scrollA) >= 0x13f)
+			_scrollA -= 0x13f0000;
+		_scrollB += 0x10000;
+		if (highWord(_scrollB) >= 0x13f)
+			_scrollB -= 0x13f0000;
+
+		if (_choiceState == 1)
+			updateCharacterPath(_character1Ptr, _sprite1Frame, _sprite1X, _sprite1Y);
+		if (_choiceState == 2)
+			updateCharacterPath(_character2Ptr, _sprite2Frame, _sprite2X, _sprite2Y);
+
+		if (_choiceState < 0 && _selectionLift == 0) {
+			_selectionX = (_choiceState == -1) ? _sprite1X : _sprite2X;
+			_selectionY += 12;
+			if (_selectionY > 0x4b)
+				_choiceState = -_choiceState;
+		}
+
+		if (_renderMode == 1 && _frameCounter <= 10)
+			return;
+
+		if (READ_BE_UINT32(_data.data() + _motionPtr) == 0xffffffff) {
+			_phaseDone = 1;
+			_motionPtr = 0x21b0;
+		}
+		_objectFrame = (int16)READ_BE_UINT16(_data.data() + _motionPtr);
+		_objectX = (int16)READ_BE_UINT16(_data.data() + _motionPtr + 2);
+		_objectY = (int16)READ_BE_UINT16(_data.data() + _motionPtr + 4);
+		_motionPtr += 6;
+		if (_selectionLift != 0 && _renderMode == 2) {
+			_objectY -= _selectionLift;
+			_selectionLift += 2;
+		}
+	}
+
+	void updateCharacterPath(uint32 &ptr, int16 &frame, int16 &x, int16 &y) {
+		if (READ_BE_UINT32(_data.data() + ptr) & 0x80000000) {
+			if (_phaseDone != 2) {
+				_phaseDone = 2;
+				_frameCounter = 0;
+			}
+			return;
+		}
+		frame = (int16)READ_BE_UINT16(_data.data() + ptr);
+		x = (int16)READ_BE_UINT16(_data.data() + ptr + 2);
+		y = (int16)READ_BE_UINT16(_data.data() + ptr + 4);
+		_selectionX = x;
+		_selectionY = y;
+		ptr += 6;
+	}
+
+	void acceptSelection(int key) {
+		if (_choiceState != 0)
+			return;
+		_choice = key;
+		_selectionLift = 1;
+		_choiceState = (key == 1) ? -2 : -1;
+	}
+
+	void setSelectionMouseEnabled(bool enabled) {
+		if (enabled) {
+			CursorMan.setDefaultArrowCursor();
+			g_system->lockMouse(false);
+			CursorMan.showMouse(true);
+		} else {
+			g_system->lockMouse(true);
+			CursorMan.showMouse(false);
+		}
+	}
+
+	uint32 paletteColor(uint16 value) const {
+		byte r = ((value >> 8) & 0xf) * 0x11;
+		byte g = ((value >> 4) & 0xf) * 0x11;
+		byte b = (value & 0xf) * 0x11;
+		return _engine->_gfx->_texturePixelFormat.ARGBToColor(0xff, r, g, b);
+	}
+
+	int displayFrame(bool acceptSelection, bool acceptAnyKey, int ticks = 1) {
+		int key = pumpEvents(acceptSelection, acceptAnyKey);
+
+		Graphics::Surface surface;
+		surface.create(kWidth, kHeight, _engine->_gfx->_texturePixelFormat);
+		const Common::Array<byte> &buf = _screen[_displayBuffer];
+		uint32 colors[32];
+		for (int i = 0; i < 32; i++)
+			colors[i] = i < 16 ? paletteColor(_palette[i]) : paletteColor(0);
+
+		for (int y = 0; y < kHeight; y++) {
+			int row = y * kRowBytes;
+			for (int x = 0; x < kWidth; x++) {
+				int byteOffset = row + (x >> 3);
+				byte bit = 0x80 >> (x & 7);
+				byte color = 0;
+				for (int plane = 0; plane < 5; plane++) {
+					if (buf[byteOffset + plane * kPlaneBytes] & bit)
+						color |= 1 << plane;
+				}
+				surface.setPixel(x, y, colors[color]);
+			}
+		}
+
+		_engine->_gfx->clear(0, 0, 0, true);
+		_engine->drawFullscreenSurface(&surface);
+		_engine->_gfx->flipBuffer();
+		g_system->updateScreen();
+		g_system->delayMillis(kVbiDelayMillis * ticks);
+		surface.free();
+		return key;
+	}
+
+	int pumpEvents(bool acceptSelection, bool acceptAnyKey) {
+		int key = 0;
+		Common::Event event;
+		while (_engine->_eventManager->pollEvent(event)) {
+			switch (event.type) {
+			case Common::EVENT_QUIT:
+			case Common::EVENT_RETURN_TO_LAUNCHER:
+				_aborted = true;
+				_engine->quitGame();
+				break;
+			case Common::EVENT_SCREEN_CHANGED:
+				_engine->_gfx->computeScreenViewport();
+				_engine->_gfx->clear(0, 0, 0, true);
+				break;
+			case Common::EVENT_KEYDOWN:
+				if (event.kbd.keycode == Common::KEYCODE_1)
+					key = 1;
+				else if (event.kbd.keycode == Common::KEYCODE_2)
+					key = 2;
+				else if (acceptAnyKey)
+					key = 3;
+				break;
+			case Common::EVENT_LBUTTONDOWN:
+			case Common::EVENT_RBUTTONDOWN:
+				if (acceptSelection) {
+					int mouseX = kWidth * event.mouse.x / g_system->getWidth();
+					key = mouseX < selectionSplitX() ? 1 : 2;
+				} else if (acceptAnyKey) {
+					key = 3;
+				}
+				break;
+			case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+				if (event.customType == kActionSelectPrince)
+					key = 1;
+				else if (event.customType == kActionSelectPrincess)
+					key = 2;
+				else if (event.customType == kActionSkip && acceptAnyKey)
+					key = 3;
+				break;
+			default:
+				break;
+			}
+		}
+		if (!acceptSelection && (key == 1 || key == 2) && acceptAnyKey)
+			return key;
+		if (!acceptSelection && (key == 1 || key == 2))
+			return 0;
+		return key;
+	}
+};
+
 Graphics::ManagedSurface *CastleEngine::loadFrameFromPlanesVertical(Common::SeekableReadStream *file, int widthInBytes, int height) {
 	Graphics::ManagedSurface *surface;
 	surface = new Graphics::ManagedSurface();
@@ -470,6 +1378,385 @@ void CastleEngine::loadAssetsAmigaDemo() {
 		it._value->addStructure(_areaMap[255]);
 }
 
+void CastleEngine::loadAssetsAmigaFullGame() {
+	Common::File file;
+	file.open("x");
+	if (!file.isOpen())
+		error("Failed to open 'x' file");
+
+	_viewArea = Common::Rect(40, 29, 280, 154);
+	loadMessagesVariableSize(&file, 0x99ac, 178);
+	loadRiddles(&file, 0xa476, 19);
+
+	file.seek(0x11540);
+	Common::Array<Graphics::ManagedSurface *> chars;
+	Common::Array<Graphics::ManagedSurface *> charsRiddle;
+	for (int i = 0; i < 90; i++) {
+		Graphics::ManagedSurface *img = loadFrameFromPlanes(&file, 8, 8);
+		Graphics::ManagedSurface *imgRiddle = new Graphics::ManagedSurface();
+		imgRiddle->copyFrom(*img);
+
+		chars.push_back(img);
+		chars[i]->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+		charsRiddle.push_back(imgRiddle);
+		charsRiddle[i]->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastleRiddlePalette, 16);
+	}
+
+	_font = Font(chars);
+	_font.setCharWidth(9);
+
+	_fontRiddle = Font(charsRiddle);
+	_fontRiddle.setCharWidth(9);
+
+	load8bitBinary(&file, 0x158fa, 16);
+	for (int i = 0; i < 3; i++) {
+		debugC(1, kFreescapeDebugParser, "Continue to parse area index %d at offset %x", _areaMap.size() + i + 1, (int)file.pos());
+		Area *newArea = load8bitArea(&file, 16);
+		if (newArea) {
+			if (!_areaMap.contains(newArea->getAreaID()))
+				_areaMap[newArea->getAreaID()] = newArea;
+			else
+				error("Repeated area ID: %d", newArea->getAreaID());
+		} else {
+			error("Invalid area %d?", i);
+		}
+	}
+
+	loadPalettes(&file, 0x147fa);
+
+	// COLOR15 cycling table: same format as the demo, terminated by 0xFFFF.
+	file.seek(0x998e);
+	while (true) {
+		uint16 val = file.readUint16BE();
+		if (val == 0xFFFF) break;
+		_gfx->_colorCyclingTable.push_back(val);
+	}
+
+	file.seek(0x2b4ea); // Area 255
+	_areaMap[255] = load8bitArea(&file, 16);
+
+	// Border NEO image (demo loaded at 0x2cf28 + 0x28 - 0x2 + 0x28 = 0x2cf76)
+	file.seek(0x2c5ca);
+	_border = loadFrameFromPlanesVertical(&file, 160, 200);
+	_border->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	// Mountains panorama (63 words × 22 rows × 4 planes, interleaved).
+	file.seek(0x49c8);
+	_background = loadFrameFromPlanesInterleaved(&file, 63, 22);
+	_background->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	// Info menu image (14 words × 116 rows).
+	file.seek(0x3473a);
+	_menu = loadFrameFromPlanesInterleaved(&file, 14, 116);
+	_menu->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	// Additional 224×54 menu-related block.
+	file.seek(0x3620a);
+	_menuButtons = loadFrameFromPlanesInterleaved(&file, 14, 54);
+	_menuButtons->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	file.seek(0x37fa6); // Spirit meter indicator background
+	_spiritsMeterIndicatorBackgroundFrame = loadFrameFromPlanesInterleaved(&file, 5, 10);
+	_spiritsMeterIndicatorBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	file.seek(0x38136); // Spirit meter indicator
+	_spiritsMeterIndicatorFrame = loadFrameFromPlanesInterleaved(&file, 1, 10);
+	_spiritsMeterIndicatorFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+
+	// Strength weight sprites: 4 frames × 1 word × 14 rows.
+	file.seek(0x38c46);
+	for (int i = 0; i < 4; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 14);
+		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_strenghtWeightsFrames.push_back(frame);
+	}
+
+	// Eye icon sprites: 12 frames × 1 word × 7 rows. Header at 0x3b6fe.
+	file.seek(0x3b706);
+	for (int i = 0; i < 12; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 1, 7);
+		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_keysBorderFrames.push_back(frame);
+	}
+
+	// Crawl/Walk/Run + Sound indicators: 5 frames × 3 words × 12 rows,
+	// preceded by a 6-byte header and a 6-byte mask (skipped here).
+	file.seek(0x379fa + 6 + 6);
+	{
+		_menuCrawlIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
+		_menuCrawlIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_menuWalkIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
+		_menuWalkIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_menuRunIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
+		_menuRunIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_menuFxOffIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
+		_menuFxOffIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_menuFxOnIndicator = loadFrameFromPlanesInterleaved(&file, 3, 12);
+		_menuFxOnIndicator->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+	}
+
+	// Mouse pointer and crosshair sprites (paired SPR0/SPR1). These live
+	// in the TEXT section at the very top of the binary and are at the
+	// same file offsets as the demo (no shift).
+	{
+		_cursorW = 16;
+		_cursorH = 16;
+		_cursorData = new byte[16 * 16];
+		memset(_cursorData, 0, 16 * 16);
+		file.seek(0x24A + 4); // SPR0 (bits 0-1)
+		for (int row = 0; row < 16; row++) {
+			uint16 p0 = file.readUint16BE();
+			uint16 p1 = file.readUint16BE();
+			for (int bit = 0; bit < 16; bit++) {
+				byte c = ((p0 >> (15 - bit)) & 1) | (((p1 >> (15 - bit)) & 1) << 1);
+				_cursorData[row * 16 + bit] = c;
+			}
+		}
+		file.seek(0x292 + 4); // SPR1 (bits 2-3)
+		for (int row = 0; row < 16; row++) {
+			uint16 p0 = file.readUint16BE();
+			uint16 p1 = file.readUint16BE();
+			for (int bit = 0; bit < 16; bit++) {
+				byte c = ((p0 >> (15 - bit)) & 1) | (((p1 >> (15 - bit)) & 1) << 1);
+				_cursorData[row * 16 + bit] |= (c << 2);
+			}
+		}
+	}
+	{
+		_crosshairData = new byte[16 * 16];
+		memset(_crosshairData, 0, 16 * 16);
+		file.seek(0x1BA + 4);
+		for (int row = 0; row < 16; row++) {
+			uint16 p0 = file.readUint16BE();
+			uint16 p1 = file.readUint16BE();
+			for (int bit = 0; bit < 16; bit++) {
+				byte c = ((p0 >> (15 - bit)) & 1) | (((p1 >> (15 - bit)) & 1) << 1);
+				_crosshairData[row * 16 + bit] = c;
+			}
+		}
+		file.seek(0x202 + 4);
+		for (int row = 0; row < 16; row++) {
+			uint16 p0 = file.readUint16BE();
+			uint16 p1 = file.readUint16BE();
+			for (int bit = 0; bit < 16; bit++) {
+				byte c = ((p0 >> (15 - bit)) & 1) | (((p1 >> (15 - bit)) & 1) << 1);
+				_crosshairData[row * 16 + bit] |= (c << 2);
+			}
+		}
+	}
+
+	// Flag animation: 5 frames × 2 words × 11 rows.
+	file.seek(0x3b9b0);
+	for (int i = 0; i < 5; i++) {
+		Graphics::ManagedSurface *frame = loadFrameFromPlanesInterleaved(&file, 2, 11);
+		frame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		_flagFrames.push_back(frame);
+	}
+
+	// Riddle mask + frames (see demo loader for layout details).
+	file.seek(0x3bd4a);
+	uint16 riddleMask[16];
+	for (int i = 0; i < 16; i++)
+		riddleMask[i] = file.readUint16BE();
+
+	file.seek(0x3bd6a);
+	_riddleTopFrame = loadFrameFromPlanesInterleaved(&file, 16, 20);
+	_riddleBackgroundFrame = loadFrameFromPlanesInterleaved(&file, 16, 1);
+	_riddleBottomFrame = loadFrameFromPlanesInterleaved(&file, 16, 8);
+
+	Graphics::ManagedSurface *riddleFrames[] = {_riddleTopFrame, _riddleBackgroundFrame, _riddleBottomFrame};
+	for (int f = 0; f < 3; f++) {
+		Graphics::ManagedSurface *frame = riddleFrames[f];
+		for (int y = 0; y < frame->h; y++) {
+			for (int x = 0; x < frame->w; x++) {
+				int col = x / 16;
+				int bit = 15 - (x % 16);
+				if (!(riddleMask[col] & (1 << bit)))
+					frame->setPixel(x, y, 0);
+			}
+		}
+	}
+
+	_riddleTopFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastleRiddlePalette, 16);
+	_riddleBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastleRiddlePalette, 16);
+	_riddleBottomFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastleRiddlePalette, 16);
+
+	// Castle gate tiles (24 top rows + 19 bottom rows, see demo loader).
+	{
+		static const int kTopRows = 24;
+		static const int kBottomRows = 19;
+		static const int kTotalSrcRows = kTopRows + kBottomRows;
+		static const int kColumnsPerRow = 16;
+		static const int kPixelBytesPerRow = kColumnsPerRow * 6;
+		static const int kMaskBytesPerRow = kColumnsPerRow * 2;
+		static const int kGateWidth = 256;
+		static const int kGateHeight = 120;
+
+		byte pixelData[kTotalSrcRows * kPixelBytesPerRow];
+		byte maskData[kTotalSrcRows * kMaskBytesPerRow];
+
+		file.seek(0x39136);
+		file.read(pixelData, sizeof(pixelData));
+		file.seek(0x3a156);
+		file.read(maskData, sizeof(maskData));
+
+		uint32 keyColor = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x24, 0xA5);
+		uint32 paletteColors[8];
+		for (int i = 0; i < 8; i++)
+			paletteColors[i] = _gfx->_texturePixelFormat.ARGBToColor(0xFF,
+				kAmigaCastlePalette[i][0], kAmigaCastlePalette[i][1], kAmigaCastlePalette[i][2]);
+
+		_gameOverBackgroundFrame = new Graphics::ManagedSurface();
+		_gameOverBackgroundFrame->create(kGateWidth, kGateHeight, _gfx->_texturePixelFormat);
+		_gameOverBackgroundFrame->fillRect(Common::Rect(0, 0, kGateWidth, kGateHeight), keyColor);
+
+		int destRow = 0;
+		for (int r = kTopRows - 5; r < kTopRows; r++) {
+			int srcRow = r;
+			for (int col = 0; col < kColumnsPerRow; col++) {
+				uint16 mask = READ_BE_UINT16(&maskData[srcRow * kMaskBytesPerRow + col * 2]);
+				int pOff = srcRow * kPixelBytesPerRow + col * 6;
+				uint16 p0 = READ_BE_UINT16(&pixelData[pOff]);
+				uint16 p1 = READ_BE_UINT16(&pixelData[pOff + 2]);
+				uint16 p2 = READ_BE_UINT16(&pixelData[pOff + 4]);
+				for (int bit = 15; bit >= 0; bit--) {
+					if (!(mask & (1 << bit))) {
+						int color = ((p0 >> bit) & 1) | (((p1 >> bit) & 1) << 1) | (((p2 >> bit) & 1) << 2);
+						_gameOverBackgroundFrame->setPixel(col * 16 + (15 - bit), destRow, paletteColors[color]);
+					}
+				}
+			}
+			destRow++;
+		}
+		for (int block = 0; block < 4; block++) {
+			for (int r = 0; r < kTopRows; r++) {
+				int srcRow = r;
+				for (int col = 0; col < kColumnsPerRow; col++) {
+					uint16 mask = READ_BE_UINT16(&maskData[srcRow * kMaskBytesPerRow + col * 2]);
+					int pOff = srcRow * kPixelBytesPerRow + col * 6;
+					uint16 p0 = READ_BE_UINT16(&pixelData[pOff]);
+					uint16 p1 = READ_BE_UINT16(&pixelData[pOff + 2]);
+					uint16 p2 = READ_BE_UINT16(&pixelData[pOff + 4]);
+					for (int bit = 15; bit >= 0; bit--) {
+						if (!(mask & (1 << bit))) {
+							int color = ((p0 >> bit) & 1) | (((p1 >> bit) & 1) << 1) | (((p2 >> bit) & 1) << 2);
+							_gameOverBackgroundFrame->setPixel(col * 16 + (15 - bit), destRow, paletteColors[color]);
+						}
+					}
+				}
+				destRow++;
+			}
+		}
+		for (int r = 0; r < kBottomRows; r++) {
+			int srcRow = kTopRows + r;
+			for (int col = 0; col < kColumnsPerRow; col++) {
+				uint16 mask = READ_BE_UINT16(&maskData[srcRow * kMaskBytesPerRow + col * 2]);
+				int pOff = srcRow * kPixelBytesPerRow + col * 6;
+				uint16 p0 = READ_BE_UINT16(&pixelData[pOff]);
+				uint16 p1 = READ_BE_UINT16(&pixelData[pOff + 2]);
+				uint16 p2 = READ_BE_UINT16(&pixelData[pOff + 4]);
+				for (int bit = 15; bit >= 0; bit--) {
+					if (!(mask & (1 << bit))) {
+						int color = ((p0 >> bit) & 1) | (((p1 >> bit) & 1) << 1) | (((p2 >> bit) & 1) << 2);
+						_gameOverBackgroundFrame->setPixel(col * 16 + (15 - bit), destRow, paletteColors[color]);
+					}
+				}
+			}
+			destRow++;
+		}
+	}
+
+	// Sound effects command table (30 entries). Pass the full-game MOD
+	// offset so DMA sample extraction reads from the right place — the
+	// demo's 0x3D5A6 hardcoded default is wrong for the full binary.
+	loadSoundsAmigaDemo(&file, 0x13cf2, 30, 0x3cbfa);
+
+	// Embedded ProTracker module for background music.
+	static const int kModOffset = 0x3cbfa;
+	file.seek(0, SEEK_END);
+	int fileSize = file.pos();
+	int modSize = fileSize - kModOffset;
+	if (modSize > 0) {
+		file.seek(kModOffset);
+		_modData.resize(modSize);
+		file.read(_modData.data(), modSize);
+	}
+
+	file.close();
+
+	_areaMap[2]->_groundColor = 1;
+	for (auto &it : _areaMap)
+		it._value->addStructure(_areaMap[255]);
+}
+
+bool CastleEngine::playAmigaIntro() {
+	Common::File introFile;
+	if (!introFile.open("intro"))
+		return false;
+
+	introFile.seek(0, SEEK_END);
+	int introFileSize = introFile.pos();
+
+	Common::Array<byte> introText;
+	if (introFileSize > 0x1c) {
+		introFile.seek(0);
+		uint16 magic = introFile.readUint16BE();
+		uint32 textSize = introFile.readUint32BE();
+		if (magic == 0x601a) {
+			if (textSize == 0 || textSize + 0x1c > (uint32)introFileSize)
+				return false;
+			introText.resize(textSize);
+			introFile.seek(0x1c);
+			introFile.read(introText.data(), textSize);
+		}
+	}
+
+	if (introText.empty()) {
+		if (introFileSize <= 0)
+			return false;
+		introText.resize(introFileSize);
+		introFile.seek(0);
+		introFile.read(introText.data(), introFileSize);
+	}
+	introFile.close();
+
+	if (introText.size() < 0x1c00)
+		return false;
+
+	Common::Array<byte> introMusic;
+	Common::File introMusicFile;
+	if (introMusicFile.open("musicdat")) {
+		introMusicFile.seek(0, SEEK_END);
+		int introMusicSize = introMusicFile.pos();
+		introMusicFile.seek(0);
+		introMusic.resize(introMusicSize);
+		if (introMusicSize > 0)
+			introMusicFile.read(introMusic.data(), introMusicSize);
+		introMusicFile.close();
+	}
+
+	Audio::SoundHandle introMusicHandle;
+	if (!introMusic.empty()) {
+		Common::MemoryReadStream modStream(introMusic.data(), introMusic.size());
+		Audio::AudioStream *musicStream = Audio::makeProtrackerStream(&modStream);
+		if (musicStream)
+			_mixer->playStream(Audio::Mixer::kMusicSoundType, &introMusicHandle, musicStream);
+	}
+
+	bool selectedPrincess = false;
+	CastleAmigaIntroPlayer player(this, introText);
+	bool played = player.run(selectedPrincess);
+	if (played)
+		_selectedPrincess = selectedPrincess;
+
+	if (_mixer->isSoundHandleActive(introMusicHandle))
+		_mixer->stopHandle(introMusicHandle);
+
+	_gfx->clear(0, 0, 0, true);
+	return played;
+}
+
 void CastleEngine::drawAmigaAtariSTUI(Graphics::Surface *surface) {
 	drawLiftingGate(surface);
 	drawDroppingGate(surface);
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 06442915ee5..37ab361e444 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -121,6 +121,7 @@ CastleEngine::CastleEngine(OSystem *syst, const ADGameDescription *gd) : Freesca
 	_thunderTicks = 0;
 	_thunderFrameDuration = 0;
 	_thunderFrameIndex = 0;
+	_selectedPrincess = false;
 }
 
 CastleEngine::~CastleEngine() {
@@ -698,6 +699,8 @@ void CastleEngine::initGameState() {
 	_spiritsMeterPosition = _spiritsMeter * _spiritsToKill / _spiritsToKill;
 
 	_exploredAreas[_startArea] = true;
+	if (_selectedPrincess)
+		setGameBit(32);
 	if (_useRockTravel) // Enable cheat
 		setGameBit(k8bitGameBitTravelRock);
 
@@ -1999,6 +2002,12 @@ void CastleEngine::updateTimeVariables() {
 void CastleEngine::borderScreen() {
 	if (isAmiga() && isDemo())
 		return; // Skip character selection
+	if (isAmiga()) {
+		if (playAmigaIntro())
+			return;
+		selectCharacterScreen();
+		return;
+	}
 
 	if (isSpectrum() || isCPC() || isC64())
 		FreescapeEngine::borderScreen();
@@ -2049,7 +2058,7 @@ void CastleEngine::selectCharacterScreen() {
 	surface->create(_screenW, _screenH, _gfx->_texturePixelFormat);
 	surface->fillRect(_fullscreenViewArea, color);
 
-	if (isSpectrum() || isCPC()) {
+	if (isSpectrum() || isCPC() || isAmiga()) {
 		if (_language == Common::ES_ESP) {
 			// No accent in "príncipe" since it is not supported by the font
 			lines.push_back(centerAndPadString("*******************", 21));
@@ -2129,7 +2138,7 @@ void CastleEngine::selectCharacterScreen() {
 	// lines[5] = prince, lines[6] = princess for ZX/CPC.
 	// For DOS, use riddle text line positions.
 	Common::Rect princeSelector, princessSelector;
-	if (isSpectrum() || isCPC()) {
+	if (isSpectrum() || isCPC() || isAmiga()) {
 		int x = _viewArea.left + 3;
 		int lineHeight = 12; // Castle Master line spacing in drawStringsInSurface
 		int princeY = _viewArea.top + 3 + 5 * lineHeight;
@@ -2180,10 +2189,10 @@ void CastleEngine::selectCharacterScreen() {
 
 				if (princeSelector.contains(mouse)) {
 					selected = true;
-					// Nothing, since game bit should be already zero
+					_selectedPrincess = false;
 				} else if (princessSelector.contains(mouse)) {
 					selected = true;
-					setGameBit(32);
+					_selectedPrincess = true;
 				}
 				break;
 			case Common::EVENT_SCREEN_CHANGED:
@@ -2196,11 +2205,11 @@ void CastleEngine::selectCharacterScreen() {
 			switch (event.customType) {
 				case kActionSelectPrince:
 					selected = true;
-					// Nothing, since game bit should be already zero
+					_selectedPrincess = false;
 					break;
 				case kActionSelectPrincess:
 					selected = true;
-					setGameBit(32);
+					_selectedPrincess = true;
 					break;
 				default:
 					break;
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 51e390bafea..207ab0969a9 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -66,10 +66,12 @@ public:
 	void loadAssetsDOSFullGame() override;
 	void loadAssetsDOSDemo() override;
 	void loadAssetsAmigaDemo() override;
+	void loadAssetsAmigaFullGame() override;
 	void loadAssetsZXFullGame() override;
 	void loadAssetsCPCFullGame() override;
 	void borderScreen() override;
 	void selectCharacterScreen();
+	bool playAmigaIntro();
 	void drawOption();
 
 	void initZX();
@@ -180,6 +182,7 @@ public:
 
 	int _lastTenSeconds;
 	int _soundIndexStartFalling;
+	bool _selectedPrincess;
 
 private:
 	Common::SeekableReadStream *decryptFile(const Common::Path &filename);
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index b63c6b4e231..8a36f73cd35 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -860,7 +860,10 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 void FreescapeEngine::load8bitBinary(Common::SeekableReadStream *file, int offset, int ncolors) {
 	file->seek(offset);
 	uint8 numberOfAreas = readField(file, 8);
-	if (isAmiga() && isCastle() && isDemo())
+	// The Castle Master Amiga binary stores the count as 0x68 (104) but the
+	// area pointer table only has 87 valid entries; the demo and the full
+	// game share the same asset section so the same override applies.
+	if (isAmiga() && isCastle())
 		numberOfAreas = 87;
 	debugC(1, kFreescapeDebugParser, "Number of areas: %d", numberOfAreas);
 
diff --git a/engines/freescape/sound/amiga.cpp b/engines/freescape/sound/amiga.cpp
index e03e8d8b0d7..cd9db335d1c 100644
--- a/engines/freescape/sound/amiga.cpp
+++ b/engines/freescape/sound/amiga.cpp
@@ -394,7 +394,7 @@ private:
 	}
 };
 
-void FreescapeEngine::loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds) {
+void FreescapeEngine::loadSoundsAmigaDemo(Common::SeekableReadStream *file, int offset, int numSounds, int modOffset) {
 	file->seek(offset);
 	_amigaSfxTable.clear();
 	for (int i = 0; i < numSounds; i++) {
@@ -415,12 +415,11 @@ void FreescapeEngine::loadSoundsAmigaDemo(Common::SeekableReadStream *file, int
 	_amigaDmaSamples.clear();
 	_amigaDmaSamples.resize(12);
 
-	static const int kModOffset = 0x3D5A6;
-	if (file->size() > kModOffset + 1084) {
-		int modSize = file->size() - kModOffset;
+	if (file->size() > modOffset + 1084) {
+		int modSize = file->size() - modOffset;
 		Common::Array<byte> modBytes;
 		modBytes.resize(modSize);
-		file->seek(kModOffset);
+		file->seek(modOffset);
 		file->read(modBytes.data(), modSize);
 
 		Common::MemoryReadStream modStream(modBytes.data(), modBytes.size());


Commit: 2e7f792636428791a7507ecefd342dc1875718b8
    https://github.com/scummvm/scummvm/commit/2e7f792636428791a7507ecefd342dc1875718b8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:51+02:00

Commit Message:
FREESCAPE: improve castle amiga endgame

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


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 798e4376b59..e99ea19e535 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -886,6 +886,7 @@ static const ADGameDescription gameDescriptions[] = {
 		"",
 		{
 			{"cm", 0, "67dcb3f62fe15f18ecc31380ffaf5c4e", 1112},
+			{"w", 0, "63c770f1008a641c5fd5d0b9df2bcbb6", 32048},
 			{"x", 0, "afc245de66ef8eb1b5fd061c6bbd602e", 349975},
 			AD_LISTEND
 		},
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 6ef8248c9e1..94f4802790d 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -1441,6 +1441,16 @@ void CastleEngine::loadAssetsAmigaFullGame() {
 	_border = loadFrameFromPlanesVertical(&file, 160, 200);
 	_border->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
 
+	// End-game throne picture. The original executable opens "W" during the
+	// escaped ending and displays the first 114 rows as a 16-word-wide
+	// interleaved Amiga bitplane image.
+	Common::File endGameFile;
+	if (endGameFile.open("w")) {
+		_endGameBackgroundFrame = loadFrameFromPlanesInterleaved(&endGameFile, 16, 114);
+		_endGameBackgroundFrame->convertToInPlace(_gfx->_texturePixelFormat, (byte *)kAmigaCastlePalette, 16);
+		endGameFile.close();
+	}
+
 	// Mountains panorama (63 words × 22 rows × 4 planes, interleaved).
 	file.seek(0x49c8);
 	_background = loadFrameFromPlanesInterleaved(&file, 63, 22);
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 37ab361e444..38abf132082 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -761,10 +761,12 @@ void CastleEngine::endGame() {
 	_endGamePlayerEndArea = true;
 
 	if (hasEscaped()) {
-		insertTemporaryMessage(_messagesList[5], INT_MIN);
+		insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], INT_MIN);
 
 		if (isDOS() && !isCastleMaster2()) {
 			drawFullscreenEndGameAndWait();
+		} else if (isAmiga() && !isDemo() && _endGameBackgroundFrame) {
+			drawFullscreenAmigaEndGameAndWait();
 		} else if (isCastleMaster2()) {
 			executeEscapeCameraSequence();
 			drawFullscreenGameOverAndWait();
@@ -1312,6 +1314,51 @@ void CastleEngine::drawFullscreenEndGameAndWait() {
 	delete surface;
 }
 
+void CastleEngine::drawFullscreenAmigaEndGameAndWait() {
+	Graphics::Surface *surface = new Graphics::Surface();
+	surface->create(_screenW, _screenH, _gfx->_texturePixelFormat);
+	surface->fillRect(_fullscreenViewArea, _gfx->_texturePixelFormat.ARGBToColor(0x00, 0x00, 0x00, 0x00));
+	surface->fillRect(_viewArea, _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x00, 0x00, 0x00));
+
+	int dx = _viewArea.left + (_viewArea.width() - _endGameBackgroundFrame->w) / 2;
+	int dy = _viewArea.top + (_viewArea.height() - _endGameBackgroundFrame->h) / 2;
+	surface->copyRectToSurface(*_endGameBackgroundFrame, dx, dy,
+		Common::Rect(0, 0, _endGameBackgroundFrame->w, _endGameBackgroundFrame->h));
+
+	Common::Event event;
+	bool cont = true;
+	while (!shouldQuit() && cont) {
+		while (_eventManager->pollEvent(event)) {
+			switch (event.type) {
+			case Common::EVENT_LBUTTONDOWN:
+				cont = false;
+				break;
+			case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+				if (event.customType == kActionShoot || event.customType == kActionChangeMode || event.customType == kActionSkip)
+					cont = false;
+				break;
+			case Common::EVENT_SCREEN_CHANGED:
+				_gfx->computeScreenViewport();
+				break;
+			default:
+				break;
+			}
+		}
+		_gfx->clear(0, 0, 0, true);
+		drawBorder();
+		if (_currentArea)
+			drawUI();
+
+		drawFullscreenSurface(surface);
+		_gfx->flipBuffer();
+		g_system->updateScreen();
+		g_system->delayMillis(15); // try to target ~60 FPS
+	}
+
+	surface->free();
+	delete surface;
+}
+
 void CastleEngine::drawFullscreenGameOverAndWait() {
 	Common::Event event;
 	bool cont = true;
@@ -1375,7 +1422,7 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	}
 
 	if (!isDOS() && hasEscaped()) {
-		insertTemporaryMessage(_messagesList[5], _countdown - 1);
+		insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], _countdown - 1);
 	}
 
 	while (!shouldQuit() && cont) {
@@ -1384,7 +1431,7 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 			insertTemporaryMessage(spiritsDestroyedString, _countdown - 4);
 			insertTemporaryMessage(keysCollectedString, _countdown - 6);
 			if (!isDOS() && hasEscaped()) {
-				insertTemporaryMessage(_messagesList[5], _countdown - 8);
+				insertTemporaryMessage(_messagesList[(isAmiga() || isAtariST()) ? 15 : 5], _countdown - 8);
 			}
 		}
 
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 207ab0969a9..9106fc7f739 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -190,6 +190,7 @@ private:
 	void loadDOSFonts(Common::SeekableReadStream *file, int pos);
 	void drawFullscreenRiddleAndWait(uint16 riddle);
 	void drawFullscreenEndGameAndWait();
+	void drawFullscreenAmigaEndGameAndWait();
 	void drawFullscreenGameOverAndWait();
 	void drawRiddle(uint16 riddle, uint32 front, uint32 back, Graphics::Surface *surface);
 	void tryToCollectKey();


Commit: 6cef8e0f346d97c162b05541a6bab17574567837
    https://github.com/scummvm/scummvm/commit/6cef8e0f346d97c162b05541a6bab17574567837
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:51+02:00

Commit Message:
FREESCAPE: experimental support red/blue stereo 3d images for the zx render

Changed paths:
    engines/freescape/area.cpp
    engines/freescape/area.h
    engines/freescape/freescape.cpp
    engines/freescape/freescape.h
    engines/freescape/gfx.cpp
    engines/freescape/gfx.h
    engines/freescape/gfx_opengl.cpp
    engines/freescape/gfx_opengl.h
    engines/freescape/gfx_opengl_shaders.cpp
    engines/freescape/gfx_opengl_shaders.h
    engines/freescape/movement.cpp


diff --git a/engines/freescape/area.cpp b/engines/freescape/area.cpp
index cfa95dd4fbc..35423f59446 100644
--- a/engines/freescape/area.cpp
+++ b/engines/freescape/area.cpp
@@ -86,6 +86,9 @@ Area::Area(uint16 areaID_, uint16 areaFlags_, ObjectMap *objectsByID_, ObjectMap
 	}
 
 	_lastTick = 0;
+	_lastDepthLayerTick = 0;
+	_lastRenderDepthLayer = kRenderDepthAll;
+	_lastForegroundDistance = 0.0f;
 }
 
 Area::~Area() {
@@ -224,6 +227,59 @@ void Area::resetArea() {
 }
 
 
+static float aabbNearestDepth(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &direction) {
+	const Math::Vector3d min = aabb.getMin();
+	const Math::Vector3d max = aabb.getMax();
+	float nearest = FLT_MAX;
+	float farthest = -FLT_MAX;
+
+	for (int x = 0; x < 2; x++) {
+		for (int y = 0; y < 2; y++) {
+			for (int z = 0; z < 2; z++) {
+				Math::Vector3d corner(
+					x ? max.x() : min.x(),
+					y ? max.y() : min.y(),
+					z ? max.z() : min.z());
+				float depth = (corner - camera).dotProduct(direction);
+				nearest = MIN(nearest, depth);
+				farthest = MAX(farthest, depth);
+			}
+		}
+	}
+
+	return farthest < 0.0f ? FLT_MAX : MAX(0.0f, nearest);
+}
+
+static float objectNearestDepth(Object *obj, const Math::Vector3d &camera, const Math::Vector3d &direction) {
+	if (!obj || obj->isDestroyed() || obj->isInvisible())
+		return FLT_MAX;
+
+	if (obj->getType() == ObjectType::kGroupType) {
+		Group *group = (Group *)obj;
+		float nearest = FLT_MAX;
+		for (auto &child : group->_objects)
+			nearest = MIN(nearest, objectNearestDepth(child, camera, direction));
+		return nearest;
+	}
+
+	Math::AABB bounds = obj->_boundingBox;
+	if (!bounds.isValid()) {
+		bounds.expand(obj->_origin);
+		bounds.expand(obj->_origin + obj->_size);
+	}
+
+	return bounds.isValid() ? aabbNearestDepth(bounds, camera, direction) : FLT_MAX;
+}
+
+static bool objectInDepthLayer(Object *obj, const Math::Vector3d &camera, const Math::Vector3d &direction, Area::RenderDepthLayer depthLayer, float foregroundDistance) {
+	if (depthLayer == Area::kRenderDepthAll)
+		return true;
+
+	float nearestDepth = objectNearestDepth(obj, camera, direction);
+	bool foreground = nearestDepth <= foregroundDistance;
+	return depthLayer == Area::kRenderDepthForeground ? foreground : !foreground;
+}
+
 void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait) {
 	bool runAnimation = animationTicks != _lastTick;
 	bool cameraChanged = camera != _lastCameraPosition;
@@ -397,6 +453,187 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 		_lastCameraPosition = camera;
 }
 
+void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance) {
+	bool runAnimation = depthLayer != kRenderDepthBackground && animationTicks != _lastDepthLayerTick;
+	bool cameraChanged = camera != _lastDepthLayerCameraPosition;
+	bool layerChanged = depthLayer != _lastRenderDepthLayer || (depthLayer != kRenderDepthAll && ABS(foregroundDistance - _lastForegroundDistance) > 0.001f);
+	bool sort = runAnimation || cameraChanged || layerChanged || _depthLayerSortedObjects.empty();
+	Math::Vector3d normalizedDirection = direction.getNormalized();
+
+	assert(_drawableObjects.size() > 0);
+	if (sort)
+		_depthLayerSortedObjects.clear();
+
+	Object *floor = nullptr;
+
+	for (auto &obj : _drawableObjects) {
+		if (!obj->isDestroyed() && !obj->isInvisible()) {
+			if (!gfx->_debugHighlightObjectIDs.empty()) {
+				bool found = false;
+				for (auto id : gfx->_debugHighlightObjectIDs) {
+					if (obj->getObjectID() == id) {
+						found = true;
+						break;
+					}
+				}
+				// if this object is not in our list, skip it completely.
+				// it will not be sorted, and it will not be drawn.
+				if (!found)
+					continue;
+			}
+			if (obj->getObjectID() == 0 && _groundColor < 255 && _skyColor < 255) {
+				if (depthLayer != kRenderDepthForeground)
+					floor = obj;
+				continue;
+			}
+
+			if (obj->getType() == ObjectType::kGroupType) {
+				if (objectInDepthLayer(obj, camera, normalizedDirection, depthLayer, foregroundDistance))
+					drawGroup(gfx, (Group *)obj, runAnimation && !insideWait);
+				continue;
+			}
+
+			if (sort && objectInDepthLayer(obj, camera, normalizedDirection, depthLayer, foregroundDistance))
+				_depthLayerSortedObjects.push_back(obj);
+		}
+	}
+
+	if (floor) {
+		floor->draw(gfx);
+	}
+
+	// Corresponds to L9c66 in assembly (bounding_box_axis_loop)
+	auto checkAxis = [](float minA, float maxA, float minB, float maxB) -> int {
+		bool signMinA = minA >= 0;
+		bool signMaxA = maxA >= 0;
+		bool signMinB = minB >= 0;
+		bool signMaxB = maxB >= 0;
+		if (minA >= maxB - 0.5f) { // A is clearly "greater" than B (L9c9b_one_object_clearly_further_than_the_other)
+			if (signMinA != signMaxB) // A covers 0 (L9ce6_first_object_is_closer)
+				return 1; // A is closer
+			if (signMinB != signMaxB) // B covers 0 (L9cec_second_object_is_closer)
+				return 2; // B is closer
+
+			if (signMinA != signMinB) // Different sides (L9cf3_objects_incomparable_in_this_axis)
+				return 0;
+
+			// Same side
+			if (!signMinA) { // Negative side (sign bit set in asm)
+				if (minA > minB) return 1; // A closer
+				if (minA < minB) return 2; // B closer
+				if (maxA > maxB) return 1; // A closer
+				return 2; // B closer
+			} else { // Positive side (sign bit clear in asm)
+				if (minA < minB) return 1; // A closer
+				if (minA > minB) return 2; // B closer
+				if (maxA > maxB) return 2; // B closer
+				return 1; // A closer
+			}
+		} else if (minB >= maxA - 0.5f) { // B is clearly "greater" than A
+			if (signMinB != signMaxB) // B covers 0 (L9cec_second_object_is_closer)
+				return 2; // B is closer
+			if (signMinA != signMaxA) // A covers 0 (L9ce6_first_object_is_closer)
+				return 1; // A is closer
+
+			if (signMinA != signMinB) // Different sides (L9cf3_objects_incomparable_in_this_axis)
+				return 0;
+
+			// Same side
+			if (!signMinB) { // Negative side
+				if (minB > minA) return 2; // B closer
+				if (minB < minA) return 1; // A closer
+				if (maxB > maxA) return 2; // B closer
+				return 1; // A closer
+			} else { // Positive side
+				if (minB < minA) return 2; // B closer
+				if (minB > minA) return 1; // A closer
+				if (maxB > maxA) return 1; // A closer
+				return 2; // B closer
+			}
+		}
+		return 0; // Overlap (L9cf3_objects_incomparable_in_this_axis)
+	};
+
+	// Bubble sort as implemented in castlemaster2-annotated.asm (L9c2d_sort_objects_for_rendering)
+	// NOTE: The sorting is performed on unprojected world-space coordinates relative to the player (L847f).
+	// The rotation/view matrix (computed in L95de) is NOT applied to the bounding boxes used for sorting.
+	// It is only applied to the vertices during the projection phase (L850f/L9177).
+	int n = _depthLayerSortedObjects.size();
+	if (n > 1 && sort) {
+		// Pre-sort by distance from camera (furthest first) to provide a stable initial
+		// ordering for the non-transitive bubble sort below. The original game achieves
+		// this by culling off-screen objects via a rendering volume check (L8bb7/L845b)
+		// before sorting, which prevents distant off-screen objects from interfering with
+		// the depth ordering of visible objects through non-transitive comparisons.
+		Common::sort(_depthLayerSortedObjects.begin(), _depthLayerSortedObjects.end(),
+			[&camera](Object *a, Object *b) {
+				Math::Vector3d centerA = (a->_occlusionBox.getMin() + a->_occlusionBox.getMax()) * 0.5f;
+				Math::Vector3d centerB = (b->_occlusionBox.getMin() + b->_occlusionBox.getMax()) * 0.5f;
+				return (centerA - camera).getSquareMagnitude() > (centerB - camera).getSquareMagnitude();
+			});
+		for (int i = 0; i < n; i++) { // L9c31_whole_object_pass_loop
+			bool changed = false;
+			for (int j = 0; j < n - 1; j++) { // L9c45_objects_loop
+				Object *a = _depthLayerSortedObjects[j];
+				Object *b = _depthLayerSortedObjects[j + 1];
+
+				Math::AABB bboxA = a->_occlusionBox;
+				Math::AABB bboxB = b->_occlusionBox;
+				Math::Vector3d minA = bboxA.getMin() - camera;
+				Math::Vector3d maxA = bboxA.getMax() - camera;
+				Math::Vector3d minB = bboxB.getMin() - camera;
+				Math::Vector3d maxB = bboxB.getMax() - camera;
+
+				int result = 0;
+
+				// X axis
+				result = (result << 2) | checkAxis(minA.x(), maxA.x(), minB.x(), maxB.x());
+				// Y axis
+				result = (result << 2) | checkAxis(minA.y(), maxA.y(), minB.y(), maxB.y());
+				// Z axis
+				result = (result << 2) | checkAxis(minA.z(), maxA.z(), minB.z(), maxB.z());
+
+				bool keepOrder = false;
+				// If result indicates B is closer in at least one axis, AND A is NEVER closer in any axis, keep order (A before B)
+				// Codes where B is closer (2) and A is not (1):
+				// 2 (Z), 8 (Y), 32 (X) -> hex: 02, 08, 20
+				// 2+8=10 (0A), 2+32=34 (22), 8+32=40 (28)
+				// 2+8+32=42 (2A)
+				// L9d37_next_object (Keep order)
+				if (result == 0x02 || result == 0x08 || result == 0x20 ||
+					result == 0x0A || result == 0x22 || result == 0x28 || result == 0x2A)
+					keepOrder = true; // A before B
+
+				if (!keepOrder) {
+					// Swap objects (L9d2c_flip_objects_loop)
+					_depthLayerSortedObjects[j] = b;
+					_depthLayerSortedObjects[j + 1] = a;
+					changed = true;
+				}
+			}
+			if (!changed)
+				break;
+		}
+	}
+
+	for (auto &obj : _depthLayerSortedObjects) {
+		obj->draw(gfx);
+
+		// draw bounding boxes
+		if (gfx->_debugRenderBoundingBoxes)
+			gfx->drawAABB(obj->_boundingBox, 0, 255, 0);
+		if (gfx->_debugRenderOcclusionBoxes)
+			gfx->drawAABB(obj->_occlusionBox, 255, 0, 0);
+	}
+	if (depthLayer != kRenderDepthBackground)
+		_lastDepthLayerTick = animationTicks;
+	if (sort) {
+		_lastDepthLayerCameraPosition = camera;
+		_lastRenderDepthLayer = depthLayer;
+		_lastForegroundDistance = foregroundDistance;
+	}
+}
+
 void Area::drawGroup(Freescape::Renderer *gfx, Group* group, bool runAnimation) {
 	if (runAnimation) {
 		group->run();
diff --git a/engines/freescape/area.h b/engines/freescape/area.h
index fba98d02378..51435e34b54 100644
--- a/engines/freescape/area.h
+++ b/engines/freescape/area.h
@@ -37,8 +37,15 @@ namespace Freescape {
 
 typedef Common::HashMap<uint16, Object *> ObjectMap;
 typedef Common::Array<Object *> ObjectArray;
+
 class Area {
 public:
+	enum RenderDepthLayer {
+		kRenderDepthAll,
+		kRenderDepthBackground,
+		kRenderDepthForeground
+	};
+
 	Area(uint16 areaID, uint16 areaFlags, ObjectMap *objectsByID, ObjectMap *entrancesByID, bool isCastle);
 	virtual ~Area();
 
@@ -58,6 +65,7 @@ public:
 	void remapColor(int index, int color);
 	void unremapColor(int index);
 	void draw(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait);
+	void drawDepthLayer(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance);
 	void drawGroup(Renderer *gfx, Group *group, bool runAnimation);
 	void show();
 
@@ -108,6 +116,11 @@ public:
 private:
 	Math::Vector3d _lastCameraPosition;
 	ObjectArray _sortedObjects;
+	ObjectArray _depthLayerSortedObjects;
+	Math::Vector3d _lastDepthLayerCameraPosition;
+	RenderDepthLayer _lastRenderDepthLayer;
+	float _lastForegroundDistance;
+	uint32 _lastDepthLayerTick;
 
 	uint16 _areaID;
 	uint16 _areaFlags;
diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index afa566745c2..863c3579e37 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -191,6 +191,7 @@ FreescapeEngine::FreescapeEngine(OSystem *syst, const ADGameDescription *gd)
 	_strafeRight = false;
 	_moveUp = false;
 	_moveDown = false;
+	_stereoMode = false;
 	_playerWasCrushed = false;
 	_forceEndGame = false;
 	_syncSound = false;
@@ -503,12 +504,148 @@ void FreescapeEngine::drawBackground() {
 	_gfx->drawBackground(_currentArea->_skyColor);
 }
 
+static bool rayAabbDistance(const Math::Vector3d &origin, const Math::Vector3d &direction, const Math::AABB &aabb, float &distance) {
+	const float originValues[3] = { origin.x(), origin.y(), origin.z() };
+	const float directionValues[3] = { direction.x(), direction.y(), direction.z() };
+	const float minValues[3] = { aabb.getMin().x(), aabb.getMin().y(), aabb.getMin().z() };
+	const float maxValues[3] = { aabb.getMax().x(), aabb.getMax().y(), aabb.getMax().z() };
+	float tMin = 0.0f;
+	float tMax = FLT_MAX;
+
+	for (int i = 0; i < 3; i++) {
+		if (ABS(directionValues[i]) < 0.0001f) {
+			if (originValues[i] < minValues[i] || originValues[i] > maxValues[i])
+				return false;
+			continue;
+		}
+
+		float t1 = (minValues[i] - originValues[i]) / directionValues[i];
+		float t2 = (maxValues[i] - originValues[i]) / directionValues[i];
+		if (t1 > t2)
+			SWAP(t1, t2);
+
+		tMin = MAX(tMin, t1);
+		tMax = MIN(tMax, t2);
+		if (tMin > tMax)
+			return false;
+	}
+
+	if (tMax < 0.0f)
+		return false;
+
+	distance = tMin > 0.0f ? tMin : tMax;
+	return true;
+}
+
+static float getStereoAreaSize(FreescapeEngine *engine) {
+	if (!engine->_currentArea)
+		return engine->_farClipPlane;
+
+	Math::AABB areaBounds;
+	ObjectMap *objectsByID = engine->_currentArea->getObjectsByID();
+	for (auto &it : *objectsByID) {
+		Object *obj = it._value;
+		if (!obj || obj->isDestroyed() || obj->isInvisible() || !obj->isGeometric() || !obj->_boundingBox.isValid())
+			continue;
+
+		areaBounds.expand(obj->_boundingBox.getMin());
+		areaBounds.expand(obj->_boundingBox.getMax());
+	}
+
+	const int areaScale = MAX<int>(engine->_currentArea->getScale(), 1);
+	if (!areaBounds.isValid())
+		return 800.0f / areaScale;
+
+	Math::Vector3d size = areaBounds.getSize();
+	float horizontalSize = MAX(size.x(), size.z());
+	return MAX(horizontalSize, size.length() * 0.5f);
+}
+
+static float getStereoConvergence(FreescapeEngine *engine) {
+	const int areaScale = MAX<int>(engine->_currentArea ? engine->_currentArea->getScale() : 1, 1);
+	float areaSize = getStereoAreaSize(engine);
+
+	return CLIP(areaSize * 0.55f, 120.0f / areaScale, 1200.0f / areaScale);
+}
+
+static float getStereoMaxSeparation(FreescapeEngine *engine, float convergence) {
+	const int areaScale = MAX<int>(engine->_currentArea ? engine->_currentArea->getScale() : 1, 1);
+	float maxSeparation = CLIP(2.0f / areaScale, 0.08f, 1.5f);
+
+	return MIN(maxSeparation, convergence * 0.02f);
+}
+
+static float getStereoForegroundDistance(FreescapeEngine *engine, float convergence) {
+	const int areaScale = MAX<int>(engine->_currentArea ? engine->_currentArea->getScale() : 1, 1);
+	float areaSize = getStereoAreaSize(engine);
+	float foregroundDistance = MIN(convergence * 0.7f, areaSize * 0.25f);
+
+	return CLIP(foregroundDistance, 60.0f / areaScale, 500.0f / areaScale);
+}
+
+static float getStereoViewDistance(FreescapeEngine *engine, float fov, float aspectRatio) {
+	if (!engine->_currentArea)
+		return engine->_farClipPlane;
+
+	float nearest = engine->_farClipPlane;
+	Math::Vector3d front = engine->_cameraFront.getNormalized();
+	Math::Vector3d up = engine->_upVector.getNormalized();
+	Math::Vector3d right = Math::Vector3d::crossProduct(front, up).getNormalized();
+	const float horizontalScale = tan(Math::deg2rad(fov) / 2);
+	const float verticalScale = horizontalScale / aspectRatio;
+	const float samples[][2] = {
+		{ 0.0f, 0.0f },
+		{ -0.75f, 0.0f },
+		{ 0.75f, 0.0f },
+		{ 0.0f, -0.65f },
+		{ 0.0f, 0.65f },
+		{ -0.55f, -0.45f },
+		{ 0.55f, -0.45f },
+		{ -0.55f, 0.45f },
+		{ 0.55f, 0.45f }
+	};
+	ObjectMap *objectsByID = engine->_currentArea->getObjectsByID();
+	for (auto &it : *objectsByID) {
+		Object *obj = it._value;
+		if (!obj || obj->isDestroyed() || obj->isInvisible() || !obj->isGeometric() || !obj->_boundingBox.isValid())
+			continue;
+
+		for (uint i = 0; i < ARRAYSIZE(samples); i++) {
+			Math::Vector3d direction = (front + right * (samples[i][0] * horizontalScale) + up * (samples[i][1] * verticalScale)).getNormalized();
+			float distance = 0.0f;
+			if (rayAabbDistance(engine->_position, direction, obj->_boundingBox, distance))
+				nearest = MIN(nearest, distance);
+		}
+	}
+
+	return MAX(nearest, engine->_nearClipPlane);
+}
+
+static float getStereoSeparation(FreescapeEngine *engine, float fov, float aspectRatio, float convergence, float maxSeparation) {
+	const float maxDisparity = 6.0f;
+	float nearest = getStereoViewDistance(engine, fov, aspectRatio);
+	if (nearest >= convergence)
+		return maxSeparation;
+
+	float focalLength = (engine->_viewArea.width() * 0.5f) / tan(Math::deg2rad(fov) / 2);
+	float denominator = 2.0f * focalLength * (1.0f / nearest - 1.0f / convergence);
+	if (denominator <= 0.0f)
+		return maxSeparation;
+
+	return MIN(maxSeparation, maxDisparity / denominator);
+}
+
 void FreescapeEngine::drawFrame() {
 	_gfx->updateColorCycling();
 	int farClipPlane = _farClipPlane;
 	if (_currentArea->isOutside())
 		farClipPlane *= 100;
 
+	if (_stereoMode) {
+		drawFrameStereo(farClipPlane);
+		return;
+	}
+
 	float aspectRatio = isCastle() ? 1.6 : 2.18;
 	_gfx->updateProjectionMatrix(75.0, aspectRatio, _nearClipPlane, farClipPlane);
 	_gfx->positionCamera(_position, _position + _cameraFront, _roll);
@@ -570,6 +707,94 @@ void FreescapeEngine::drawFrame() {
 	drawUI();
 }
 
+void FreescapeEngine::drawFrameStereo(int farClipPlane) {
+	const float fov = 75.0f;
+	float aspectRatio = isCastle() ? 1.6 : 2.18;
+	const float stereoConvergence = getStereoConvergence(this);
+	const float stereoMaxSeparation = getStereoMaxSeparation(this, stereoConvergence);
+	const float stereoForegroundDistance = getStereoForegroundDistance(this, stereoConvergence);
+	_gfx->setStereoParameters(getStereoSeparation(this, fov, aspectRatio, stereoConvergence, stereoMaxSeparation), stereoConvergence);
+
+	if (_underFireFrames > 0) {
+		int underFireColor = _currentArea->_underFireBackgroundColor;
+
+		if (isDriller() && (isDOS() || isAmiga() || isAtariST()))
+			underFireColor = 1;
+		else if (isDark() && (isDOS() || isAmiga() || isAtariST())) {
+			if (_renderMode == Common::kRenderCGA)
+				underFireColor = 3;
+			else
+				underFireColor = 4;
+		}
+
+		_currentArea->remapColor(_currentArea->_usualBackgroundColor, underFireColor);
+		_currentArea->remapColor(_currentArea->_skyColor, underFireColor);
+	}
+
+	_gfx->setStereoEye(Renderer::kStereoEyeNone);
+	_gfx->clear(0, 0, 0, true);
+	_gfx->setStereoEye(Renderer::kStereoEyeFlatAnaglyph);
+	_gfx->updateProjectionMatrix(fov, aspectRatio, _nearClipPlane, farClipPlane);
+	_gfx->positionCamera(_position, _position + _cameraFront, _roll);
+
+	drawBackground();
+	if (_avoidRenderingFrames == 0)
+		_currentArea->drawDepthLayer(_gfx, _ticks / 10, _position, _cameraFront, false, Area::kRenderDepthBackground, stereoForegroundDistance);
+
+	for (int pass = 0; pass < 2; pass++) {
+		_gfx->setStereoEye(pass == 0 ? Renderer::kStereoEyeLeft : Renderer::kStereoEyeRight);
+		_gfx->updateProjectionMatrix(fov, aspectRatio, _nearClipPlane, farClipPlane);
+		_gfx->positionCamera(_position, _position + _cameraFront, _roll);
+
+		_gfx->clearDepthBuffer();
+
+		if (_avoidRenderingFrames == 0) // Avoid rendering inside objects
+			_currentArea->drawDepthLayer(_gfx, _ticks / 10, _position, _cameraFront, false, Area::kRenderDepthForeground, stereoForegroundDistance);
+
+		if (_underFireFrames > 0) {
+			for (auto &it : _sensors) {
+				Sensor *sensor = (Sensor *)it;
+				if (it->isDestroyed() || it->isInvisible())
+					continue;
+				if (isCastle() || sensor->isShooting())
+					drawSensorShoot(sensor);
+			}
+		}
+
+		if (_shootingFrames > 0) {
+			_gfx->setViewport(_fullscreenViewArea);
+			if (isDriller() || isDark())
+				_gfx->renderPlayerShootRay(0, _crossairPosition, _viewArea);
+			else
+				_gfx->renderPlayerShootBall(0, _crossairPosition, _shootingFrames, _viewArea);
+
+			_gfx->setViewport(_viewArea);
+		}
+	}
+	_gfx->setStereoEye(Renderer::kStereoEyeNone);
+
+	if (_avoidRenderingFrames == 0) {
+		if (_gameStateControl == kFreescapeGameStatePlaying &&
+		    _currentArea->hasActiveGroups() && _ticks % 50 == 0)
+			executeMovementConditions();
+	} else
+		_avoidRenderingFrames--;
+
+	if (_underFireFrames > 0) {
+		_underFireFrames--;
+		if (_underFireFrames == 0) {
+			_currentArea->unremapColor(_currentArea->_usualBackgroundColor);
+			_currentArea->unremapColor(_currentArea->_skyColor);
+		}
+	}
+
+	if (_shootingFrames > 0)
+		_shootingFrames--;
+
+	drawBorder();
+	drawUI();
+}
+
 void FreescapeEngine::pressedKey(const int keycode) {}
 
 void FreescapeEngine::releasedKey(const int keycode) {}
@@ -670,6 +895,14 @@ void FreescapeEngine::processInput() {
 				_noClipMode = !_noClipMode;
 				_flyMode = _noClipMode;
 				break;
+			case kActionToggleStereoscopic:
+				// Limit this to the ZX Spectrum games for now; their restricted
+				// palette survives red/blue channel separation cleanly.
+				if (isSpectrum()) {
+					_stereoMode = !_stereoMode;
+					insertTemporaryMessage(_stereoMode ? "3D ON" : "3D OFF", _countdown - 2);
+				}
+				break;
 			case kActionEscape:
 				drawFrame();
 				_savedScreen = _gfx->getScreenshot();
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index a7ada28eba7..960019dc506 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -113,6 +113,7 @@ enum FreescapeAction {
 	kActionSelectPrincess,
 	kActionQuit,
 	kActionToggleFlashlight,
+	kActionToggleStereoscopic,
 
 	// Demo actions
 	kActionUnknownKey,
@@ -569,6 +570,10 @@ public:
 	int _shootingFrames;
 	GeometricObject *_delayedShootObject;
 	void drawFrame();
+	void drawFrameStereo(int farClipPlane);
+
+	// Red/blue anaglyph 3D ("two eyes") effect, toggled with the 3 key.
+	bool _stereoMode;
 	void flashScreen(int backgroundColor);
 	uint8 _colorNumber;
 	Math::Vector3d _scaleVector;
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index d8172b7ad87..532d325d4ae 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -59,6 +59,9 @@ Renderer::Renderer(int screenW, int screenH, Common::RenderMode renderMode, bool
 	_debugRenderWireframe = false;
 	_debugRenderNormals = false;
 	_authenticGraphics = authenticGraphics;
+	_stereoEye = kStereoEyeNone;
+	_stereoSeparation = 0.2f;
+	_stereoConvergence = 800.0f;
 
 	for (int i = 0; i < 16; i++) {
 		for (int j = 0; j < 128; j++) {
@@ -70,6 +73,54 @@ Renderer::Renderer(int screenW, int screenH, Common::RenderMode renderMode, bool
 	_scale = 1;
 }
 
+void Renderer::applyStereoTint(uint8 &r, uint8 &g, uint8 &b) const {
+	if (_stereoEye == kStereoEyeNone)
+		return;
+
+	uint8 lum = (uint8)((r * 77 + g * 150 + b * 29) >> 8);
+	if (_stereoEye == kStereoEyeLeft) {
+		r = lum;
+		g = 0;
+		b = 0;
+	} else if (_stereoEye == kStereoEyeRight) {
+		r = 0;
+		g = 0;
+		b = lum;
+	} else if (_stereoEye == kStereoEyeFlatAnaglyph) {
+		r = lum;
+		g = 0;
+		b = lum;
+	}
+}
+
+void Renderer::setStereoParameters(float separation, float convergence) {
+	_stereoSeparation = separation;
+	_stereoConvergence = convergence;
+}
+
+void Renderer::getStereoCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, Math::Vector3d &eyePos, Math::Vector3d &eyeInterest) const {
+	eyePos = pos;
+	eyeInterest = interest;
+	if (_stereoEye != kStereoEyeLeft && _stereoEye != kStereoEyeRight)
+		return;
+
+	Math::Vector3d up(0, 1, 0);
+	Math::Vector3d front = (interest - pos).getNormalized();
+	Math::Vector3d right = Math::Vector3d::crossProduct(front, up).getNormalized();
+
+	Math::Vector3d eyeOffset = right * (-_stereoSeparation * _stereoEye);
+	eyePos = pos + eyeOffset;
+	eyeInterest = interest + eyeOffset;
+}
+
+float Renderer::getStereoFrustumOffset(float nearClipPlane, bool mirroredProjection) const {
+	if (_stereoEye != kStereoEyeLeft && _stereoEye != kStereoEyeRight)
+		return 0.0f;
+
+	float offset = (-_stereoSeparation * _stereoEye) * nearClipPlane / _stereoConvergence;
+	return mirroredProjection ? -offset : offset;
+}
+
 Renderer::~Renderer() {}
 
 byte getCPCStipple(byte cpc_byte, int back, int fore) {
diff --git a/engines/freescape/gfx.h b/engines/freescape/gfx.h
index 1986ea5db35..37432fb2ebd 100644
--- a/engines/freescape/gfx.h
+++ b/engines/freescape/gfx.h
@@ -76,6 +76,21 @@ public:
 	 *  Swap the buffers, making the drawn screen visible
 	 */
 	virtual void flipBuffer() {}
+
+	/**
+	 * Select the current eye for the red/blue stereoscopic 3D effect.
+	 * 0 = disabled (normal rendering), -1 = left eye (red), +1 = right eye (blue),
+	 * 2 = mono anaglyph at screen depth (red + blue).
+	 */
+	enum StereoEye {
+		kStereoEyeNone = 0,
+		kStereoEyeLeft = -1,
+		kStereoEyeRight = 1,
+		kStereoEyeFlatAnaglyph = 2
+	};
+	virtual void setStereoEye(StereoEye eye) { _stereoEye = eye; }
+	void setStereoParameters(float separation, float convergence);
+
 	virtual void useColor(uint8 r, uint8 g, uint8 b) = 0;
 	virtual void enableCulling(bool enabled) {};
 	virtual void polygonOffset(bool enabled) = 0;
@@ -100,6 +115,7 @@ public:
 
 	void setColorRemaps(ColorReMap *colorRemaps);
 	virtual void clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport = false) = 0;
+	virtual void clearDepthBuffer(bool ignoreViewport = false) {}
 	virtual void drawFloor(uint8 color) = 0;
 	virtual void drawBackground(uint8 color);
 
@@ -343,6 +359,13 @@ protected:
 	Math::Frustum _frustum;
 
 	Math::Matrix4 makeProjectionMatrix(float fov, float nearClipPlane, float farClipPlane) const;
+	void applyStereoTint(uint8 &r, uint8 &g, uint8 &b) const;
+	void getStereoCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, Math::Vector3d &eyePos, Math::Vector3d &eyeInterest) const;
+	float getStereoFrustumOffset(float nearClipPlane, bool mirroredProjection) const;
+
+	StereoEye _stereoEye;
+	float _stereoSeparation; // half of the inter-ocular distance, in world units
+	float _stereoConvergence; // distance to the zero-parallax plane, in world units
 };
 
 Graphics::RendererType determinateRenderType();
diff --git a/engines/freescape/gfx_opengl.cpp b/engines/freescape/gfx_opengl.cpp
index 7f11d3e750c..7d3adfe0ea4 100644
--- a/engines/freescape/gfx_opengl.cpp
+++ b/engines/freescape/gfx_opengl.cpp
@@ -255,7 +255,12 @@ void OpenGLRenderer::updateProjectionMatrix(float fov, float aspectRatio, float
 	float ymaxValue = xmaxValue / aspectRatio;
 
 	// Corrected glFrustum call
-	glFrustum(-xmaxValue, xmaxValue, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	if (_stereoEye == kStereoEyeLeft || _stereoEye == kStereoEyeRight) {
+		float stereoOffset = getStereoFrustumOffset(nearClipPlane, false);
+		glFrustum(-xmaxValue + stereoOffset, xmaxValue + stereoOffset, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	} else {
+		glFrustum(-xmaxValue, xmaxValue, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	}
 	glScalef(-1.0f, 1.0f, 1.0f);
 
 	glMatrixMode(GL_MODELVIEW);
@@ -265,10 +270,20 @@ void OpenGLRenderer::updateProjectionMatrix(float fov, float aspectRatio, float
 void OpenGLRenderer::positionCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, float rollAngle) {
 	Math::Vector3d up_vec(0, 1, 0);
 
-	Math::Matrix4 lookMatrix = Math::makeLookAtMatrix(pos, interest, up_vec);
-	glMultMatrixf(lookMatrix.getData());
-	glRotatef(rollAngle, 0.0f, 0.0f, 1.0f);
-	glTranslatef(-pos.x(), -pos.y(), -pos.z());
+	if (_stereoEye == kStereoEyeLeft || _stereoEye == kStereoEyeRight) {
+		Math::Vector3d eyePos;
+		Math::Vector3d eyeInterest;
+		getStereoCamera(pos, interest, eyePos, eyeInterest);
+		Math::Matrix4 lookMatrix = Math::makeLookAtMatrix(eyePos, eyeInterest, up_vec);
+		glMultMatrixf(lookMatrix.getData());
+		glRotatef(rollAngle, 0.0f, 0.0f, 1.0f);
+		glTranslatef(-eyePos.x(), -eyePos.y(), -eyePos.z());
+	} else {
+		Math::Matrix4 lookMatrix = Math::makeLookAtMatrix(pos, interest, up_vec);
+		glMultMatrixf(lookMatrix.getData());
+		glRotatef(rollAngle, 0.0f, 0.0f, 1.0f);
+		glTranslatef(-pos.x(), -pos.y(), -pos.z());
+	}
 
 	// Apply a 2D shake effect on the projection matrix.
 	// This avoids moving the camera in the 3D world, which could cause clipping issues.
@@ -602,11 +617,28 @@ void OpenGLRenderer::useStipple(bool enabled) {
 	}
 }
 
+void OpenGLRenderer::setStereoEye(StereoEye eye) {
+	Renderer::setStereoEye(eye);
+	// Restrict stereo passes to the anaglyph channels they are allowed to touch.
+	if (eye == kStereoEyeNone)
+		glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
+	else if (eye == kStereoEyeLeft)
+		glColorMask(GL_TRUE, GL_FALSE, GL_FALSE, GL_TRUE); // left eye -> red
+	else if (eye == kStereoEyeRight)
+		glColorMask(GL_FALSE, GL_FALSE, GL_TRUE, GL_TRUE); // right eye -> blue
+	else if (eye == kStereoEyeFlatAnaglyph)
+		glColorMask(GL_TRUE, GL_FALSE, GL_TRUE, GL_TRUE); // flat background -> red + blue
+}
+
 void OpenGLRenderer::useColor(uint8 r, uint8 g, uint8 b) {
+	if (_stereoEye != kStereoEyeNone)
+		applyStereoTint(r, g, b);
 	glColor4ub(r, g, b, 255);
 }
 
 void OpenGLRenderer::clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport) {
+	if (_stereoEye != kStereoEyeNone)
+		applyStereoTint(r, g, b);
 	if (ignoreViewport)
 		glDisable(GL_SCISSOR_TEST);
 	glClearColor(r / 255., g / 255., b / 255., 1.0);
@@ -615,10 +647,20 @@ void OpenGLRenderer::clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport) {
 		glEnable(GL_SCISSOR_TEST);
 }
 
+void OpenGLRenderer::clearDepthBuffer(bool ignoreViewport) {
+	if (ignoreViewport)
+		glDisable(GL_SCISSOR_TEST);
+	glClear(GL_DEPTH_BUFFER_BIT);
+	if (ignoreViewport)
+		glEnable(GL_SCISSOR_TEST);
+}
+
 void OpenGLRenderer::drawFloor(uint8 color) {
 	uint8 r1, g1, b1, r2, g2, b2;
 	byte *stipple;
 	assert(getRGBAt(color, 0, r1, g1, b1, r2, g2, b2, stipple)); // TODO: move check inside this function
+	if (_stereoEye != kStereoEyeNone)
+		applyStereoTint(r1, g1, b1);
 	glColor4ub(r1, g1, b1, 255);
 
 	glEnableClientState(GL_VERTEX_ARRAY);
diff --git a/engines/freescape/gfx_opengl.h b/engines/freescape/gfx_opengl.h
index 691faa42222..eda2b72b43e 100644
--- a/engines/freescape/gfx_opengl.h
+++ b/engines/freescape/gfx_opengl.h
@@ -67,6 +67,7 @@ public:
 
 	virtual void init() override;
 	virtual void clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport = false) override;
+	virtual void clearDepthBuffer(bool ignoreViewport = false) override;
 	virtual void setViewport(const Common::Rect &rect) override;
 	virtual Common::Point nativeResolution() override;
 	virtual void positionCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, float rollAngle = 0.0) override;
@@ -78,6 +79,7 @@ public:
 	virtual void useStipple(bool enabled) override;
 	virtual void enableCulling(bool enabled) override;
 
+	virtual void setStereoEye(StereoEye eye) override;
 
 	Texture *createTexture(const Graphics::Surface *surface, bool is3D = false) override;
 	void freeTexture(Texture *texture) override;
diff --git a/engines/freescape/gfx_opengl_shaders.cpp b/engines/freescape/gfx_opengl_shaders.cpp
index 0effc9fb0ce..bcb5aa16881 100644
--- a/engines/freescape/gfx_opengl_shaders.cpp
+++ b/engines/freescape/gfx_opengl_shaders.cpp
@@ -286,15 +286,31 @@ void OpenGLShaderRenderer::drawThunder(Texture *texture, const Math::Vector3d po
 void OpenGLShaderRenderer::updateProjectionMatrix(float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
 	float xmaxValue = nearClipPlane * tan(Math::deg2rad(fov) / 2);
 	float ymaxValue = xmaxValue / aspectRatio;
-	_projectionMatrix = Math::makeFrustumMatrix(xmaxValue, -xmaxValue, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	if (_stereoEye == kStereoEyeLeft || _stereoEye == kStereoEyeRight) {
+		float stereoOffset = getStereoFrustumOffset(nearClipPlane, true);
+		_projectionMatrix = Math::makeFrustumMatrix(xmaxValue + stereoOffset, -xmaxValue + stereoOffset, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	} else {
+		_projectionMatrix = Math::makeFrustumMatrix(xmaxValue, -xmaxValue, -ymaxValue, ymaxValue, nearClipPlane, farClipPlane);
+	}
 }
 
 void OpenGLShaderRenderer::positionCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, float rollAngle) {
 	Math::Vector3d up_vec(0, 1, 0);
 
-	Math::Matrix4 lookMatrix = Math::makeLookAtMatrix(pos, interest, up_vec);
+	Math::Matrix4 lookMatrix;
+	Math::Vector3d viewPosition;
+	if (_stereoEye == kStereoEyeLeft || _stereoEye == kStereoEyeRight) {
+		Math::Vector3d eyePos;
+		Math::Vector3d eyeInterest;
+		getStereoCamera(pos, interest, eyePos, eyeInterest);
+		lookMatrix = Math::makeLookAtMatrix(eyePos, eyeInterest, up_vec);
+		viewPosition = eyePos;
+	} else {
+		lookMatrix = Math::makeLookAtMatrix(pos, interest, up_vec);
+		viewPosition = pos;
+	}
 	Math::Matrix4 viewMatrix;
-	viewMatrix.translate(-pos);
+	viewMatrix.translate(-viewPosition);
 	viewMatrix.transpose();
 
 	// Roll around the camera's forward axis. The matrix is stored in the
@@ -810,13 +826,29 @@ void OpenGLShaderRenderer::useStipple(bool enabled) {
 	}
 }
 
+void OpenGLShaderRenderer::setStereoEye(StereoEye eye) {
+	Renderer::setStereoEye(eye);
+	if (eye == kStereoEyeNone)
+		glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
+	else if (eye == kStereoEyeLeft)
+		glColorMask(GL_TRUE, GL_FALSE, GL_FALSE, GL_TRUE);
+	else if (eye == kStereoEyeRight)
+		glColorMask(GL_FALSE, GL_FALSE, GL_TRUE, GL_TRUE);
+	else if (eye == kStereoEyeFlatAnaglyph)
+		glColorMask(GL_TRUE, GL_FALSE, GL_TRUE, GL_TRUE);
+}
+
 void OpenGLShaderRenderer::useColor(uint8 r, uint8 g, uint8 b) {
+	if (_stereoEye != kStereoEyeNone)
+		applyStereoTint(r, g, b);
 	Math::Vector3d color(r / 256.0, g / 256.0, b / 256.0);
 	_triangleShader->use();
 	_triangleShader->setUniform("color", color);
 }
 
 void OpenGLShaderRenderer::clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport) {
+	if (_stereoEye != kStereoEyeNone)
+		applyStereoTint(r, g, b);
 	if (ignoreViewport)
 		glDisable(GL_SCISSOR_TEST);
 	glClearColor(r / 255., g / 255., b / 255., 1.0);
@@ -825,6 +857,14 @@ void OpenGLShaderRenderer::clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport)
 		glEnable(GL_SCISSOR_TEST);
 }
 
+void OpenGLShaderRenderer::clearDepthBuffer(bool ignoreViewport) {
+	if (ignoreViewport)
+		glDisable(GL_SCISSOR_TEST);
+	glClear(GL_DEPTH_BUFFER_BIT);
+	if (ignoreViewport)
+		glEnable(GL_SCISSOR_TEST);
+}
+
 void OpenGLShaderRenderer::drawFloor(uint8 color) {
 	/*uint8 r1, g1, b1, r2, g2, b2;
 	byte *stipple;
diff --git a/engines/freescape/gfx_opengl_shaders.h b/engines/freescape/gfx_opengl_shaders.h
index e6fb9d0affc..824f5672d00 100644
--- a/engines/freescape/gfx_opengl_shaders.h
+++ b/engines/freescape/gfx_opengl_shaders.h
@@ -75,6 +75,7 @@ public:
 	virtual void init() override;
 	virtual void drawAABB(const Math::AABB &aabb, uint8 r, uint8 g, uint8 b) override;
 	virtual void clear(uint8 r, uint8 g, uint8 b, bool ignoreViewport = false) override;
+	virtual void clearDepthBuffer(bool ignoreViewport = false) override;
 	virtual void setViewport(const Common::Rect &rect) override;
 	virtual Common::Point nativeResolution() override;
 	virtual void positionCamera(const Math::Vector3d &pos, const Math::Vector3d &interest, float rollAngle = 0.0f) override;
@@ -83,6 +84,7 @@ public:
 	virtual void useColor(uint8 r, uint8 g, uint8 b) override;
 	virtual void polygonOffset(bool enabled) override;
 	virtual void enableCulling(bool enabled) override;
+	virtual void setStereoEye(StereoEye eye) override;
 
 	virtual void setStippleData(byte *data) override;
 	virtual void useStipple(bool enabled) override;
diff --git a/engines/freescape/movement.cpp b/engines/freescape/movement.cpp
index 81d389cecb9..b694793a5bb 100644
--- a/engines/freescape/movement.cpp
+++ b/engines/freescape/movement.cpp
@@ -114,6 +114,12 @@ void FreescapeEngine::initKeymaps(Common::Keymap *engineKeyMap, Common::Keymap *
 	act->addDefaultInputMapping("i");
 	act->addDefaultInputMapping("JOY_GUIDE");
 	engineKeyMap->addAction(act);
+
+	// I18N: Toggles the red/blue stereoscopic 3D effect (anaglyph glasses).
+	act = new Common::Action("STEREO3D", _("Toggle red/blue 3D"));
+	act->setCustomEngineActionEvent(kActionToggleStereoscopic);
+	act->addDefaultInputMapping("3");
+	engineKeyMap->addAction(act);
 }
 
 Math::AABB createPlayerAABB(Math::Vector3d const position, int playerHeight, float reductionHeight = 0.0f) {


Commit: 05707392b982676f16135b7aea61e9633dcdeacf
    https://github.com/scummvm/scummvm/commit/05707392b982676f16135b7aea61e9633dcdeacf
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:51+02:00

Commit Message:
FREESCAPE: removed buggy collision workaround

Changed paths:
    engines/freescape/sweepAABB.cpp


diff --git a/engines/freescape/sweepAABB.cpp b/engines/freescape/sweepAABB.cpp
index 0055fd3b1e8..426c0e15c38 100644
--- a/engines/freescape/sweepAABB.cpp
+++ b/engines/freescape/sweepAABB.cpp
@@ -44,37 +44,6 @@ float sweepAABB(Math::AABB const &a, Math::AABB const &b, Math::Vector3d const &
 	Math::Vector3d m = b.getMin() - a.getMax();
 	Math::Vector3d mh = a.getSize() + b.getSize();
 
-	// Overlap-at-start: the AABBs already intersect on every axis. The
-	// plane sweep below requires s >= 0 for every test, so it would
-	// incorrectly return "no collision" and the caller would let the
-	// player walk straight through the geometry they are already inside.
-	// Detect that case here, pick the axis of smallest overlap, and emit
-	// a t=0 collision with a unit normal pointing out along that axis.
-	// The caller's epsilon * normal push then pries the player out of
-	// the overlap incrementally over a few frames.
-	if (m.x() < 0 && m.x() + mh.x() > 0 &&
-	    m.y() < 0 && m.y() + mh.y() > 0 &&
-	    m.z() < 0 && m.z() + mh.z() > 0) {
-		// Overlap depth on the "push -axis" side vs the "push +axis" side
-		// (both values are positive while we are overlapping).
-		float ovNegX = -m.x();
-		float ovPosX = m.x() + mh.x();
-		float ovNegY = -m.y();
-		float ovPosY = m.y() + mh.y();
-		float ovNegZ = -m.z();
-		float ovPosZ = m.z() + mh.z();
-		float minX = MIN(ovNegX, ovPosX);
-		float minY = MIN(ovNegY, ovPosY);
-		float minZ = MIN(ovNegZ, ovPosZ);
-		if (minX <= minY && minX <= minZ)
-			normal = Math::Vector3d(ovNegX < ovPosX ? -1.0f : 1.0f, 0, 0);
-		else if (minY <= minZ)
-			normal = Math::Vector3d(0, ovNegY < ovPosY ? -1.0f : 1.0f, 0);
-		else
-			normal = Math::Vector3d(0, 0, ovNegZ < ovPosZ ? -1.0f : 1.0f);
-		return 0.0f;
-	}
-
 	float h = 1.0;
 	float s = 0.0;
 	Math::Vector3d zero;


Commit: 4c18772a9818934854883774999485ce016f627e
    https://github.com/scummvm/scummvm/commit/4c18772a9818934854883774999485ce016f627e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:51+02:00

Commit Message:
FREESCAPE: sensor fixes, in particular for driller amiga/atari

Changed paths:
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/objects/sensor.cpp


diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 8a36f73cd35..838e05deec8 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -561,6 +561,10 @@ Object *FreescapeEngine::load8bitObject(Common::SeekableReadStream *file) {
 		assert(color > 0);
 		byte firingInterval = readField(file, 8);
 		uint16 firingRange = readPtr(file) / 2;
+		// Driller Amiga/ST stores sensor ranges in the original 64-units-per-cell
+		// coordinate space. ScummVM normalizes Driller geometry to 32 units per cell.
+		if (isDriller() && (isAmiga() || isAtariST()))
+			firingRange = firingRange / 2;
 		if (isDark())
 			firingRange = firingRange / 2;
 		byte sensorAxis = readField(file, 8);
diff --git a/engines/freescape/objects/sensor.cpp b/engines/freescape/objects/sensor.cpp
index 165018717d0..cb1788f1698 100644
--- a/engines/freescape/objects/sensor.cpp
+++ b/engines/freescape/objects/sensor.cpp
@@ -68,6 +68,7 @@ Sensor::Sensor(
 void Sensor::scale(int factor) {
 	_origin = _origin / factor;
 	_size = _size / factor;
+	_firingRange = _firingRange / factor;
 }
 
 Object *Sensor::duplicate() {
@@ -105,7 +106,8 @@ bool Sensor::playerDetected(const Math::Vector3d &position, Area *area) {
 	}
 
 	if (detected) {
-		detected = ABS(diff.x() + ABS(diff.y())) + ABS(diff.z()) <= _firingRange;
+		float distance = ABS(diff.x()) + ABS(diff.y()) + ABS(diff.z());
+		detected = distance < _firingRange;
 	}
 
 	return detected;


Commit: 04795e7633ec19cf515870c8eb69f55116cbd3d8
    https://github.com/scummvm/scummvm/commit/04795e7633ec19cf515870c8eb69f55116cbd3d8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-05-23T20:56:51+02:00

Commit Message:
FREESCAPE: make sure saving/loading keep the game state consistent specially when the player dies

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


diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index 863c3579e37..43a3b06f69f 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -1321,6 +1321,7 @@ void FreescapeEngine::initGameState() {
 
 	_flyMode = false;
 	_noClipMode = false;
+	_hasFallen = false;
 	_playerWasCrushed = false;
 	_shootingFrames = 0;
 	_delayedShootObject = nullptr;
@@ -1432,6 +1433,14 @@ Common::Error FreescapeEngine::loadGameStream(Common::SeekableReadStream *stream
 
 	_flyMode = stream->readByte();
 	_noClipMode = false;
+	// Reset transient "player has fallen" state. Otherwise, having fallen out
+	// of the world before saving (or loading in-game right after a fall)
+	// persists and immediately ends the loaded game. Falling also tilts the
+	// camera (_roll) in some games and that value is not stored in the save,
+	// so reset it as well. Reported for Driller (Amiga), but applies to all games.
+	_hasFallen = false;
+	_roll = 0;
+	_avoidRenderingFrames = 0;
 	_playerHeightNumber = stream->readSint32LE();
 	_playerStepIndex = stream->readUint32LE();
 	_countdown = stream->readUint32LE();
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index 960019dc506..7aabd4adf40 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -39,6 +39,7 @@
 #include "freescape/area.h"
 #include "freescape/font.h"
 #include "freescape/gfx.h"
+#include "freescape/language/8bitDetokeniser.h"
 #include "freescape/objects/entrance.h"
 #include "freescape/objects/geometricobject.h"
 #include "freescape/objects/sensor.h"
@@ -643,7 +644,11 @@ public:
 	bool hasFeature(EngineFeature f) const override;
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return true; }
 	bool canSaveAutosaveCurrently() override { return false; }
-	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return _gameStateControl == kFreescapeGameStatePlaying && _currentArea; }
+	// Disallow saving while the game is not in the playing state, or while the
+	// player is dead/not controllable (fallen out of the world, crushed, or out
+	// of health/shield). Otherwise that transient state would be captured into
+	// the savegame.
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return _gameStateControl == kFreescapeGameStatePlaying && _currentArea && !_hasFallen && !_playerWasCrushed && _gameStateVars.getValOrDefault(k8bitVariableShield) > 0; }
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 	Common::Error saveGameStream(Common::WriteStream *stream, bool isAutosave = false) override;
 	virtual Common::Error saveGameStreamExtended(Common::WriteStream *stream, bool isAutosave = false);




More information about the Scummvm-git-logs mailing list