[Scummvm-git-logs] scummvm master -> 241b138f5e385b5d8f2f25e3ab58564f7e70d5e9

neuromancer noreply at scummvm.org
Mon Sep 7 05:34:14 UTC 2026


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

Summary:
6a20f330d8 SCUMM: RA1: better coordination when playing frames out of order during player path decisions
69dc0313b5 FREESCAPE: completed castle UI for C64
7ca4207a12 FREESCAPE: added gate parsing/rendering for castle c64
217eb9e715 FREESCAPE: refactored music and added sound effects for castle c64
81583b292c FREESCAPE: background and lighting in castle c64
1e6f0d0504 FREESCAPE: ghost attack effect implemented in castle c64
3d55dacb49 FREESCAPE: reworked painters algorithm to fix some artifacts
253f3b0ea9 FREESCAPE: implemented loading of simple geometry for 3DCK games
be883d1a56 FREESCAPE: allow to load geometry of the 3DCK sample game
f65035d8f0 FREESCAPE: initial implementation of 3DCK opcodes and scripting
c567de577a FREESCAPE: refactored instructions code into functions for the 3dck
effb13c821 FREESCAPE: mouselook unlocked for 3dck
01bb2300a0 FREESCAPE: added 8bit opcodes and scripting for 3dck
ce36579e2f FREESCAPE: refactored language scripting for freescape/3dck
241b138f5e FREESCAPE: refactored freescape script detokenizer to match other similar functions


Commit: 6a20f330d8ede2816283fdcc175eb77c440782d6
    https://github.com/scummvm/scummvm/commit/6a20f330d8ede2816283fdcc175eb77c440782d6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:52+02:00

Commit Message:
SCUMM: RA1: better coordination when playing frames out of order during player path decisions

Changed paths:
    engines/scumm/insane/rebel1/iact.cpp
    engines/scumm/insane/rebel1/rebel.h
    engines/scumm/insane/rebel1/render.cpp
    engines/scumm/insane/rebel1/runlevels.cpp


diff --git a/engines/scumm/insane/rebel1/iact.cpp b/engines/scumm/insane/rebel1/iact.cpp
index 1a799bbbbf7..683657b0946 100644
--- a/engines/scumm/insane/rebel1/iact.cpp
+++ b/engines/scumm/insane/rebel1/iact.cpp
@@ -762,8 +762,8 @@ void InsaneRebel1::checkDynamicLevelBranch(int32 curFrame) {
 		}
 	}
 
-	// Level 8 owns its branch choice in updateLevel8WalkerState(), where the
-	// choice variable. This function only performs the delayed route cutover.
+	// Level 8 schedules its branch in updateLevel8WalkerState() and commits it
+	// after rendering the following frame.
 }
 
 void InsaneRebel1::projectGameplayPoint(int16 &x, int16 &y) const {
@@ -1228,17 +1228,21 @@ void InsaneRebel1::updateShipPhysics() {
 
 	_damageFlags = 0;
 
-	// After this point, drift goes strongly negative (pushing ship left for the hard path).
-	if (_pathBranchEnabled && _gameCounter >= kPathBranchCounter) {
-		if (_shipPosX > kRA1CenterX) {
-			_rightPathSelected = true;
+	// The original chooses at frame 386, then keeps the source through frame
+	// 391. The right-hand clip resumes at local frame 1 after that shared frame.
+	if (_pathBranchEnabled && _currentSmushFrame >= kLevel1BranchDecisionFrame) {
+		if (!_rightPathSelected) {
+			_rightPathSelected = _shipPosX > kRA1CenterX;
+			if (!_rightPathSelected)
+				_pathBranchEnabled = false;
+			debugC(DEBUG_INSANE, "L1 path selected: right=%d localFrame=%d shipX=%d",
+				_rightPathSelected ? 1 : 0, (int)_currentSmushFrame, _shipPosX);
+		}
+		if (_rightPathSelected && _currentSmushFrame >= kLevel1BranchCutoverFrame) {
+			_pathBranchEnabled = false;
 			preserveInteractiveVideoAudioState();
 			_vm->_smushVideoShouldFinish = true;
-			debugC(DEBUG_INSANE, "Right path selected (counter=%d, shipX=%d)", _gameCounter, _shipPosX);
-		} else {
-			debugC(DEBUG_INSANE, "Left path retained (counter=%d, shipX=%d)", _gameCounter, _shipPosX);
 		}
-		_pathBranchEnabled = false;
 	}
 
 	if (_currentLevel != 6)
diff --git a/engines/scumm/insane/rebel1/rebel.h b/engines/scumm/insane/rebel1/rebel.h
index 1b77886e368..40bd8709062 100644
--- a/engines/scumm/insane/rebel1/rebel.h
+++ b/engines/scumm/insane/rebel1/rebel.h
@@ -246,7 +246,7 @@ private:
 	void preserveInteractiveVideoAudioState();
 	void setupInteractiveVideoState(int32 startFrame);
 	void resolveSeek(const char *filename, int32 startFrame, int32 &videoOffset, int32 &videoStartFrame);
-	void captureInteractiveVideoInput();
+	void captureInteractiveVideoInput(bool preserveInputState);
 	void releaseInteractiveVideoInput();
 	void playInteractiveVideoFile(const char *filename, int32 videoOffset, int32 videoStartFrame);
 	void enableIOSGamepadController();
@@ -555,7 +555,8 @@ private:
 	RebelTouchTapDetector _touchTapDetector;
 
 	// Path branching for levels with left/right alternative videos.
-	static const int32 kPathBranchCounter = 394;
+	static const int32 kLevel1BranchDecisionFrame = 0x182;
+	static const int32 kLevel1BranchCutoverFrame = 0x187;
 	int32 _gameCounter;
 	bool _pathBranchEnabled;
 	bool _rightPathSelected;
diff --git a/engines/scumm/insane/rebel1/render.cpp b/engines/scumm/insane/rebel1/render.cpp
index 957d149ec53..d10ba45825e 100644
--- a/engines/scumm/insane/rebel1/render.cpp
+++ b/engines/scumm/insane/rebel1/render.cpp
@@ -776,6 +776,7 @@ void InsaneRebel1::procPostRendering(byte *renderBitmap, int32 codecparam, int32
 
 	if (_currentLevel == 7) {
 		updateLevel8WalkerState();
+		checkDynamicLevelBranch(curFrame);
 		const int viewportX = _player ? ra1Player()->_ra1ViewportOffsetX : 0;
 		const int viewportY = _player ? ra1Player()->_ra1ViewportOffsetY : 0;
 		renderLevel8Overlay(renderBitmap, pitch, width, height, viewportX, viewportY);
@@ -1855,10 +1856,11 @@ void InsaneRebel1::updateLevel8WalkerState() {
 
 		if (newRoute != 0) {
 			_pendingRouteIndex = newRoute;
-			_pendingRouteCutoverFrame = _currentSmushFrame + 7;
-			// The destination starts at frame 1, advanced by the source tail
-			// already displayed. This also applies when repeating the same route.
-			_pendingRouteStartFrame = 1 + (_pendingRouteCutoverFrame - _currentSmushFrame);
+			// The original makes this choice in the post-frame callback, after
+			// that frame's splice check. It renders one more source frame before
+			// switching, advancing the destination's frame-1 start by that frame.
+			_pendingRouteCutoverFrame = _currentSmushFrame + 1;
+			_pendingRouteStartFrame = 2;
 			debugC(DEBUG_INSANE, "L8 branch: route=%d -> %d at localFrame=%u shipX=%d resumeLocalFrame=%d cutoverFrame=%d",
 				route, newRoute, (unsigned)fc, _shipPosX,
 				(int)_pendingRouteStartFrame, (int)_pendingRouteCutoverFrame);
diff --git a/engines/scumm/insane/rebel1/runlevels.cpp b/engines/scumm/insane/rebel1/runlevels.cpp
index 14ad794e5ec..c9a28805994 100644
--- a/engines/scumm/insane/rebel1/runlevels.cpp
+++ b/engines/scumm/insane/rebel1/runlevels.cpp
@@ -287,10 +287,10 @@ bool InsaneRebel1::runLevel1() {
 		if (shouldAbortGameFlow())
 			return false;
 
-		if (_rightPathSelected && _health >= 0) {
+		if (_rightPathSelected && !_interactiveVideoCheatSkipped && _health >= 0) {
 			_pathBranchEnabled = false;
 			_flyControlMode = 1;
-			playInteractiveVideo("LVL1/L1PLAY1R.ANM", 0x187);
+			playInteractiveVideo("LVL1/L1PLAY1R.ANM", 1);
 			if (shouldAbortGameFlow())
 				return false;
 		}
@@ -1543,19 +1543,20 @@ void InsaneRebel1::resolveSeek(const char *filename, int32 startFrame, int32 &vi
 				_levelRouteIndex, (int)_pendingRouteStartFrame,
 				(int)videoStartFrame, (unsigned)videoOffset);
 		}
-	} else if (_currentLevel == 7 && resumingRoute) {
-		// Walker routes restart at their own local frame, even when branching
-		// back to the same ANM. Their embedded GAME counters are not seek targets.
+	} else if ((_currentLevel == 0 || _currentLevel == 7) && resumingRoute) {
+		// Route continuations use destination-local frames. In L1 this skips
+		// both the overlapping frame and the GAME reset at the start of the
+		// right-hand clip. L8 can also branch back to the same ANM.
 		videoStartFrame = startFrame;
 		videoOffset = findAnimFrameChunkOffset(_vm, filename, videoStartFrame);
 		if (videoOffset < 0) {
-			debugC(DEBUG_INSANE, "L8 resume: route=%d localFrame=%d offset lookup failed",
-				_levelRouteIndex, (int)videoStartFrame);
+			debugC(DEBUG_INSANE, "L%d resume: localFrame=%d offset lookup failed",
+				_currentLevel + 1, (int)videoStartFrame);
 			videoStartFrame = 0;
 			videoOffset = 0;
 		} else {
-			debugC(DEBUG_INSANE, "L8 resume: route=%d localFrame=%d offset=0x%x",
-				_levelRouteIndex, (int)videoStartFrame, (unsigned)videoOffset);
+			debugC(DEBUG_INSANE, "L%d resume: localFrame=%d offset=0x%x",
+				_currentLevel + 1, (int)videoStartFrame, (unsigned)videoOffset);
 		}
 	} else if (_currentLevel == 13 && resumingRoute) {
 		// L14PLY2B is already the continuation clip. Preserve state, but do not seek.
@@ -1564,12 +1565,7 @@ void InsaneRebel1::resolveSeek(const char *filename, int32 startFrame, int32 &vi
 	}
 }
 
-void InsaneRebel1::captureInteractiveVideoInput() {
-	const bool level7RouteSplice = (_currentLevel == 6 && _levelRouteIndex > 0);
-	const bool walkerRouteContinuation = (_currentLevel == 7 &&
-		(_walkerRoundReplay || _pendingRouteStartFrame > 0));
-	const bool preserveInputState = _preserveInteractiveRuntimeState || level7RouteSplice || walkerRouteContinuation;
-
+void InsaneRebel1::captureInteractiveVideoInput(bool preserveInputState) {
 	enableIOSGamepadController();
 
 	// Center mouse, hide system cursor, and lock mouse to window.
@@ -1625,7 +1621,7 @@ void InsaneRebel1::playInteractiveVideo(const char *filename, int32 startFrame)
 		resetInteractiveVideoAudio();
 	setupInteractiveVideoState(startFrame);
 	resolveSeek(filename, startFrame, videoOffset, videoStartFrame);
-	captureInteractiveVideoInput();
+	captureInteractiveVideoInput(preserveRuntimeState);
 	playInteractiveVideoFile(filename, videoOffset, videoStartFrame);
 	releaseInteractiveVideoInput();
 	_preserveInteractiveRuntimeState = false;


Commit: 69dc0313b574cd5d3e94a99009872e2ac35367b4
    https://github.com/scummvm/scummvm/commit/69dc0313b574cd5d3e94a99009872e2ac35367b4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:52+02:00

Commit Message:
FREESCAPE: completed castle UI for C64

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


diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index 7057c05b5b2..6ae46e4dca8 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "common/file.h"
+#include "common/memstream.h"
 #include "graphics/managed_surface.h"
 
 #include "freescape/freescape.h"
@@ -46,6 +47,93 @@ enum {
 	kCastleC64MessageY = 182
 };
 
+// Match the colors of the bundled C64 border. The unused palette entries are
+// black; the HUD uses only these ten VIC colors.
+static const byte kCastleC64UIPalette[16][3] = {
+	{0, 0, 0}, {255, 255, 255}, {0, 0, 0}, {0, 0, 0},
+	{0, 0, 0}, {98, 213, 50}, {0, 0, 0}, {255, 255, 70},
+	{183, 99, 30}, {119, 83, 0}, {0, 0, 0}, {98, 98, 98},
+	{148, 148, 148}, {183, 255, 134}, {0, 0, 0}, {205, 205, 205}
+};
+
+static uint32 castleC64UIColor(const Graphics::PixelFormat &format, byte color) {
+	const byte *rgb = kCastleC64UIPalette[color];
+	return format.ARGBToColor(255, rgb[0], rgb[1], rgb[2]);
+}
+
+static Common::Array<byte> unpackCastleC64UI(Common::SeekableReadStream *file) {
+	// The startup relocates the packed stream from $0d50 to $2708, then
+	// expands it into $0200..$ffff. Decode only through the screen attributes
+	// at $c400..$c7e7: the later bitmap pages require the separate tape loader.
+	// Page flags at $09ff descend through memory, least significant bit first.
+	// A clear bit selects a page with its own escape byte and count/value runs.
+	Common::Array<byte> packed;
+	packed.resize(file->size());
+	file->seek(0);
+	if (packed.size() < 0x551 || file->read(packed.data(), packed.size()) != packed.size())
+		error("Unable to read Castle C64 UI data");
+
+	Common::Array<byte> data;
+	data.resize(0xc800);
+	uint32 source = 0x551; // $0d50, including the PRG load-address adjustment
+	int flagOffset = 0x200; // $09ff
+	byte flags = packed[flagOffset];
+	int bitsLeft = 6; // The first two pages are not part of the packed stream.
+	for (uint page = 2; page < 0xc8; page++) {
+		if (!bitsLeft) {
+			flags = packed[--flagOffset];
+			bitsLeft = 8;
+		}
+		bool raw = flags & 1;
+		flags >>= 1;
+		bitsLeft--;
+		uint end = (page + 1) * 256;
+		if (source >= packed.size())
+			error("Truncated Castle C64 UI page %x", page);
+		byte escape = raw ? 0 : packed[source++];
+		for (uint dest = page * 256; dest < end;) {
+			if (source >= packed.size())
+				error("Truncated Castle C64 UI page %x", page);
+			byte value = packed[source++];
+			uint count = 1;
+			if (!raw && value == escape) {
+				if (source + 2 > packed.size())
+					error("Truncated Castle C64 UI run");
+				count = packed[source++];
+				if (!count)
+					count = 256;
+				value = packed[source++];
+			}
+			if (count > end - dest)
+				error("Castle C64 UI run crosses a page boundary");
+			while (count--)
+				data[dest++] = value;
+		}
+	}
+	return data;
+}
+
+static void loadCastleC64Frame(const Common::Array<byte> &data, uint address, Graphics::ManagedSurface *surface, int frame = 0) {
+	// $7cf6 reads a five-byte header: byte width, height, final-byte mask,
+	// and frame size. These HUD frames all use the whole final byte.
+	if (address + 5 > data.size())
+		error("Missing Castle C64 UI frame header at %x", address);
+	uint width = data[address];
+	uint height = data[address + 1];
+	uint size = data[address + 3] | (data[address + 4] << 8);
+	uint pixels = address + 5 + frame * size;
+	if (!width || !height || data[address + 2] != 0xff || size != width * height || pixels + size > data.size())
+		error("Invalid Castle C64 UI frame at %x", address);
+	surface->create(width * 8, height, Graphics::PixelFormat::createFormatCLUT8());
+	for (uint y = 0; y < height; y++) {
+		for (uint x = 0; x < width * 8; x += 2) {
+			byte color = (data[pixels + y * width + x / 8] >> (6 - x % 8)) & 3;
+			surface->setPixel(x, y, color);
+			surface->setPixel(x + 1, y, color);
+		}
+	}
+}
+
 struct CastleC64Repeat {
 	uint16 offset;
 	byte count;
@@ -72,105 +160,6 @@ const CastleC64Repeat kCastleC64DatabaseRepeats[] = {
 	{ 0x2255, 113, 0x00 }
 };
 
-static const byte kCastleC64FontData[] = {
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x18, 0x00,
-	0x66, 0x66, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0x66, 0x66, 0xff, 0x66, 0xff, 0x66, 0x66, 0x00,
-	0x18, 0x3e, 0x58, 0x3c, 0x1a, 0x7c, 0x18, 0x00,
-	0x62, 0x66, 0x0c, 0x18, 0x30, 0x66, 0x46, 0x00,
-	0x3c, 0x66, 0x3c, 0x38, 0x67, 0x66, 0x3f, 0x00,
-	0x06, 0x0c, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0x0c, 0x18, 0x30, 0x30, 0x30, 0x18, 0x0c, 0x00,
-	0x30, 0x18, 0x0c, 0x0c, 0x0c, 0x18, 0x30, 0x00,
-	0x00, 0x66, 0x3c, 0xff, 0x3c, 0x66, 0x00, 0x00,
-	0x00, 0x18, 0x18, 0x7e, 0x18, 0x18, 0x00, 0x00,
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30,
-	0x00, 0x00, 0x00, 0x7e, 0x00, 0x00, 0x00, 0x00,
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00,
-	0x00, 0x03, 0x06, 0x0c, 0x18, 0x30, 0x60, 0x00,
-	0x3c, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3c, 0x00,
-	0x18, 0x18, 0x38, 0x18, 0x18, 0x18, 0x7e, 0x00,
-	0x3c, 0x66, 0x06, 0x0c, 0x30, 0x60, 0x7e, 0x00,
-	0x3c, 0x66, 0x06, 0x1c, 0x06, 0x66, 0x3c, 0x00,
-	0x06, 0x0e, 0x1e, 0x66, 0x7f, 0x06, 0x06, 0x00,
-	0x7e, 0x60, 0x7c, 0x06, 0x06, 0x66, 0x3c, 0x00,
-	0x3c, 0x66, 0x60, 0x7c, 0x66, 0x66, 0x3c, 0x00,
-	0x7e, 0x66, 0x0c, 0x18, 0x18, 0x18, 0x18, 0x00,
-	0x3c, 0x66, 0x66, 0x3c, 0x66, 0x66, 0x3c, 0x00,
-	0x3c, 0x66, 0x66, 0x3e, 0x06, 0x66, 0x3c, 0x00,
-	0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0x00, 0x00,
-	0x00, 0x00, 0x18, 0x00, 0x00, 0x18, 0x18, 0x30,
-	0x0e, 0x18, 0x30, 0x60, 0x30, 0x18, 0x0e, 0x00,
-	0x00, 0x00, 0x7e, 0x00, 0x7e, 0x00, 0x00, 0x00,
-	0x70, 0x18, 0x0c, 0x06, 0x0c, 0x18, 0x70, 0x00,
-	0x3c, 0x66, 0x06, 0x0c, 0x18, 0x00, 0x18, 0x00,
-	0x3c, 0x66, 0x6e, 0x6e, 0x60, 0x62, 0x3c, 0x00,
-	0x18, 0x3c, 0x66, 0x7e, 0x66, 0x66, 0x66, 0x00,
-	0x7c, 0x66, 0x66, 0x7c, 0x66, 0x66, 0x7c, 0x00,
-	0x3c, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3c, 0x00,
-	0x78, 0x6c, 0x66, 0x66, 0x66, 0x6c, 0x78, 0x00,
-	0x7e, 0x60, 0x60, 0x78, 0x60, 0x60, 0x7e, 0x00,
-	0x7e, 0x60, 0x60, 0x78, 0x60, 0x60, 0x60, 0x00,
-	0x3c, 0x66, 0x60, 0x6e, 0x66, 0x66, 0x3c, 0x00,
-	0x66, 0x66, 0x66, 0x7e, 0x66, 0x66, 0x66, 0x00,
-	0x3c, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3c, 0x00,
-	0x1e, 0x0c, 0x0c, 0x0c, 0x0c, 0x6c, 0x38, 0x00,
-	0x66, 0x6c, 0x78, 0x70, 0x78, 0x6c, 0x66, 0x00,
-	0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x7e, 0x00,
-	0x63, 0x77, 0x7f, 0x6b, 0x63, 0x63, 0x63, 0x00,
-	0x66, 0x76, 0x7e, 0x7e, 0x6e, 0x66, 0x66, 0x00,
-	0x3c, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3c, 0x00,
-	0x7c, 0x66, 0x66, 0x7c, 0x60, 0x60, 0x60, 0x00,
-	0x3c, 0x66, 0x66, 0x66, 0x66, 0x3c, 0x0e, 0x00,
-	0x7c, 0x66, 0x66, 0x7c, 0x78, 0x6c, 0x66, 0x00,
-	0x3c, 0x66, 0x60, 0x3c, 0x06, 0x66, 0x3c, 0x00,
-	0x7e, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00,
-	0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3c, 0x00,
-	0x66, 0x66, 0x66, 0x66, 0x66, 0x3c, 0x18, 0x00,
-	0x63, 0x63, 0x63, 0x6b, 0x7f, 0x77, 0x63, 0x00,
-	0x66, 0x66, 0x3c, 0x18, 0x3c, 0x66, 0x66, 0x00,
-	0x66, 0x66, 0x66, 0x3c, 0x18, 0x18, 0x18, 0x00,
-	0x7e, 0x06, 0x0c, 0x18, 0x30, 0x60, 0x7e, 0x00,
-	0x3c, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3c, 0x00,
-	0x00, 0x60, 0x30, 0x18, 0x0c, 0x06, 0x03, 0x00,
-	0x3c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x3c, 0x00,
-	0x18, 0x3c, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00,
-	0x18, 0x0c, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0x00, 0x00, 0x3c, 0x06, 0x3e, 0x66, 0x3e, 0x00,
-	0x00, 0x60, 0x60, 0x7c, 0x66, 0x66, 0x7c, 0x00,
-	0x00, 0x00, 0x3c, 0x60, 0x60, 0x60, 0x3c, 0x00,
-	0x00, 0x06, 0x06, 0x3e, 0x66, 0x66, 0x3e, 0x00,
-	0x00, 0x00, 0x3c, 0x66, 0x7e, 0x60, 0x3c, 0x00,
-	0x00, 0x0e, 0x18, 0x3e, 0x18, 0x18, 0x18, 0x00,
-	0x00, 0x00, 0x3e, 0x66, 0x66, 0x3e, 0x06, 0x7c,
-	0x00, 0x60, 0x60, 0x7c, 0x66, 0x66, 0x66, 0x00,
-	0x00, 0x18, 0x00, 0x38, 0x18, 0x18, 0x3c, 0x00,
-	0x00, 0x06, 0x00, 0x06, 0x06, 0x06, 0x06, 0x3c,
-	0x00, 0x60, 0x60, 0x6c, 0x78, 0x6c, 0x66, 0x00,
-	0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x3c, 0x00,
-	0x00, 0x00, 0x66, 0x7f, 0x7f, 0x6b, 0x63, 0x00,
-	0x00, 0x00, 0x7c, 0x66, 0x66, 0x66, 0x66, 0x00,
-	0x00, 0x00, 0x3c, 0x66, 0x66, 0x66, 0x3c, 0x00,
-	0x00, 0x00, 0x7c, 0x66, 0x66, 0x7c, 0x60, 0x60,
-	0x00, 0x00, 0x3e, 0x66, 0x66, 0x3e, 0x06, 0x06,
-	0x00, 0x00, 0x7c, 0x66, 0x60, 0x60, 0x60, 0x00,
-	0x00, 0x00, 0x3e, 0x60, 0x3c, 0x06, 0x7c, 0x00,
-	0x00, 0x18, 0x7e, 0x18, 0x18, 0x18, 0x0e, 0x00,
-	0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x3e, 0x00,
-	0x00, 0x00, 0x66, 0x66, 0x66, 0x3c, 0x18, 0x00,
-	0x00, 0x00, 0x63, 0x6b, 0x7f, 0x3e, 0x36, 0x00,
-	0x00, 0x00, 0x66, 0x3c, 0x18, 0x3c, 0x66, 0x00,
-	0x00, 0x00, 0x66, 0x66, 0x66, 0x3e, 0x0c, 0x78,
-	0x00, 0x00, 0x7e, 0x0c, 0x18, 0x30, 0x7e, 0x00,
-	0x0e, 0x18, 0x18, 0x70, 0x18, 0x18, 0x0e, 0x00,
-	0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00,
-	0x70, 0x18, 0x18, 0x0e, 0x18, 0x18, 0x70, 0x00,
-	0x31, 0x6b, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00,
-	0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
-};
-
 uint16 readCastleC64Uint16LE(const Common::Array<byte> &data, uint32 offset) {
 	if (offset + 1 >= data.size())
 		error("Castle C64 database pointer read out of range at 0x%x", offset);
@@ -232,25 +221,30 @@ Common::Array<byte> normalizeCastleC64Database(Common::SeekableReadStream *file)
 	return decoded;
 }
 
-Common::Array<Graphics::ManagedSurface *> loadCastleC64Font() {
+static Common::Array<Graphics::ManagedSurface *> loadCastleC64Font(const Common::Array<byte> &data) {
 	Common::Array<Graphics::ManagedSurface *> chars;
 
-	for (uint chr = 0; chr < ARRAYSIZE(kCastleC64FontData) / 8; chr++) {
+	// $85eb expands four packed bytes per character into eight rows of
+	// double-width pixels. Row 1 uses color RAM for the highlight.
+	for (uint chr = 32; chr < 128; chr++) {
 		Graphics::ManagedSurface *surface = new Graphics::ManagedSurface();
-		surface->create(8, 8, Graphics::PixelFormat::createFormatCLUT8());
+		surface->create(10, 8, Graphics::PixelFormat::createFormatCLUT8());
 		surface->clear(0);
-
-		for (int y = 0; y < 8; y++) {
-			byte row = kCastleC64FontData[chr * 8 + y];
-			for (int x = 0; x < 8; x++) {
-				if (row & (0x80 >> x))
-					surface->setPixel(x, y, 1);
+		uint glyph = (chr >= 'a' && chr <= 'z') ? chr - 'a' + 'A' : chr;
+		if (glyph <= 'Z') {
+			for (int y = 0; y < 8; y++) {
+				byte row = data[0x231a + (glyph - 32) * 4 + y / 2];
+				row = (y & 1) ? row & 15 : row >> 4;
+				for (int x = 0; x < 4; x++) {
+					if (row & (8 >> x)) {
+						surface->setPixel(x * 2, y, y == 1 ? 2 : 1);
+						surface->setPixel(x * 2 + 1, y, y == 1 ? 2 : 1);
+					}
+				}
 			}
 		}
-
 		chars.push_back(surface);
 	}
-
 	return chars;
 }
 
@@ -359,8 +353,6 @@ void CastleEngine::initC64() {
 	_viewArea = Common::Rect(40, 32, 280, 152);
 }
 
-extern byte kC64Palette[16][3];
-
 void CastleEngine::loadMessagesC64(Common::SeekableReadStream *file, int offset, int number) {
 	file->seek(offset);
 	debugC(1, kFreescapeDebugParser, "String table:");
@@ -389,32 +381,37 @@ void CastleEngine::loadRiddlesC64(Common::SeekableReadStream *file, int offset,
 
 	for (int i = 0; i < number; i++) {
 		Riddle riddle;
-		riddle._origin = Common::Point(40, 33);
+		riddle._origin = Common::Point(40, 32);
+		int previousInset = 0;
 
 		int numberLines = file->readByte();
 		debugC(1, kFreescapeDebugParser, "c64 riddle %d number of lines: %d", i, numberLines);
 
 		for (int j = 0; j < numberLines; j++) {
-			int8 x = (int8)file->readByte();
+			int8 x = 2 * (int8)file->readByte(); // C64 horizontal offsets count pixel pairs.
 			int8 y = (int8)file->readByte();
 			int size = file->readByte();
 
-			if (size == 0xff)
+			// $6dd6 applies both deltas to every record, including the rows
+			// of asterisks. Discarding those rows also loses the text origin.
+			if (size == 0xff) {
+				riddle._lines.push_back(RiddleText(x + 6 - previousInset, y, "********************"));
+				previousInset = 6;
 				continue;
+			}
 
-			file->readByte(); // color/control byte
+			int inset = file->readByte() ? 6 : 0;
+			if (inset)
+				size--;
+			if (size < 0 || file->pos() + size > file->size())
+				error("Truncated Castle C64 riddle %d", i);
 			Common::String message;
-			int chars = 0;
-			while (chars < size) {
-				byte c = file->readByte();
-				if (c <= 1 || c < 0x20 || c > 0xf0)
-					continue;
-				message += c;
-				chars++;
-			}
+			for (int chars = 0; chars < size; chars++)
+				message += file->readByte();
 
 			debugC(1, kFreescapeDebugParser, "'%s' with offset: %d, %d", message.c_str(), x, y);
-			riddle._lines.push_back(RiddleText(x, y, message));
+			riddle._lines.push_back(RiddleText(x + inset - previousInset, y, message));
+			previousInset = inset;
 		}
 
 		_riddleList.push_back(riddle);
@@ -430,16 +427,87 @@ void CastleEngine::loadAssetsC64FullGame() {
 	if (!file.isOpen())
 		error("Failed to open castlemaster.c64.data");
 
-	// The original tape loader preloads display support into high RAM before
-	// this main program image starts; castlemaster.c64.data has no standalone
-	// font block like the other C64 Freescape games.
-	Common::Array<Graphics::ManagedSurface *> chars = loadCastleC64Font();
+	Common::Array<byte> uiData = unpackCastleC64UI(&file);
+	Common::MemoryReadStream uiStream(uiData.data(), uiData.size());
+	Common::Array<Graphics::ManagedSurface *> chars = loadCastleC64Font(uiData);
 	_font = Font(chars);
-	_font.setCharWidth(8);
+	_font.setCharWidth(10);
+	_font.setSecondaryColor(castleC64UIColor(_gfx->_texturePixelFormat, 15));
 	_fontLoaded = true;
 
-	loadMessagesC64(&file, 0x13a9, 75);
-	loadRiddlesC64(&file, 0x1823, 9);
+	loadMessagesC64(&uiStream, 0x1401, 75);
+	loadRiddlesC64(&uiStream, 0x18ae, 9);
+
+	// Preserve multicolor pixel indices until drawing. VIC colors depend on
+	// the destination 8x8 cell, even within a single moving weight or key.
+	file.seek(0x301); // Packed color RAM at $0b00, high nibble first.
+	_c64UIColors.resize(1000 * 4);
+	for (int cell = 0; cell < 1000; cell += 2) {
+		byte colorRAM = file.readByte();
+		for (int i = 0; i < 2; i++) {
+			byte screen = uiData[0xc400 + cell + i];
+			byte colors[4] = {0, byte(screen >> 4), byte(screen & 15), byte(i ? colorRAM & 15 : colorRAM >> 4)};
+			for (int color = 0; color < 4; color++)
+				_c64UIColors[(cell + i) * 4 + color] = castleC64UIColor(_gfx->_texturePixelFormat, colors[color]);
+		}
+	}
+
+	loadCastleC64Frame(uiData, 0x1dd9, &_c64KeysBackground);
+	_keysBorderFrames.push_back(new Graphics::ManagedSurface());
+	loadCastleC64Frame(uiData, 0x1e32, _keysBorderFrames[0]);
+	_spiritsMeterIndicatorBackgroundFrame = new Graphics::ManagedSurface();
+	loadCastleC64Frame(uiData, 0x1e45, _spiritsMeterIndicatorBackgroundFrame);
+	_spiritsMeterIndicatorFrame = new Graphics::ManagedSurface();
+	loadCastleC64Frame(uiData, 0x1e8a, _spiritsMeterIndicatorFrame);
+	_strenghtBackgroundFrame = new Graphics::ManagedSurface();
+	loadCastleC64Frame(uiData, 0x1e9f, _strenghtBackgroundFrame);
+	_strenghtBarFrame = new Graphics::ManagedSurface();
+	loadCastleC64Frame(uiData, 0x1f49, _strenghtBarFrame);
+	for (int frame = 0; frame < 4; frame++) {
+		_strenghtWeightsFrames.push_back(new Graphics::ManagedSurface());
+		loadCastleC64Frame(uiData, 0x1f6f, _strenghtWeightsFrames[frame], frame);
+	}
+
+	// $6ee7 stretches seven three-byte rows across the riddle board. The
+	// bottom reverses the top six rows; row six fills the middle of the board.
+	Graphics::ManagedSurface *riddleFrames[3];
+	const byte riddleColors[4] = {0, 9, 7, 9};
+	for (int frame = 0; frame < 3; frame++) {
+		riddleFrames[frame] = new Graphics::ManagedSurface();
+		int height = frame == 1 ? 1 : 6;
+		riddleFrames[frame]->create(240, height, _gfx->_texturePixelFormat);
+		for (int y = 0; y < height; y++) {
+			int row = frame == 0 ? y : (frame == 1 ? 6 : 5 - y);
+			for (int x = 0; x < 240; x += 2) {
+				int column = x < 8 ? 0 : (x >= 232 ? 2 : 1);
+				byte pixels = uiData[0x2023 + row * 3 + column];
+				byte color = riddleColors[(pixels >> (6 - x % 8)) & 3];
+				uint32 pixel = castleC64UIColor(_gfx->_texturePixelFormat, color);
+				riddleFrames[frame]->setPixel(x, y, pixel);
+				riddleFrames[frame]->setPixel(x + 1, y, pixel);
+			}
+		}
+	}
+	_riddleTopFrame = riddleFrames[0];
+	_riddleBackgroundFrame = riddleFrames[1];
+	_riddleBottomFrame = riddleFrames[2];
+
+	// The IRQ at $74be advances the flag every eight PAL ticks. Its frames
+	// are six bitmap cells in C64 cell order, copied to $e128 and $e268.
+	for (int frame = 0; frame < 4; frame++) {
+		Graphics::ManagedSurface *flag = new Graphics::ManagedSurface();
+		flag->create(24, 16, Graphics::PixelFormat::createFormatCLUT8());
+		for (int y = 0; y < 16; y++) {
+			for (int x = 0; x < 24; x += 2) {
+				byte pixels = uiData[0x2038 + frame * 48 + (y / 8) * 24 + (x / 8) * 8 + y % 8];
+				byte color = (pixels >> (6 - x % 8)) & 3;
+				flag->setPixel(x, y, color);
+				flag->setPixel(x + 1, y, color);
+			}
+		}
+		_flagFrames.push_back(flag);
+	}
+
 	Common::Array<byte> database = normalizeCastleC64Database(&file);
 	CastleC64DatabaseReadStream databaseStream(database);
 	load8bitBinary(&databaseStream, 0, 16);
@@ -485,12 +553,82 @@ void CastleEngine::loadAssetsC64FullGame() {
 	// TODO: title screen is in BASIC loader (file 009) - not yet extracted
 }
 
-void CastleEngine::drawC64UI(Graphics::Surface *surface) {
-	uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0x62, 0xD5, 0x32);
+void CastleEngine::drawC64HudSurface(Graphics::Surface *surface, const Graphics::Surface &frame, const Common::Point &origin) {
+	for (int y = 0; y < frame.h; y++) {
+		const byte *src = (const byte *)frame.getBasePtr(0, y);
+		for (int x = 0; x < frame.w; x++) {
+			int cell = ((origin.y + y) / 8) * 40 + (origin.x + x) / 8;
+			surface->setPixel(origin.x + x, origin.y + y, _c64UIColors[cell * 4 + src[x]]);
+		}
+	}
+}
+
+void CastleEngine::drawC64InfoMenu(Graphics::Surface *surface) {
+	uint32 front = castleC64UIColor(surface->format, 9);
+	uint32 highlight = castleC64UIColor(surface->format, 15);
+	uint32 back = castleC64UIColor(surface->format, 0);
+	surface->fillRect(_viewArea, back);
+	Common::String keys = _messagesList[72];
+	Common::String spirits = _messagesList[73];
+	Common::String score = _messagesList[74];
+	Common::replace(keys, "XX", Common::String::format("%2d", MIN<uint>(_keysCollected.size(), 10)));
+	Common::replace(spirits, "XX", Common::String::format("%2d", _gameStateVars[k8bitVariableSpiritsDestroyed]));
+	Common::replace(score, "XXXXXXX", Common::String::format("%07d", _gameStateVars[k8bitVariableScore]));
+
+	// The original menu at $7808 uses these rows within the 3D viewport.
+	drawStringInSurface("********************", 60, 46, front, highlight, back, surface);
+	drawStringInSurface(_messagesList[68], 50, 61, front, highlight, back, surface);
+	drawStringInSurface(_messagesList[69], 50, 82, front, highlight, back, surface);
+	drawStringInSurface(keys, 130, 82, front, highlight, back, surface);
+	drawStringInSurface(_messagesList[70], 50, 93, front, highlight, back, surface);
+	drawStringInSurface(spirits, 130, 93, front, highlight, back, surface);
+	drawStringInSurface(_messagesList[71], 50, 104, front, highlight, back, surface);
+	int strength = CLIP<int>(_gameStateVars[k8bitVariableShield], 1, 24);
+	drawStringInSurface(_messagesList[62 + (strength - 1) / 4], 150, 104, front, highlight, back, surface);
+	drawStringInSurface(score, 80, 115, front, highlight, back, surface);
+	drawStringInSurface("********************", 60, 133, front, highlight, back, surface);
+}
 
-	uint8 r, g, b;
-	_gfx->readFromPalette(0, r, g, b);
-	uint32 back = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
+void CastleEngine::drawC64UI(Graphics::Surface *surface) {
+	uint32 front = castleC64UIColor(surface->format, 5);
+	uint32 back = castleC64UIColor(surface->format, 0);
+	_font.setSecondaryColor(castleC64UIColor(surface->format, 15));
+
+	// $702c rebuilds the key rack on a full redraw, including after loading.
+	// Composing it afresh also removes keys when restarting or loading a save.
+	Graphics::ManagedSurface buffer(96, 19, Graphics::PixelFormat::createFormatCLUT8());
+	buffer.copyRectToSurface(_c64KeysBackground, 0, 0, Common::Rect(48, 14));
+	for (uint key = 0; key < MIN<uint>(_keysCollected.size(), 10); key++)
+		buffer.copyRectToSurfaceWithKey(*_keysBorderFrames[0], 42 - 4 * key, 0, Common::Rect(8, 14), 0);
+	drawC64HudSurface(surface, buffer.getSubArea(Common::Rect(48, 14)), Common::Point(48, 179));
+
+	// $7171 draws paired discs from the outside inward, four pixels apart.
+	// A partial disc precedes the full discs; strength below four lowers the
+	// bar and both discs. Only the original 88x15 window is copied to the HUD.
+	buffer.copyRectToSurface(*_strenghtBackgroundFrame, 0, 0, Common::Rect(88, 15));
+	int strength = CLIP<int>(_gameStateVars[k8bitVariableShield], 0, 24);
+	int drop = MAX(0, 4 - strength);
+	buffer.copyRectToSurface(*_strenghtBarFrame, 6, 6 + drop, Common::Rect(88, 3));
+	int pairs = (strength + 3) / 4;
+	for (int pair = 0; pair < pairs; pair++) {
+		int frame = (pair == 0 && strength % 4) ? 4 - strength % 4 : 0;
+		buffer.copyRectToSurfaceWithKey(*_strenghtWeightsFrames[frame], 8 + pair * 4, drop, Common::Rect(8, 15), 0);
+		buffer.copyRectToSurfaceWithKey(*_strenghtWeightsFrames[frame], 80 - pair * 4, drop, Common::Rect(8, 15), 0);
+	}
+	drawC64HudSurface(surface, buffer.getSubArea(Common::Rect(88, 15)), Common::Point(40, 158));
+
+	// $726a rounds the spirit position up to a pixel pair, then clips the
+	// moving face to the middle eight bitmap cells. Derive it from saved
+	// state here so a loaded game does not display the previous position.
+	int spiritsDestroyed = CLIP<int>(_gameStateVars[k8bitVariableSpiritsDestroyed], 0, _spiritsToKill);
+	int position = CLIP<int>(_spiritsMeter * (_spiritsToKill - spiritsDestroyed) / _spiritsToKill, 0, 64);
+	buffer.copyRectToSurface(*_spiritsMeterIndicatorBackgroundFrame, 8, 0, Common::Rect(64, 8));
+	buffer.copyRectToSurfaceWithKey(*_spiritsMeterIndicatorFrame, (position + 1) & ~1, 0, Common::Rect(16, 8), 0);
+	drawC64HudSurface(surface, buffer.getSubArea(Common::Rect(8, 0, 72, 8)), Common::Point(160, 161));
+
+	int flagFrame = (g_system->getMillis() / 160) % 4;
+	drawC64HudSurface(surface, *_flagFrames[flagFrame], Common::Point(296, 0));
+	// TODO: animate the eye indicator using the frames at $1fb0.
 
 	// The original loader leaves "CASTLE MASTER" in the bottom message strip.
 	// Clear the whole writable part of that strip before drawing runtime text.
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 3b66a2de526..d8df1748037 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -1164,6 +1164,8 @@ void CastleEngine::drawInfoMenu() {
 				surface->copyRectToSurfaceWithKey((const Graphics::Surface)*sndIndicator, 96, 103,
 					Common::Rect(0, 0, sndIndicator->w, sndIndicator->h), black);
 		}
+	} else if (isC64()) {
+		drawC64InfoMenu(surface);
 	} else if (isSpectrum() || isCPC()) {
 		Common::Array<Common::String> lines;
 		lines.push_back(centerAndPadString("********************", 21));
@@ -1252,6 +1254,10 @@ void CastleEngine::drawInfoMenu() {
 
 					loadGameDialog();
 					_eventManager->purgeMouseEvents();
+					if (isC64()) {
+						drawC64InfoMenu(surface);
+						menuTexture->update(surface);
+					}
 					if (isDOS() || isAmiga() || isAtariST()) {
 						g_system->lockMouse(false);
 						CursorMan.showMouse(true);
@@ -1295,7 +1301,7 @@ void CastleEngine::drawInfoMenu() {
 			case Common::EVENT_RBUTTONDOWN:
 			// fallthrough
 			case Common::EVENT_LBUTTONDOWN:
-				if (isSpectrum() || isCPC())
+				if (isSpectrum() || isCPC() || isC64())
 					break;
 
 				mousePos = getNormalizedPosition(event.mouse);
@@ -1470,7 +1476,10 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	Common::String keysCollectedString;
 	if (isDOS())
 		keysCollectedString = _messagesList[130];
-	else if (isSpectrum()) {
+	else if (isC64()) {
+		keysCollectedString = _messagesList[72];
+		Common::replace(keysCollectedString, "XX", "X");
+	} else if (isSpectrum()) {
 		if (_language == Common::EN_ANY)
 			keysCollectedString = "X COLLECTED";
 		else if (_language == Common::ES_ESP)
@@ -1488,6 +1497,8 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	Common::String scoreString;
 	if (isDOS())
 		scoreString = _messagesList[131];
+	else if (isC64())
+		scoreString = _messagesList[74];
 	else if (isSpectrum() || isCPC()) {
 		if (_language == Common::EN_ANY)
 			scoreString = "SCORE XXXXXXX";
@@ -1503,7 +1514,10 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	Common::String spiritsDestroyedString;
 	if (isDOS())
 		spiritsDestroyedString = _messagesList[133];
-	else if (isSpectrum() || isCPC()) {
+	else if (isC64()) {
+		spiritsDestroyedString = _messagesList[73];
+		Common::replace(spiritsDestroyedString, "XX", "X");
+	} else if (isSpectrum() || isCPC()) {
 		if (_language == Common::EN_ANY)
 			spiritsDestroyedString = "X DESTROYED";
 		else if (_language == Common::ES_ESP)
@@ -1823,6 +1837,8 @@ void CastleEngine::drawFullscreenRiddleAndWait(uint16 riddle) {
 	uint32 front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
 	if (isAmiga())
 		front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 0xEE, 0xAA, 0x00);
+	else if (isC64())
+		front = _gfx->_texturePixelFormat.ARGBToColor(0xFF, 119, 83, 0);
 	uint32 transparent = _gfx->_texturePixelFormat.ARGBToColor(0x00, 0x00, 0x00, 0x00);
 
 	Graphics::Surface *surface = new Graphics::Surface();
@@ -1887,6 +1903,10 @@ void CastleEngine::drawRiddle(uint16 riddle, uint32 front, uint32 back, Graphics
 		x = 40;
 		y = 46;
 		maxWidth = 139;
+	} else if (isC64()) {
+		x = 40;
+		y = 45;
+		maxWidth = 137;
 	} else if (isSpectrum()) {
 		x = 64;
 		y = 37;
@@ -1929,8 +1949,9 @@ void CastleEngine::drawRiddle(uint16 riddle, uint32 front, uint32 back, Graphics
 		}
 	}
 	if (_riddleBottomFrame) {
-		Common::Rect srcRect(0, 0, _riddleBottomFrame->w, _riddleBottomFrame->h - 1);
-		Common::Rect destRect(x, maxWidth, x + _riddleBottomFrame->w, maxWidth + _riddleBottomFrame->h - 1);
+		int height = _riddleBottomFrame->h - (isC64() ? 0 : 1);
+		Common::Rect srcRect(0, 0, _riddleBottomFrame->w, height);
+		Common::Rect destRect(x, maxWidth, x + _riddleBottomFrame->w, maxWidth + height);
 		destRect.clip(_viewArea);
 		srcRect = Common::Rect(destRect.left - x, destRect.top - maxWidth, destRect.right - x, destRect.bottom - maxWidth);
 		if (srcRect.isValidRect() && !srcRect.isEmpty())
@@ -1974,6 +1995,8 @@ void CastleEngine::drawRiddleStringInSurface(const Common::String &str, int x, i
 		_fontRiddle.drawString(surface, ustr, x, y, _screenW, fontColor);
 	} else {
 		_font.setBackground(backColor);
+		if (isC64())
+			_font.setSecondaryColor(fontColor);
 		_font.drawString(surface, ustr, x, y, _screenW, fontColor);
 	}
 }
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index dedc4dc5bb6..ed17e58bf09 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -105,6 +105,10 @@ public:
 
 	void loadAssetsC64FullGame() override;
 	void drawC64UI(Graphics::Surface *surface) override;
+	void drawC64InfoMenu(Graphics::Surface *surface);
+	void drawC64HudSurface(Graphics::Surface *surface, const Graphics::Surface &frame, const Common::Point &origin);
+	Graphics::ManagedSurface _c64KeysBackground;
+	Common::Array<uint32> _c64UIColors;
 
 	void drawDOSUI(Graphics::Surface *surface) override;
 	void drawZXUI(Graphics::Surface *surface) override;


Commit: 7ca4207a12c18ebbea33f74e3a4e67dd0ec31e52
    https://github.com/scummvm/scummvm/commit/7ca4207a12c18ebbea33f74e3a4e67dd0ec31e52
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:52+02:00

Commit Message:
FREESCAPE: added gate parsing/rendering for castle c64

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


diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index 6ae46e4dca8..06484de979a 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -44,7 +44,10 @@ enum {
 	kCastleC64MessageLeft = 118,
 	kCastleC64MessageRight = 280,
 	kCastleC64MessageX = 120,
-	kCastleC64MessageY = 182
+	kCastleC64MessageY = 182,
+	kCastleC64GateFrameTicks = 2,
+	kCastleC64GateLiftStep = 2,
+	kCastleC64GateTransparent = 4
 };
 
 // Match the colors of the bundled C64 border. The unused palette entries are
@@ -351,6 +354,7 @@ private:
 
 void CastleEngine::initC64() {
 	_viewArea = Common::Rect(40, 32, 280, 152);
+	_c64LiftingGateStartTicks = -1;
 }
 
 void CastleEngine::loadMessagesC64(Common::SeekableReadStream *file, int offset, int number) {
@@ -468,6 +472,36 @@ void CastleEngine::loadAssetsC64FullGame() {
 		loadCastleC64Frame(uiData, 0x1f6f, _strenghtWeightsFrames[frame], frame);
 	}
 
+	// $8367 draws ten 24-pixel columns. Four-row crossbars repeat every
+	// 24 rows, with fifteen rows of vertical bars below the lowest crossbar.
+	// Crossbars overwrite all pixels, including pen 0; between them only
+	// the leftmost and rightmost pixel pairs cover the scene ($84a4).
+	_c64Gate.create(240, 120, Graphics::PixelFormat::createFormatCLUT8());
+	_c64Gate.fillRect(Common::Rect(240, 120), kCastleC64GateTransparent);
+	for (int y = 0; y < _c64Gate.h; y++) {
+		int fromBottom = _c64Gate.h - 1 - y;
+		if (fromBottom < 15 || (fromBottom - 15) % 24 >= 4) {
+			for (int x = 0; x < _c64Gate.w; x += 24) {
+				_c64Gate.fillRect(Common::Rect(x, y, x + 2, y + 1), 1);
+				_c64Gate.fillRect(Common::Rect(x + 22, y, x + 24, y + 1), 3);
+			}
+			continue;
+		}
+		int row = 3 - (fromBottom - 15) % 24;
+		for (int x = 0; x < _c64Gate.w; x += 2) {
+			byte pixels = uiData[0x82d7 + row * 3 + (x / 8) % 3];
+			byte color = (pixels >> (6 - x % 8)) & 3;
+			_c64Gate.setPixel(x, y, color);
+			_c64Gate.setPixel(x + 1, y, color);
+		}
+	}
+
+	// $82e3 uses twenty heights, followed by $ff, to accelerate the fall
+	// and bounce twice after landing. Each step waits for two timer ticks.
+	_c64GateDropHeights.clear();
+	for (int frame = 0; frame < 20; frame++)
+		_c64GateDropHeights.push_back(uiData[0x82c2 + frame]);
+
 	// $6ee7 stretches seven three-byte rows across the riddle board. The
 	// bottom reverses the top six rows; row six fills the middle of the board.
 	Graphics::ManagedSurface *riddleFrames[3];
@@ -589,7 +623,56 @@ void CastleEngine::drawC64InfoMenu(Graphics::Surface *surface) {
 	drawStringInSurface("********************", 60, 133, front, highlight, back, surface);
 }
 
+void CastleEngine::liftC64Gate() {
+	// $8347 raises the gate by two rows per step. Start the clock after the
+	// initial area is ready so its setup does not consume animation time.
+	_c64LiftingGateStartTicks = _ticks;
+	waitInLoop(_c64Gate.h / kCastleC64GateLiftStep * kCastleC64GateFrameTicks);
+	_c64LiftingGateStartTicks = -1;
+}
+
+void CastleEngine::dropC64Gate() {
+	// $49bb completes the fall before polling for a restart. The wait loop
+	// consumes pending gameplay input while still allowing the user to quit.
+	_droppingGateStartTicks = _ticks;
+	waitInLoop(_c64GateDropHeights.size() * kCastleC64GateFrameTicks);
+}
+
+void CastleEngine::drawC64Gate(Graphics::Surface *surface) {
+	int height;
+	if ((_gameStateControl == kFreescapeGameStateStart || _gameStateControl == kFreescapeGameStateRestart) && _c64LiftingGateStartTicks >= 0) {
+		int ticks = MAX(0, _ticks - _c64LiftingGateStartTicks);
+		height = MAX(0, _c64Gate.h - (ticks / kCastleC64GateFrameTicks) * kCastleC64GateLiftStep);
+	} else if (_gameStateControl == kFreescapeGameStateEnd && _droppingGateStartTicks >= 0 && !hasEscaped()) {
+		int ticks = MAX(0, _ticks - _droppingGateStartTicks);
+		int frame = MIN<int>(ticks / kCastleC64GateFrameTicks, _c64GateDropHeights.size() - 1);
+		height = _c64GateDropHeights[frame];
+	} else {
+		return;
+	}
+	if (!height)
+		return;
+
+	// The gate is drawn into the viewport bitmap and uses its current VIC
+	// colors, unlike the HUD frames whose colors come from the static border.
+	uint32 colors[4];
+	for (int color = 0; color < 4; color++) {
+		uint8 r, g, b;
+		_gfx->selectColorFromFourColorPalette(color, r, g, b);
+		colors[color] = surface->format.ARGBToColor(255, r, g, b);
+	}
+	for (int y = 0; y < height; y++) {
+		const byte *src = (const byte *)_c64Gate.getBasePtr(0, _c64Gate.h - height + y);
+		for (int x = 0; x < _c64Gate.w; x++) {
+			if (src[x] != kCastleC64GateTransparent)
+				surface->setPixel(_viewArea.left + x, _viewArea.top + y, colors[src[x]]);
+		}
+	}
+}
+
 void CastleEngine::drawC64UI(Graphics::Surface *surface) {
+	drawC64Gate(surface);
+
 	uint32 front = castleC64UIColor(surface->format, 5);
 	uint32 back = castleC64UIColor(surface->format, 0);
 	_font.setSecondaryColor(castleC64UIColor(surface->format, 15));
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index d8df1748037..9747f75b330 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -610,6 +610,8 @@ void CastleEngine::beforeStarting() {
 		waitInLoop(250);
 	else if (isSpectrum() || isCPC())
 		waitInLoop(100);
+	else if (isC64())
+		liftC64Gate();
 	else if (isAmiga() || isAtariST())
 		waitInLoop(250);
 }
@@ -799,7 +801,7 @@ void CastleEngine::initGameState() {
 	_lastMinute = minutes;
 	_lastTenSeconds = seconds / 10;
 
-	_droppingGateStartTicks = 0;
+	_droppingGateStartTicks = isC64() ? -1 : 0;
 	_thunderFrameDuration = 0;
 
 	if (_playerMusic)
@@ -1534,6 +1536,8 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 		// TODO: playSound(X, false);
 	} else if (isSpectrum() || isCPC()) {
 		playSound(9, false);
+	} else if (isC64() && !hasEscaped()) {
+		dropC64Gate();
 	}
 
 	if (!isDOS() && hasEscaped()) {
@@ -2592,6 +2596,11 @@ Common::Error CastleEngine::saveGameStreamExtended(Common::WriteStream *stream,
 }
 
 Common::Error CastleEngine::loadGameStreamExtended(Common::SeekableReadStream *stream) {
+	if (isC64()) {
+		_c64LiftingGateStartTicks = -1;
+		_droppingGateStartTicks = -1;
+	}
+
 	_keysCollected.clear();
 	int numberKeys = stream->readUint32LE();
 	for (int i = 0; i < numberKeys; i++) {
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index ed17e58bf09..99525ffcac3 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -107,8 +107,14 @@ public:
 	void drawC64UI(Graphics::Surface *surface) override;
 	void drawC64InfoMenu(Graphics::Surface *surface);
 	void drawC64HudSurface(Graphics::Surface *surface, const Graphics::Surface &frame, const Common::Point &origin);
+	void liftC64Gate();
+	void dropC64Gate();
+	void drawC64Gate(Graphics::Surface *surface);
 	Graphics::ManagedSurface _c64KeysBackground;
+	Graphics::ManagedSurface _c64Gate;
 	Common::Array<uint32> _c64UIColors;
+	Common::Array<byte> _c64GateDropHeights;
+	int _c64LiftingGateStartTicks;
 
 	void drawDOSUI(Graphics::Surface *surface) override;
 	void drawZXUI(Graphics::Surface *surface) override;


Commit: 217eb9e715a42834f68f247a60508cf35c0e7821
    https://github.com/scummvm/scummvm/commit/217eb9e715a42834f68f247a60508cf35c0e7821
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:52+02:00

Commit Message:
FREESCAPE: refactored music and added sound effects for castle c64

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


diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index 06484de979a..383ccb26a00 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -355,6 +355,18 @@ private:
 void CastleEngine::initC64() {
 	_viewArea = Common::Rect(40, 32, 280, 152);
 	_c64LiftingGateStartTicks = -1;
+	_c64MusicEnabled = true;
+
+	// C64 call sites: throw $63b3, climb/drop $53cf/$549f, area change
+	// $6cde, and a damaging landing $8142. The gate supplies the start sound.
+	_soundIndexShoot = 5;
+	_soundIndexCollide = 3;
+	_soundIndexStepUp = 12;
+	_soundIndexStepDown = 12;
+	_soundIndexMenu = 3;
+	_soundIndexFallen = 8;
+	_soundIndexStart = -1;
+	_soundIndexAreaChange = 7;
 }
 
 void CastleEngine::loadMessagesC64(Common::SeekableReadStream *file, int offset, int number) {
@@ -582,7 +594,8 @@ void CastleEngine::loadAssetsC64FullGame() {
 	surf->free();
 	delete surf;
 
-	_playerMusic = new CastleC64MusicPlayer();
+	_sound = createCastleC64Sound(_mixer, uiData);
+	_playerMusic = new CastleC64MusicPlayer(_mixer);
 
 	// TODO: title screen is in BASIC loader (file 009) - not yet extracted
 }
@@ -612,6 +625,7 @@ void CastleEngine::drawC64InfoMenu(Graphics::Surface *surface) {
 	// The original menu at $7808 uses these rows within the 3D viewport.
 	drawStringInSurface("********************", 60, 46, front, highlight, back, surface);
 	drawStringInSurface(_messagesList[68], 50, 61, front, highlight, back, surface);
+	drawStringInSurface(_c64MusicEnabled ? "F-AUDIO: MUSIC" : "F-AUDIO: EFFECTS", 50, 72, front, highlight, back, surface);
 	drawStringInSurface(_messagesList[69], 50, 82, front, highlight, back, surface);
 	drawStringInSurface(keys, 130, 82, front, highlight, back, surface);
 	drawStringInSurface(_messagesList[70], 50, 93, front, highlight, back, surface);
@@ -623,11 +637,40 @@ void CastleEngine::drawC64InfoMenu(Graphics::Surface *surface) {
 	drawStringInSurface("********************", 60, 133, front, highlight, back, surface);
 }
 
+void CastleEngine::toggleC64AudioMode() {
+	_c64MusicEnabled = !_c64MusicEnabled;
+	_syncSound = false;
+	// $79bf switches between music and effects. Stop and release the old
+	// SID first, since both modes use all three voices of the same chip.
+	if (_c64MusicEnabled) {
+		enableCastleC64Sound(_sound, false);
+		if (_playerMusic)
+			_playerMusic->startMusic();
+	} else {
+		if (_playerMusic)
+			_playerMusic->stopMusic();
+		enableCastleC64Sound(_sound, true);
+		if (_sound)
+			_sound->playSound(3, Sound::kTypeNormal);
+	}
+}
+
 void CastleEngine::liftC64Gate() {
 	// $8347 raises the gate by two rows per step. Start the clock after the
 	// initial area is ready so its setup does not consume animation time.
 	_c64LiftingGateStartTicks = _ticks;
-	waitInLoop(_c64Gate.h / kCastleC64GateLiftStep * kCastleC64GateFrameTicks);
+	for (int step = 0; step < _c64Gate.h / kCastleC64GateLiftStep && !shouldQuit(); step++) {
+		int remaining = _c64LiftingGateStartTicks + (step + 1) * kCastleC64GateFrameTicks - _ticks;
+		if (remaining <= 0)
+			continue;
+		// $834b retriggers the rattle on every step, then $8363 plays the
+		// impact. Gate sounds interrupt effects without waiting for them.
+		if (_sound)
+			_sound->playSound(3, Sound::kTypeNormal);
+		waitInLoop(remaining - 1);
+	}
+	if (_sound && !shouldQuit())
+		_sound->playSound(2, Sound::kTypeNormal);
 	_c64LiftingGateStartTicks = -1;
 }
 
@@ -635,7 +678,15 @@ void CastleEngine::dropC64Gate() {
 	// $49bb completes the fall before polling for a restart. The wait loop
 	// consumes pending gameplay input while still allowing the user to quit.
 	_droppingGateStartTicks = _ticks;
-	waitInLoop(_c64GateDropHeights.size() * kCastleC64GateFrameTicks);
+	for (uint frame = 0; frame < _c64GateDropHeights.size() && !shouldQuit(); frame++) {
+		int remaining = _droppingGateStartTicks + (frame + 1) * kCastleC64GateFrameTicks - _ticks;
+		if (remaining <= 0)
+			continue;
+		// $8300 plays an impact each time the gate reaches the ground.
+		if (_sound && _c64GateDropHeights[frame] == _c64Gate.h)
+			_sound->playSound(2, Sound::kTypeNormal);
+		waitInLoop(remaining - 1);
+	}
 }
 
 void CastleEngine::drawC64Gate(Graphics::Surface *surface) {
diff --git a/engines/freescape/games/castle/c64.music.cpp b/engines/freescape/games/castle/c64.music.cpp
index 8d4b8e930fc..40e97ebc26f 100644
--- a/engines/freescape/games/castle/c64.music.cpp
+++ b/engines/freescape/games/castle/c64.music.cpp
@@ -21,6 +21,8 @@
 
 #include "engines/freescape/games/castle/c64.music.h"
 
+#include "audio/softsynth/sid.h"
+#include "common/mutex.h"
 #include "common/textconsole.h"
 #include "common/util.h"
 #include "freescape/freescape.h"
@@ -31,6 +33,360 @@ using namespace Freescape::CastleMusicData;
 
 namespace Freescape {
 
+// The original $cfd8 selects subtune index + 1 of the driver at $c800,
+// but ignores effects while music is selected. Interpret effects at 50 Hz
+// during playback, releasing the SID when switching back to music.
+class CastleC64Sound : public Sound, private Audio::AudioStream {
+public:
+	CastleC64Sound(Audio::Mixer *mixer, const Common::Array<byte> &data)
+		: _mixer(mixer), _sid(nullptr), _type(kTypeNormal), _enabled(false),
+		  _ghostPlaying(false), _volume(0), _active(false), _finished(true),
+		  _index(0), _ticks(0), _samplesUntilTick(0), _sampleRemainder(0),
+		  _releaseTicks(0), _quietTicks(0), _peak(0) {
+		// Keep only the original score and instrument data, using C64 addresses.
+		_data.assign(data.begin(), data.begin() + 0x1047);
+	}
+
+	~CastleC64Sound() override { setEnabled(false); }
+
+	void setEnabled(bool enabled) {
+		_enabled = enabled;
+		if (!enabled) {
+			stopSound(_type);
+			delete _sid;
+			_sid = nullptr;
+		}
+	}
+
+	void playSound(int index, Type type) override {
+		if (!isSoundAvailable(index) || !_mixer->isReady())
+			return;
+
+		// Remove the mixer callback before changing its SID or sequencer state.
+		_mixer->stopHandle(_handle);
+		if (!_sid) {
+			_sid = new Resid::SID(SID::Config::kSidPAL);
+			_sid->init();
+			_sid->setCallbackFrequency(50);
+		}
+		_sid->reset();
+		memset(_channels, 0, sizeof(_channels));
+		_type = type;
+		_ghostPlaying = false;
+		_volume = 15;
+		_active = true;
+		_finished = false;
+		_index = index;
+		_ticks = 0;
+		_samplesUntilTick = _sampleRemainder = 0;
+		_releaseTicks = _quietTicks = _peak = 0;
+		for (int ch = 0; ch < 3; ch++) {
+			Channel &c = _channels[ch];
+			uint address = 0x0d71 + (index + 1) * 6 + ch * 2;
+			c.order = read(address) | (read(address + 1) << 8);
+			for (int i = 0; i < 8; i++)
+				c.instruments[i] = read(0x102f + ch * 8 + i);
+		}
+
+		_mixer->playStream(Audio::Mixer::kSFXSoundType, &_handle, this, -1,
+			Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
+	}
+
+	void stopSound(Type type) override {
+		if (_type == type) {
+			_mixer->stopHandle(_handle);
+			_active = false;
+			_finished = true;
+			_ghostPlaying = false;
+		}
+	}
+
+	bool isPlayingSound(Type type) const override {
+		if (_type != type || !_mixer->isSoundHandleActive(_handle))
+			return false;
+		Common::StackLock lock(_mixer->mutex());
+		// The sequence's end releases SOUND waits while the SID decays.
+		return _active;
+	}
+
+	bool isSoundAvailable(int index) const override { return _enabled && index >= 0 && index < 16; }
+
+	void updateGhost(bool active) {
+		if (!_enabled)
+			return;
+		if (!active) {
+			if (_ghostPlaying)
+				stopSound(_type);
+		} else if (!isPlayingSound(_type)) {
+			playSound(15, kTypeNormal);
+			_ghostPlaying = true;
+		}
+	}
+
+private:
+	struct Channel {
+		uint16 order, pattern;
+		byte orderPosition, patternPosition, repeats;
+		byte transpose, detune, instruments[8];
+		byte registers[7];
+		byte duration, rest, gateOffTime;
+		byte vibrato, vibratoDelay, vibratoStep;
+		byte waveform, waveformDelay, waveformStep;
+	};
+
+	Audio::Mixer *_mixer;
+	Audio::SoundHandle _handle;
+	Common::Array<byte> _data;
+	Resid::SID *_sid;
+	Type _type;
+	bool _enabled;
+	bool _ghostPlaying;
+	Channel _channels[3];
+	byte _volume;
+	bool _active;
+	bool _finished;
+	int _index;
+	int _ticks;
+	int _samplesUntilTick;
+	int _sampleRemainder;
+	int _releaseTicks;
+	int _quietTicks;
+	int _peak;
+
+	int getRate() const override { return _mixer->getOutputRate(); }
+	bool isStereo() const override { return false; }
+	bool endOfData() const override { return _finished; }
+
+	int readBuffer(int16 *buffer, int numSamples) override {
+		int generated = 0;
+		while (generated < numSamples && !_finished) {
+			if (!_samplesUntilTick) {
+				if (_active) {
+					// Subtune 1 is a longer musical cue; bound malformed data
+					// without cutting that sequence short.
+					if (++_ticks > 180 * 50)
+						error("Unterminated Castle C64 sound %d", _index);
+					advance();
+				}
+				_sampleRemainder += getRate();
+				_samplesUntilTick = _sampleRemainder / 50;
+				_sampleRemainder %= 50;
+				_peak = 0;
+			}
+			int count = MIN(numSamples - generated, _samplesUntilTick);
+			_sid->readBuffer(buffer + generated, count);
+			if (!_active) {
+				for (int i = 0; i < count; i++)
+					_peak = MAX(_peak, ABS(int(buffer[generated + i])));
+			}
+			generated += count;
+			_samplesUntilTick -= count;
+			if (!_active && !_samplesUntilTick) {
+				// Let the release envelopes finish in the live stream.
+				_quietTicks = _peak < 8 ? _quietTicks + 1 : 0;
+				_finished = ++_releaseTicks >= 30 * 50 || _quietTicks >= 5;
+			}
+		}
+		return generated;
+	}
+
+	byte read(uint address) const {
+		if (address >= _data.size())
+			error("Invalid Castle C64 sound address %x", address);
+		return _data[address];
+	}
+
+	byte readOrder(Channel &c) { return read(c.order + c.orderPosition++); }
+	byte readPattern(Channel &c) { return read(c.pattern + c.patternPosition++); }
+
+	bool nextPattern(Channel &c) {
+		if (c.repeats) {
+			c.repeats--;
+			c.patternPosition = 0;
+			return true;
+		}
+		for (int commands = 0; commands < 256; commands++) {
+			byte command = readOrder(c);
+			if (command == 0xff)
+				return false;
+			if (command < 0x80) {
+				if (command >= 51)
+					error("Invalid Castle C64 sound pattern %d", command);
+				c.pattern = read(0x0d07 + command) | (read(0x0d3c + command) << 8);
+				c.patternPosition = 0;
+				return true;
+			}
+			// $ca72: transpose, repeat, detune, or per-channel instrument map.
+			switch (command & 0x60) {
+			case 0x00:
+				c.transpose = readOrder(c);
+				break;
+			case 0x20:
+				c.repeats = (command & 0x1f) - 1;
+				break;
+			case 0x40:
+				c.detune = readOrder(c);
+				break;
+			case 0x60:
+				if (command == 0xe8)
+					_volume = readOrder(c);
+				else
+					c.instruments[command & 7] = readOrder(c);
+				break;
+			}
+		}
+		error("Invalid Castle C64 sound order list");
+	}
+
+	void setInstrument(Channel &c, byte instrument) {
+		if (instrument >= 8)
+			error("Invalid Castle C64 sound instrument %d", instrument);
+		uint address = 0x0f15 + c.instruments[instrument];
+		// All instruments used by the effect subtunes have fixed pulse width.
+		if (read(address) != 0)
+			error("Unsupported Castle C64 sound pulse modulation");
+		for (int reg = 3; reg < 7; reg++)
+			c.registers[reg] = read(address + reg - 2);
+		c.gateOffTime = read(address + 5);
+		c.vibrato = read(address + 7);
+		c.waveform = read(address + 8);
+	}
+
+	bool parse(Channel &c, int ch) {
+		for (int commands = 0; commands < 256; commands++) {
+			if (!c.pattern && !nextPattern(c))
+				return false;
+			byte command = readPattern(c);
+			if (command == 0xff) {
+				if (!nextPattern(c))
+					return false;
+			} else if (command >= 0x80 && command < 0x90) {
+				setInstrument(c, command & 15);
+			} else if (command == 0xa0) {
+				c.duration = c.rest = readPattern(c);
+				_sid->writeReg(ch * 7 + kSIDV1Ctrl, 0);
+				return true;
+			} else if (command < 0x80) {
+				byte note = command + c.transpose + 20;
+				if (note >= 96)
+					error("Invalid Castle C64 sound note %d", note);
+				uint16 frequency = (read(0x065a + note) | (read(0x06ba + note) << 8)) + c.detune;
+				c.registers[0] = frequency;
+				c.registers[1] = frequency >> 8;
+				c.registers[4] |= 1;
+				c.duration = readPattern(c);
+				if (c.vibrato) {
+					c.vibratoDelay = read(0x0fe2 + c.vibrato);
+					c.vibratoStep = 1;
+				}
+				if (c.waveform) {
+					byte entry = read(0x100e + c.waveform);
+					c.waveformDelay = (entry >> 4) + 1;
+					c.waveformStep = 1;
+					c.registers[4] = read(0x100a + (entry & 7)) | 1;
+				}
+				return true;
+			} else {
+				// The sixteen effect subtunes use notes, instruments and rests.
+				error("Unsupported Castle C64 sound command %x", command);
+			}
+		}
+		error("Invalid Castle C64 sound pattern");
+	}
+
+	void updateEffects(Channel &c) {
+		// $cd97: signed frequency deltas, with the high bit of the step
+		// reversing the table after each seven-entry vibrato half-cycle.
+		if (c.vibrato && c.vibratoDelay != 0x80) {
+			if (c.vibratoDelay && !(c.vibratoDelay & 0x80)) {
+				c.vibratoDelay--;
+			} else {
+				int delta = int8(read(0x0fe2 + c.vibrato + (c.vibratoStep & 0x7f)));
+				if (c.vibratoStep & 0x80)
+					delta = -delta;
+				uint16 frequency = (c.registers[0] | (c.registers[1] << 8)) + delta;
+				c.registers[0] = frequency;
+				c.registers[1] = frequency >> 8;
+				if ((++c.vibratoStep & 0x7f) == 8) {
+					c.vibratoStep ^= 0x89;
+					if (c.vibratoStep == 1 && c.vibratoDelay)
+						c.vibratoDelay--;
+				}
+			}
+		}
+		// $ce26: timed waveform changes, including the noise attack used by
+		// the collision/menu instrument. Preserve the current gate bit.
+		if (c.waveform && !--c.waveformDelay && !(c.waveformStep & 0x80)) {
+			byte entry = read(0x100e + c.waveform + c.waveformStep);
+			c.waveformDelay = entry >> 4;
+			c.registers[4] = (c.registers[4] & 1) | read(0x100a + (entry & 7));
+			byte control = read(0x100d + c.waveform);
+			if (++c.waveformStep == (control & 7))
+				c.waveformStep = control & 0x80 ? 0 : 0xff;
+		}
+	}
+
+	void advance() {
+		// $c885 parses the next note at duration 1, suppressing that frame's
+		// register copy so that the previous gate-off reaches the SID first.
+		bool skipWrites = false;
+		for (int ch = 0; ch < 3; ch++) {
+			Channel &c = _channels[ch];
+			if (c.duration == 1) {
+				c.duration = c.rest = 0;
+				skipWrites = true;
+			}
+		}
+		for (int ch = 0; ch < 3; ch++) {
+			Channel &c = _channels[ch];
+			if (!c.duration) {
+				if (!parse(c, ch)) {
+					_active = false;
+					break;
+				}
+			} else {
+				if (c.duration == c.gateOffTime)
+					c.registers[4] &= 0xfe;
+				c.duration--;
+				if (c.rest)
+					c.rest--;
+				else if (!skipWrites)
+					updateEffects(c);
+			}
+		}
+		if (!_active) {
+			for (int ch = 0; ch < 3; ch++)
+				_sid->writeReg(ch * 7 + kSIDV1Ctrl, 0);
+		} else if (!skipWrites) {
+			for (int ch = 0; ch < 3; ch++) {
+				if (!_channels[ch].rest) {
+					for (int reg = 0; reg < 7; reg++)
+						_sid->writeReg(ch * 7 + reg, _channels[ch].registers[reg]);
+				}
+			}
+			_sid->writeReg(kSIDVolume, _volume);
+		}
+	}
+};
+
+Sound *createCastleC64Sound(Audio::Mixer *mixer, const Common::Array<byte> &data) {
+	if (data.size() < 0x1047)
+		error("Missing Castle C64 sound data");
+	if (mixer->isReady())
+		return new CastleC64Sound(mixer, data);
+	return nullptr;
+}
+
+void enableCastleC64Sound(Sound *sound, bool enabled) {
+	if (sound)
+		static_cast<CastleC64Sound *>(sound)->setEnabled(enabled);
+}
+
+void updateCastleC64GhostSound(Sound *sound, bool active) {
+	if (sound)
+		static_cast<CastleC64Sound *>(sound)->updateGhost(active);
+}
+
 const int kCastleSIDVoiceOffset[] = { 0, 7, 14 };
 
 void CastleC64MusicPlayer::ChannelState::reset(const byte *channelOrderList) {
@@ -51,9 +407,12 @@ void CastleC64MusicPlayer::ChannelState::reset(const byte *channelOrderList) {
 	active = false;
 }
 
-CastleC64MusicPlayer::CastleC64MusicPlayer()
+CastleC64MusicPlayer::CastleC64MusicPlayer(Audio::Mixer *mixer)
 	: _sid(nullptr),
+	  _mixer(mixer),
 	  _musicActive(false),
+	  _samplesUntilTick(0),
+	  _sampleRemainder(0),
 	  _tick(0) {
 }
 
@@ -71,7 +430,9 @@ void CastleC64MusicPlayer::initSID() {
 		return;
 	}
 
-	_sid->start(new Common::Functor0Mem<void, CastleC64MusicPlayer>(this, &CastleC64MusicPlayer::onTimer), 50);
+	// Pull samples through our own music stream. Chip::start() would attach
+	// the SID as plain audio, bypassing ScummVM's music volume control.
+	_sid->setCallbackFrequency(50);
 }
 
 void CastleC64MusicPlayer::destroySID() {
@@ -89,21 +450,50 @@ void CastleC64MusicPlayer::sidWrite(int reg, byte data) {
 
 void CastleC64MusicPlayer::startMusic() {
 	stopMusic();
+	if (!_mixer->isReady())
+		return;
 	initSID();
 	if (!_sid)
 		return;
 
 	setupSong();
+	_samplesUntilTick = 0;
+	_sampleRemainder = 0;
+	_mixer->playStream(Audio::Mixer::kMusicSoundType, &_handle, this, -1,
+		Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::NO);
 }
 
 void CastleC64MusicPlayer::stopMusic() {
+	// Stop the mixer callback before touching its SID or channel state.
+	_mixer->stopHandle(_handle);
 	_musicActive = false;
 	silenceAll();
 	destroySID();
 }
 
 bool CastleC64MusicPlayer::isPlaying() const {
-	return _musicActive;
+	return _mixer->isSoundHandleActive(_handle);
+}
+
+int CastleC64MusicPlayer::getRate() const {
+	return _mixer->getOutputRate();
+}
+
+int CastleC64MusicPlayer::readBuffer(int16 *buffer, int numSamples) {
+	int generated = 0;
+	while (generated < numSamples) {
+		if (!_samplesUntilTick) {
+			onTimer();
+			_sampleRemainder += getRate();
+			_samplesUntilTick = _sampleRemainder / 50;
+			_sampleRemainder %= 50;
+		}
+		int count = MIN(numSamples - generated, _samplesUntilTick);
+		static_cast<Resid::SID *>(_sid)->readBuffer(buffer + generated, count);
+		generated += count;
+		_samplesUntilTick -= count;
+	}
+	return numSamples;
 }
 
 void CastleC64MusicPlayer::silenceAll() {
diff --git a/engines/freescape/games/castle/c64.music.h b/engines/freescape/games/castle/c64.music.h
index 368eba5f71c..9657a443a3c 100644
--- a/engines/freescape/games/castle/c64.music.h
+++ b/engines/freescape/games/castle/c64.music.h
@@ -23,18 +23,30 @@
 #define FREESCAPE_CASTLE_C64_MUSIC_H
 
 #include "audio/sid.h"
+#include "audio/audiostream.h"
+#include "audio/mixer.h"
+#include "common/array.h"
 #include "freescape/music.h"
 
 namespace Freescape {
 
-class CastleC64MusicPlayer : public MusicPlayer {
+class Sound;
+Sound *createCastleC64Sound(Audio::Mixer *mixer, const Common::Array<byte> &data);
+void enableCastleC64Sound(Sound *sound, bool enabled);
+void updateCastleC64GhostSound(Sound *sound, bool active);
+
+class CastleC64MusicPlayer : public MusicPlayer, private Audio::AudioStream {
 public:
-	CastleC64MusicPlayer();
+	CastleC64MusicPlayer(Audio::Mixer *mixer);
 	~CastleC64MusicPlayer() override;
 
 	void startMusic() override;
 	void stopMusic() override;
 	bool isPlaying() const override;
+	int readBuffer(int16 *buffer, int numSamples) override;
+	int getRate() const override;
+	bool isStereo() const override { return false; }
+	bool endOfData() const override { return false; }
 
 private:
 	enum {
@@ -63,7 +75,11 @@ private:
 	};
 
 	SID::SID *_sid;
+	Audio::Mixer *_mixer;
+	Audio::SoundHandle _handle;
 	bool _musicActive;
+	int _samplesUntilTick;
+	int _sampleRemainder;
 	uint32 _tick;
 	ChannelState _channels[kChannelCount];
 
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 9747f75b330..837da24fd7c 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -35,6 +35,7 @@
 #include "freescape/freescape.h"
 #include "freescape/gfx.h"
 #include "freescape/games/castle/castle.h"
+#include "freescape/games/castle/c64.music.h"
 #include "freescape/language/8bitDetokeniser.h"
 #include "freescape/music.h"
 
@@ -553,9 +554,9 @@ void CastleEngine::initKeymaps(Common::Keymap *engineKeyMap, Common::Keymap *inf
 	act->addDefaultInputMapping("q");
 	infoScreenKeyMap->addAction(act);
 
-	act = new Common::Action("TOGGLESOUND", _("Toggle sound"));
+	act = new Common::Action("TOGGLESOUND", isC64() ? _("Toggle music/sound effects") : _("Toggle sound"));
 	act->setCustomEngineActionEvent(kActionToggleSound);
-	act->addDefaultInputMapping("t");
+	act->addDefaultInputMapping(isC64() ? "f" : "t");
 	infoScreenKeyMap->addAction(act);
 
 	act = new Common::Action("ROTL", _("Rotate left"));
@@ -742,6 +743,11 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 }
 
 void CastleEngine::initGameState() {
+	if (isC64()) {
+		stopAllSounds();
+		stopAllSounds(Sound::kTypeMovement);
+		_syncSound = false;
+	}
 	FreescapeEngine::initGameState();
 	_playerHeightNumber = 1;
 
@@ -804,7 +810,9 @@ void CastleEngine::initGameState() {
 	_droppingGateStartTicks = isC64() ? -1 : 0;
 	_thunderFrameDuration = 0;
 
-	if (_playerMusic)
+	if (isC64())
+		enableCastleC64Sound(_sound, !_c64MusicEnabled);
+	if (_playerMusic && (!isC64() || _c64MusicEnabled))
 		_playerMusic->startMusic();
 }
 
@@ -1279,6 +1287,10 @@ void CastleEngine::drawInfoMenu() {
 					}
 
 					_gfx->setViewport(_viewArea);
+				} else if (isC64() && event.customType == kActionToggleSound) {
+					toggleC64AudioMode();
+					drawC64InfoMenu(surface);
+					menuTexture->update(surface);
 				} else if (isDOS() && event.customType == kActionToggleSound) {
 					// TODO
 				} else if (event.customType == kActionQuit) {
@@ -2133,6 +2145,12 @@ void CastleEngine::checkSensors() {
 
 	_lastTick = _ticks;
 
+	if (isC64()) {
+		// The IRQ at $73a1 supplies the ghost tone while a live spirit is
+		// present. Ordinary effects take priority over this repeating cue.
+		updateCastleC64GhostSound(_sound, _gameStateControl == kFreescapeGameStatePlaying && !_disableSensors && ghostInArea());
+	}
+
 	if (_sensors.empty()) {
 		_gfx->_shakeOffset = Common::Point();
 		return;
@@ -2599,6 +2617,9 @@ Common::Error CastleEngine::loadGameStreamExtended(Common::SeekableReadStream *s
 	if (isC64()) {
 		_c64LiftingGateStartTicks = -1;
 		_droppingGateStartTicks = -1;
+		stopAllSounds();
+		stopAllSounds(Sound::kTypeMovement);
+		_syncSound = false;
 	}
 
 	_keysCollected.clear();
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 99525ffcac3..74c85b0a2d1 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -106,6 +106,7 @@ public:
 	void loadAssetsC64FullGame() override;
 	void drawC64UI(Graphics::Surface *surface) override;
 	void drawC64InfoMenu(Graphics::Surface *surface);
+	void toggleC64AudioMode();
 	void drawC64HudSurface(Graphics::Surface *surface, const Graphics::Surface &frame, const Common::Point &origin);
 	void liftC64Gate();
 	void dropC64Gate();
@@ -115,6 +116,7 @@ public:
 	Common::Array<uint32> _c64UIColors;
 	Common::Array<byte> _c64GateDropHeights;
 	int _c64LiftingGateStartTicks;
+	bool _c64MusicEnabled;
 
 	void drawDOSUI(Graphics::Surface *surface) override;
 	void drawZXUI(Graphics::Surface *surface) override;


Commit: 81583b292ce3065c72186b0827c40c302b10ba86
    https://github.com/scummvm/scummvm/commit/81583b292ce3065c72186b0827c40c302b10ba86
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:52+02:00

Commit Message:
FREESCAPE: background and lighting in castle c64

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


diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index 383ccb26a00..3df3296ca9b 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -21,6 +21,7 @@
 
 #include "common/file.h"
 #include "common/memstream.h"
+#include "common/random.h"
 #include "graphics/managed_surface.h"
 
 #include "freescape/freescape.h"
@@ -47,7 +48,11 @@ enum {
 	kCastleC64MessageY = 182,
 	kCastleC64GateFrameTicks = 2,
 	kCastleC64GateLiftStep = 2,
-	kCastleC64GateTransparent = 4
+	kCastleC64GateTransparent = 4,
+	kCastleC64ThunderClockTicks = 60,
+	kCastleC64LightningIdle = 0,
+	kCastleC64LightningBolt = 1,
+	kCastleC64LightningFlash = 2
 };
 
 // Match the colors of the bundled C64 border. The unused palette entries are
@@ -116,6 +121,19 @@ static Common::Array<byte> unpackCastleC64UI(Common::SeekableReadStream *file) {
 	return data;
 }
 
+static void loadCastleC64Bitmap(const Common::Array<byte> &data, uint address, uint width, uint height, Graphics::ManagedSurface *surface) {
+	if (!width || !height || address + width * height > data.size())
+		error("Invalid Castle C64 bitmap at %x", address);
+	surface->create(width * 8, height, Graphics::PixelFormat::createFormatCLUT8());
+	for (uint y = 0; y < height; y++) {
+		for (uint x = 0; x < width * 8; x += 2) {
+			byte color = (data[address + y * width + x / 8] >> (6 - x % 8)) & 3;
+			surface->setPixel(x, y, color);
+			surface->setPixel(x + 1, y, color);
+		}
+	}
+}
+
 static void loadCastleC64Frame(const Common::Array<byte> &data, uint address, Graphics::ManagedSurface *surface, int frame = 0) {
 	// $7cf6 reads a five-byte header: byte width, height, final-byte mask,
 	// and frame size. These HUD frames all use the whole final byte.
@@ -127,14 +145,7 @@ static void loadCastleC64Frame(const Common::Array<byte> &data, uint address, Gr
 	uint pixels = address + 5 + frame * size;
 	if (!width || !height || data[address + 2] != 0xff || size != width * height || pixels + size > data.size())
 		error("Invalid Castle C64 UI frame at %x", address);
-	surface->create(width * 8, height, Graphics::PixelFormat::createFormatCLUT8());
-	for (uint y = 0; y < height; y++) {
-		for (uint x = 0; x < width * 8; x += 2) {
-			byte color = (data[pixels + y * width + x / 8] >> (6 - x % 8)) & 3;
-			surface->setPixel(x, y, color);
-			surface->setPixel(x + 1, y, color);
-		}
-	}
+	loadCastleC64Bitmap(data, pixels, width, height, surface);
 }
 
 struct CastleC64Repeat {
@@ -356,6 +367,7 @@ void CastleEngine::initC64() {
 	_viewArea = Common::Rect(40, 32, 280, 152);
 	_c64LiftingGateStartTicks = -1;
 	_c64MusicEnabled = true;
+	resetC64Lightning();
 
 	// C64 call sites: throw $63b3, climb/drop $53cf/$549f, area change
 	// $6cde, and a damaging landing $8142. The gate supplies the start sound.
@@ -454,6 +466,11 @@ void CastleEngine::loadAssetsC64FullGame() {
 	loadMessagesC64(&uiStream, 0x1401, 75);
 	loadRiddlesC64(&uiStream, 0x18ae, 9);
 
+	// $4d06 tiles sixteen bytes per row; $4ddd overlays a single 85-row
+	// bolt. Both bitmaps use VIC multicolor pixel pairs, without headers.
+	loadCastleC64Bitmap(uiData, 0x21fa, 16, 18, &_c64MountainBackground);
+	loadCastleC64Bitmap(uiData, 0x20f8, 3, 85, &_c64Lightning);
+
 	// Preserve multicolor pixel indices until drawing. VIC colors depend on
 	// the destination 8x8 cell, even within a single moving weight or key.
 	file.seek(0x301); // Packed color RAM at $0b00, high nibble first.
@@ -655,6 +672,142 @@ void CastleEngine::toggleC64AudioMode() {
 	}
 }
 
+void CastleEngine::updateC64BackgroundPalette() {
+	uint32 colors[4];
+	for (int color = 0; color < 4; color++) {
+		uint8 r, g, b;
+		_gfx->selectColorFromFourColorPalette(color, r, g, b);
+		// Pen 0 is transparent when compositing the lightning bitmap.
+		colors[color] = _gfx->_texturePixelFormat.ARGBToColor(color ? 255 : 0, r, g, b);
+	}
+	if (!_background)
+		_background = new Graphics::ManagedSurface();
+	if (_thunderFrames.empty())
+		_thunderFrames.push_back(new Graphics::ManagedSurface());
+
+	const Graphics::Surface *sources[] = {&_c64MountainBackground.rawSurface(), &_c64Lightning.rawSurface()};
+	Graphics::ManagedSurface *destinations[] = {_background, _thunderFrames[0]};
+	for (uint frame = 0; frame < ARRAYSIZE(sources); frame++) {
+		const Graphics::Surface &src = *sources[frame];
+		Graphics::ManagedSurface *dst = destinations[frame];
+		dst->create(src.w, src.h, _gfx->_texturePixelFormat);
+		for (int y = 0; y < src.h; y++) {
+			const byte *pixels = (const byte *)src.getBasePtr(0, y);
+			for (int x = 0; x < src.w; x++)
+				dst->setPixel(x, y, colors[pixels[x]]);
+		}
+	}
+
+	delete _skyTexture;
+	_skyTexture = nullptr;
+	for (auto *texture : _thunderTextures)
+		delete texture;
+	_thunderTextures.clear();
+}
+
+void CastleEngine::resetC64Lightning() {
+	_c64NextLightningTicks = -1;
+	_c64LightningPhase = kCastleC64LightningIdle;
+	_c64LightningPhaseTicks = 0;
+	_c64LightningX = 0;
+}
+
+void CastleEngine::updateC64Lightning() {
+	int ticks = _ticks;
+	if (_gameStateControl != kFreescapeGameStatePlaying) {
+		resetC64Lightning();
+		return;
+	}
+
+	// $7531 decrements the initial counter of 5 every 60 PAL ticks,
+	// stopping at 1 until the main loop draws the bolt ($4cb8).
+	if (_c64NextLightningTicks < 0)
+		_c64NextLightningTicks = ticks + 4 * kCastleC64ThunderClockTicks;
+	if (_c64LightningPhase == kCastleC64LightningIdle && ticks >= _c64NextLightningTicks) {
+		// $483e reloads from the low six timer bits plus ten; the next
+		// bolt appears when that counter reaches 1.
+		_c64NextLightningTicks = ticks + (9 + _rnd->getRandomNumber(63)) * kCastleC64ThunderClockTicks;
+		if (_currentArea->isOutside() && !_avoidRenderingFrames) {
+			_c64LightningPhase = kCastleC64LightningBolt;
+			_c64LightningPhaseTicks = ticks + 1;
+			// $4d90 chooses one of 27 byte-aligned positions in the viewport.
+			_c64LightningX = 8 * (1 + _rnd->getRandomNumber(26));
+		}
+	} else if (_c64LightningPhase != kCastleC64LightningIdle && ticks >= _c64LightningPhaseTicks) {
+		if (_c64LightningPhase == kCastleC64LightningBolt) {
+			_c64LightningPhase = kCastleC64LightningFlash;
+			_c64LightningPhaseTicks = ticks + 1;
+			// $484d plays sound 8 and flashes the background for one PAL tick.
+			// A rendering callback must not enter the scripted SOUND wait loop.
+			if (_sound && _currentArea->isOutside())
+				_sound->playSound(8, Sound::kTypeNormal);
+		} else {
+			_c64LightningPhase = kCastleC64LightningIdle;
+		}
+	}
+	if (!_currentArea->isOutside())
+		_c64LightningPhase = kCastleC64LightningIdle;
+}
+
+void CastleEngine::drawC64Background() {
+	updateC64Lightning();
+	clearBackground();
+	_gfx->drawBackground(_currentArea->_skyColor);
+	if (_avoidRenderingFrames || !_currentArea->isOutside())
+		return;
+
+	// Use the same perspective skybox as the other Castle releases. Center
+	// it on the camera so movement does not introduce foreground parallax.
+	Math::Vector3d camera = _inWaitLoop ? _position : getCameraRenderPosition();
+	if (_currentArea->getAreaID() == 1 && _background) {
+		if (!_skyTexture)
+			_skyTexture = _gfx->createTexture(_background->surfacePtr(), true);
+		_gfx->drawSkybox(_skyTexture, camera);
+	}
+
+	if (_c64LightningPhase == kCastleC64LightningFlash)
+		_gfx->clear(255, 255, 255);
+	if (_c64LightningPhase != kCastleC64LightningBolt || _thunderFrames.empty())
+		return;
+
+	// $4c19/$4da5 place the bottom of the lightning bitmap ten rows above
+	// the horizon. Project that horizon using the Castle viewport's FOV.
+	float horizontal = sqrt(_cameraFront.x() * _cameraFront.x() + _cameraFront.z() * _cameraFront.z());
+	if (horizontal < 0.001f)
+		return;
+	float focalLength = _viewArea.height() * 0.5f * 1.6f / tan(Math::deg2rad(75.0f) * 0.5f);
+	float horizon = _viewArea.top + _viewArea.height() * 0.5f + focalLength * _cameraFront.y() / horizontal;
+	if (horizon < _viewArea.top || horizon > _viewArea.bottom + 95)
+		return;
+	int horizonY = int(horizon) + 1;
+
+	// Compose a full-screen layer: the shader renderer's 2D path does not
+	// support partial source/destination rectangles. Clip before uploading.
+	const Graphics::Surface &source = _thunderFrames[0]->rawSurface();
+	int x = _viewArea.left + _c64LightningX;
+	int y = horizonY - 95;
+	Common::Rect dst(x, y, x + source.w, y + source.h);
+	dst.clip(_viewArea);
+	if (dst.isEmpty())
+		return;
+	Common::Rect src(dst.left - x, dst.top - y, dst.right - x, dst.bottom - y);
+	Graphics::ManagedSurface lightning(_screenW, _screenH, _gfx->_texturePixelFormat);
+	lightning.clear(0);
+	lightning.copyRectToSurfaceWithKey(source, dst.left, dst.top, src, 0);
+	if (_thunderTextures.empty())
+		_thunderTextures.push_back(_gfx->createTexture(lightning.surfacePtr()));
+	else
+		_thunderTextures[0]->update(lightning.surfacePtr());
+	_gfx->setViewport(_fullscreenViewArea);
+	_gfx->drawTexturedRect2D(_fullscreenViewArea, _fullscreenViewArea, _thunderTextures[0]);
+	_gfx->setViewport(_viewArea);
+
+	// The lightning blit changes the OpenGL matrices. Restore the camera
+	// before drawing the 3D scene, which must occlude the background.
+	_gfx->updateProjectionMatrix(75.0f, 1.6f, _nearClipPlane, _farClipPlane * 100);
+	_gfx->positionCamera(camera, camera + _cameraFront, _roll);
+}
+
 void CastleEngine::liftC64Gate() {
 	// $8347 raises the gate by two rows per step. Start the clock after the
 	// initial area is ready so its setup does not consume animation time.
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 837da24fd7c..87f5020ba6d 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -681,6 +681,8 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 		_gfx->fillColorPairArray();
 
 	swapPalette(areaID);
+	if (isC64())
+		updateC64BackgroundPalette();
 
 	// Enable/disable COLOR15 cycling based on per-area flag (Amiga/Atari)
 	if ((isAmiga() || isAtariST()) && _currentArea)
@@ -747,6 +749,7 @@ void CastleEngine::initGameState() {
 		stopAllSounds();
 		stopAllSounds(Sound::kTypeMovement);
 		_syncSound = false;
+		resetC64Lightning();
 	}
 	FreescapeEngine::initGameState();
 	_playerHeightNumber = 1;
@@ -2617,6 +2620,7 @@ Common::Error CastleEngine::loadGameStreamExtended(Common::SeekableReadStream *s
 	if (isC64()) {
 		_c64LiftingGateStartTicks = -1;
 		_droppingGateStartTicks = -1;
+		resetC64Lightning();
 		stopAllSounds();
 		stopAllSounds(Sound::kTypeMovement);
 		_syncSound = false;
@@ -2646,6 +2650,10 @@ Common::Error CastleEngine::loadGameStreamExtended(Common::SeekableReadStream *s
 
 
 void CastleEngine::drawBackground() {
+	if (isC64()) {
+		drawC64Background();
+		return;
+	}
 	clearBackground();
 	_gfx->drawBackground(_currentArea->_skyColor);
 
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index 74c85b0a2d1..f48672351da 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -107,16 +107,26 @@ public:
 	void drawC64UI(Graphics::Surface *surface) override;
 	void drawC64InfoMenu(Graphics::Surface *surface);
 	void toggleC64AudioMode();
+	void updateC64BackgroundPalette();
+	void resetC64Lightning();
+	void updateC64Lightning();
+	void drawC64Background();
 	void drawC64HudSurface(Graphics::Surface *surface, const Graphics::Surface &frame, const Common::Point &origin);
 	void liftC64Gate();
 	void dropC64Gate();
 	void drawC64Gate(Graphics::Surface *surface);
 	Graphics::ManagedSurface _c64KeysBackground;
 	Graphics::ManagedSurface _c64Gate;
+	Graphics::ManagedSurface _c64MountainBackground;
+	Graphics::ManagedSurface _c64Lightning;
 	Common::Array<uint32> _c64UIColors;
 	Common::Array<byte> _c64GateDropHeights;
 	int _c64LiftingGateStartTicks;
 	bool _c64MusicEnabled;
+	int _c64NextLightningTicks;
+	int _c64LightningPhase;
+	int _c64LightningPhaseTicks;
+	int _c64LightningX;
 
 	void drawDOSUI(Graphics::Surface *surface) override;
 	void drawZXUI(Graphics::Surface *surface) override;


Commit: 1e6f0d0504e4f8048ac4cfa941547808dc1a362c
    https://github.com/scummvm/scummvm/commit/1e6f0d0504e4f8048ac4cfa941547808dc1a362c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: ghost attack effect implemented in castle c64

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


diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index 3df3296ca9b..c024a0e105f 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -367,6 +367,7 @@ void CastleEngine::initC64() {
 	_viewArea = Common::Rect(40, 32, 280, 152);
 	_c64LiftingGateStartTicks = -1;
 	_c64MusicEnabled = true;
+	_c64SpiritAttackStartTicks = -1;
 	resetC64Lightning();
 
 	// C64 call sites: throw $63b3, climb/drop $53cf/$549f, area change
@@ -466,6 +467,10 @@ void CastleEngine::loadAssetsC64FullGame() {
 	loadMessagesC64(&uiStream, 0x1401, 75);
 	loadRiddlesC64(&uiStream, 0x18ae, 9);
 
+	// $7403 selects the screen-RAM colour pairs at counter values 8 and 4.
+	_c64SpiritAttackColors[0] = uiData[0x735c];
+	_c64SpiritAttackColors[1] = uiData[0x735b];
+
 	// $4d06 tiles sixteen bytes per row; $4ddd overlays a single 85-row
 	// bolt. Both bitmaps use VIC multicolor pixel pairs, without headers.
 	loadCastleC64Bitmap(uiData, 0x21fa, 16, 18, &_c64MountainBackground);
@@ -672,6 +677,34 @@ void CastleEngine::toggleC64AudioMode() {
 	}
 }
 
+void CastleEngine::updateC64SpiritPalette() {
+	int screenHigh = _currentArea->_underFireBackgroundColor;
+	int screenLow = _currentArea->_paperColor;
+	if (_gameStateControl == kFreescapeGameStatePlaying && !isPaused() && !_disableSensors && ghostInArea()) {
+		int ticks = _ticks;
+		if (_c64SpiritAttackStartTicks < 0)
+			_c64SpiritAttackStartTicks = ticks;
+
+		// $73cb-$742d counts down from ten at 50 Hz: two ticks of the
+		// area colours, four of $78, four of $82, then repeat. Only the
+		// viewport's screen RAM changes; background and colour RAM do not.
+		int phase = (ticks - _c64SpiritAttackStartTicks) % 10;
+		if (phase >= 2) {
+			byte colors = _c64SpiritAttackColors[phase < 6 ? 0 : 1];
+			screenHigh = colors >> 4;
+			screenLow = colors & 15;
+		}
+	} else {
+		_c64SpiritAttackStartTicks = -1;
+	}
+
+	if (_gfx->_underFireBackgroundColor == screenHigh && _gfx->_paperColor == screenLow)
+		return;
+	_gfx->_underFireBackgroundColor = screenHigh;
+	_gfx->_paperColor = screenLow;
+	updateC64BackgroundPalette();
+}
+
 void CastleEngine::updateC64BackgroundPalette() {
 	uint32 colors[4];
 	for (int color = 0; color < 4; color++) {
@@ -750,6 +783,7 @@ void CastleEngine::updateC64Lightning() {
 }
 
 void CastleEngine::drawC64Background() {
+	updateC64SpiritPalette();
 	updateC64Lightning();
 	clearBackground();
 	_gfx->drawBackground(_currentArea->_skyColor);
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 87f5020ba6d..5a1c88b47fc 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -630,6 +630,8 @@ void CastleEngine::gotoArea(uint16 areaID, int entranceID) {
 
 	assert(_areaMap.contains(areaID));
 	_currentArea = _areaMap[areaID];
+	if (isC64())
+		_c64SpiritAttackStartTicks = -1;
 	_currentArea->show();
 	_maxFallingDistance = MAX(32, _currentArea->getScale() * 16 - 2);
 
@@ -749,6 +751,7 @@ void CastleEngine::initGameState() {
 		stopAllSounds();
 		stopAllSounds(Sound::kTypeMovement);
 		_syncSound = false;
+		_c64SpiritAttackStartTicks = -1;
 		resetC64Lightning();
 	}
 	FreescapeEngine::initGameState();
@@ -2175,8 +2178,9 @@ void CastleEngine::checkSensors() {
 		_mixer->playStream(Audio::Mixer::kSFXSoundType, &_soundFxGhostHandle, speaker, -1, Audio::Mixer::kMaxChannelVolume, 0, DisposeAfterUse::YES);
 	}*/
 
-	// This is the frequency to shake the screen
-	if (_ticks % 5 == 0) {
+	// C64 cycles its viewport colours before rendering in drawC64Background.
+	// The other platforms use the generic attack flash/shake path.
+	if (!isC64() && _ticks % 5 == 0) {
 		if (_underFireFrames <= 0)
 			_underFireFrames = 1;
 	}
@@ -2620,6 +2624,7 @@ Common::Error CastleEngine::loadGameStreamExtended(Common::SeekableReadStream *s
 	if (isC64()) {
 		_c64LiftingGateStartTicks = -1;
 		_droppingGateStartTicks = -1;
+		_c64SpiritAttackStartTicks = -1;
 		resetC64Lightning();
 		stopAllSounds();
 		stopAllSounds(Sound::kTypeMovement);
diff --git a/engines/freescape/games/castle/castle.h b/engines/freescape/games/castle/castle.h
index f48672351da..056fdd026f1 100644
--- a/engines/freescape/games/castle/castle.h
+++ b/engines/freescape/games/castle/castle.h
@@ -107,6 +107,7 @@ public:
 	void drawC64UI(Graphics::Surface *surface) override;
 	void drawC64InfoMenu(Graphics::Surface *surface);
 	void toggleC64AudioMode();
+	void updateC64SpiritPalette();
 	void updateC64BackgroundPalette();
 	void resetC64Lightning();
 	void updateC64Lightning();
@@ -123,6 +124,8 @@ public:
 	Common::Array<byte> _c64GateDropHeights;
 	int _c64LiftingGateStartTicks;
 	bool _c64MusicEnabled;
+	byte _c64SpiritAttackColors[2];
+	int _c64SpiritAttackStartTicks;
 	int _c64NextLightningTicks;
 	int _c64LightningPhase;
 	int _c64LightningPhaseTicks;


Commit: 3d55dacb49eb0302a56f09ecc28608045e319846
    https://github.com/scummvm/scummvm/commit/3d55dacb49eb0302a56f09ecc28608045e319846
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: reworked painters algorithm to fix some artifacts

Changed paths:
    engines/freescape/area.cpp
    engines/freescape/area.h
    engines/freescape/freescape.cpp
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/objects/geometricobject.cpp
    engines/freescape/ui.cpp


diff --git a/engines/freescape/area.cpp b/engines/freescape/area.cpp
index 61894ad188c..5c53b8148b8 100644
--- a/engines/freescape/area.cpp
+++ b/engines/freescape/area.cpp
@@ -88,6 +88,8 @@ Area::Area(uint16 areaID_, uint16 areaFlags_, ObjectMap *objectsByID_, ObjectMap
 
 	_lastTick = 0;
 	_lastDepthLayerTick = 0;
+	_lastCameraRoll = 0.0f;
+	_lastDepthLayerCameraRoll = 0.0f;
 	_lastFov = 0.0f;
 	_lastAspectRatio = 0.0f;
 	_lastNearClipPlane = 0.0f;
@@ -258,8 +260,7 @@ static float aabbMinProjection(const Math::AABB &aabb, const Math::Vector3d &axi
 	return support.dotProduct(axis);
 }
 
-static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &direction, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
-	(void)aspectRatio;
+static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &direction, float roll, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
 	if (!aabb.isValid())
 		return false;
 
@@ -267,40 +268,110 @@ static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3
 	if (front.getSquareMagnitude() == 0.0f)
 		return true;
 
+	// Exclude offscreen objects before sorting; they can change the visible draw order.
+	Math::Vector3d right = Math::Vector3d::crossProduct(front, Math::Vector3d(0.0f, 1.0f, 0.0f));
+	if (right.getSquareMagnitude() < 0.0001f)
+		right = Math::Vector3d(1.0f, 0.0f, 0.0f);
+	else
+		right.normalize();
+	Math::Vector3d up = Math::Vector3d::crossProduct(right, front).getNormalized();
+	if (roll != 0.0f) {
+		// Match positionCamera's roll by rotating the view axes inversely.
+		const float c = cos(Math::deg2rad(roll));
+		const float s = sin(Math::deg2rad(roll));
+		auto rotateAxis = [c, s](const Math::Vector3d &axis) {
+			return Math::Vector3d(c * axis.x() + s * axis.y(), -s * axis.x() + c * axis.y(), axis.z());
+		};
+		front = rotateAxis(front);
+		right = rotateAxis(right);
+		up = rotateAxis(up);
+	}
+
 	const float padding = 32.0f;
 	const float minDepth = aabbMinProjection(aabb, front) - camera.dotProduct(front);
 	const float maxDepth = aabbMaxProjection(aabb, front) - camera.dotProduct(front);
-
 	if (maxDepth < nearClipPlane - padding)
 		return false;
 	if (minDepth > farClipPlane + padding)
 		return false;
 
-	// Coarse view-octant cull matching the original (compute_view_clip_bounds):
-	// keep every object in the world octant(s) the frustum spans rather than a tight
-	// cone, and on axes the frustum straddles (its component within the fov
-	// half-angle) keep both sides. The kept set must match the original because the
-	// painter's bubble sort below is non-transitive: a tighter cone drops
-	// straddle-axis side objects and reorders the visible ones.
-	const float threshold = (float)sin(Math::deg2rad(fov) / 2.0f);
-	const Math::Vector3d mn = aabb.getMin();
-	const Math::Vector3d mx = aabb.getMax();
-	for (int i = 0; i < 3; i++) {
-		const float comp = front.getValue(i);
-		if (comp > threshold && mx.getValue(i) < camera.getValue(i))
-			return false;
-		if (comp < -threshold && mn.getValue(i) > camera.getValue(i))
+	// Match updateProjectionMatrix's horizontal FOV.
+	const float horizontalScale = tan(Math::deg2rad(fov) / 2.0f);
+	const float verticalScale = horizontalScale / aspectRatio;
+	const Math::Vector3d planes[] = {
+		front * horizontalScale + right, front * horizontalScale - right,
+		front * verticalScale + up, front * verticalScale - up
+	};
+	for (uint i = 0; i < ARRAYSIZE(planes); i++) {
+		if (aabbMaxProjection(aabb, planes[i]) - camera.dotProduct(planes[i]) < -padding)
 			return false;
 	}
 
 	return true;
 }
 
-static bool objectIsSortCandidate(Object *obj, const Math::Vector3d &camera, const Math::Vector3d &direction, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
+static bool objectIsSortCandidate(Object *obj, const Math::Vector3d &camera, const Math::Vector3d &direction, float roll, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
 	if (!obj || obj->isDestroyed() || obj->isInvisible() || !obj->isGeometric())
 		return false;
 
-	return aabbIntersectsViewVolume(obj->_occlusionBox, camera, direction, fov, aspectRatio, nearClipPlane, farClipPlane);
+	// Sorting bounds may exclude geometry; cull using the actual bounds.
+	return aabbIntersectsViewVolume(obj->_boundingBox, camera, direction, roll, fov, aspectRatio, nearClipPlane, farClipPlane);
+}
+
+// Returns 0 if incomparable, 1 if A is closer, or 2 if B is closer.
+static int compareBoundingBoxAxis(float minA, float maxA, float minB, float maxB) {
+	// Touching bounds are comparable; overlapping intervals are not.
+	if (minA < maxB && minB < maxA)
+		return 0;
+
+	const bool negativeA = minA < 0.0f;
+	const bool negativeB = minB < 0.0f;
+	if (negativeA != (maxA < 0.0f))
+		return 1;
+	if (negativeB != (maxB < 0.0f))
+		return 2;
+	if (negativeA != negativeB)
+		return 0;
+
+	float difference = minB - minA;
+	if (difference == 0.0f)
+		difference = maxB - maxA;
+	return (difference < 0.0f) == negativeB ? 1 : 2;
+}
+
+static void sortObjectsForRendering(ObjectArray &objects, const Math::Vector3d &camera) {
+	const int n = objects.size();
+	if (n < 2)
+		return;
+
+	// Start in file order, with globals first.
+	Common::sort(objects.begin(), objects.end(), [](Object *a, Object *b) {
+		return a->_loadIndex < b->_loadIndex;
+	});
+
+	// Incomparable pairs also swap, so keep n - 1 passes over all adjacent pairs.
+	for (int pass = 1; pass < n; pass++) {
+		bool changed = false;
+		for (int j = 0; j < n - 1; j++) {
+			// Sort using unrotated header bounds relative to the camera.
+			const Math::Vector3d minA = objects[j]->_occlusionBox.getMin() - camera;
+			const Math::Vector3d maxA = objects[j]->_occlusionBox.getMax() - camera;
+			const Math::Vector3d minB = objects[j + 1]->_occlusionBox.getMin() - camera;
+			const Math::Vector3d maxB = objects[j + 1]->_occlusionBox.getMax() - camera;
+			int result = 0;
+			for (int axis = 0; axis < 3; axis++)
+				result = (result << 2) | compareBoundingBoxAxis(minA.getValue(axis), maxA.getValue(axis), minB.getValue(axis), maxB.getValue(axis));
+
+			// Keep order only if B is closer on some axis and A is closer on none.
+			if (result != 0 && (result & 0x15) == 0)
+				continue;
+
+			SWAP(objects[j], objects[j + 1]);
+			changed = true;
+		}
+		if (!changed)
+			break;
+	}
 }
 
 static float aabbNearestDepth(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &direction) {
@@ -356,10 +427,10 @@ static bool objectInDepthLayer(Object *obj, const Math::Vector3d &camera, const
 	return depthLayer == Area::kRenderDepthForeground ? foreground : !foreground;
 }
 
-void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
+void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, float roll, bool insideWait, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
 	bool runAnimation = animationTicks != _lastTick;
 	bool cameraChanged = camera != _lastCameraPosition;
-	bool directionChanged = direction != _lastCameraDirection;
+	bool directionChanged = direction != _lastCameraDirection || roll != _lastCameraRoll;
 	bool projectionChanged = fov != _lastFov || aspectRatio != _lastAspectRatio || nearClipPlane != _lastNearClipPlane || farClipPlane != _lastFarClipPlane;
 	bool sort = runAnimation || cameraChanged || directionChanged || projectionChanged || _sortedObjects.empty();
 
@@ -394,7 +465,7 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 				continue;
 			}
 
-			if (sort && objectIsSortCandidate(obj, camera, direction, fov, aspectRatio, nearClipPlane, farClipPlane))
+			if (sort && objectIsSortCandidate(obj, camera, direction, roll, fov, aspectRatio, nearClipPlane, farClipPlane))
 				_sortedObjects.push_back(obj);
 		}
 	}
@@ -403,117 +474,8 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 		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 != signMaxA) // 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 = _sortedObjects.size();
-	if (n > 1 && sort) {
-		// Seed the non-transitive bubble sort below in object load (file) order --
-		// the same order the original game's renderer iterates its object list.
-		// That data order is what makes the Newell pairwise sort resolve
-		// correctly; any depth heuristic (center distance, nearest depth) is only
-		// an approximation and mis-orders some scenes, the file order does not.
-		Common::sort(_sortedObjects.begin(), _sortedObjects.end(),
-			[](Object *a, Object *b) {
-				return a->_loadIndex < b->_loadIndex;
-			});
-		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 = _sortedObjects[j];
-				Object *b = _sortedObjects[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)
-					_sortedObjects[j] = b;
-					_sortedObjects[j + 1] = a;
-					changed = true;
-				}
-			}
-			if (!changed)
-				break;
-		}
-	}
+	if (sort)
+		sortObjectsForRendering(_sortedObjects, camera);
 
 	for (auto &obj : _sortedObjects) {
 		obj->draw(gfx);
@@ -528,6 +490,7 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 	if (sort) {
 		_lastCameraPosition = camera;
 		_lastCameraDirection = direction;
+		_lastCameraRoll = roll;
 		_lastFov = fov;
 		_lastAspectRatio = aspectRatio;
 		_lastNearClipPlane = nearClipPlane;
@@ -535,10 +498,10 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 	}
 }
 
-void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
+void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, float roll, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
 	bool runAnimation = depthLayer != kRenderDepthBackground && animationTicks != _lastDepthLayerTick;
 	bool cameraChanged = camera != _lastDepthLayerCameraPosition;
-	bool directionChanged = direction != _lastDepthLayerCameraDirection;
+	bool directionChanged = direction != _lastDepthLayerCameraDirection || roll != _lastDepthLayerCameraRoll;
 	bool projectionChanged = fov != _lastDepthLayerFov || aspectRatio != _lastDepthLayerAspectRatio || nearClipPlane != _lastDepthLayerNearClipPlane || farClipPlane != _lastDepthLayerFarClipPlane;
 	bool layerChanged = depthLayer != _lastRenderDepthLayer || (depthLayer != kRenderDepthAll && ABS(foregroundDistance - _lastForegroundDistance) > 0.001f);
 	bool sort = runAnimation || cameraChanged || directionChanged || projectionChanged || layerChanged || _depthLayerSortedObjects.empty();
@@ -579,7 +542,7 @@ void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math:
 
 			if (sort &&
 					objectInDepthLayer(obj, camera, normalizedDirection, depthLayer, foregroundDistance) &&
-					objectIsSortCandidate(obj, camera, direction, fov, aspectRatio, nearClipPlane, farClipPlane))
+					objectIsSortCandidate(obj, camera, direction, roll, fov, aspectRatio, nearClipPlane, farClipPlane))
 				_depthLayerSortedObjects.push_back(obj);
 		}
 	}
@@ -588,113 +551,8 @@ void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math:
 		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 != signMaxA) // 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) {
-		// Seed in object load (file) order, matching the original (see Area::draw).
-		Common::sort(_depthLayerSortedObjects.begin(), _depthLayerSortedObjects.end(),
-			[](Object *a, Object *b) {
-				return a->_loadIndex < b->_loadIndex;
-			});
-		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;
-		}
-	}
+	if (sort)
+		sortObjectsForRendering(_depthLayerSortedObjects, camera);
 
 	for (auto &obj : _depthLayerSortedObjects) {
 		obj->draw(gfx);
@@ -710,6 +568,7 @@ void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math:
 	if (sort) {
 		_lastDepthLayerCameraPosition = camera;
 		_lastDepthLayerCameraDirection = direction;
+		_lastDepthLayerCameraRoll = roll;
 		_lastDepthLayerFov = fov;
 		_lastDepthLayerAspectRatio = aspectRatio;
 		_lastDepthLayerNearClipPlane = nearClipPlane;
diff --git a/engines/freescape/area.h b/engines/freescape/area.h
index 0e1d908cc7c..988c476f799 100644
--- a/engines/freescape/area.h
+++ b/engines/freescape/area.h
@@ -64,8 +64,8 @@ public:
 	uint8 getScale();
 	void remapColor(int index, int color);
 	void unremapColor(int index);
-	void draw(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, float fov, float aspectRatio, float nearClipPlane, float farClipPlane);
-	void drawDepthLayer(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance, float fov, float aspectRatio, float nearClipPlane, float farClipPlane);
+	void draw(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, float roll, bool insideWait, float fov, float aspectRatio, float nearClipPlane, float farClipPlane);
+	void drawDepthLayer(Renderer *gfx, uint32 animationTicks, Math::Vector3d camera, Math::Vector3d direction, float roll, bool insideWait, RenderDepthLayer depthLayer, float foregroundDistance, float fov, float aspectRatio, float nearClipPlane, float farClipPlane);
 	void drawGroup(Renderer *gfx, Group *group, bool runAnimation);
 	void show();
 
@@ -117,6 +117,7 @@ public:
 private:
 	Math::Vector3d _lastCameraPosition;
 	Math::Vector3d _lastCameraDirection;
+	float _lastCameraRoll;
 	float _lastFov;
 	float _lastAspectRatio;
 	float _lastNearClipPlane;
@@ -125,6 +126,7 @@ private:
 	ObjectArray _depthLayerSortedObjects;
 	Math::Vector3d _lastDepthLayerCameraPosition;
 	Math::Vector3d _lastDepthLayerCameraDirection;
+	float _lastDepthLayerCameraRoll;
 	float _lastDepthLayerFov;
 	float _lastDepthLayerAspectRatio;
 	float _lastDepthLayerNearClipPlane;
diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index e435cbc80b6..0f8c645fe0c 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -747,7 +747,7 @@ void FreescapeEngine::drawFrame() {
 
 	drawBackground();
 	if (_avoidRenderingFrames == 0) { // Avoid rendering inside objects
-		_currentArea->draw(_gfx, _ticks / 10, renderPosition, _cameraFront, false, fov, aspectRatio, _nearClipPlane, farClipPlane);
+		_currentArea->draw(_gfx, _ticks / 10, renderPosition, _cameraFront, _roll, false, fov, aspectRatio, _nearClipPlane, farClipPlane);
 		if (_gameStateControl == kFreescapeGameStatePlaying &&
 		    _currentArea->hasActiveGroups() && _ticks % 50 == 0) {
 			executeMovementConditions();
@@ -821,7 +821,7 @@ void FreescapeEngine::drawFrameStereo(int farClipPlane) {
 
 	drawBackground();
 	if (_avoidRenderingFrames == 0)
-		_currentArea->drawDepthLayer(_gfx, _ticks / 10, renderPosition, _cameraFront, false, Area::kRenderDepthBackground, stereoForegroundDistance, fov, aspectRatio, _nearClipPlane, farClipPlane);
+		_currentArea->drawDepthLayer(_gfx, _ticks / 10, renderPosition, _cameraFront, _roll, false, Area::kRenderDepthBackground, stereoForegroundDistance, fov, aspectRatio, _nearClipPlane, farClipPlane);
 
 	for (int pass = 0; pass < 2; pass++) {
 		_gfx->setStereoEye(pass == 0 ? Renderer::kStereoEyeLeft : Renderer::kStereoEyeRight);
@@ -831,7 +831,7 @@ void FreescapeEngine::drawFrameStereo(int farClipPlane) {
 		_gfx->clearDepthBuffer();
 
 		if (_avoidRenderingFrames == 0) // Avoid rendering inside objects
-			_currentArea->drawDepthLayer(_gfx, _ticks / 10, renderPosition, _cameraFront, false, Area::kRenderDepthForeground, stereoForegroundDistance, fov, aspectRatio, _nearClipPlane, farClipPlane);
+			_currentArea->drawDepthLayer(_gfx, _ticks / 10, renderPosition, _cameraFront, _roll, false, Area::kRenderDepthForeground, stereoForegroundDistance, fov, aspectRatio, _nearClipPlane, farClipPlane);
 
 		if (_underFireFrames > 0) {
 			for (auto &it : _sensors) {
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 8b732d7903e..f1f99c47df6 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -793,7 +793,8 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 
 		if (newObject) {
 			newObject->scale(scale);
-			newObject->_loadIndex = 0x4000 + object; // area objects render after globals (original pass 2)
+			// Seed the sort with globals (area 255) before local objects.
+			newObject->_loadIndex = (areaNumber == 255 ? 0 : 0x4000) + object;
 			if (newObject->getType() == kEntranceType) {
 				if (entrancesByID->contains(newObject->getObjectID() & 0x7fff))
 					error("WARNING: replacing object id %d (%d)", newObject->getObjectID(), newObject->getObjectID() & 0x7fff);
diff --git a/engines/freescape/objects/geometricobject.cpp b/engines/freescape/objects/geometricobject.cpp
index 9741105a7b3..16042386de4 100644
--- a/engines/freescape/objects/geometricobject.cpp
+++ b/engines/freescape/objects/geometricobject.cpp
@@ -190,10 +190,15 @@ GeometricObject::GeometricObject(
 		assert(flatAxes <= 1);
 	}
 
+	// Preserve header bounds for sorting, separately from geometry bounds.
+	_occlusionBox.expand(_origin);
+	_occlusionBox.expand(_origin + _size);
 	computeBoundingBox();
 }
 
 void GeometricObject::setOrigin(Math::Vector3d origin_) {
+	const Math::Vector3d offset = origin_ - _origin;
+	_occlusionBox = Math::AABB(_occlusionBox.getMin() + offset, _occlusionBox.getMax() + offset);
 	_origin = origin_;
 	computeBoundingBox();
 }
@@ -223,6 +228,8 @@ void GeometricObject::offsetOrigin(Math::Vector3d origin_) {
 }
 
 void GeometricObject::scale(int factor) {
+	// Scale endpoints directly to avoid rounding overlaps between touching objects.
+	_occlusionBox = Math::AABB(_occlusionBox.getMin() / factor, _occlusionBox.getMax() / factor);
 	_origin = _origin / factor;
 	_size = _size / factor;
 	if (_ordinates) {
@@ -278,16 +285,12 @@ Object *GeometricObject::duplicate() {
 
 	copy->_cyclingColors = _cyclingColors;
 	copy->_loadIndex = _loadIndex;
+	copy->_occlusionBox = _occlusionBox;
 	return copy;
 }
 
 void GeometricObject::computeBoundingBox() {
 	_boundingBox = Math::AABB();
-	_occlusionBox = Math::AABB();
-
-	// These are used for the rendered, they should NOT be refined or it will break the sorting algorithm
-	_occlusionBox.expand(_origin);
-	_occlusionBox.expand(_origin + _size);
 
 	Math::Vector3d v;
 	switch (_type) {
diff --git a/engines/freescape/ui.cpp b/engines/freescape/ui.cpp
index 0ef9f6b0ce2..ea85db50f24 100644
--- a/engines/freescape/ui.cpp
+++ b/engines/freescape/ui.cpp
@@ -92,7 +92,7 @@ void FreescapeEngine::waitInLoop(int maxWait) {
 		_gfx->positionCamera(_position, _position + _cameraFront, _roll);
 
 		drawBackground();
-		_currentArea->draw(_gfx, _ticks / 10, _position, _cameraFront, true, fov, aspectRatio, _nearClipPlane, farClipPlane);
+		_currentArea->draw(_gfx, _ticks / 10, _position, _cameraFront, _roll, true, fov, aspectRatio, _nearClipPlane, farClipPlane);
 		drawBorder();
 		drawUI();
 


Commit: 253f3b0ea97eb9840a31f81019d90f6077ff82f5
    https://github.com/scummvm/scummvm/commit/253f3b0ea97eb9840a31f81019d90f6077ff82f5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: implemented loading of simple geometry for 3DCK games

Changed paths:
  A engines/freescape/games/3dck/3dck.cpp
  A engines/freescape/games/3dck/3dck.h
    engines/freescape/detection.cpp
    engines/freescape/freescape.cpp
    engines/freescape/freescape.h
    engines/freescape/games/palettes.cpp
    engines/freescape/gfx.cpp
    engines/freescape/metaengine.cpp
    engines/freescape/module.mk
    engines/freescape/movement.cpp
    engines/freescape/ui.cpp


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 3f4e6ef5d32..32d1e535374 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1156,6 +1156,15 @@ const ADGameDescription gameDescriptions[] = {
 		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// 3D Construction Kit games
+	{
+		"3dkit",
+		"Cube",
+		AD_ENTRY1s("CUBE.RUN", "3b7930be0f646b98885cfb70c26c89a2", 66138),
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_UNSTABLE,
+		GUIO2(GUIO_NOMIDI, GUIO_RENDERVGA)
+	},
 	{
 		"3dkit",
 		"The 3-D Kit Game",
diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index 0f8c645fe0c..8928e213545 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -258,6 +258,8 @@ FreescapeEngine::FreescapeEngine(OSystem *syst, const ADGameDescription *gd)
 	// close-up detail. Other games need a smaller value to avoid clipping of nearby objects
 	_nearClipPlane = (isDriller() || isDark()) ? 2 : 0.5;
 	_farClipPlane = 8192 + 1802; // Added some extra distance to avoid flickering
+	_fieldOfView = 75.0f;
+	_viewAspectRatio = isCastle() ? 1.6f : 2.18f;
 
 	// These depends on the specific game
 	_playerHeight = 0;
@@ -721,8 +723,8 @@ void FreescapeEngine::drawFrame() {
 		return;
 	}
 
-	const float fov = 75.0f;
-	float aspectRatio = isCastle() ? 1.6 : 2.18;
+	const float fov = _fieldOfView;
+	float aspectRatio = _viewAspectRatio;
 
 	Math::Vector3d renderPosition = getCameraRenderPosition();
 
@@ -787,8 +789,8 @@ void FreescapeEngine::drawFrame() {
 }
 
 void FreescapeEngine::drawFrameStereo(int farClipPlane) {
-	const float fov = 75.0f;
-	float aspectRatio = isCastle() ? 1.6 : 2.18;
+	const float fov = _fieldOfView;
+	float aspectRatio = _viewAspectRatio;
 
 	Math::Vector3d renderPosition = getCameraRenderPosition();
 
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index f8dcd533c2a..f3798dfb4ce 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -575,6 +575,8 @@ public:
 	void flashScreen(int backgroundColor);
 	uint8 _colorNumber;
 	Math::Vector3d _scaleVector;
+	float _fieldOfView;
+	float _viewAspectRatio;
 	float _nearClipPlane;
 	float _farClipPlane;
 
diff --git a/engines/freescape/games/3dck/3dck.cpp b/engines/freescape/games/3dck/3dck.cpp
new file mode 100644
index 00000000000..8957d71e7ea
--- /dev/null
+++ b/engines/freescape/games/3dck/3dck.cpp
@@ -0,0 +1,289 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/substream.h"
+#include "math/utils.h"
+
+#include "freescape/games/3dck/3dck.h"
+
+namespace Freescape {
+
+static void requireBytes(Common::SeekableReadStream &file, uint32 size) {
+	if (file.err() || file.pos() > file.size() || size > file.size() - file.pos())
+		error("Truncated 3D Construction Kit data");
+}
+
+static uint16 readBlockSize(Common::SeekableReadStream &file) {
+	requireBytes(file, 2);
+	uint16 size = file.readUint16LE();
+	requireBytes(file, size);
+	return size;
+}
+
+static Math::Vector3d readVector(Common::SeekableReadStream &file) {
+	uint16 x = file.readUint16BE();
+	uint16 y = file.readUint16BE();
+	uint16 z = file.readUint16BE();
+	return Math::Vector3d(x, y, z);
+}
+
+KitEngine::KitEngine(OSystem *syst, const ADGameDescription *gd) : FreescapeEngine(syst, gd), _initialPlayerHeight(0) {
+	_renderMode = Common::kRenderVGA;
+	_screenW = 320;
+	_screenH = 200;
+	_fullscreenViewArea = Common::Rect(_screenW, _screenH);
+	_playerHeightNumber = 0;
+	_playerHeightMaxNumber = 0;
+	_playerWidth = 12;
+	_playerDepth = 32;
+	_soundIndexShoot = -1;
+}
+
+void KitEngine::loadAssets() {
+	Common::File file;
+	if (!file.open(_gameDescription->filesDescriptions[0].fileName))
+		error("Unable to open 3D Construction Kit RUN file");
+
+	char description[80];
+	if (file.read(description, sizeof(description)) != sizeof(description) ||
+			memcmp(description + 75, "VGA\x1a", 5) != 0)
+		error("Unsupported 3D Construction Kit RUN format");
+
+	// RUN blocks use little-endian lengths; the world data is big-endian.
+	uint16 worldSize = readBlockSize(file);
+	uint32 worldEnd = file.pos() + worldSize;
+	Common::SeekableSubReadStream world(&file, file.pos(), worldEnd);
+	loadWorld(world);
+	file.seek(worldEnd);
+
+	uint16 menuSize = readBlockSize(file);
+	file.skip(menuSize);
+	uint16 borderSize = readBlockSize(file);
+	if (borderSize != _screenW * _screenH)
+		error("Invalid 3D Construction Kit border size");
+	_border = new Graphics::ManagedSurface();
+	_border->create(_screenW, _screenH, Graphics::PixelFormat::createFormatCLUT8());
+	for (int y = 0; y < _screenH; y++) {
+		if (file.read(_border->getBasePtr(0, y), _screenW) != uint(_screenW))
+			error("Truncated 3D Construction Kit border");
+	}
+
+	if (file.read(_palette, sizeof(_palette)) != sizeof(_palette))
+		error("Truncated 3D Construction Kit palette");
+	for (uint i = 0; i < sizeof(_palette); i++) {
+		byte component = _palette[i] & 0x3f;
+		_palette[i] = (component << 2) | (component >> 4);
+	}
+	_border->setPalette(_palette, 0, 256);
+	_gfx->_palette = _palette;
+	_gfx->_keyColor = 0;
+}
+
+void KitEngine::loadWorld(Common::SeekableReadStream &file) {
+	requireBytes(file, 500);
+	uint32 signature = file.readUint32BE();
+	if (signature != MKTAG('C', 'P', 0, 0) && signature != MKTAG('C', 'P', '0', '1'))
+		error("Unsupported 3D Construction Kit world format");
+	uint16 areaCount = file.readUint16BE();
+	uint32 globalConditions = 2 * file.readUint16BE();
+	uint16 centerX = file.readUint16BE();
+	uint16 centerY = file.readUint16BE();
+	uint16 halfWidth = file.readUint16BE();
+	uint16 halfHeight = file.readUint16BE();
+	if (!halfWidth || !halfHeight || centerX < halfWidth || centerY < halfHeight ||
+			centerX + halfWidth > _screenW || centerY + halfHeight >= _screenH)
+		error("Invalid 3D Construction Kit viewport");
+	int top = _screenH - 1 - centerY - halfHeight;
+	_viewArea = Common::Rect(centerX - halfWidth, top, centerX + halfWidth, top + 2 * halfHeight);
+
+	// RUNVGA derives its projection scales from the viewport dimensions.
+	int xScale = 74 * (_viewArea.height() - 1) / 256;
+	int yScale = 55 * (_viewArea.width() - 1) / 256;
+	if (!xScale || !yScale)
+		error("3D Construction Kit viewport is too small");
+	_fieldOfView = 2.0f * Math::rad2deg(atan(24.0f / xScale));
+	_viewAspectRatio = float(yScale) / xScale;
+
+	file.skip(10);
+	_maxFallingDistance = file.readUint16BE();
+	_stepUpDistance = file.readUint16BE();
+	_startArea = file.readUint16BE();
+	_startEntrance = file.readUint16BE();
+	_initialPlayerHeight = file.readUint16BE();
+	uint16 step = file.readUint16BE();
+	uint16 angle = file.readUint16BE();
+	uint16 vehicle = file.readUint16BE();
+	if (!_initialPlayerHeight || !step || !angle || angle > 90 || vehicle)
+		error("Unsupported 3D Construction Kit player settings");
+	_playerSteps.clear();
+	_playerSteps.push_back(step);
+	_playerStepIndex = 0;
+	_angleRotations.clear();
+	_angleRotations.push_back(angle);
+	_angleRotationIndex = 0;
+
+	file.seek(500);
+	requireBytes(file, uint32(areaCount) * 4);
+	Common::Array<uint32> areaOffsets;
+	for (uint i = 0; i < areaCount; i++) {
+		uint32 offset = file.readUint32BE();
+		if (offset > uint32(file.size()) / 2)
+			error("Invalid 3D Construction Kit area offset");
+		areaOffsets.push_back(2 * offset);
+	}
+	if (globalConditions < uint32(file.pos()) || globalConditions > uint32(file.size()))
+		error("Invalid 3D Construction Kit global condition offset");
+	file.seek(globalConditions);
+	requireBytes(file, 2);
+	if (file.readUint16BE())
+		error("3D Construction Kit conditions are not implemented yet");
+
+	for (uint i = 0; i < areaOffsets.size(); i++) {
+		if (areaOffsets[i] < globalConditions + 2)
+			error("Invalid 3D Construction Kit area offset");
+		file.seek(areaOffsets[i]);
+		Area *area = loadArea(file);
+		uint16 id = area->getAreaID();
+		if (_areaMap.contains(id))
+			error("Duplicate 3D Construction Kit area %u", id);
+		_areaMap[id] = area;
+	}
+	if (!_areaMap.contains(_startArea) || !_areaMap[_startArea]->entranceWithID(_startEntrance))
+		error("Invalid 3D Construction Kit starting area or entrance");
+}
+
+Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
+	uint32 start = file.pos();
+	requireBytes(file, 30);
+	uint16 flags = file.readUint16BE();
+	uint16 objectCount = file.readUint16BE();
+	uint16 id = file.readUint16BE();
+	file.skip(2);
+	uint32 conditions = start + 2 * file.readUint16BE();
+	uint16 scale = file.readUint16BE();
+	uint16 sky = file.readUint16BE();
+	uint16 ground = file.readUint16BE();
+	file.skip(14);
+	if (!scale || scale > 255 || conditions < uint32(file.pos()) || conditions > uint32(file.size()))
+		error("Invalid 3D Construction Kit area header");
+	if (id == 255 && objectCount)
+		error("3D Construction Kit global objects are not implemented yet");
+
+	ObjectMap *objects = new ObjectMap();
+	ObjectMap *entrances = new ObjectMap();
+	Common::SeekableSubReadStream objectData(&file, file.pos(), conditions);
+	for (uint i = 0; i < objectCount; i++) {
+		Object *obj = loadObject(objectData);
+		obj->scale(scale);
+		obj->_loadIndex = i;
+		ObjectMap *map = obj->getType() == kEntranceType ? entrances : objects;
+		if (map->contains(obj->getObjectID()))
+			error("Duplicate 3D Construction Kit object %u", obj->getObjectID());
+		(*map)[obj->getObjectID()] = obj;
+	}
+	if (objectData.pos() != objectData.size())
+		error("Invalid 3D Construction Kit object count");
+	file.seek(conditions);
+	requireBytes(file, 2);
+	if (file.readUint16BE())
+		error("3D Construction Kit conditions are not implemented yet");
+
+	Area *area = new Area(id, flags, objects, entrances, false);
+	area->_scale = scale;
+	area->_name = Common::String::format("AREA %u", id);
+	area->_skyColor = ((sky & 0xf) << 4) | ((sky >> 8) & 0xf);
+	area->_groundColor = ((ground & 0xf) << 4) | ((ground >> 8) & 0xf);
+	area->_usualBackgroundColor = 0;
+	area->_underFireBackgroundColor = 0;
+	return area;
+}
+
+Object *KitEngine::loadObject(Common::SeekableReadStream &file) {
+	requireBytes(file, 20);
+	byte flags = file.readByte();
+	byte type = file.readByte();
+	file.skip(2);
+	Math::Vector3d origin = readVector(file);
+	Math::Vector3d size = readVector(file);
+	uint16 id = file.readUint16BE();
+	uint16 words = file.readUint16BE();
+	if (words < 10)
+		error("Invalid 3D Construction Kit object size");
+	requireBytes(file, 2 * (words - 10));
+
+	if (type == kEntranceType && words == 10)
+		return new Entrance(id & 0x7fff, origin, size, FCLInstructionVector(), "");
+	if (type != kCubeType || words != 13 || (flags & 0x80))
+		error("Unsupported 3D Construction Kit object %u (type %u)", id, type);
+
+	Common::Array<uint8> *colors = new Common::Array<uint8>();
+	for (uint i = 0; i < 3; i++) {
+		// Each word interleaves the nibbles of two VGA palette indices.
+		uint16 pair = file.readUint16BE();
+		colors->push_back(((pair & 0xf) << 4) | ((pair >> 8) & 0xf));
+		colors->push_back((pair & 0xf0) | ((pair >> 12) & 0xf));
+	}
+	return new GeometricObject(kCubeType, id, (flags & 4) ? 0x80 : 0,
+		origin, size, colors, nullptr, nullptr, FCLInstructionVector());
+}
+
+void KitEngine::initGameState() {
+	FreescapeEngine::initGameState();
+	_playerHeight = _initialPlayerHeight;
+}
+
+void KitEngine::gotoArea(uint16 areaID, int entranceID) {
+	if (!_areaMap.contains(areaID))
+		error("Unknown 3D Construction Kit area %u", areaID);
+	_currentArea = _areaMap[areaID];
+	Entrance *entrance = static_cast<Entrance *>(_currentArea->entranceWithID(entranceID));
+	if (!entrance)
+		error("Unknown 3D Construction Kit entrance %d", entranceID);
+	_position = entrance->getOrigin();
+	_position.y() += _playerHeight;
+	Math::Vector3d rotation = entrance->getRotation();
+	_pitch = rotation.x();
+	_yaw = 90.0f - rotation.y();
+	_roll = rotation.z();
+	_lastPosition = _position;
+	_gfx->_scale = _currentArea->getScale();
+	_gotoExecuted = true;
+	resetInput();
+}
+
+void KitEngine::checkIfStillInArea() {
+	float limit = 8192.0f / _currentArea->getScale();
+	_position.x() = CLIP(_position.x(), 0.0f, limit);
+	_position.z() = CLIP(_position.z(), 0.0f, limit);
+}
+
+bool KitEngine::checkIfGameEnded() {
+	if (_hasFallen || _playerWasCrushed)
+		_gameStateControl = kFreescapeGameStateRestart;
+	return false;
+}
+
+void KitEngine::drawUI() {
+	_gfx->setViewport(_fullscreenViewArea);
+	_gfx->renderCrossair(_crossairPosition);
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/games/3dck/3dck.h b/engines/freescape/games/3dck/3dck.h
new file mode 100644
index 00000000000..2fb235baa88
--- /dev/null
+++ b/engines/freescape/games/3dck/3dck.h
@@ -0,0 +1,54 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_GAMES_3DCK_H
+#define FREESCAPE_GAMES_3DCK_H
+
+#include "freescape/freescape.h"
+
+namespace Freescape {
+
+class KitEngine : public FreescapeEngine {
+public:
+	KitEngine(OSystem *syst, const ADGameDescription *gd);
+
+	void loadAssets() override;
+	void initGameState() override;
+	void gotoArea(uint16 areaID, int entranceID) override;
+	void checkIfStillInArea() override;
+	bool checkIfGameEnded() override;
+	void borderScreen() override {}
+	void drawUI() override;
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
+
+private:
+	void loadWorld(Common::SeekableReadStream &file);
+	Area *loadArea(Common::SeekableReadStream &file);
+	Object *loadObject(Common::SeekableReadStream &file);
+
+	byte _palette[256 * 3];
+	uint16 _initialPlayerHeight;
+};
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/games/palettes.cpp b/engines/freescape/games/palettes.cpp
index 062f1c0498c..639a7144138 100644
--- a/engines/freescape/games/palettes.cpp
+++ b/engines/freescape/games/palettes.cpp
@@ -144,6 +144,9 @@ byte kDrillerCPCPalette[32][3] = {
 void FreescapeEngine::loadColorPalette() {
 	if (_renderMode == Common::kRenderEGA) {
 		_gfx->_palette = (byte *)&kEGADefaultPalette;
+	} else if (_renderMode == Common::kRenderVGA) {
+		if (!_gfx->_palette)
+			error("Missing VGA palette");
 	} else if (_renderMode == Common::kRenderC64) {
 		_gfx->_palette = (byte *)&kC64Palette;
 	} else if (_renderMode == Common::kRenderZX) {
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index 57a9b6669c9..bcfa6e3c884 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -640,7 +640,13 @@ bool Renderer::getRGBAt(uint8 index, uint8 ecolor, uint8 &r1, uint8 &g1, uint8 &
 		return true;
 	}
 
-	if (_renderMode == Common::kRenderAmiga || _renderMode == Common::kRenderAtariST) {
+	if (_renderMode == Common::kRenderVGA) {
+		readFromPalette(index, r1, g1, b1);
+		r2 = r1;
+		g2 = g1;
+		b2 = b1;
+		return true;
+	} else if (_renderMode == Common::kRenderAmiga || _renderMode == Common::kRenderAtariST) {
 		// Hardware palette cycling: if the main color index matches the cycling
 		// palette entry and cycling is active, use the cycling color directly.
 		// This must happen BEFORE color pair resolution since on real hardware
diff --git a/engines/freescape/metaengine.cpp b/engines/freescape/metaengine.cpp
index 5d08dc4d5ee..7ca7b9ed389 100644
--- a/engines/freescape/metaengine.cpp
+++ b/engines/freescape/metaengine.cpp
@@ -31,6 +31,7 @@
 #include "freescape/games/dark/dark.h"
 #include "freescape/games/driller/driller.h"
 #include "freescape/games/eclipse/eclipse.h"
+#include "freescape/games/3dck/3dck.h"
 #include "freescape/detection.h"
 
 
@@ -208,6 +209,8 @@ Common::Error FreescapeMetaEngine::createInstance(OSystem *syst, Engine **engine
 		*engine = (Engine *)new Freescape::EclipseEngine(syst, gd);
 	} else if (Common::String(gd->gameId) == "castlemaster" || Common::String(gd->gameId) == "castlemaster2") {
 		*engine = (Engine *)new Freescape::CastleEngine(syst, gd);
+	} else if (Common::String(gd->gameId) == "3dkit") {
+		*engine = new Freescape::KitEngine(syst, gd);
 	} else
 		*engine = new Freescape::FreescapeEngine(syst, gd);
 
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index cf4c1f7cbee..933e7c7e52e 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -52,6 +52,7 @@ MODULE_OBJS := \
 	games/eclipse/opl.music.o \
 	games/eclipse/cpc.o \
 	games/eclipse/zx.o \
+	games/3dck/3dck.o \
 	games/palettes.o \
 	gfx.o \
 	loaders/8bitImage.o \
diff --git a/engines/freescape/movement.cpp b/engines/freescape/movement.cpp
index c6753ab517b..e699693df56 100644
--- a/engines/freescape/movement.cpp
+++ b/engines/freescape/movement.cpp
@@ -317,8 +317,8 @@ void FreescapeEngine::shoot() {
 	float ndcY = 1.0f - (2.0f * (_crossairPosition.y - _viewArea.top) / _viewArea.height());
 
 	// Calculate angular offsets using perspective projection
-	float fovHorizontalRad = (float)(75.0f * M_PI / 180.0f);
-	float aspectRatio = isCastle() ? 1.6 : 2.18;
+	float fovHorizontalRad = Math::deg2rad(_fieldOfView);
+	float aspectRatio = _viewAspectRatio;
 	float fovVerticalRad = 2.0f * atan(tan(fovHorizontalRad / 2.0f) / aspectRatio);
 
 	// Convert NDC to angle offset
diff --git a/engines/freescape/ui.cpp b/engines/freescape/ui.cpp
index ea85db50f24..88fb63133fd 100644
--- a/engines/freescape/ui.cpp
+++ b/engines/freescape/ui.cpp
@@ -86,8 +86,8 @@ void FreescapeEngine::waitInLoop(int maxWait) {
 		if (_currentArea->isOutside())
 			farClipPlane *= 100;
 
-		const float fov = 75.0f;
-		float aspectRatio = isCastle() ? 1.6 : 2.18;
+		const float fov = _fieldOfView;
+		float aspectRatio = _viewAspectRatio;
 		_gfx->updateProjectionMatrix(fov, aspectRatio, _nearClipPlane, farClipPlane);
 		_gfx->positionCamera(_position, _position + _cameraFront, _roll);
 


Commit: be883d1a563a20b3e992ebd401d96b2e3a96b543
    https://github.com/scummvm/scummvm/commit/be883d1a563a20b3e992ebd401d96b2e3a96b543
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: allow to load geometry of the 3DCK sample game

Changed paths:
    engines/freescape/detection.cpp
    engines/freescape/games/3dck/3dck.cpp
    engines/freescape/games/3dck/3dck.h


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 32d1e535374..17be9887e18 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1171,8 +1171,8 @@ const ADGameDescription gameDescriptions[] = {
 		AD_ENTRY1s("3DKIT.RUN", "f35147729a2f5b6852a504223aeb6a57", 112158),
 		Common::EN_ANY,
 		Common::kPlatformDOS,
-		ADGF_UNSUPPORTED,
-		GUIO1(GUIO_NOMIDI)
+		ADGF_UNSTABLE,
+		GUIO2(GUIO_NOMIDI, GUIO_RENDERVGA)
 	},
 	{
 		"3dkit",
diff --git a/engines/freescape/games/3dck/3dck.cpp b/engines/freescape/games/3dck/3dck.cpp
index 8957d71e7ea..517ce2b8137 100644
--- a/engines/freescape/games/3dck/3dck.cpp
+++ b/engines/freescape/games/3dck/3dck.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/algorithm.h"
 #include "common/substream.h"
 #include "math/utils.h"
 
@@ -26,6 +27,12 @@
 
 namespace Freescape {
 
+enum {
+	kKitAnimatorType = 16,
+	kKitInitiallyInvisible = 0x04,
+	kKitMovable = 0x80
+};
+
 static void requireBytes(Common::SeekableReadStream &file, uint32 size) {
 	if (file.err() || file.pos() > file.size() || size > file.size() - file.pos())
 		error("Truncated 3D Construction Kit data");
@@ -39,12 +46,37 @@ static uint16 readBlockSize(Common::SeekableReadStream &file) {
 }
 
 static Math::Vector3d readVector(Common::SeekableReadStream &file) {
-	uint16 x = file.readUint16BE();
-	uint16 y = file.readUint16BE();
-	uint16 z = file.readUint16BE();
+	int16 x = file.readSint16BE();
+	int16 y = file.readSint16BE();
+	int16 z = file.readSint16BE();
 	return Math::Vector3d(x, y, z);
 }
 
+static Common::Array<uint16> readWords(Common::SeekableReadStream &file, uint32 count) {
+	requireBytes(file, 2 * count);
+	Common::Array<uint16> words;
+	for (uint32 i = 0; i < count; i++)
+		words.push_back(file.readUint16BE());
+	return words;
+}
+
+static Common::Array<byte> readCode(Common::SeekableReadStream &file, uint32 size) {
+	requireBytes(file, size);
+	Common::Array<byte> code;
+	code.resize(size);
+	if (size && file.read(code.data(), size) != size)
+		error("Truncated 3D Construction Kit condition");
+	return code;
+}
+
+static void readColors(Common::SeekableReadStream &file, byte &first, byte &second) {
+	requireBytes(file, 2);
+	// Each word interleaves the nibbles of two VGA palette indices.
+	uint16 pair = file.readUint16BE();
+	first = ((pair & 0xf) << 4) | ((pair >> 8) & 0xf);
+	second = (pair & 0xf0) | ((pair >> 12) & 0xf);
+}
+
 KitEngine::KitEngine(OSystem *syst, const ADGameDescription *gd) : FreescapeEngine(syst, gd), _initialPlayerHeight(0) {
 	_renderMode = Common::kRenderVGA;
 	_screenW = 320;
@@ -140,42 +172,74 @@ void KitEngine::loadWorld(Common::SeekableReadStream &file) {
 	_angleRotations.push_back(angle);
 	_angleRotationIndex = 0;
 
+	file.skip(2);
+	uint32 indicatorOffset = 2 * file.readUint16BE();
+	uint16 indicatorCount = file.readUint16BE();
+	if (indicatorOffset > uint32(file.size()) || (!indicatorOffset && indicatorCount))
+		error("Invalid 3D Construction Kit indicator offset");
+	uint32 areasEnd = indicatorOffset ? indicatorOffset : file.size();
+	if (indicatorCount) {
+		file.seek(indicatorOffset);
+		// The indicator table follows the last area's conditions.
+		_indicatorData = readWords(file, 17 * indicatorCount);
+	}
+
 	file.seek(500);
 	requireBytes(file, uint32(areaCount) * 4);
 	Common::Array<uint32> areaOffsets;
 	for (uint i = 0; i < areaCount; i++) {
 		uint32 offset = file.readUint32BE();
-		if (offset > uint32(file.size()) / 2)
+		if (offset >= areasEnd / 2)
 			error("Invalid 3D Construction Kit area offset");
 		areaOffsets.push_back(2 * offset);
 	}
-	if (globalConditions < uint32(file.pos()) || globalConditions > uint32(file.size()))
+	Common::sort(areaOffsets.begin(), areaOffsets.end());
+	if (areaOffsets.empty() || globalConditions < uint32(file.pos()) ||
+			globalConditions + 2 > areaOffsets.front())
 		error("Invalid 3D Construction Kit global condition offset");
-	file.seek(globalConditions);
-	requireBytes(file, 2);
-	if (file.readUint16BE())
-		error("3D Construction Kit conditions are not implemented yet");
+	Common::SeekableSubReadStream conditionData(&file, globalConditions, areaOffsets.front());
+	_globalConditions = loadConditions(conditionData);
+	if (conditionData.pos() != conditionData.size())
+		error("Invalid 3D Construction Kit global condition size");
 
 	for (uint i = 0; i < areaOffsets.size(); i++) {
-		if (areaOffsets[i] < globalConditions + 2)
+		uint32 end = i + 1 < areaOffsets.size() ? areaOffsets[i + 1] : areasEnd;
+		if (areaOffsets[i] >= end)
 			error("Invalid 3D Construction Kit area offset");
-		file.seek(areaOffsets[i]);
-		Area *area = loadArea(file);
+		Common::SeekableSubReadStream areaData(&file, areaOffsets[i], end);
+		Area *area = loadArea(areaData);
 		uint16 id = area->getAreaID();
-		if (_areaMap.contains(id))
-			error("Duplicate 3D Construction Kit area %u", id);
 		_areaMap[id] = area;
 	}
 	if (!_areaMap.contains(_startArea) || !_areaMap[_startArea]->entranceWithID(_startEntrance))
 		error("Invalid 3D Construction Kit starting area or entrance");
 }
 
+Common::Array<KitEngine::ConditionData> KitEngine::loadConditions(Common::SeekableReadStream &file) {
+	requireBytes(file, 2);
+	uint16 count = file.readUint16BE();
+	Common::Array<ConditionData> conditions;
+	for (uint i = 0; i < count; i++) {
+		requireBytes(file, 14);
+		char name[13] = {};
+		file.read(name, 12);
+		uint16 words = file.readUint16BE() & 0x7fff;
+		ConditionData condition;
+		condition.name = name;
+		condition.code = readCode(file, 2 * words);
+		conditions.push_back(condition);
+	}
+	return conditions;
+}
+
 Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
 	uint32 start = file.pos();
 	requireBytes(file, 30);
 	uint16 flags = file.readUint16BE();
 	uint16 objectCount = file.readUint16BE();
 	uint16 id = file.readUint16BE();
+	if (_areaMap.contains(id))
+		error("Duplicate 3D Construction Kit area %u", id);
 	file.skip(2);
 	uint32 conditions = start + 2 * file.readUint16BE();
 	uint16 scale = file.readUint16BE();
@@ -184,16 +248,22 @@ Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
 	file.skip(14);
 	if (!scale || scale > 255 || conditions < uint32(file.pos()) || conditions > uint32(file.size()))
 		error("Invalid 3D Construction Kit area header");
-	if (id == 255 && objectCount)
-		error("3D Construction Kit global objects are not implemented yet");
 
+	AreaData &data = _areaData[id];
 	ObjectMap *objects = new ObjectMap();
 	ObjectMap *entrances = new ObjectMap();
 	Common::SeekableSubReadStream objectData(&file, file.pos(), conditions);
 	for (uint i = 0; i < objectCount; i++) {
-		Object *obj = loadObject(objectData);
-		obj->scale(scale);
-		obj->_loadIndex = i;
+		ObjectData record;
+		Object *obj = loadObject(objectData, record);
+		if (data.objects.contains(record.id))
+			error("Duplicate 3D Construction Kit object %u in area %u", record.id, id);
+		data.objects[record.id] = record;
+		if (!obj)
+			continue;
+		if (id != 255)
+			obj->scale(scale);
+		obj->_loadIndex = (id == 255 ? 0 : 0x4000) + i;
 		ObjectMap *map = obj->getType() == kEntranceType ? entrances : objects;
 		if (map->contains(obj->getObjectID()))
 			error("Duplicate 3D Construction Kit object %u", obj->getObjectID());
@@ -202,9 +272,10 @@ Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
 	if (objectData.pos() != objectData.size())
 		error("Invalid 3D Construction Kit object count");
 	file.seek(conditions);
-	requireBytes(file, 2);
-	if (file.readUint16BE())
-		error("3D Construction Kit conditions are not implemented yet");
+	data.conditions = loadConditions(file);
+	if (file.pos() != file.size())
+		error("Invalid 3D Construction Kit area condition size");
+	debugC(1, kFreescapeDebugParser, "3DCK area %u: %u objects, %u conditions", id, objectCount, data.conditions.size());
 
 	Area *area = new Area(id, flags, objects, entrances, false);
 	area->_scale = scale;
@@ -213,36 +284,92 @@ Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
 	area->_groundColor = ((ground & 0xf) << 4) | ((ground >> 8) & 0xf);
 	area->_usualBackgroundColor = 0;
 	area->_underFireBackgroundColor = 0;
+	// The runner supplies a default floor at Y=0.
+	if (id != 255)
+		area->addFloor();
 	return area;
 }
 
-Object *KitEngine::loadObject(Common::SeekableReadStream &file) {
+Object *KitEngine::loadObject(Common::SeekableReadStream &file, ObjectData &data) {
 	requireBytes(file, 20);
-	byte flags = file.readByte();
-	byte type = file.readByte();
-	file.skip(2);
-	Math::Vector3d origin = readVector(file);
-	Math::Vector3d size = readVector(file);
-	uint16 id = file.readUint16BE();
+	data.flags = file.readByte();
+	data.type = file.readByte() & 0x7f;
+	data.state = file.readUint16BE();
+	data.origin = readVector(file);
+	data.size = readVector(file);
+	data.initialOrigin = data.origin;
+	data.id = file.readUint16BE();
 	uint16 words = file.readUint16BE();
 	if (words < 10)
 		error("Invalid 3D Construction Kit object size");
 	requireBytes(file, 2 * (words - 10));
+	uint32 end = file.pos() + 2 * (words - 10);
+	Common::SeekableSubReadStream payload(&file, file.pos(), end);
+	if (data.type > kKitAnimatorType)
+		error("Unsupported 3D Construction Kit object %u (type %u)", data.id, data.type);
+
+	bool geometric = data.type >= kCubeType && data.type <= kHexagonType && data.type != kSensorType;
+	Common::Array<uint8> *colors = nullptr;
+	Common::Array<float> *ordinates = nullptr;
+	if (geometric) {
+		ObjectType type = ObjectType(data.type);
+		int colorCount = GeometricObject::numberOfColoursForObjectOfType(type);
+		colors = new Common::Array<uint8>();
+		for (int i = 0; i < colorCount; i += 2) {
+			byte first, second;
+			readColors(payload, first, second);
+			colors->push_back(first);
+			colors->push_back(second);
+		}
+		int ordinateCount = GeometricObject::numberOfOrdinatesForType(type);
+		if (ordinateCount) {
+			requireBytes(payload, 2 * ordinateCount);
+			ordinates = new Common::Array<float>();
+			for (int i = 0; i < ordinateCount; i++)
+				ordinates->push_back(payload.readSint16BE());
+		}
+	} else if (data.type == kSensorType) {
+		requireBytes(payload, 10);
+		readColors(payload, data.sensor.colors[0], data.sensor.colors[1]);
+		data.sensor.interval = payload.readUint16BE();
+		data.sensor.range = payload.readUint16BE();
+		data.sensor.unknown = payload.readUint16BE();
+		data.sensor.directions = payload.readUint16BE();
+	} else if (data.type == kKitAnimatorType) {
+		data.extra = readWords(payload, 3);
+	}
+
+	if (data.flags & kKitMovable) {
+		requireBytes(payload, 6);
+		data.initialOrigin = readVector(payload);
+	}
+	if (data.type == kGroupType || (data.type == kEntranceType && data.id == 255)) {
+		data.members = readWords(payload, data.state);
+		if (payload.pos() != payload.size())
+			error("Invalid 3D Construction Kit object list %u", data.id);
+	} else if (data.type == kEntranceType) {
+		// Entrances can retain editor data after their header.
+		data.extra = readWords(payload, (payload.size() - payload.pos()) / 2);
+	} else {
+		data.code = readCode(payload, payload.size() - payload.pos());
+	}
+	file.seek(end);
+	debugC(1, kFreescapeDebugParser, "3DCK object %u: type %u, flags %02x, %u script bytes",
+		data.id, data.type, data.flags, data.code.size());
+
+	if (data.type == kEntranceType && data.id != 255)
+		return new Entrance(data.id & 0x7fff, data.initialOrigin, data.size, FCLInstructionVector(), "");
+	if (!geometric)
+		return nullptr;
 
-	if (type == kEntranceType && words == 10)
-		return new Entrance(id & 0x7fff, origin, size, FCLInstructionVector(), "");
-	if (type != kCubeType || words != 13 || (flags & 0x80))
-		error("Unsupported 3D Construction Kit object %u (type %u)", id, type);
-
-	Common::Array<uint8> *colors = new Common::Array<uint8>();
-	for (uint i = 0; i < 3; i++) {
-		// Each word interleaves the nibbles of two VGA palette indices.
-		uint16 pair = file.readUint16BE();
-		colors->push_back(((pair & 0xf) << 4) | ((pair >> 8) & 0xf));
-		colors->push_back((pair & 0xf0) | ((pair >> 12) & 0xf));
+	ObjectType type = ObjectType(data.type);
+	if (GeometricObject::isPolygon(type)) {
+		// Polygon vertices are relative; pyramid ordinates are already offsets.
+		for (uint i = 0; i < ordinates->size(); i++)
+			(*ordinates)[i] += data.initialOrigin.getValue(i % 3);
 	}
-	return new GeometricObject(kCubeType, id, (flags & 4) ? 0x80 : 0,
-		origin, size, colors, nullptr, nullptr, FCLInstructionVector());
+	return new GeometricObject(type, data.id, (data.flags & kKitInitiallyInvisible) ? 0x80 : 0,
+		data.initialOrigin, data.size, colors, nullptr, ordinates, FCLInstructionVector());
 }
 
 void KitEngine::initGameState() {
diff --git a/engines/freescape/games/3dck/3dck.h b/engines/freescape/games/3dck/3dck.h
index 2fb235baa88..fb58fbdda91 100644
--- a/engines/freescape/games/3dck/3dck.h
+++ b/engines/freescape/games/3dck/3dck.h
@@ -41,9 +41,44 @@ public:
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
 
 private:
+	struct ConditionData {
+		Common::String name;
+		Common::Array<byte> code;
+	};
+
+	struct SensorData {
+		byte colors[2] = {};
+		uint16 interval = 0;
+		uint16 range = 0;
+		uint16 unknown = 0;
+		uint16 directions = 0;
+	};
+
+	struct ObjectData {
+		uint16 id = 0;
+		byte type = 0;
+		byte flags = 0;
+		uint16 state = 0;
+		Math::Vector3d origin, size, initialOrigin;
+		Common::Array<uint16> members;
+		Common::Array<uint16> extra;
+		Common::Array<byte> code;
+		SensorData sensor;
+	};
+
+	struct AreaData {
+		Common::HashMap<uint16, ObjectData> objects;
+		Common::Array<ConditionData> conditions;
+	};
+
 	void loadWorld(Common::SeekableReadStream &file);
 	Area *loadArea(Common::SeekableReadStream &file);
-	Object *loadObject(Common::SeekableReadStream &file);
+	Object *loadObject(Common::SeekableReadStream &file, ObjectData &data);
+	Common::Array<ConditionData> loadConditions(Common::SeekableReadStream &file);
+
+	Common::HashMap<uint16, AreaData> _areaData;
+	Common::Array<ConditionData> _globalConditions;
+	Common::Array<uint16> _indicatorData;
 
 	byte _palette[256 * 3];
 	uint16 _initialPlayerHeight;


Commit: f65035d8f055907b35be2e0d913fe700ba5ea972
    https://github.com/scummvm/scummvm/commit/f65035d8f055907b35be2e0d913fe700ba5ea972
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: initial implementation of 3DCK opcodes and scripting

Changed paths:
  A engines/freescape/games/3dck/ui.cpp
  A engines/freescape/language/16bitDetokeniser.cpp
  A engines/freescape/language/16bitDetokeniser.h
  A engines/freescape/language/instruction16bit.cpp
  A engines/freescape/language/instruction16bit.h
    engines/freescape/freescape.cpp
    engines/freescape/freescape.h
    engines/freescape/games/3dck/3dck.cpp
    engines/freescape/games/3dck/3dck.h
    engines/freescape/language/8bitDetokeniser.h
    engines/freescape/language/instruction.cpp
    engines/freescape/language/instruction.h
    engines/freescape/language/token.h
    engines/freescape/module.mk


diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index 8928e213545..09d618480ef 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -940,6 +940,9 @@ void FreescapeEngine::processInput() {
 				continue;
 		}
 
+		if (handleInput(event))
+			continue;
+
 		switch (event.type) {
 		case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
 			if (_hasFallen || _playerWasCrushed)
@@ -1233,6 +1236,7 @@ Common::Error FreescapeEngine::run() {
 
 		checkSensors();
 		checkIfPlayerWasCrushed();
+		updateScripts();
 		drawFrame();
 
 		if (_shootingFrames == 0) {
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index f3798dfb4ce..c73ef2591ab 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -394,6 +394,7 @@ public:
 	virtual void initKeymaps(Common::Keymap *engineKeyMap, Common::Keymap *infoScreenKeyMap, const char *target);
 	EventManagerWrapper *_eventManager;
 	void processInput();
+	virtual bool handleInput(const Common::Event &event) { return false; }
 	void resetInput();
 	void stopMovement();
 	void generateDemoInput();
@@ -401,7 +402,7 @@ public:
 	virtual void releasedKey(const int keycode);
 	Common::Point getNormalizedPosition(Common::Point position);
 	virtual bool onScreenControls(Common::Point mouse);
-	void updatePlayerMovement(float deltaTime);
+	virtual void updatePlayerMovement(float deltaTime);
 	void updatePlayerMovementSmooth(float deltaTime);
 	void updatePlayerMovementClassic(float deltaTime);
 	void resolveCollisions(Math::Vector3d newPosition);
@@ -477,13 +478,14 @@ public:
 	Math::Vector3d _objExecutingCodeSize;
 	bool _executingGlobalCode;
 	virtual void executeMovementConditions();
-	bool executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated);
+	virtual bool executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated);
 	void executeEntranceConditions(Entrance *entrance);
-	void executeLocalGlobalConditions(bool shot, bool collided, bool timer);
+	virtual void executeLocalGlobalConditions(bool shot, bool collided, bool timer);
+	virtual void updateScripts() {}
 	bool executeCode(FCLInstructionVector &code, bool shot, bool collided, bool timer, bool activated);
 
 	// Instructions
-	bool checkConditional(FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated);
+	bool checkConditional(const FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated);
 	bool checkIfGreaterOrEqual(FCLInstruction &instruction);
 	bool checkIfLessOrEqual(FCLInstruction &instruction);
 	void executeExecute(FCLInstruction &instruction);
diff --git a/engines/freescape/games/3dck/3dck.cpp b/engines/freescape/games/3dck/3dck.cpp
index 517ce2b8137..b11e91cda74 100644
--- a/engines/freescape/games/3dck/3dck.cpp
+++ b/engines/freescape/games/3dck/3dck.cpp
@@ -24,6 +24,7 @@
 #include "math/utils.h"
 
 #include "freescape/games/3dck/3dck.h"
+#include "freescape/language/16bitDetokeniser.h"
 
 namespace Freescape {
 
@@ -127,6 +128,8 @@ void KitEngine::loadAssets() {
 	_border->setPalette(_palette, 0, 256);
 	_gfx->_palette = _palette;
 	_gfx->_keyColor = 0;
+	_scriptSurface.create(_screenW, _screenH, _gfx->_texturePixelFormat);
+	_scriptSurface.fillRect(_fullscreenViewArea, 0);
 }
 
 void KitEngine::loadWorld(Common::SeekableReadStream &file) {
@@ -154,7 +157,9 @@ void KitEngine::loadWorld(Common::SeekableReadStream &file) {
 	_fieldOfView = 2.0f * Math::rad2deg(atan(24.0f / xScale));
 	_viewAspectRatio = float(yScale) / xScale;
 
-	file.skip(10);
+	file.skip(6);
+	_timerInterval = file.readUint16BE();
+	_activationRange = file.readUint16BE();
 	_maxFallingDistance = file.readUint16BE();
 	_stepUpDistance = file.readUint16BE();
 	_startArea = file.readUint16BE();
@@ -175,6 +180,7 @@ void KitEngine::loadWorld(Common::SeekableReadStream &file) {
 	file.skip(2);
 	uint32 indicatorOffset = 2 * file.readUint16BE();
 	uint16 indicatorCount = file.readUint16BE();
+	_initialCondition = file.readUint16BE();
 	if (indicatorOffset > uint32(file.size()) || (!indicatorOffset && indicatorCount))
 		error("Invalid 3D Construction Kit indicator offset");
 	uint32 areasEnd = indicatorOffset ? indicatorOffset : file.size();
@@ -184,7 +190,8 @@ void KitEngine::loadWorld(Common::SeekableReadStream &file) {
 		_indicatorData = readWords(file, 17 * indicatorCount);
 	}
 
-	file.seek(500);
+	file.seek(150);
+	_controlData = readWords(file, 35 * 5);
 	requireBytes(file, uint32(areaCount) * 4);
 	Common::Array<uint32> areaOffsets;
 	for (uint i = 0; i < areaCount; i++) {
@@ -199,6 +206,8 @@ void KitEngine::loadWorld(Common::SeekableReadStream &file) {
 		error("Invalid 3D Construction Kit global condition offset");
 	Common::SeekableSubReadStream conditionData(&file, globalConditions, areaOffsets.front());
 	_globalConditions = loadConditions(conditionData);
+	if (_initialCondition > _globalConditions.size())
+		error("Invalid 3D Construction Kit initial condition");
 	if (conditionData.pos() != conditionData.size())
 		error("Invalid 3D Construction Kit global condition size");
 
@@ -226,7 +235,8 @@ Common::Array<KitEngine::ConditionData> KitEngine::loadConditions(Common::Seekab
 		uint16 words = file.readUint16BE() & 0x7fff;
 		ConditionData condition;
 		condition.name = name;
-		condition.code = readCode(file, 2 * words);
+		Common::String source = detokenise16bitCondition(readCode(file, 2 * words), condition.condition);
+		debugC(1, kFreescapeDebugParser, "3DCK condition %s:\n%s", name, source.c_str());
 		conditions.push_back(condition);
 	}
 	return conditions;
@@ -259,6 +269,7 @@ Area *KitEngine::loadArea(Common::SeekableReadStream &file) {
 		if (data.objects.contains(record.id))
 			error("Duplicate 3D Construction Kit object %u in area %u", record.id, id);
 		data.objects[record.id] = record;
+		data.objectOrder.push_back(record.id);
 		if (!obj)
 			continue;
 		if (id != 255)
@@ -351,11 +362,12 @@ Object *KitEngine::loadObject(Common::SeekableReadStream &file, ObjectData &data
 		// Entrances can retain editor data after their header.
 		data.extra = readWords(payload, (payload.size() - payload.pos()) / 2);
 	} else {
-		data.code = readCode(payload, payload.size() - payload.pos());
+		Common::String source = detokenise16bitCondition(readCode(payload, payload.size() - payload.pos()), data.condition);
+		debugC(1, kFreescapeDebugParser, "3DCK object %u condition:\n%s", data.id, source.c_str());
 	}
 	file.seek(end);
-	debugC(1, kFreescapeDebugParser, "3DCK object %u: type %u, flags %02x, %u script bytes",
-		data.id, data.type, data.flags, data.code.size());
+	debugC(1, kFreescapeDebugParser, "3DCK object %u: type %u, flags %02x, %u instructions",
+		data.id, data.type, data.flags, data.condition.size());
 
 	if (data.type == kEntranceType && data.id != 255)
 		return new Entrance(data.id & 0x7fff, data.initialOrigin, data.size, FCLInstructionVector(), "");
@@ -375,11 +387,15 @@ Object *KitEngine::loadObject(Common::SeekableReadStream &file, ObjectData &data
 void KitEngine::initGameState() {
 	FreescapeEngine::initGameState();
 	_playerHeight = _initialPlayerHeight;
+	resetScripts();
+	_currentArea = nullptr;
 }
 
 void KitEngine::gotoArea(uint16 areaID, int entranceID) {
 	if (!_areaMap.contains(areaID))
 		error("Unknown 3D Construction Kit area %u", areaID);
+	if (_currentArea)
+		_kitVariables[9] = _currentArea->getAreaID();
 	_currentArea = _areaMap[areaID];
 	Entrance *entrance = static_cast<Entrance *>(_currentArea->entranceWithID(entranceID));
 	if (!entrance)
@@ -393,7 +409,13 @@ void KitEngine::gotoArea(uint16 areaID, int entranceID) {
 	_lastPosition = _position;
 	_gfx->_scale = _currentArea->getScale();
 	_gotoExecuted = true;
+	_delayedShootObject = nullptr;
+	_timerTicks = 0;
+	_scriptSurface.fillRect(_viewArea, 0);
 	resetInput();
+	_shootMode = true;
+	g_system->lockMouse(false);
+	readSystemVariables();
 }
 
 void KitEngine::checkIfStillInArea() {
@@ -403,14 +425,7 @@ void KitEngine::checkIfStillInArea() {
 }
 
 bool KitEngine::checkIfGameEnded() {
-	if (_hasFallen || _playerWasCrushed)
-		_gameStateControl = kFreescapeGameStateRestart;
 	return false;
 }
 
-void KitEngine::drawUI() {
-	_gfx->setViewport(_fullscreenViewArea);
-	_gfx->renderCrossair(_crossairPosition);
-}
-
 } // namespace Freescape
diff --git a/engines/freescape/games/3dck/3dck.h b/engines/freescape/games/3dck/3dck.h
index fb58fbdda91..1dd0a9837d6 100644
--- a/engines/freescape/games/3dck/3dck.h
+++ b/engines/freescape/games/3dck/3dck.h
@@ -23,6 +23,7 @@
 #define FREESCAPE_GAMES_3DCK_H
 
 #include "freescape/freescape.h"
+#include "freescape/language/instruction16bit.h"
 
 namespace Freescape {
 
@@ -37,13 +38,27 @@ public:
 	bool checkIfGameEnded() override;
 	void borderScreen() override {}
 	void drawUI() override;
+	bool handleInput(const Common::Event &event) override;
+	void updatePlayerMovement(float deltaTime) override;
+	void updateTimeVariables() override;
+	void updateScripts() override;
+	bool executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) override;
+	void executeLocalGlobalConditions(bool shot, bool collided, bool timer) override {}
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
 
 private:
+	struct ObjectData;
+	struct ScriptState : FCLExecutionState {
+		ObjectData *object = nullptr;
+		uint16 area = 0;
+		byte events = 0;
+	};
+
 	struct ConditionData {
 		Common::String name;
-		Common::Array<byte> code;
+		FCLInstructionVector condition;
+		ScriptState script;
 	};
 
 	struct SensorData {
@@ -62,26 +77,66 @@ private:
 		Math::Vector3d origin, size, initialOrigin;
 		Common::Array<uint16> members;
 		Common::Array<uint16> extra;
-		Common::Array<byte> code;
+		FCLInstructionVector condition;
 		SensorData sensor;
+		ScriptState script;
+		Common::Array<uint16> animatedObjects;
+		uint16 animator = 0;
 	};
 
 	struct AreaData {
 		Common::HashMap<uint16, ObjectData> objects;
+		Common::Array<uint16> objectOrder;
 		Common::Array<ConditionData> conditions;
 	};
 
+	struct ScriptEntry {
+		ScriptState *script;
+		bool resume;
+		ScriptEntry(ScriptState *s, bool r) : script(s), resume(r) {}
+	};
+
 	void loadWorld(Common::SeekableReadStream &file);
 	Area *loadArea(Common::SeekableReadStream &file);
 	Object *loadObject(Common::SeekableReadStream &file, ObjectData &data);
 	Common::Array<ConditionData> loadConditions(Common::SeekableReadStream &file);
+	void resetScripts();
+	void startScript(ScriptState &script);
+	void beginScriptFrame();
+	FCLExecutionResult executeCode(ScriptState &script, uint &budget);
+	void readSystemVariables();
+	void writeSystemVariables();
+	void setScriptVariable(byte index, uint32 value);
+	int32 getVariableOrConstant(int32 operand, Token::Type type) const;
+	void setScriptPredicate(ScriptState &script, bool value);
+	ObjectData *scriptObject(uint16 area, uint16 id);
+	void collectObjects(uint16 area, uint16 id, Common::Array<uint16> &objects);
+	void setObjectStatus(uint16 area, uint16 id, Token::Type operation);
+	bool moveAnimation(ScriptState &script, Math::Vector3d movement, bool absolute);
+	void interact(bool shot);
+	void printMessage(uint16 indicator, const Common::String &message);
+	void updateIndicators();
+	uint32 indicatorColor(byte color) const;
 
 	Common::HashMap<uint16, AreaData> _areaData;
 	Common::Array<ConditionData> _globalConditions;
 	Common::Array<uint16> _indicatorData;
+	Common::Array<uint16> _controlData;
 
 	byte _palette[256 * 3];
 	uint16 _initialPlayerHeight;
+	uint16 _initialCondition = 0, _timerInterval = 0, _activationRange = 0;
+	uint32 _kitVariables[256] = {};
+	uint32 _changedVariables = 0;
+	uint32 _scriptTicks = 0, _timerTicks = 0, _delayUntil = 0;
+	int _lastScriptTick = 0;
+	bool _timerTriggered = false, _initialScriptPending = false;
+	bool _scriptFrameActive = false, _scriptDelayed = false;
+	bool _soundWarning = false;
+	uint _scriptQueueIndex = 0;
+	Common::Array<ScriptEntry> _scriptQueue;
+	Common::Array<ScriptState *> _suspendedScripts;
+	Graphics::ManagedSurface _scriptSurface;
 };
 
 } // namespace Freescape
diff --git a/engines/freescape/games/3dck/ui.cpp b/engines/freescape/games/3dck/ui.cpp
new file mode 100644
index 00000000000..76757e87e9f
--- /dev/null
+++ b/engines/freescape/games/3dck/ui.cpp
@@ -0,0 +1,201 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "graphics/fonts/dosfont.h"
+#include "math/utils.h"
+
+#include "freescape/games/3dck/3dck.h"
+
+namespace Freescape {
+
+uint32 KitEngine::indicatorColor(byte color) const {
+	return _scriptSurface.format.ARGBToColor(255,
+		_palette[3 * color], _palette[3 * color + 1], _palette[3 * color + 2]);
+}
+
+void KitEngine::printMessage(uint16 indicator, const Common::String &message) {
+	if (!indicator || indicator > _indicatorData.size() / 17)
+		return;
+	const uint16 *data = &_indicatorData[17 * (indicator - 1)];
+	if (data[0] != 1 || data[1] >= _screenW || data[2] >= _screenH)
+		return;
+	Common::Rect rect(data[1], data[2], MIN<int>(data[1] + data[3], _screenW),
+		MIN<int>(data[2] + data[4], _screenH));
+	if (rect.isEmpty())
+		return;
+	Graphics::Surface surface = _scriptSurface.getSubArea(rect);
+	Graphics::DosFont font;
+	int x = 0, y = 0;
+	for (uint i = 0; i < message.size(); i++) {
+		byte chr = message[i];
+		if (chr == 0x1b && i + 1 < message.size()) {
+			chr = message[++i];
+			if (chr == 'N') {
+				x = 0;
+				y += 8;
+				continue;
+			}
+			if (chr >= 'A' && chr <= 'Z')
+				continue;
+		}
+		if (y >= surface.h)
+			break;
+		Common::Rect cell(x, y, MIN(x + 8, int(surface.w)), MIN(y + 8, int(surface.h)));
+		surface.fillRect(cell, indicatorColor(data[11]));
+		font.drawChar(&surface, chr, x, y, indicatorColor(data[10]));
+		x += 8;
+		if (x >= surface.w) {
+			x = 0;
+			y += 8;
+		}
+	}
+}
+
+void KitEngine::updateIndicators() {
+	Graphics::DosFont font;
+	for (uint i = 0; i < _indicatorData.size(); i += 17) {
+		const uint16 *data = &_indicatorData[i];
+		if (data[0] < 2 || data[0] > 4 || data[1] >= _screenW || data[2] >= _screenH)
+			continue;
+		Common::Rect rect(data[1], data[2], MIN<int>(data[1] + data[3], _screenW),
+			MIN<int>(data[2] + data[4], _screenH));
+		if (rect.isEmpty())
+			continue;
+		int32 first = int32((uint32(data[5]) << 16) | data[6]);
+		int32 last = int32((uint32(data[7]) << 16) | data[8]);
+		int32 value = CLIP<int32>(_kitVariables[data[9] & 0xff], MIN(first, last), MAX(first, last));
+		uint32 foreground = indicatorColor(data[10]), background = indicatorColor(data[11]);
+		_scriptSurface.fillRect(rect, background);
+		if (data[0] == 2) {
+			int digits = MIN<int>(rect.width() / 8, 8);
+			Common::String number = Common::String::format("%0*d", digits, value);
+			Graphics::Surface surface = _scriptSurface.getSubArea(rect);
+			font.drawString(&surface, number, 0, 0, rect.width(), foreground);
+		} else if (first != last) {
+			int length = data[0] == 3 ? rect.width() : rect.height();
+			int filled = (int64(value) - first) * length / (int64(last) - first);
+			if (data[0] == 3)
+				rect.right = rect.left + filled;
+			else
+				rect.top = rect.bottom - filled;
+			if (!rect.isEmpty())
+				_scriptSurface.fillRect(rect, foreground);
+		}
+	}
+}
+
+void KitEngine::drawUI() {
+	updateIndicators();
+	drawFullscreenSurface(_scriptSurface.surfacePtr());
+	_gfx->setViewport(_fullscreenViewArea);
+	_gfx->renderCrossair(_crossairPosition);
+}
+
+bool KitEngine::handleInput(const Common::Event &event) {
+	if (event.type == Common::EVENT_KEYDOWN) {
+		_kitVariables[15] = event.kbd.ascii;
+		if (!_kitVariables[15] && event.kbd.keycode < 128)
+			_kitVariables[15] = event.kbd.keycode;
+	} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
+		int control = -1;
+		switch (event.customType) {
+		case kActionEscape:
+			return false;
+		case kActionChangeMode: case kActionSkip:
+			_kitVariables[15] = ' ';
+			return true;
+		case kActionMoveUp: control = 0; break;
+		case kActionMoveDown: control = 1; break;
+		case kActionMoveLeft: control = 3; break;
+		case kActionMoveRight: control = 2; break;
+		case kActionRotateUp: control = 8; break;
+		case kActionRotateDown: control = 9; break;
+		case kActionRotateLeft: control = 6; break;
+		case kActionRotateRight: control = 7; break;
+		case kActionTurnBack: control = 12; break;
+		case kActionShoot: control = 30; break;
+		case kActionActivate: control = 31; break;
+		case kActionInfoMenu:
+			_kitVariables[15] = 'I';
+			return true;
+		default: break;
+		}
+		if (control >= 0) {
+			byte key = _controlData[5 * control + 4] >> 8;
+			if (key != 0xff)
+				_kitVariables[15] = key;
+		}
+		if (event.customType == kActionShoot || event.customType == kActionActivate) {
+			if (!_scriptFrameActive)
+				interact(event.customType == kActionShoot);
+			return true;
+		}
+		if (_scriptFrameActive)
+			return true;
+	} else if (_scriptFrameActive && event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END &&
+			(event.customType == kActionRiseOrFlyUp || event.customType == kActionLowerOrFlyDown)) {
+		_moveUp = _moveDown = false;
+		return true;
+	} else if (event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN) {
+		_crossairPosition = getNormalizedPosition(event.mouse);
+		int buttons = g_system->getEventManager()->getButtonState();
+		_kitVariables[16] = ((buttons & Common::EventManager::LBUTTON) ? 1 : 0) |
+			((buttons & Common::EventManager::RBUTTON) ? 2 : 0);
+		_kitVariables[16] |= event.type == Common::EVENT_LBUTTONDOWN ? 1 : 2;
+		_kitVariables[17] = _crossairPosition.x;
+		_kitVariables[18] = _crossairPosition.y;
+		if (!_scriptFrameActive)
+			interact(event.type == Common::EVENT_LBUTTONDOWN);
+		return true;
+	}
+	return false;
+}
+
+void KitEngine::interact(bool shot) {
+	if (!_viewArea.contains(_crossairPosition) || (shot && !(_kitVariables[20] & 1)))
+		return;
+	float x = 2.0f * (_crossairPosition.x - _viewArea.left) / _viewArea.width() - 1;
+	float y = 1 - 2.0f * (_crossairPosition.y - _viewArea.top) / _viewArea.height();
+	float projection = tan(Math::deg2rad(_fieldOfView) / 2);
+	Math::Vector3d direction = directionToVector(_pitch + Math::rad2deg(atan(y * projection / _viewAspectRatio)),
+		_yaw - Math::rad2deg(atan(x * projection)), false);
+	Object *object = _currentArea->checkCollisionRay(Math::Ray(_position, direction), 8192, true);
+	if (shot) {
+		_kitVariables[21]++;
+		if (_kitVariables[20] & 2)
+			_shootingFrames = 3;
+	}
+	if (!object || !object->isGeometric())
+		return;
+	if (!shot) {
+		float distance = 0;
+		for (int axis = 0; axis < 3; axis++) {
+			if (object->getSize().getValue(axis) * _currentArea->getScale() > 1000)
+				return;
+			distance += ABS(object->getOrigin().getValue(axis) + object->getSize().getValue(axis) / 2 - _position.getValue(axis));
+		}
+		if (distance > _activationRange)
+			return;
+	}
+	executeObjectConditions(static_cast<GeometricObject *>(object), shot, false, !shot);
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/16bitDetokeniser.cpp b/engines/freescape/language/16bitDetokeniser.cpp
new file mode 100644
index 00000000000..4e8585e2f12
--- /dev/null
+++ b/engines/freescape/language/16bitDetokeniser.cpp
@@ -0,0 +1,181 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/endian.h"
+#include "common/textconsole.h"
+
+#include "freescape/language/16bitDetokeniser.h"
+
+namespace Freescape {
+
+static const struct {
+	byte opcode;
+	Token::Type type;
+	byte minArgs, maxArgs;
+	const char *name;
+} opcodeTable[] = {
+	{0x00, Token::NOP,          0, 0, "NOP"},
+	{0x01, Token::CONDITIONAL,  0, 0, "ACTIVATED?"},
+	{0x02, Token::CONDITIONAL,  0, 0, "COLLIDED?"},
+	{0x03, Token::CONDITIONAL,  0, 0, "SHOT?"},
+	{0x04, Token::CONDITIONAL,  0, 0, "TIMER?"},
+	{0x10, Token::SETVAR,       2, 2, "SETVAR"},
+	{0x11, Token::ADDVAR,       2, 2, "ADDVAR"},
+	{0x12, Token::SUBVAR,       2, 2, "SUBVAR"},
+	{0x13, Token::ANDV,         2, 2, "ANDV"},
+	{0x14, Token::ORV,          2, 2, "ORV"},
+	{0x15, Token::NOTV,         1, 1, "NOTV"},
+	{0x16, Token::VAREQ,        2, 2, "VAR=?"},
+	{0x17, Token::VARGT,        2, 2, "VAR>?"},
+	{0x18, Token::VARLT,        2, 2, "VAR<?"},
+	{0x2f, Token::DESTROYEDQ,   1, 2, "DESTROYED?"},
+	{0x30, Token::INVIS,        1, 2, "INVIS"},
+	{0x31, Token::VIS,          1, 2, "VIS"},
+	{0x32, Token::TOGVIS,       1, 2, "TOGVIS"},
+	{0x33, Token::DESTROY,      1, 2, "DESTROY"},
+	{0x34, Token::INVISQ,       1, 2, "INVIS?"},
+	{0x35, Token::VISQ,         1, 2, "VIS?"},
+	{0x36, Token::MOVE,         3, 3, "MOVE"},
+	{0x37, Token::GETXPOS,      2, 3, "GETXPOS"},
+	{0x38, Token::GETYPOS,      2, 3, "GETYPOS"},
+	{0x39, Token::GETZPOS,      2, 3, "GETZPOS"},
+	{0x3a, Token::MOVETO,       3, 3, "MOVETO"},
+	{0x40, Token::IF,           0, 0, "IF"},
+	{0x41, Token::THEN,         0, 0, "THEN"},
+	{0x42, Token::ELSE,         0, 0, "ELSE"},
+	{0x43, Token::ENDIF,        0, 0, "ENDIF"},
+	{0x44, Token::AND,          0, 0, "AND"},
+	{0x45, Token::OR,           0, 0, "OR"},
+	{0x50, Token::STARTANIM,    1, 2, "STARTANIM"},
+	{0x51, Token::STOPANIM,     1, 2, "STOPANIM"},
+	{0x52, Token::START,        0, 0, "START"},
+	{0x53, Token::RESTART,      0, 0, "RESTART"},
+	{0x54, Token::INCLUDE,      1, 1, "INCLUDE"},
+	{0x55, Token::WAITTRIG,     0, 0, "WAITTRIG"},
+	{0x56, Token::TRIGANIM,     1, 2, "TRIGANIM"},
+	{0x57, Token::REMOVE,       1, 1, "REMOVE"},
+	{0x60, Token::LOOP,         1, 1, "LOOP"},
+	{0x61, Token::AGAIN,        2, 2, "AGAIN"},
+	{0x70, Token::SOUND,        1, 1, "SOUND"},
+	{0x71, Token::SYNCSND,      1, 1, "SYNCSND"},
+	{0x80, Token::WAIT,         0, 0, "WAIT"},
+	{0x81, Token::DELAY,        1, 1, "DELAY"},
+	{0x82, Token::UPDATEI,      1, 1, "UPDATEI"},
+	{0x83, Token::PRINT,        2, 255, "PRINT"},
+	{0x84, Token::REDRAW,       0, 0, "REDRAW"},
+	{0x85, Token::MODE,         1, 1, "MODE"},
+	{0x86, Token::ENDGAME,      0, 0, "ENDGAME"},
+	{0x87, Token::EXECUTE,      1, 1, "EXECUTE"},
+	{0x90, Token::GOTO,         1, 2, "GOTO"},
+	{0xff, Token::END,          0, 0, "END"}
+};
+
+Common::String detokenise16bitCondition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions) {
+	Common::String detokenisedStream;
+	int loops = 0;
+	for (uint32 bytePointer = 0; bytePointer < tokenisedCondition.size();) {
+		if (tokenisedCondition.size() - bytePointer < 2)
+			error("Truncated 16-bit FCL instruction at %u", bytePointer);
+		byte count = tokenisedCondition[bytePointer], opcode = tokenisedCondition[bytePointer + 1];
+		if (tokenisedCondition.size() - bytePointer < 2U + 2U * count)
+			error("Truncated 16-bit FCL instruction at %u", bytePointer);
+		uint index = 0;
+		while (index < ARRAYSIZE(opcodeTable) && opcodeTable[index].opcode != opcode)
+			index++;
+		if (index == ARRAYSIZE(opcodeTable))
+			error("Unknown 16-bit FCL opcode %02x at %u", opcode, bytePointer);
+		const auto &entry = opcodeTable[index];
+		if (count < entry.minArgs || count > entry.maxArgs)
+			error("Invalid argument count for 16-bit FCL opcode %02x at %u", opcode, bytePointer);
+
+		FCLInstruction instruction(entry.type);
+		int32 operands[3] = {};
+		Token::Type types[3] = {Token::UNKNOWN, Token::UNKNOWN, Token::UNKNOWN};
+		uint32 argumentPointer = bytePointer + 2;
+		uint argumentCount = count;
+		detokenisedStream += entry.name;
+		if (entry.type == Token::PRINT) {
+			uint16 length = READ_BE_UINT16(&tokenisedCondition[argumentPointer]);
+			if (count != 2 + (length + 1) / 2)
+				error("Invalid 16-bit FCL PRINT instruction at %u", bytePointer);
+			instruction._text = Common::String(reinterpret_cast<const char *>(&tokenisedCondition[argumentPointer + 2]), length);
+			detokenisedStream += Common::String::format(" (\"%s\", ", instruction._text.c_str());
+			argumentPointer += 2 + ((length + 1) & ~1);
+			argumentCount = 1;
+		} else if (entry.type == Token::AGAIN) {
+			argumentCount = 0; // The two words hold the runner's loop state.
+		} else if (count) {
+			detokenisedStream += " (";
+		}
+		for (uint i = 0; i < argumentCount; i++) {
+			uint16 operand = READ_BE_UINT16(&tokenisedCondition[argumentPointer + 2 * i]);
+			types[i] = operand & 0x8000 ? Token::VARIABLE : Token::CONSTANT;
+			operands[i] = types[i] == Token::VARIABLE ? operand & 0xff : (operand & 0x4000 ? int32(operand) - 0x8000 : operand);
+			if (i)
+				detokenisedStream += ", ";
+			detokenisedStream += Common::String::format(types[i] == Token::VARIABLE ? "v%d" : "%d", operands[i]);
+		}
+		if (argumentCount)
+			detokenisedStream += ")";
+		detokenisedStream += "\n";
+
+		// Match the operand order used by the 8-bit detokeniser.
+		switch (entry.type) {
+		case Token::SETVAR: case Token::ADDVAR: case Token::SUBVAR: case Token::ANDV: case Token::ORV:
+		case Token::GOTO:
+			SWAP(operands[0], operands[1]);
+			SWAP(types[0], types[1]);
+			break;
+		case Token::INVIS: case Token::VIS: case Token::TOGVIS: case Token::DESTROY:
+		case Token::INVISQ: case Token::VISQ: case Token::DESTROYEDQ:
+		case Token::STARTANIM: case Token::STOPANIM: case Token::TRIGANIM:
+			if (count == 2) {
+				SWAP(operands[0], operands[1]);
+				SWAP(types[0], types[1]);
+			}
+			break;
+		case Token::CONDITIONAL:
+			operands[0] = opcode == 1 ? kConditionalActivated : opcode == 2 ? kConditionalCollided :
+				opcode == 3 ? kConditionalShot : kConditionalTimeout;
+			types[0] = Token::CONSTANT;
+			break;
+		case Token::LOOP:
+			loops++;
+			break;
+		case Token::AGAIN:
+			if (--loops < 0)
+				error("16-bit FCL AGAIN without LOOP");
+			break;
+		default:
+			break;
+		}
+		instruction.setSource(operands[0], types[0]);
+		instruction.setDestination(operands[1], types[1]);
+		instruction.setAdditional(operands[2], types[2]);
+		instructions.push_back(instruction);
+		bytePointer += 2 + 2 * count;
+	}
+	if (loops)
+		error("Unterminated 16-bit FCL LOOP");
+	return detokenisedStream;
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/16bitDetokeniser.h b/engines/freescape/language/16bitDetokeniser.h
new file mode 100644
index 00000000000..08b63b214ca
--- /dev/null
+++ b/engines/freescape/language/16bitDetokeniser.h
@@ -0,0 +1,33 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_16BITDETOKENISER_H
+#define FREESCAPE_16BITDETOKENISER_H
+
+#include "freescape/language/instruction.h"
+
+namespace Freescape {
+
+Common::String detokenise16bitCondition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions);
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/language/8bitDetokeniser.h b/engines/freescape/language/8bitDetokeniser.h
index 7f00dcd04de..c06d5b73c5b 100644
--- a/engines/freescape/language/8bitDetokeniser.h
+++ b/engines/freescape/language/8bitDetokeniser.h
@@ -42,13 +42,6 @@ enum {
 	k8bitMaxVariable = 64
 };
 
-enum {
-	kConditionalShot = 1 << 0,
-	kConditionalTimeout = 1 << 1,
-	kConditionalCollided = 1 << 2,
-	kConditionalActivated = 1 << 3,
-};
-
 extern uint8 k8bitVariableShield;
 
 Common::String detokenise8bitCondition(Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool enableActivated);
diff --git a/engines/freescape/language/instruction.cpp b/engines/freescape/language/instruction.cpp
index 8d6c924d57a..eae6d37fcad 100644
--- a/engines/freescape/language/instruction.cpp
+++ b/engines/freescape/language/instruction.cpp
@@ -41,9 +41,10 @@ FCLInstructionVector *duplicateCondition(FCLInstructionVector *condition) {
 
 FCLInstruction FCLInstruction::duplicate() {
 	FCLInstruction copy(_type);
-	copy.setSource(_source);
-	copy.setDestination(_destination);
-	copy.setAdditional(_additional);
+	copy.setSource(_source, _sourceType);
+	copy.setDestination(_destination, _destinationType);
+	copy.setAdditional(_additional, _additionalType);
+	copy._text = _text;
 
 	copy._thenInstructions = duplicateCondition(_thenInstructions);
 	copy._elseInstructions = duplicateCondition(_elseInstructions);
@@ -55,6 +56,7 @@ FCLInstruction::FCLInstruction(Token::Type type_) {
 	_source = 0;
 	_destination = 0;
 	_additional = 0;
+	_sourceType = _destinationType = _additionalType = Token::UNKNOWN;
 	_type = type_;
 	_thenInstructions = nullptr;
 	_elseInstructions = nullptr;
@@ -64,21 +66,25 @@ FCLInstruction::FCLInstruction() {
 	_source = 0;
 	_destination = 0;
 	_additional = 0;
+	_sourceType = _destinationType = _additionalType = Token::UNKNOWN;
 	_type = Token::UNKNOWN;
 	_thenInstructions = nullptr;
 	_elseInstructions = nullptr;
 }
 
-void FCLInstruction::setSource(int32 source_) {
+void FCLInstruction::setSource(int32 source_, Token::Type type) {
 	_source = source_;
+	_sourceType = type;
 }
 
-void FCLInstruction::setAdditional(int32 additional_) {
+void FCLInstruction::setAdditional(int32 additional_, Token::Type type) {
 	_additional = additional_;
+	_additionalType = type;
 }
 
-void FCLInstruction::setDestination(int32 destination_) {
+void FCLInstruction::setDestination(int32 destination_, Token::Type type) {
 	_destination = destination_;
+	_destinationType = type;
 }
 
 void FCLInstruction::setBranches(FCLInstructionVector *thenBranch, FCLInstructionVector *elseBranch) {
@@ -587,7 +593,7 @@ bool FreescapeEngine::executeEndIfVisibilityIsEqual(FCLInstruction &instruction)
 	return (obj->isInvisible() == (value != 0));
 }
 
-bool FreescapeEngine::checkConditional(FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated) {
+bool FreescapeEngine::checkConditional(const FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated) {
 	uint16 conditional = instruction._source;
 	bool result = false;
 
diff --git a/engines/freescape/language/instruction.h b/engines/freescape/language/instruction.h
index c7f6835cba4..c3feb0e0dec 100644
--- a/engines/freescape/language/instruction.h
+++ b/engines/freescape/language/instruction.h
@@ -26,10 +26,18 @@
 #define FREESCAPE_INSTRUCTION_H
 
 #include "common/array.h"
+#include "common/str.h"
 #include "freescape/language/token.h"
 
 namespace Freescape {
 
+enum {
+	kConditionalShot = 1 << 0,
+	kConditionalTimeout = 1 << 1,
+	kConditionalCollided = 1 << 2,
+	kConditionalActivated = 1 << 3,
+};
+
 class FCLInstruction;
 typedef Common::Array<FCLInstruction> FCLInstructionVector;
 
@@ -37,9 +45,9 @@ class FCLInstruction {
 public:
 	FCLInstruction();
 	FCLInstruction(Token::Type type);
-	void setSource(int32 source);
-	void setAdditional(int32 additional);
-	void setDestination(int32 destination);
+	void setSource(int32 source, Token::Type type = Token::CONSTANT);
+	void setAdditional(int32 additional, Token::Type type = Token::CONSTANT);
+	void setDestination(int32 destination, Token::Type type = Token::CONSTANT);
 
 	Token::Type getType() const;
 
@@ -57,6 +65,11 @@ public:
 	int32 _source;
 	int32 _additional;
 	int32 _destination;
+	// UNKNOWN denotes an omitted operand.
+	Token::Type _sourceType;
+	Token::Type _additionalType;
+	Token::Type _destinationType;
+	Common::String _text;
 
 	FCLInstructionVector *_thenInstructions;
 	FCLInstructionVector *_elseInstructions;
diff --git a/engines/freescape/language/instruction16bit.cpp b/engines/freescape/language/instruction16bit.cpp
new file mode 100644
index 00000000000..581c84323fd
--- /dev/null
+++ b/engines/freescape/language/instruction16bit.cpp
@@ -0,0 +1,640 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/algorithm.h"
+#include "math/utils.h"
+
+#include "freescape/games/3dck/3dck.h"
+
+namespace Freescape {
+
+void KitEngine::resetScripts() {
+	// V255 survives ENDGAME, as in the original runner.
+	memset(_kitVariables, 0, 255 * sizeof(_kitVariables[0]));
+	_changedVariables = 0;
+	_kitVariables[20] = 7;
+	_scriptTicks = _timerTicks = 0;
+	_lastScriptTick = _ticks;
+	_scriptFrameActive = _scriptDelayed = false;
+	_initialScriptPending = _initialCondition != 0;
+	_scriptQueue.clear();
+	_suspendedScripts.clear();
+	_scriptSurface.fillRect(_fullscreenViewArea, 0);
+	for (auto &condition : _globalConditions) {
+		condition.script = ScriptState();
+		condition.script.source = &condition.condition;
+	}
+	for (auto &entry : _areaData) {
+		AreaData &area = entry._value;
+		for (auto &condition : area.conditions) {
+			condition.script = ScriptState();
+			condition.script.area = entry._key;
+			condition.script.source = &condition.condition;
+		}
+		for (auto &item : area.objects) {
+			ObjectData &object = item._value;
+			object.script = ScriptState();
+			object.script.object = &object;
+			object.script.area = entry._key;
+			object.script.source = &object.condition;
+			object.animatedObjects.clear();
+			object.animator = 0;
+			object.flags &= 0x84;
+			if ((object.flags & 4) || object.type == 16)
+				object.flags |= 2;
+			object.origin = object.initialOrigin;
+			Object *geometry = _areaMap[entry._key]->objectWithID(object.id);
+			if (geometry && geometry->isGeometric()) {
+				float scale = entry._key == 255 ? 1 : _areaMap[entry._key]->getScale();
+				static_cast<GeometricObject *>(geometry)->offsetOrigin(object.origin / scale);
+			}
+		}
+		_areaMap[entry._key]->getSortedObjects().clear();
+	}
+}
+
+void KitEngine::startScript(ScriptState &script) {
+	script.code = script.source;
+	script.ip = script.restart = 0;
+	script.loops.clear();
+	script.predicate = script.previousPredicate = true;
+	script.booleanOp = Token::UNKNOWN;
+	script.events = script.object ? script.object->flags & 0x38 : 0;
+	if (script.object)
+		script.object->flags &= ~0x38;
+	script.running = true;
+}
+
+void KitEngine::updateTimeVariables() {
+	uint32 elapsed = _ticks - _lastScriptTick;
+	_lastScriptTick = _ticks;
+	_scriptTicks += elapsed;
+	_timerTicks += elapsed;
+	_kitVariables[19] += elapsed;
+}
+
+void KitEngine::readSystemVariables() {
+	float scale = _currentArea->getScale();
+	for (int axis = 0; axis < 3; axis++)
+		_kitVariables[axis] = int32(_position.getValue(axis) * scale);
+	_kitVariables[3] = (int(_pitch) % 360 + 360) % 360;
+	_kitVariables[4] = (int(90 - _yaw) % 360 + 360) % 360;
+	_kitVariables[5] = (int(_roll) % 360 + 360) % 360;
+	_kitVariables[7] = int32(_playerHeight * scale);
+	_kitVariables[8] = _currentArea->getAreaID();
+}
+
+void KitEngine::writeSystemVariables() {
+	float scale = _currentArea->getScale();
+	for (int axis = 0; axis < 3; axis++) {
+		if (_changedVariables & (1 << axis)) {
+			_position.setValue(axis, int16(_kitVariables[axis]) / scale);
+			_lastPosition = _position;
+		}
+	}
+	if (_changedVariables & (1 << 3))
+		_pitch = int16(_kitVariables[3]) / 5 * 5;
+	if (_changedVariables & (1 << 4))
+		_yaw = 90 - int16(_kitVariables[4]) / 5 * 5;
+	if (_changedVariables & (1 << 5))
+		_roll = int16(_kitVariables[5]) / 5 * 5;
+	if ((_changedVariables & (1 << 7)) && int16(_kitVariables[7]) > 0)
+		_playerHeight = int16(_kitVariables[7]) / scale;
+	if (_changedVariables & (1 << 6))
+		_flyMode = _kitVariables[6] != 0 || _noClipMode;
+	if (_changedVariables & 0x38)
+		updateCamera();
+	_changedVariables = 0;
+}
+
+void KitEngine::setScriptVariable(byte index, uint32 value) {
+	_kitVariables[index] = value;
+	if (index < 8)
+		_changedVariables |= 1 << index;
+}
+
+void KitEngine::beginScriptFrame() {
+	readSystemVariables();
+	_timerTriggered = _timerTicks >= _timerInterval;
+	if (_timerTriggered)
+		_timerTicks = 0;
+	_scriptQueue.clear();
+	_scriptQueueIndex = 0;
+	_scriptFrameActive = true;
+	if (_initialScriptPending) {
+		_scriptQueue.push_back(ScriptEntry(&_globalConditions[_initialCondition - 1].script, false));
+		_initialScriptPending = false;
+	}
+	for (auto *script : _suspendedScripts) {
+		if (script->object) {
+			script->events = script->object->flags & 0x38;
+			script->object->flags &= ~0x38;
+		}
+		_scriptQueue.push_back(ScriptEntry(script, true));
+	}
+	_suspendedScripts.clear();
+
+	AreaData &area = _areaData[_currentArea->getAreaID()];
+	for (uint16 id : area.objectOrder) {
+		ObjectData &object = area.objects[id];
+		if (object.type != 16 && !object.condition.empty())
+			_scriptQueue.push_back(ScriptEntry(&object.script, false));
+	}
+	for (auto &condition : area.conditions)
+		_scriptQueue.push_back(ScriptEntry(&condition.script, false));
+	for (uint i = 0; i < _globalConditions.size(); i++) {
+		if (i + 1 != _initialCondition)
+			_scriptQueue.push_back(ScriptEntry(&_globalConditions[i].script, false));
+	}
+	for (uint16 id : area.objectOrder) {
+		ObjectData &object = area.objects[id];
+		if (object.type == 16)
+			_scriptQueue.push_back(ScriptEntry(&object.script, false));
+	}
+}
+
+void KitEngine::updateScripts() {
+	if (_scriptDelayed) {
+		if (int32(_scriptTicks - _delayUntil) < 0)
+			return;
+		_scriptDelayed = false;
+	}
+	if (!_scriptFrameActive) {
+		if (_playerWasCrushed) {
+			_kitVariables[12]++;
+			_playerWasCrushed = false;
+			_avoidRenderingFrames = 0;
+		}
+		beginScriptFrame();
+	}
+
+	uint budget = 4096;
+	uint16 areaID = _currentArea->getAreaID();
+	while (_scriptQueueIndex < _scriptQueue.size()) {
+		ScriptEntry &entry = _scriptQueue[_scriptQueueIndex];
+		ScriptState &script = *entry.script;
+		ObjectData *object = script.object;
+		bool animator = object && object->type == 16;
+		if (script.area && script.area != areaID) {
+			if (!animator)
+				script.running = false;
+			_scriptQueueIndex++;
+			continue;
+		}
+		if (!entry.resume) {
+			if ((animator && (object->flags & 2)) || (!animator && script.running) ||
+					(object && !animator && !(object->flags & 0x38))) {
+				_scriptQueueIndex++;
+				continue;
+			}
+			if (!script.running)
+				startScript(script);
+			entry.resume = true;
+		}
+		FCLExecutionResult result = executeCode(script, budget);
+		if (result == kFCLPaused) {
+			writeSystemVariables();
+			return;
+		}
+		if (result == kFCLFinished) {
+			script.running = false;
+			if (animator)
+				object->flags |= 2;
+		} else if (!animator) {
+			_suspendedScripts.push_back(&script);
+		}
+		_scriptQueueIndex++;
+		if (_gameStateControl == kFreescapeGameStateRestart)
+			break;
+		if (areaID != _currentArea->getAreaID()) {
+			for (uint i = _scriptQueueIndex; i < _scriptQueue.size(); i++) {
+				ScriptEntry &pending = _scriptQueue[i];
+				if (!pending.resume || !pending.script->running)
+					continue;
+				if (!pending.script->area)
+					_suspendedScripts.push_back(pending.script);
+				else if (!pending.script->object || pending.script->object->type != 16)
+					pending.script->running = false;
+			}
+			break;
+		}
+	}
+	writeSystemVariables();
+	_scriptFrameActive = false;
+}
+
+int32 KitEngine::getVariableOrConstant(int32 operand, Token::Type type) const {
+	return type == Token::VARIABLE ? int32(_kitVariables[operand]) : operand;
+}
+
+void KitEngine::setScriptPredicate(ScriptState &script, bool value) {
+	if (script.booleanOp == Token::AND)
+		value = script.previousPredicate && value;
+	else if (script.booleanOp == Token::OR)
+		value = script.previousPredicate || value;
+	script.predicate = value;
+	script.booleanOp = Token::UNKNOWN;
+}
+
+KitEngine::ObjectData *KitEngine::scriptObject(uint16 area, uint16 id) {
+	if (!area)
+		area = _currentArea->getAreaID();
+	auto found = _areaData.find(area);
+	if (found == _areaData.end())
+		return nullptr;
+	auto object = found->_value.objects.find(id);
+	return object == found->_value.objects.end() ? nullptr : &object->_value;
+}
+
+void KitEngine::collectObjects(uint16 area, uint16 id, Common::Array<uint16> &objects) {
+	Common::Array<uint16> pending, visited;
+	pending.push_back(id);
+	while (!pending.empty()) {
+		id = pending.back();
+		pending.pop_back();
+		if (Common::find(visited.begin(), visited.end(), id) != visited.end())
+			continue;
+		visited.push_back(id);
+		ObjectData *object = scriptObject(area, id);
+		if (!object)
+			continue;
+		if (object->type == kGroupType) {
+			for (int i = int(object->members.size()) - 1; i >= 0; i--)
+				pending.push_back(object->members[i]);
+		} else {
+			objects.push_back(id);
+		}
+	}
+}
+
+void KitEngine::setObjectStatus(uint16 area, uint16 id, Token::Type operation) {
+	Common::Array<uint16> objects;
+	collectObjects(area, id, objects);
+	for (uint16 member : objects) {
+		ObjectData &object = *scriptObject(area, member);
+		switch (operation) {
+		case Token::INVIS: object.flags |= 2; break;
+		case Token::VIS: object.flags &= ~2; break;
+		case Token::TOGVIS: object.flags ^= 2; break;
+		case Token::DESTROY: object.flags |= 3; break;
+		default: break;
+		}
+		Object *geometry = _areaMap[area]->objectWithID(member);
+		if (!geometry)
+			continue;
+		if (object.flags & 1)
+			geometry->destroy();
+		if (object.flags & 2)
+			geometry->makeInvisible();
+		else
+			geometry->makeVisible();
+		_areaMap[area]->getSortedObjects().clear();
+	}
+}
+
+FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
+	bool animator = script.object && script.object->type == 16;
+	while (script.ip < script.code->size()) {
+		if (!budget)
+			return kFCLPaused;
+		budget--;
+		const FCLInstructionVector &code = *script.code;
+		uint32 ip = script.ip++;
+		const FCLInstruction &instruction = code[ip];
+		Token::Type op = instruction.getType();
+		int32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+		int32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+		int32 additional = getVariableOrConstant(instruction._additional, instruction._additionalType);
+		bool hasDestination = instruction._destinationType != Token::UNKNOWN;
+		switch (op) {
+		case Token::NOP: case Token::ENDIF:
+			break;
+		case Token::IF:
+			script.predicate = true;
+			script.booleanOp = Token::UNKNOWN;
+			break;
+		case Token::AND: case Token::OR:
+			script.previousPredicate = script.predicate;
+			script.booleanOp = op;
+			break;
+		case Token::THEN: case Token::ELSE:
+			if (op == Token::ELSE || !script.predicate) {
+				int depth = 0;
+				while (script.ip < code.size()) {
+					Token::Type next = code[script.ip++].getType();
+					if (next == Token::IF)
+						depth++;
+					else if (next == Token::ENDIF) {
+						if (!depth)
+							break;
+						depth--;
+					} else if (next == Token::ELSE && !depth && op == Token::THEN) {
+						break;
+					}
+				}
+			}
+			break;
+		case Token::CONDITIONAL:
+			// RUNVGA retains these flags until the object's execution yields or ends.
+			setScriptPredicate(script, checkConditional(instruction,
+				script.events & 16, script.events & 32, _timerTriggered, script.events & 8));
+			break;
+		case Token::SETVAR: case Token::ADDVAR: case Token::SUBVAR: case Token::ANDV: case Token::ORV: case Token::NOTV: {
+			uint32 value = destination;
+			switch (op) {
+			case Token::ADDVAR: value += uint32(source); break;
+			case Token::SUBVAR: value = uint32(source) - value; break;
+			case Token::ANDV: value &= uint32(source); break;
+			case Token::ORV: value |= uint32(source); break;
+			case Token::NOTV: value = ~uint32(source); break;
+			default: break;
+			}
+			if (instruction._sourceType == Token::VARIABLE)
+				setScriptVariable(instruction._source, value);
+			setScriptPredicate(script, value != 0);
+			break;
+		}
+		case Token::VAREQ: case Token::VARGT: case Token::VARLT:
+			setScriptPredicate(script, op == Token::VAREQ ? source == destination : op == Token::VARGT ? source > destination : source < destination);
+			break;
+		case Token::INVIS: case Token::VIS: case Token::TOGVIS: case Token::DESTROY:
+		case Token::INVISQ: case Token::VISQ: case Token::DESTROYEDQ: {
+			uint16 area = hasDestination && source ? source : _currentArea->getAreaID();
+			uint16 object = hasDestination ? destination : source;
+			if (op == Token::INVIS || op == Token::VIS || op == Token::TOGVIS || op == Token::DESTROY)
+				setObjectStatus(area, object, op);
+			else {
+				Common::Array<uint16> objects;
+				collectObjects(area, object, objects);
+				bool result = false;
+				for (uint16 member : objects) {
+					byte flags = scriptObject(area, member)->flags;
+					result = op == Token::DESTROYEDQ ? (flags & 1) != 0 : op == Token::INVISQ ? (flags & 2) != 0 : !(flags & 2);
+				}
+				setScriptPredicate(script, result);
+			}
+			break;
+		}
+		case Token::GETXPOS: case Token::GETYPOS: case Token::GETZPOS: {
+			ObjectData *object = scriptObject(additional, destination);
+			if (object)
+				setScriptVariable(instruction._source & 0xff, int32(object->origin.getValue(op - Token::GETXPOS)));
+			break;
+		}
+		case Token::EXECUTE: {
+			ObjectData *object = scriptObject(0, source);
+			if (!object || object->type == kGroupType)
+				return kFCLFinished;
+			// EXECUTE replaces the code, retaining the original object's event flags.
+			script.code = &object->condition;
+			script.ip = 0;
+			script.loops.clear();
+			break;
+		}
+		case Token::GOTO: {
+			uint16 area = instruction._sourceType != Token::UNKNOWN ? source : _currentArea->getAreaID();
+			if (!_areaMap.contains(area) || !_areaMap[area]->entranceWithID(destination & 0x7fff)) {
+				warning("Invalid 3D Construction Kit GOTO (%d, %u)", destination, area);
+				return kFCLFinished;
+			}
+			writeSystemVariables();
+			uint16 previous = _currentArea->getAreaID();
+			gotoArea(area, destination & 0x7fff);
+			if (previous != area)
+				return kFCLFinished;
+			break;
+		}
+		case Token::MODE:
+			setScriptVariable(6, CLIP<int32>(source, 1, 3) - 1);
+			break;
+		case Token::ENDGAME:
+			_gameStateControl = kFreescapeGameStateRestart;
+			return kFCLFinished;
+		case Token::END:
+			return animator ? kFCLYielded : kFCLFinished;
+		case Token::WAIT:
+			return kFCLYielded;
+		case Token::DELAY:
+			if (uint16(source)) {
+				_delayUntil = _scriptTicks + uint16(source);
+				_scriptDelayed = true;
+				return kFCLPaused;
+			}
+			break;
+		case Token::REDRAW:
+			_scriptSurface.fillRect(_viewArea, 0);
+			return animator ? kFCLYielded : kFCLPaused;
+		case Token::LOOP: {
+			int depth = 0;
+			uint32 end = script.ip;
+			while (end < code.size()) {
+				Token::Type next = code[end].getType();
+				if (next == Token::LOOP)
+					depth++;
+				else if (next == Token::AGAIN) {
+					if (!depth)
+						break;
+					depth--;
+				}
+				end++;
+			}
+			FCLLoop &loop = script.loops[end];
+			loop.start = script.ip;
+			loop.remaining = uint16(source);
+			break;
+		}
+		case Token::AGAIN: {
+			auto loop = script.loops.find(ip);
+			if (loop == script.loops.end())
+				return kFCLFinished;
+			if (--loop->_value.remaining != 0) {
+				script.ip = loop->_value.start;
+			} else {
+				script.loops.erase(loop);
+			}
+			break;
+		}
+		case Token::STARTANIM: case Token::STOPANIM: case Token::TRIGANIM: {
+			ObjectData *object = scriptObject(hasDestination ? source : 0, uint16(hasDestination ? destination : source) | 0x4000);
+			if (object && object->type == 16) {
+				if (op == Token::STARTANIM)
+					object->flags &= ~2;
+				else if (op == Token::STOPANIM)
+					object->flags |= 2;
+				else
+					object->flags |= 1;
+			}
+			if (animator && op == Token::STOPANIM)
+				return kFCLYielded;
+			break;
+		}
+		case Token::START:
+			if (animator)
+				script.restart = script.ip;
+			break;
+		case Token::RESTART:
+			if (animator)
+				script.ip = script.restart;
+			break;
+		case Token::INCLUDE: case Token::REMOVE:
+			if (animator) {
+				Common::Array<uint16> objects;
+				collectObjects(script.area, source, objects);
+				for (uint16 member : objects) {
+					ObjectData &object = *scriptObject(script.area, member);
+					if (!(object.flags & 0x80))
+						continue;
+					if (op == Token::INCLUDE && !object.animator) {
+						object.animator = script.object->id;
+						script.object->animatedObjects.push_back(member);
+					} else if (op == Token::REMOVE && object.animator == script.object->id) {
+						auto &members = script.object->animatedObjects;
+						for (uint i = 0; i < members.size(); i++) {
+							if (members[i] == member) {
+								members.remove_at(i);
+								break;
+							}
+						}
+						object.animator = 0;
+					}
+				}
+			}
+			break;
+		case Token::WAITTRIG:
+			if (animator) {
+				if (!(script.object->flags & 1)) {
+					script.ip = ip;
+					return kFCLYielded;
+				}
+				script.object->flags &= ~1;
+			}
+			break;
+		case Token::MOVE: case Token::MOVETO:
+			if (animator) {
+				setScriptPredicate(script, moveAnimation(script,
+					Math::Vector3d(int16(source), int16(destination), int16(additional)), op == Token::MOVETO));
+				return kFCLYielded;
+			}
+			break;
+		case Token::PRINT:
+			printMessage(source, instruction._text);
+			break;
+		case Token::UPDATEI:
+			updateIndicators();
+			break;
+		case Token::SOUND: case Token::SYNCSND:
+			if (!_soundWarning) {
+				warning("3D Construction Kit sound playback is not implemented");
+				_soundWarning = true;
+			}
+			break;
+		default:
+			error("Unhandled 16-bit FCL instruction %d at ip: %u", op, ip);
+		}
+	}
+	return kFCLFinished;
+}
+
+bool KitEngine::executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) {
+	ObjectData *object = scriptObject(0, obj->getObjectID());
+	if (object)
+		object->flags |= (shot ? 16 : 0) | (collided ? 32 : 0) | (activated ? 8 : 0);
+	return false;
+}
+
+bool KitEngine::moveAnimation(ScriptState &script, Math::Vector3d movement, bool absolute) {
+	const auto &members = script.object->animatedObjects;
+	if (members.empty())
+		return true;
+	Math::Vector3d minimum(8191, 8191, 8191), maximum;
+	for (uint16 id : members) {
+		ObjectData &object = *scriptObject(script.area, id);
+		for (int axis = 0; axis < 3; axis++) {
+			minimum.setValue(axis, MIN(minimum.getValue(axis), object.origin.getValue(axis)));
+			maximum.setValue(axis, MAX(maximum.getValue(axis), object.origin.getValue(axis) + object.size.getValue(axis)));
+		}
+	}
+	if (absolute)
+		movement -= minimum;
+	bool unobstructed = true;
+	for (int axis = 0; axis < 3; axis++) {
+		float delta = movement.getValue(axis);
+		float clipped = CLIP(delta, -minimum.getValue(axis), 8191 - maximum.getValue(axis));
+		if (delta != clipped)
+			unobstructed = false;
+		movement.setValue(axis, clipped);
+	}
+	// The runner collides the combined header bounds of all included objects.
+	AreaData &area = _areaData[script.area];
+	for (uint16 id : area.objectOrder) {
+		ObjectData &object = area.objects[id];
+		if ((id & 0xc000) || (object.flags & 3) || object.type == kGroupType ||
+				object.animator == script.object->id)
+			continue;
+		bool overlaps = true;
+		for (int axis = 0; axis < 3; axis++) {
+			float delta = movement.getValue(axis);
+			if (minimum.getValue(axis) + delta >= object.origin.getValue(axis) + object.size.getValue(axis) ||
+					maximum.getValue(axis) + delta <= object.origin.getValue(axis))
+				overlaps = false;
+		}
+		if (!overlaps)
+			continue;
+		object.flags |= 32;
+		unobstructed = false;
+		for (int axis = 0; axis < 3; axis++) {
+			float delta = movement.getValue(axis);
+			if (delta >= 0) {
+				float gap = object.origin.getValue(axis) - maximum.getValue(axis);
+				if (gap >= 0 && gap < delta)
+					movement.setValue(axis, gap);
+			} else {
+				float gap = minimum.getValue(axis) - object.origin.getValue(axis) - object.size.getValue(axis);
+				if (gap >= 0 && gap < -delta)
+					movement.setValue(axis, -gap);
+			}
+		}
+	}
+	float scale = script.area == 255 ? 1 : _areaMap[script.area]->getScale();
+	for (uint16 id : members) {
+		ObjectData &object = *scriptObject(script.area, id);
+		object.origin += movement;
+		Object *geometry = _areaMap[script.area]->objectWithID(id);
+		if (geometry && geometry->isGeometric())
+			static_cast<GeometricObject *>(geometry)->offsetOrigin(object.origin / scale);
+	}
+	_areaMap[script.area]->getSortedObjects().clear();
+	return unobstructed;
+}
+
+void KitEngine::updatePlayerMovement(float deltaTime) {
+	if (_scriptFrameActive)
+		return;
+	float height = _position.y();
+	FreescapeEngine::updatePlayerMovement(deltaTime);
+	if (_hasFallen) {
+		_kitVariables[10] += MAX<int>(0, height - _position.y() - _maxFallingDistance);
+		_hasFallen = false;
+		_avoidRenderingFrames = 0;
+	}
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/instruction16bit.h b/engines/freescape/language/instruction16bit.h
new file mode 100644
index 00000000000..7b1795c3a9b
--- /dev/null
+++ b/engines/freescape/language/instruction16bit.h
@@ -0,0 +1,48 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_INSTRUCTION16BIT_H
+#define FREESCAPE_INSTRUCTION16BIT_H
+
+#include "common/hashmap.h"
+#include "freescape/language/instruction.h"
+
+namespace Freescape {
+
+struct FCLLoop {
+	uint32 start = 0;
+	uint16 remaining = 0;
+};
+
+struct FCLExecutionState {
+	const FCLInstructionVector *source = nullptr, *code = nullptr;
+	uint32 ip = 0, restart = 0;
+	Common::HashMap<uint32, FCLLoop> loops;
+	bool running = false;
+	bool predicate = true, previousPredicate = true;
+	Token::Type booleanOp = Token::UNKNOWN;
+};
+
+enum FCLExecutionResult { kFCLFinished, kFCLYielded, kFCLPaused };
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/language/token.h b/engines/freescape/language/token.h
index 942802dd346..f3992c743cc 100644
--- a/engines/freescape/language/token.h
+++ b/engines/freescape/language/token.h
@@ -78,6 +78,8 @@ public:
 		TRIGANIM,
 		UPDATEI,
 		VAREQ,
+		VARGT,
+		VARLT,
 		IFGTEQ,
 		IFLTEQ,
 		VISQ,
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index 933e7c7e52e..16915b5fa6c 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -53,13 +53,16 @@ MODULE_OBJS := \
 	games/eclipse/cpc.o \
 	games/eclipse/zx.o \
 	games/3dck/3dck.o \
+	games/3dck/ui.o \
 	games/palettes.o \
 	gfx.o \
 	loaders/8bitImage.o \
 	loaders/8bitBinaryLoader.o \
 	loaders/c64.o \
 	language/8bitDetokeniser.o \
+	language/16bitDetokeniser.o \
 	language/instruction.o \
+	language/instruction16bit.o \
 	metaengine.o \
 	movement.o \
 	objects/geometricobject.o \


Commit: c567de577ac1ff7a8292633d8c8c13be34e222d6
    https://github.com/scummvm/scummvm/commit/c567de577ac1ff7a8292633d8c8c13be34e222d6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: refactored instructions code into functions for the 3dck

Changed paths:
    engines/freescape/games/3dck/3dck.h
    engines/freescape/language/instruction16bit.cpp


diff --git a/engines/freescape/games/3dck/3dck.h b/engines/freescape/games/3dck/3dck.h
index 1dd0a9837d6..421bd1dd373 100644
--- a/engines/freescape/games/3dck/3dck.h
+++ b/engines/freescape/games/3dck/3dck.h
@@ -104,12 +104,41 @@ private:
 	void startScript(ScriptState &script);
 	void beginScriptFrame();
 	FCLExecutionResult executeCode(ScriptState &script, uint &budget);
+	void executeIfThenElse(const FCLInstruction &instruction, ScriptState &script);
+	void executeConditional(const FCLInstruction &instruction, ScriptState &script);
+	void executeSetVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeIncrementVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeDecrementVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeAndVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeOrVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeNotVariable(const FCLInstruction &instruction, ScriptState &script);
+	void executeVariableComparison(const FCLInstruction &instruction, ScriptState &script);
+	void executeObjectStatus(const FCLInstruction &instruction);
+	bool checkObjectStatus(const FCLInstruction &instruction);
+	void executeGetPosition(const FCLInstruction &instruction);
+	bool executeExecute(const FCLInstruction &instruction, ScriptState &script);
+	bool executeGoto(const FCLInstruction &instruction);
+	void executeMode(const FCLInstruction &instruction);
+	bool executeDelay(const FCLInstruction &instruction);
+	void executeLoop(const FCLInstruction &instruction, ScriptState &script);
+	bool executeAgain(ScriptState &script, uint32 ip);
+	void executeStartAnim(const FCLInstruction &instruction);
+	void executeStopAnim(const FCLInstruction &instruction);
+	void executeTriggerAnim(const FCLInstruction &instruction);
+	void executeInclude(const FCLInstruction &instruction, ScriptState &script);
+	void executeRemove(const FCLInstruction &instruction, ScriptState &script);
+	bool executeWaitTrigger(ScriptState &script, uint32 ip);
+	void executeMove(const FCLInstruction &instruction, ScriptState &script);
+	void executeSound(const FCLInstruction &instruction);
 	void readSystemVariables();
 	void writeSystemVariables();
 	void setScriptVariable(byte index, uint32 value);
+	void setVariableResult(const FCLInstruction &instruction, ScriptState &script, uint32 value);
 	int32 getVariableOrConstant(int32 operand, Token::Type type) const;
 	void setScriptPredicate(ScriptState &script, bool value);
+	void getObjectReference(const FCLInstruction &instruction, uint16 &area, uint16 &id) const;
 	ObjectData *scriptObject(uint16 area, uint16 id);
+	ObjectData *scriptAnimator(const FCLInstruction &instruction);
 	void collectObjects(uint16 area, uint16 id, Common::Array<uint16> &objects);
 	void setObjectStatus(uint16 area, uint16 id, Token::Type operation);
 	bool moveAnimation(ScriptState &script, Math::Vector3d movement, bool absolute);
diff --git a/engines/freescape/language/instruction16bit.cpp b/engines/freescape/language/instruction16bit.cpp
index 581c84323fd..d923c963486 100644
--- a/engines/freescape/language/instruction16bit.cpp
+++ b/engines/freescape/language/instruction16bit.cpp
@@ -316,114 +316,77 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 		if (!budget)
 			return kFCLPaused;
 		budget--;
-		const FCLInstructionVector &code = *script.code;
 		uint32 ip = script.ip++;
-		const FCLInstruction &instruction = code[ip];
-		Token::Type op = instruction.getType();
-		int32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
-		int32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
-		int32 additional = getVariableOrConstant(instruction._additional, instruction._additionalType);
-		bool hasDestination = instruction._destinationType != Token::UNKNOWN;
-		switch (op) {
-		case Token::NOP: case Token::ENDIF:
+		const FCLInstruction &instruction = (*script.code)[ip];
+		switch (instruction.getType()) {
+		case Token::NOP:
+		case Token::ENDIF:
 			break;
 		case Token::IF:
 			script.predicate = true;
 			script.booleanOp = Token::UNKNOWN;
 			break;
-		case Token::AND: case Token::OR:
+		case Token::AND:
+		case Token::OR:
 			script.previousPredicate = script.predicate;
-			script.booleanOp = op;
-			break;
-		case Token::THEN: case Token::ELSE:
-			if (op == Token::ELSE || !script.predicate) {
-				int depth = 0;
-				while (script.ip < code.size()) {
-					Token::Type next = code[script.ip++].getType();
-					if (next == Token::IF)
-						depth++;
-					else if (next == Token::ENDIF) {
-						if (!depth)
-							break;
-						depth--;
-					} else if (next == Token::ELSE && !depth && op == Token::THEN) {
-						break;
-					}
-				}
-			}
+			script.booleanOp = instruction.getType();
+			break;
+		case Token::THEN:
+		case Token::ELSE:
+			executeIfThenElse(instruction, script);
 			break;
 		case Token::CONDITIONAL:
-			// RUNVGA retains these flags until the object's execution yields or ends.
-			setScriptPredicate(script, checkConditional(instruction,
-				script.events & 16, script.events & 32, _timerTriggered, script.events & 8));
-			break;
-		case Token::SETVAR: case Token::ADDVAR: case Token::SUBVAR: case Token::ANDV: case Token::ORV: case Token::NOTV: {
-			uint32 value = destination;
-			switch (op) {
-			case Token::ADDVAR: value += uint32(source); break;
-			case Token::SUBVAR: value = uint32(source) - value; break;
-			case Token::ANDV: value &= uint32(source); break;
-			case Token::ORV: value |= uint32(source); break;
-			case Token::NOTV: value = ~uint32(source); break;
-			default: break;
-			}
-			if (instruction._sourceType == Token::VARIABLE)
-				setScriptVariable(instruction._source, value);
-			setScriptPredicate(script, value != 0);
+			executeConditional(instruction, script);
 			break;
-		}
-		case Token::VAREQ: case Token::VARGT: case Token::VARLT:
-			setScriptPredicate(script, op == Token::VAREQ ? source == destination : op == Token::VARGT ? source > destination : source < destination);
-			break;
-		case Token::INVIS: case Token::VIS: case Token::TOGVIS: case Token::DESTROY:
-		case Token::INVISQ: case Token::VISQ: case Token::DESTROYEDQ: {
-			uint16 area = hasDestination && source ? source : _currentArea->getAreaID();
-			uint16 object = hasDestination ? destination : source;
-			if (op == Token::INVIS || op == Token::VIS || op == Token::TOGVIS || op == Token::DESTROY)
-				setObjectStatus(area, object, op);
-			else {
-				Common::Array<uint16> objects;
-				collectObjects(area, object, objects);
-				bool result = false;
-				for (uint16 member : objects) {
-					byte flags = scriptObject(area, member)->flags;
-					result = op == Token::DESTROYEDQ ? (flags & 1) != 0 : op == Token::INVISQ ? (flags & 2) != 0 : !(flags & 2);
-				}
-				setScriptPredicate(script, result);
-			}
+		case Token::SETVAR:
+			executeSetVariable(instruction, script);
 			break;
-		}
-		case Token::GETXPOS: case Token::GETYPOS: case Token::GETZPOS: {
-			ObjectData *object = scriptObject(additional, destination);
-			if (object)
-				setScriptVariable(instruction._source & 0xff, int32(object->origin.getValue(op - Token::GETXPOS)));
+		case Token::ADDVAR:
+			executeIncrementVariable(instruction, script);
 			break;
-		}
-		case Token::EXECUTE: {
-			ObjectData *object = scriptObject(0, source);
-			if (!object || object->type == kGroupType)
-				return kFCLFinished;
-			// EXECUTE replaces the code, retaining the original object's event flags.
-			script.code = &object->condition;
-			script.ip = 0;
-			script.loops.clear();
+		case Token::SUBVAR:
+			executeDecrementVariable(instruction, script);
 			break;
-		}
-		case Token::GOTO: {
-			uint16 area = instruction._sourceType != Token::UNKNOWN ? source : _currentArea->getAreaID();
-			if (!_areaMap.contains(area) || !_areaMap[area]->entranceWithID(destination & 0x7fff)) {
-				warning("Invalid 3D Construction Kit GOTO (%d, %u)", destination, area);
+		case Token::ANDV:
+			executeAndVariable(instruction, script);
+			break;
+		case Token::ORV:
+			executeOrVariable(instruction, script);
+			break;
+		case Token::NOTV:
+			executeNotVariable(instruction, script);
+			break;
+		case Token::VAREQ:
+		case Token::VARGT:
+		case Token::VARLT:
+			executeVariableComparison(instruction, script);
+			break;
+		case Token::INVIS:
+		case Token::VIS:
+		case Token::TOGVIS:
+		case Token::DESTROY:
+			executeObjectStatus(instruction);
+			break;
+		case Token::INVISQ:
+		case Token::VISQ:
+		case Token::DESTROYEDQ:
+			setScriptPredicate(script, checkObjectStatus(instruction));
+			break;
+		case Token::GETXPOS:
+		case Token::GETYPOS:
+		case Token::GETZPOS:
+			executeGetPosition(instruction);
+			break;
+		case Token::EXECUTE:
+			if (!executeExecute(instruction, script))
 				return kFCLFinished;
-			}
-			writeSystemVariables();
-			uint16 previous = _currentArea->getAreaID();
-			gotoArea(area, destination & 0x7fff);
-			if (previous != area)
+			break;
+		case Token::GOTO:
+			if (!executeGoto(instruction))
 				return kFCLFinished;
 			break;
-		}
 		case Token::MODE:
-			setScriptVariable(6, CLIP<int32>(source, 1, 3) - 1);
+			executeMode(instruction);
 			break;
 		case Token::ENDGAME:
 			_gameStateControl = kFreescapeGameStateRestart;
@@ -433,59 +396,30 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 		case Token::WAIT:
 			return kFCLYielded;
 		case Token::DELAY:
-			if (uint16(source)) {
-				_delayUntil = _scriptTicks + uint16(source);
-				_scriptDelayed = true;
+			if (executeDelay(instruction))
 				return kFCLPaused;
-			}
 			break;
 		case Token::REDRAW:
 			_scriptSurface.fillRect(_viewArea, 0);
 			return animator ? kFCLYielded : kFCLPaused;
-		case Token::LOOP: {
-			int depth = 0;
-			uint32 end = script.ip;
-			while (end < code.size()) {
-				Token::Type next = code[end].getType();
-				if (next == Token::LOOP)
-					depth++;
-				else if (next == Token::AGAIN) {
-					if (!depth)
-						break;
-					depth--;
-				}
-				end++;
-			}
-			FCLLoop &loop = script.loops[end];
-			loop.start = script.ip;
-			loop.remaining = uint16(source);
+		case Token::LOOP:
+			executeLoop(instruction, script);
 			break;
-		}
-		case Token::AGAIN: {
-			auto loop = script.loops.find(ip);
-			if (loop == script.loops.end())
+		case Token::AGAIN:
+			if (!executeAgain(script, ip))
 				return kFCLFinished;
-			if (--loop->_value.remaining != 0) {
-				script.ip = loop->_value.start;
-			} else {
-				script.loops.erase(loop);
-			}
 			break;
-		}
-		case Token::STARTANIM: case Token::STOPANIM: case Token::TRIGANIM: {
-			ObjectData *object = scriptObject(hasDestination ? source : 0, uint16(hasDestination ? destination : source) | 0x4000);
-			if (object && object->type == 16) {
-				if (op == Token::STARTANIM)
-					object->flags &= ~2;
-				else if (op == Token::STOPANIM)
-					object->flags |= 2;
-				else
-					object->flags |= 1;
-			}
-			if (animator && op == Token::STOPANIM)
+		case Token::STARTANIM:
+			executeStartAnim(instruction);
+			break;
+		case Token::STOPANIM:
+			executeStopAnim(instruction);
+			if (animator)
 				return kFCLYielded;
 			break;
-		}
+		case Token::TRIGANIM:
+			executeTriggerAnim(instruction);
+			break;
 		case Token::START:
 			if (animator)
 				script.restart = script.ip;
@@ -494,65 +428,318 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 			if (animator)
 				script.ip = script.restart;
 			break;
-		case Token::INCLUDE: case Token::REMOVE:
-			if (animator) {
-				Common::Array<uint16> objects;
-				collectObjects(script.area, source, objects);
-				for (uint16 member : objects) {
-					ObjectData &object = *scriptObject(script.area, member);
-					if (!(object.flags & 0x80))
-						continue;
-					if (op == Token::INCLUDE && !object.animator) {
-						object.animator = script.object->id;
-						script.object->animatedObjects.push_back(member);
-					} else if (op == Token::REMOVE && object.animator == script.object->id) {
-						auto &members = script.object->animatedObjects;
-						for (uint i = 0; i < members.size(); i++) {
-							if (members[i] == member) {
-								members.remove_at(i);
-								break;
-							}
-						}
-						object.animator = 0;
-					}
-				}
-			}
+		case Token::INCLUDE:
+			if (animator)
+				executeInclude(instruction, script);
+			break;
+		case Token::REMOVE:
+			if (animator)
+				executeRemove(instruction, script);
 			break;
 		case Token::WAITTRIG:
-			if (animator) {
-				if (!(script.object->flags & 1)) {
-					script.ip = ip;
-					return kFCLYielded;
-				}
-				script.object->flags &= ~1;
-			}
+			if (animator && executeWaitTrigger(script, ip))
+				return kFCLYielded;
 			break;
-		case Token::MOVE: case Token::MOVETO:
+		case Token::MOVE:
+		case Token::MOVETO:
 			if (animator) {
-				setScriptPredicate(script, moveAnimation(script,
-					Math::Vector3d(int16(source), int16(destination), int16(additional)), op == Token::MOVETO));
+				executeMove(instruction, script);
 				return kFCLYielded;
 			}
 			break;
 		case Token::PRINT:
-			printMessage(source, instruction._text);
+			printMessage(getVariableOrConstant(instruction._source, instruction._sourceType), instruction._text);
 			break;
 		case Token::UPDATEI:
 			updateIndicators();
 			break;
-		case Token::SOUND: case Token::SYNCSND:
-			if (!_soundWarning) {
-				warning("3D Construction Kit sound playback is not implemented");
-				_soundWarning = true;
-			}
+		case Token::SOUND:
+		case Token::SYNCSND:
+			executeSound(instruction);
 			break;
 		default:
-			error("Unhandled 16-bit FCL instruction %d at ip: %u", op, ip);
+			error("Unhandled 16-bit FCL instruction %d at ip: %u", instruction.getType(), ip);
 		}
 	}
 	return kFCLFinished;
 }
 
+void KitEngine::executeIfThenElse(const FCLInstruction &instruction, ScriptState &script) {
+	if (instruction.getType() == Token::THEN && script.predicate)
+		return;
+	const FCLInstructionVector &code = *script.code;
+	int depth = 0;
+	while (script.ip < code.size()) {
+		Token::Type next = code[script.ip++].getType();
+		if (next == Token::IF) {
+			depth++;
+		} else if (next == Token::ENDIF) {
+			if (!depth)
+				break;
+			depth--;
+		} else if (next == Token::ELSE && !depth && instruction.getType() == Token::THEN) {
+			break;
+		}
+	}
+}
+
+void KitEngine::executeConditional(const FCLInstruction &instruction, ScriptState &script) {
+	// RUNVGA retains these flags until the object's execution yields or ends.
+	setScriptPredicate(script, checkConditional(instruction,
+		script.events & 16, script.events & 32, _timerTriggered, script.events & 8));
+}
+
+void KitEngine::setVariableResult(const FCLInstruction &instruction, ScriptState &script, uint32 value) {
+	if (instruction._sourceType == Token::VARIABLE)
+		setScriptVariable(instruction._source, value);
+	setScriptPredicate(script, value != 0);
+}
+
+void KitEngine::executeSetVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 value = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	setVariableResult(instruction, script, value);
+}
+
+void KitEngine::executeIncrementVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	uint32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	setVariableResult(instruction, script, source + destination);
+}
+
+void KitEngine::executeDecrementVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	uint32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	setVariableResult(instruction, script, source - destination);
+}
+
+void KitEngine::executeAndVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	uint32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	setVariableResult(instruction, script, source & destination);
+}
+
+void KitEngine::executeOrVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	uint32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	setVariableResult(instruction, script, source | destination);
+}
+
+void KitEngine::executeNotVariable(const FCLInstruction &instruction, ScriptState &script) {
+	uint32 value = getVariableOrConstant(instruction._source, instruction._sourceType);
+	setVariableResult(instruction, script, ~value);
+}
+
+void KitEngine::executeVariableComparison(const FCLInstruction &instruction, ScriptState &script) {
+	int32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	int32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	switch (instruction.getType()) {
+	case Token::VAREQ:
+		setScriptPredicate(script, source == destination);
+		break;
+	case Token::VARGT:
+		setScriptPredicate(script, source > destination);
+		break;
+	case Token::VARLT:
+		setScriptPredicate(script, source < destination);
+		break;
+	default:
+		break;
+	}
+}
+
+void KitEngine::getObjectReference(const FCLInstruction &instruction, uint16 &area, uint16 &id) const {
+	int32 source = getVariableOrConstant(instruction._source, instruction._sourceType);
+	area = _currentArea->getAreaID();
+	id = source;
+	if (instruction._destinationType != Token::UNKNOWN) {
+		if (source)
+			area = source;
+		id = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	}
+}
+
+void KitEngine::executeObjectStatus(const FCLInstruction &instruction) {
+	uint16 area, id;
+	getObjectReference(instruction, area, id);
+	setObjectStatus(area, id, instruction.getType());
+}
+
+bool KitEngine::checkObjectStatus(const FCLInstruction &instruction) {
+	uint16 area, id;
+	getObjectReference(instruction, area, id);
+	Common::Array<uint16> objects;
+	collectObjects(area, id, objects);
+	byte mask = instruction.getType() == Token::DESTROYEDQ ? 1 : 2;
+	bool expected = instruction.getType() != Token::VISQ;
+	bool result = false;
+	for (uint16 member : objects)
+		result = ((scriptObject(area, member)->flags & mask) != 0) == expected;
+	return result;
+}
+
+void KitEngine::executeGetPosition(const FCLInstruction &instruction) {
+	uint16 area = getVariableOrConstant(instruction._additional, instruction._additionalType);
+	uint16 id = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	ObjectData *object = scriptObject(area, id);
+	if (object) {
+		int axis = instruction.getType() - Token::GETXPOS;
+		setScriptVariable(instruction._source & 0xff, int32(object->origin.getValue(axis)));
+	}
+}
+
+bool KitEngine::executeExecute(const FCLInstruction &instruction, ScriptState &script) {
+	uint16 id = getVariableOrConstant(instruction._source, instruction._sourceType);
+	ObjectData *object = scriptObject(0, id);
+	if (!object || object->type == kGroupType)
+		return false;
+	// EXECUTE replaces the code, retaining the original object's event flags.
+	script.code = &object->condition;
+	script.ip = 0;
+	script.loops.clear();
+	return true;
+}
+
+bool KitEngine::executeGoto(const FCLInstruction &instruction) {
+	uint16 area = _currentArea->getAreaID();
+	if (instruction._sourceType != Token::UNKNOWN)
+		area = getVariableOrConstant(instruction._source, instruction._sourceType);
+	int32 entrance = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	if (!_areaMap.contains(area) || !_areaMap[area]->entranceWithID(entrance & 0x7fff)) {
+		warning("Invalid 3D Construction Kit GOTO (%d, %u)", entrance, area);
+		return false;
+	}
+	writeSystemVariables();
+	uint16 previous = _currentArea->getAreaID();
+	gotoArea(area, entrance & 0x7fff);
+	return previous == area;
+}
+
+void KitEngine::executeMode(const FCLInstruction &instruction) {
+	int32 mode = getVariableOrConstant(instruction._source, instruction._sourceType);
+	setScriptVariable(6, CLIP<int32>(mode, 1, 3) - 1);
+}
+
+bool KitEngine::executeDelay(const FCLInstruction &instruction) {
+	uint16 delay = getVariableOrConstant(instruction._source, instruction._sourceType);
+	if (!delay)
+		return false;
+	_delayUntil = _scriptTicks + delay;
+	_scriptDelayed = true;
+	return true;
+}
+
+void KitEngine::executeLoop(const FCLInstruction &instruction, ScriptState &script) {
+	const FCLInstructionVector &code = *script.code;
+	int depth = 0;
+	uint32 end = script.ip;
+	while (end < code.size()) {
+		Token::Type next = code[end].getType();
+		if (next == Token::LOOP) {
+			depth++;
+		} else if (next == Token::AGAIN) {
+			if (!depth)
+				break;
+			depth--;
+		}
+		end++;
+	}
+	FCLLoop &loop = script.loops[end];
+	loop.start = script.ip;
+	loop.remaining = getVariableOrConstant(instruction._source, instruction._sourceType);
+}
+
+bool KitEngine::executeAgain(ScriptState &script, uint32 ip) {
+	auto loop = script.loops.find(ip);
+	if (loop == script.loops.end())
+		return false;
+	if (--loop->_value.remaining != 0)
+		script.ip = loop->_value.start;
+	else
+		script.loops.erase(loop);
+	return true;
+}
+
+KitEngine::ObjectData *KitEngine::scriptAnimator(const FCLInstruction &instruction) {
+	uint16 area, id;
+	getObjectReference(instruction, area, id);
+	ObjectData *object = scriptObject(area, id | 0x4000);
+	return object && object->type == 16 ? object : nullptr;
+}
+
+void KitEngine::executeStartAnim(const FCLInstruction &instruction) {
+	ObjectData *object = scriptAnimator(instruction);
+	if (object)
+		object->flags &= ~2;
+}
+
+void KitEngine::executeStopAnim(const FCLInstruction &instruction) {
+	ObjectData *object = scriptAnimator(instruction);
+	if (object)
+		object->flags |= 2;
+}
+
+void KitEngine::executeTriggerAnim(const FCLInstruction &instruction) {
+	ObjectData *object = scriptAnimator(instruction);
+	if (object)
+		object->flags |= 1;
+}
+
+void KitEngine::executeInclude(const FCLInstruction &instruction, ScriptState &script) {
+	uint16 id = getVariableOrConstant(instruction._source, instruction._sourceType);
+	Common::Array<uint16> objects;
+	collectObjects(script.area, id, objects);
+	for (uint16 member : objects) {
+		ObjectData &object = *scriptObject(script.area, member);
+		if (!(object.flags & 0x80) || object.animator)
+			continue;
+		object.animator = script.object->id;
+		script.object->animatedObjects.push_back(member);
+	}
+}
+
+void KitEngine::executeRemove(const FCLInstruction &instruction, ScriptState &script) {
+	uint16 id = getVariableOrConstant(instruction._source, instruction._sourceType);
+	Common::Array<uint16> objects;
+	collectObjects(script.area, id, objects);
+	for (uint16 member : objects) {
+		ObjectData &object = *scriptObject(script.area, member);
+		if (!(object.flags & 0x80) || object.animator != script.object->id)
+			continue;
+		auto &members = script.object->animatedObjects;
+		for (uint i = 0; i < members.size(); i++) {
+			if (members[i] == member) {
+				members.remove_at(i);
+				break;
+			}
+		}
+		object.animator = 0;
+	}
+}
+
+bool KitEngine::executeWaitTrigger(ScriptState &script, uint32 ip) {
+	if (!(script.object->flags & 1)) {
+		script.ip = ip;
+		return true;
+	}
+	script.object->flags &= ~1;
+	return false;
+}
+
+void KitEngine::executeMove(const FCLInstruction &instruction, ScriptState &script) {
+	int16 x = getVariableOrConstant(instruction._source, instruction._sourceType);
+	int16 y = getVariableOrConstant(instruction._destination, instruction._destinationType);
+	int16 z = getVariableOrConstant(instruction._additional, instruction._additionalType);
+	bool absolute = instruction.getType() == Token::MOVETO;
+	setScriptPredicate(script, moveAnimation(script, Math::Vector3d(x, y, z), absolute));
+}
+
+void KitEngine::executeSound(const FCLInstruction &instruction) {
+	if (!_soundWarning) {
+		warning("3D Construction Kit sound playback is not implemented");
+		_soundWarning = true;
+	}
+}
+
 bool KitEngine::executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) {
 	ObjectData *object = scriptObject(0, obj->getObjectID());
 	if (object)


Commit: effb13c82171488c60039c446f493f164a24efa5
    https://github.com/scummvm/scummvm/commit/effb13c82171488c60039c446f493f164a24efa5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: mouselook unlocked for 3dck

Changed paths:
    engines/freescape/games/3dck/3dck.cpp
    engines/freescape/games/3dck/ui.cpp


diff --git a/engines/freescape/games/3dck/3dck.cpp b/engines/freescape/games/3dck/3dck.cpp
index b11e91cda74..6c80aafa23c 100644
--- a/engines/freescape/games/3dck/3dck.cpp
+++ b/engines/freescape/games/3dck/3dck.cpp
@@ -413,8 +413,7 @@ void KitEngine::gotoArea(uint16 areaID, int entranceID) {
 	_timerTicks = 0;
 	_scriptSurface.fillRect(_viewArea, 0);
 	resetInput();
-	_shootMode = true;
-	g_system->lockMouse(false);
+	g_system->lockMouse(true);
 	readSystemVariables();
 }
 
diff --git a/engines/freescape/games/3dck/ui.cpp b/engines/freescape/games/3dck/ui.cpp
index 76757e87e9f..ce116adce4f 100644
--- a/engines/freescape/games/3dck/ui.cpp
+++ b/engines/freescape/games/3dck/ui.cpp
@@ -119,7 +119,10 @@ bool KitEngine::handleInput(const Common::Event &event) {
 		switch (event.customType) {
 		case kActionEscape:
 			return false;
-		case kActionChangeMode: case kActionSkip:
+		case kActionChangeMode:
+			_kitVariables[15] = ' ';
+			return false;
+		case kActionSkip:
 			_kitVariables[15] = ' ';
 			return true;
 		case kActionMoveUp: control = 0; break;
@@ -148,20 +151,24 @@ bool KitEngine::handleInput(const Common::Event &event) {
 				interact(event.customType == kActionShoot);
 			return true;
 		}
-		if (_scriptFrameActive)
-			return true;
+		// Track held movement keys during DELAY; movement itself waits.
+		bool movement = event.customType == kActionMoveUp || event.customType == kActionMoveDown ||
+			event.customType == kActionMoveLeft || event.customType == kActionMoveRight;
+		return _scriptFrameActive && !movement;
 	} else if (_scriptFrameActive && event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_END &&
 			(event.customType == kActionRiseOrFlyUp || event.customType == kActionLowerOrFlyDown)) {
 		_moveUp = _moveDown = false;
 		return true;
 	} else if (event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN) {
-		_crossairPosition = getNormalizedPosition(event.mouse);
+		Common::Point mouse = getNormalizedPosition(event.mouse);
+		if (_shootMode)
+			_crossairPosition = mouse;
 		int buttons = g_system->getEventManager()->getButtonState();
 		_kitVariables[16] = ((buttons & Common::EventManager::LBUTTON) ? 1 : 0) |
 			((buttons & Common::EventManager::RBUTTON) ? 2 : 0);
 		_kitVariables[16] |= event.type == Common::EVENT_LBUTTONDOWN ? 1 : 2;
-		_kitVariables[17] = _crossairPosition.x;
-		_kitVariables[18] = _crossairPosition.y;
+		_kitVariables[17] = mouse.x;
+		_kitVariables[18] = mouse.y;
 		if (!_scriptFrameActive)
 			interact(event.type == Common::EVENT_LBUTTONDOWN);
 		return true;


Commit: 01bb2300a0026e1c0892189d900c939d867aa375
    https://github.com/scummvm/scummvm/commit/01bb2300a0026e1c0892189d900c939d867aa375
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: added 8bit opcodes and scripting for 3dck

Changed paths:
  A engines/freescape/games/3dck/8bit.cpp
  A engines/freescape/games/3dck/8bit.h
  A engines/freescape/games/3dck/8bitUI.cpp
  A engines/freescape/language/8bitKitDetokeniser.cpp
  A engines/freescape/language/8bitKitDetokeniser.h
  A engines/freescape/language/instruction8bitKit.cpp
    engines/freescape/detection.cpp
    engines/freescape/gfx.cpp
    engines/freescape/gfx.h
    engines/freescape/language/instruction.h
    engines/freescape/language/token.h
    engines/freescape/metaengine.cpp
    engines/freescape/module.mk


diff --git a/engines/freescape/detection.cpp b/engines/freescape/detection.cpp
index 17be9887e18..cbd4f199658 100644
--- a/engines/freescape/detection.cpp
+++ b/engines/freescape/detection.cpp
@@ -1156,6 +1156,15 @@ const ADGameDescription gameDescriptions[] = {
 		GUIO3(GUIO_NOMIDI, GUIO_RENDEREGA, GAMEOPTION_WASD_CONTROLS)
 	},
 	// 3D Construction Kit games
+	{
+		"3dkit",
+		"A Chance in Hell",
+		AD_ENTRY1s("Datafile0.bin", "8b4d53e7758b69a8df43947baddcf94a", 5589),
+		Common::EN_ANY,
+		Common::kPlatformAmstradCPC,
+		ADGF_UNSTABLE,
+		GUIO2(GUIO_NOMIDI, GUIO_RENDERCPC)
+	},
 	{
 		"3dkit",
 		"Cube",
diff --git a/engines/freescape/games/3dck/8bit.cpp b/engines/freescape/games/3dck/8bit.cpp
new file mode 100644
index 00000000000..aac773e5d14
--- /dev/null
+++ b/engines/freescape/games/3dck/8bit.cpp
@@ -0,0 +1,384 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/algorithm.h"
+#include "common/substream.h"
+#include "math/utils.h"
+
+#include "freescape/games/3dck/8bit.h"
+#include "freescape/language/8bitKitDetokeniser.h"
+
+namespace Freescape {
+
+static void requireBytes(Common::SeekableReadStream &file, uint32 size) {
+	if (file.err() || file.pos() > file.size() || size > file.size() - file.pos())
+		error("Truncated 8-bit 3D Construction Kit data");
+}
+
+Kit8Engine::Kit8Engine(OSystem *syst, const ADGameDescription *gd) : FreescapeEngine(syst, gd) {
+	_screenW = 320;
+	_screenH = 200;
+	_fullscreenViewArea = Common::Rect(_screenW, _screenH);
+	_playerHeightNumber = _playerHeightMaxNumber = 0;
+	_playerWidth = _playerDepth = 16;
+	_soundIndexShoot = -1;
+}
+
+void Kit8Engine::loadAssets() {
+	Common::File file;
+	if (!file.open(_gameDescription->filesDescriptions[0].fileName))
+		error("Unable to open 8-bit 3D Construction Kit data");
+	requireBytes(file, 160);
+	if (file.readUint32BE() != MKTAG('K', 'I', 'T', 'C') || file.readUint16LE() != file.size())
+		error("Unsupported 8-bit 3D Construction Kit data format");
+	uint16 procedures = file.readUint16LE();
+	uint16 conditions = file.readUint16LE();
+	uint16 messages = file.readUint16LE();
+	byte areaCount = file.readByte();
+	_climbHeight = file.readByte();
+	_fallHeight = file.readByte();
+	_walkSpeed = file.readByte();
+	byte turnSpeed = file.readByte();
+	_startArea = file.readByte();
+	_startEntrance = file.readByte();
+	file.skip(1);
+	_activationRange = file.readByte();
+	int x = 8 * file.readByte();
+	int y = 8 * file.readByte();
+	int width = 8 * file.readByte();
+	int height = 8 * file.readByte();
+	if (!width || !height || x + width > _screenW || y + height > _screenH || !_walkSpeed || !turnSpeed)
+		error("Invalid 8-bit 3D Construction Kit display or movement settings");
+	_viewArea = Common::Rect(x, y, x + width, y + height);
+	// CPC projection scales: 125 * 64 / (extent - 1), with a depth scale of 18.
+	int xScale = 8000 / (width - 1);
+	int yScale = 8000 / (height - 1);
+	if (xScale > 127 || yScale > 127)
+		error("Unsupported 8-bit 3D Construction Kit viewport size");
+	_fieldOfView = 2 * Math::rad2deg(atan(18.0f / xScale));
+	_viewAspectRatio = float(yScale) / xScale;
+	_angleRotations.clear();
+	_angleRotations.push_back(5 * turnSpeed);
+	_angleRotationIndex = 0;
+	_playerSteps.clear();
+	_playerSteps.push_back(_walkSpeed);
+	_playerStepIndex = 0;
+	file.seek(0x30);
+	file.read(_instruments, sizeof(_instruments));
+	file.seek(0xa0);
+	requireBytes(file, 2 * areaCount);
+	Common::Array<uint16> offsets;
+	for (uint i = 0; i < areaCount; i++)
+		offsets.push_back(file.readUint16LE());
+	Common::sort(offsets.begin(), offsets.end());
+	if (offsets.empty() || offsets.back() > file.size() - 12 || messages < file.pos() ||
+			messages >= procedures || procedures >= conditions || conditions >= offsets.front())
+		error("Invalid 8-bit 3D Construction Kit table offsets");
+
+	Common::SeekableSubReadStream messageData(&file, messages, procedures);
+	requireBytes(messageData, 1);
+	byte messageCount = messageData.readByte();
+	for (uint i = 0; i < messageCount; i++) {
+		requireBytes(messageData, 2);
+		byte id = messageData.readByte();
+		byte length = messageData.readByte();
+		requireBytes(messageData, length);
+		if (_kitMessages.contains(id))
+			error("Duplicate 8-bit 3D Construction Kit message %u", id);
+		Common::String text;
+		while (length--)
+			text += char(messageData.readByte());
+		_kitMessages[id] = text;
+	}
+	Common::SeekableSubReadStream procedureData(&file, procedures, conditions);
+	_procedures = loadConditions(procedureData);
+	Common::SeekableSubReadStream conditionData(&file, conditions, offsets.front());
+	_globalConditions = loadConditions(conditionData);
+	for (uint i = 0; i < offsets.size(); i++) {
+		uint32 end = i + 1 < offsets.size() ? offsets[i + 1] : file.size();
+		if (offsets[i] >= end || end > uint32(file.size()))
+			error("Invalid 8-bit 3D Construction Kit area offset");
+		Common::SeekableSubReadStream areaData(&file, offsets[i], end);
+		Area *area = loadArea(areaData);
+		if (_areaMap.contains(area->getAreaID()))
+			error("Duplicate 8-bit 3D Construction Kit area %u", area->getAreaID());
+		_areaMap[area->getAreaID()] = area;
+	}
+	for (auto &entry : _areaMap) {
+		if (entry._key == 255)
+			continue;
+		for (byte id : _areaData[entry._key].globals) {
+			if (!_areaMap.contains(255) || !_areaMap[255]->objectWithID(id) || entry._value->objectWithID(id))
+				error("Invalid 8-bit 3D Construction Kit global object %u", id);
+			entry._value->addObjectFromArea(id, _areaMap[255]);
+		}
+	}
+	if (!_areaMap.contains(_startArea) || !_areaMap[_startArea]->entranceWithID(_startEntrance))
+		error("Invalid 8-bit 3D Construction Kit starting entrance");
+	loadPresentation();
+}
+
+Common::Array<Kit8Engine::ConditionData> Kit8Engine::loadConditions(Common::SeekableReadStream &file) {
+	requireBytes(file, 1);
+	byte count = file.readByte();
+	Common::Array<ConditionData> conditions;
+	for (uint i = 0; i < count; i++) {
+		requireBytes(file, 2);
+		ConditionData condition;
+		condition.id = file.readByte();
+		byte length = file.readByte();
+		requireBytes(file, length);
+		for (const auto &existing : conditions) {
+			if (existing.id == condition.id)
+				error("Duplicate 8-bit 3D Construction Kit condition %u", condition.id);
+		}
+		Common::Array<byte> code;
+		code.resize(length);
+		file.read(code.data(), length);
+		Common::String source = detokenise8bitKitCondition(code, condition.code);
+		debugC(1, kFreescapeDebugParser, "Condition %u:\n%s", condition.id, source.c_str());
+		conditions.push_back(condition);
+	}
+	return conditions;
+}
+
+Area *Kit8Engine::loadArea(Common::SeekableReadStream &file) {
+	requireBytes(file, 12);
+	byte id = file.readByte();
+	byte count = file.readByte();
+	byte flags = file.readByte();
+	uint16 conditions = file.readUint16LE();
+	file.skip(2);
+	AreaData &data = _areaData[id];
+	for (uint i = 0; i < 4; i++) {
+		data.palette[i] = file.readByte();
+		if (data.palette[i] > 26)
+			error("Invalid 8-bit 3D Construction Kit palette");
+	}
+	byte scale = file.readByte();
+	if (!scale || conditions < file.pos() || conditions > file.size())
+		error("Invalid 8-bit 3D Construction Kit area header");
+	ObjectMap *objects = new ObjectMap;
+	ObjectMap *entrances = new ObjectMap;
+	for (uint i = 0; i < count; i++) {
+		requireBytes(file, 9);
+		uint32 start = file.pos();
+		byte header[9];
+		file.read(header, sizeof(header));
+		byte type = header[0] & 0x0f;
+		byte objectID = header[7];
+		byte size = header[8];
+		if (size < 9 || start + size > conditions)
+			error("Invalid 8-bit 3D Construction Kit object size");
+		if (objectID == 255) {
+			for (uint j = 9; j < size; j++)
+				data.globals.push_back(file.readByte());
+			continue;
+		}
+		if (objects->contains(objectID) || entrances->contains(objectID))
+			error("Duplicate 8-bit 3D Construction Kit object %u", objectID);
+		Object *object = nullptr;
+		if (type == kEntranceType) {
+			if (size != 12)
+				error("Invalid 8-bit 3D Construction Kit entrance size");
+			Math::Vector3d origin, rotation;
+			for (int axis = 0; axis < 3; axis++) {
+				byte fine = file.readByte();
+				origin.setValue(axis, header[axis + 1] == 255 ? -1 : 32 * header[axis + 1] + fine / 2.0f);
+				rotation.setValue(axis, header[axis + 4] == 255 ? -1 : 5 * header[axis + 4]);
+			}
+			object = new Entrance(objectID, origin, rotation, FCLInstructionVector(), "");
+		} else if (type == kSensorType) {
+			if (size != 10)
+				error("Invalid 8-bit 3D Construction Kit sensor size");
+			byte color = file.readByte();
+			object = new Sensor(objectID, Math::Vector3d(32 * header[1], 32 * header[2], 32 * header[3]),
+				Math::Vector3d(), color & 15, header[5], 32 * header[4], 0, header[0], FCLInstructionVector(), "");
+		} else
+			object = loadGeometricObject(file, header);
+		file.seek(start + size);
+		object->_loadIndex = (id == 255 ? 0 : 0x4000) + i;
+		if (id != 255)
+			object->scale(scale);
+		if (type == kEntranceType)
+			(*entrances)[objectID] = object;
+		else
+			(*objects)[objectID] = object;
+	}
+	if (file.pos() != conditions)
+		error("Invalid 8-bit 3D Construction Kit object table length");
+	if (id != 255)
+		data.conditions = loadConditions(file);
+	Area *area = new Area(id, flags, objects, entrances, false);
+	area->_scale = scale;
+	area->_name = Common::String::format("Area %u", id);
+	area->_groundColor = 255;
+	return area;
+}
+
+GeometricObject *Kit8Engine::loadGeometricObject(Common::SeekableReadStream &file, const byte header[9]) {
+	ObjectType type = ObjectType(header[0] & 0x0f);
+	int colorCount = GeometricObject::numberOfColoursForObjectOfType(type);
+	int ordinateCount = GeometricObject::numberOfOrdinatesForType(type);
+	if (type > kHexagonType || header[8] != 9 + colorCount / 2 + ordinateCount)
+		error("Unsupported 8-bit 3D Construction Kit object type %u", type);
+	requireBytes(file, colorCount / 2 + ordinateCount);
+	Math::Vector3d origin(32 * header[1], 32 * header[2], 32 * header[3]);
+	Math::Vector3d size(32 * header[4], 32 * header[5], 32 * header[6]);
+	Common::Array<byte> *colors = new Common::Array<byte>;
+	for (int i = 0; i < colorCount / 2; i++) {
+		byte color = file.readByte();
+		colors->push_back(color & 15);
+		colors->push_back(color >> 4);
+	}
+	Common::Array<float> *ordinates = nullptr;
+	if (ordinateCount) {
+		static const byte pyramidAxes[3][2] = {{1, 2}, {0, 2}, {0, 1}};
+		ordinates = new Common::Array<float>;
+		for (int i = 0; i < ordinateCount; i++) {
+			int axis = i % 3;
+			if (GeometricObject::isPyramid(type))
+				axis = pyramidAxes[(type - kEastPyramidType) / 2][i % 2];
+			// Kit points use 1/64 of the corresponding bounding-box dimension.
+			float ordinate = size.getValue(axis) * file.readByte() / 64.0f;
+			if (GeometricObject::isPolygon(type))
+				ordinate += origin.getValue(axis);
+			ordinates->push_back(ordinate);
+		}
+	}
+	return new GeometricObject(type, header[7], header[0], origin, size,
+		colors, nullptr, ordinates, FCLInstructionVector(), "");
+}
+
+void Kit8Engine::initGameState() {
+	FreescapeEngine::initGameState();
+	memset(_variables, 0, sizeof(_variables));
+	_changedVariables = 0;
+	_currentKey = 255;
+	_textColor = 7;
+	_variables[121] = _variables[125] = 255;
+	_variables[127] = 0x9c;
+	_scriptStack.clear();
+	_conditions = nullptr;
+	_initialScriptPending = true;
+	_scriptFrameActive = false;
+	_zero = _carry = _previousZero = false;
+	_shotObject = _hitObject = _activatedObject = 0;
+	_fallen = _crushed = _pendingTimer = _timerTriggered = false;
+	_crossVisible = true;
+	_timerTicks = _timerInterval = _delayUntil = 0;
+	_lastTime = g_system->getMillis();
+	_scriptSurface.fillRect(_fullscreenViewArea, 255);
+	_currentArea = nullptr;
+	_movementMode = 1;
+	_playerHeight = 0;
+}
+
+void Kit8Engine::gotoArea(uint16 areaID, int entranceID) {
+	if (!_areaMap.contains(areaID) || areaID == 255)
+		error("Unknown 8-bit 3D Construction Kit area %u", areaID);
+	float oldScale = _currentArea ? _currentArea->getScale() : 1;
+	_currentArea = _areaMap[areaID];
+	float scale = _currentArea->getScale();
+	Math::Vector3d position = _position * (oldScale / scale);
+	_playerHeight = 0;
+	setMovementMode(_movementMode);
+	_position = position;
+	Entrance *entrance = static_cast<Entrance *>(_currentArea->entranceWithID(entranceID));
+	if (entrance) {
+		Math::Vector3d origin = entrance->getOrigin(), rotation = entrance->getRotation();
+		for (int axis = 0; axis < 3; axis++) {
+			if (origin.getValue(axis) >= 0)
+				_position.setValue(axis, origin.getValue(axis) + (axis == 1 ? _playerHeight : 0));
+		}
+		if (rotation.x() >= 0)
+			_pitch = rotation.x() > 180 ? rotation.x() - 360 : rotation.x();
+		if (rotation.y() >= 0)
+			_yaw = 90 - rotation.y();
+		if (rotation.z() >= 0)
+			_roll = rotation.z();
+	}
+	AreaData &data = _areaData[areaID];
+	memcpy(_palette, data.palette, sizeof(_palette));
+	for (byte id : data.globals) {
+		Object *object = _currentArea->objectWithID(id);
+		object->restore();
+		object->makeVisible();
+	}
+	applyPalette();
+	_sensors = _currentArea->getSensors();
+	_gfx->_scale = scale;
+	_gotoExecuted = true;
+	_lastPosition = _position;
+	float pitch = _pitch;
+	resetInput();
+	_pitch = pitch;
+	updateCamera();
+	g_system->lockMouse(true);
+	readSystemVariables();
+}
+
+void Kit8Engine::setMovementMode(byte mode) {
+	if (mode > 4)
+		return;
+	_position.y() -= _playerHeight;
+	_movementMode = mode;
+	_flyMode = mode >= 3;
+	float scale = _currentArea ? _currentArea->getScale() : 1;
+	_playerHeight = _flyMode ? 0 : (mode == 0 ? 64 : 128) - 26 / scale;
+	_position.y() += _playerHeight;
+	_playerSteps[0] = mode == 0 ? _walkSpeed / 2 : mode == 2 ? 2 * _walkSpeed : _walkSpeed;
+	_stepUpDistance = 32 * _climbHeight;
+	_maxFallingDistance = 32 * _fallHeight;
+	_lastPosition = _position;
+}
+
+void Kit8Engine::checkIfStillInArea() {
+	float scale = _currentArea->getScale();
+	_position.x() = CLIP(_position.x(), 0.0f, 4063.5f / scale);
+	_position.y() = CLIP(_position.y(), 0.0f, 2015.5f / scale);
+	_position.z() = CLIP(_position.z(), 0.0f, 4063.5f / scale);
+}
+
+void Kit8Engine::updatePlayerMovement(float deltaTime) {
+	if (_scriptFrameActive || _initialScriptPending)
+		return;
+	Math::Vector3d front = _cameraFront;
+	if (_movementMode == 3)
+		_cameraFront = directionToVector(0, _yaw, false);
+	FreescapeEngine::updatePlayerMovement(deltaTime);
+	_cameraFront = front;
+}
+
+void Kit8Engine::checkSensors() {
+	// TODO: sensor firing.
+	if (_scriptFrameActive || !_currentArea)
+		return;
+	for (auto *object : _sensors) {
+		Sensor *sensor = static_cast<Sensor *>(object);
+		Math::Vector3d diff = _position - sensor->getOrigin();
+		bool detected = !sensor->isInvisible() && !sensor->isDestroyed() &&
+			ABS(diff.x()) + ABS(diff.y()) + ABS(diff.z()) < sensor->_firingRange;
+		sensor->shouldShoot(detected);
+	}
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/games/3dck/8bit.h b/engines/freescape/games/3dck/8bit.h
new file mode 100644
index 00000000000..541b460b2ab
--- /dev/null
+++ b/engines/freescape/games/3dck/8bit.h
@@ -0,0 +1,122 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_GAMES_3DCK_8BIT_H
+#define FREESCAPE_GAMES_3DCK_8BIT_H
+
+#include "freescape/freescape.h"
+
+namespace Freescape {
+
+class Kit8Engine : public FreescapeEngine {
+public:
+	Kit8Engine(OSystem *syst, const ADGameDescription *gd);
+
+	void loadAssets() override;
+	void initGameState() override;
+	void gotoArea(uint16 areaID, int entranceID) override;
+	void checkIfStillInArea() override;
+	bool checkIfGameEnded() override { return false; }
+	void borderScreen() override {}
+	void drawUI() override;
+	bool handleInput(const Common::Event &event) override;
+	void updatePlayerMovement(float deltaTime) override;
+	void updateTimeVariables() override;
+	void checkSensors() override;
+	void updateScripts() override;
+	bool executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) override;
+	void executeLocalGlobalConditions(bool shot, bool collided, bool timer) override {}
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
+
+private:
+	struct ConditionData {
+		byte id;
+		FCLInstructionVector code;
+	};
+
+	struct AreaData {
+		byte palette[4];
+		Common::Array<byte> globals;
+		Common::Array<ConditionData> conditions;
+	};
+
+	struct ScriptFrame {
+		const FCLInstructionVector *code;
+		uint ip;
+		ScriptFrame(const FCLInstructionVector &instructions) : code(&instructions), ip(0) {}
+	};
+
+	Common::Array<ConditionData> loadConditions(Common::SeekableReadStream &file);
+	Area *loadArea(Common::SeekableReadStream &file);
+	GeometricObject *loadGeometricObject(Common::SeekableReadStream &file, const byte header[9]);
+	void loadPresentation();
+	void applyPalette();
+	void setMovementMode(byte mode);
+	void readSystemVariables();
+	void writeSystemVariables();
+	void startScript(const FCLInstructionVector &code);
+	bool executeCode(uint &budget);
+	void executeArithmetic(const FCLInstruction &instruction);
+	void executeComparison(const FCLInstruction &instruction);
+	void executeConditional(const FCLInstruction &instruction);
+	void executeObjectStatus(const FCLInstruction &instruction);
+	void executeGoto(const FCLInstruction &instruction);
+	void executeMode(const FCLInstruction &instruction);
+	void executeCall(const FCLInstruction &instruction);
+	void executeSound(const FCLInstruction &instruction);
+	void executeColour(const FCLInstruction &instruction);
+	void setPredicate(bool value);
+	Object *scriptObject(uint16 area, uint16 id);
+	void interact(bool shot);
+	void printMessage(byte id, byte x, byte y);
+	void printText(const Common::String &text, byte x, byte y, byte color);
+	void updateInstruments();
+
+	Common::HashMap<uint16, AreaData> _areaData;
+	Common::Array<ConditionData> _globalConditions, _procedures;
+	Common::HashMap<byte, Common::String> _kitMessages;
+	Common::Array<ScriptFrame> _scriptStack;
+	const Common::Array<ConditionData> *_conditions = nullptr;
+	uint _conditionIndex = 0;
+	bool _initialScriptPending = true, _scriptFrameActive = false, _globalPhase = false;
+	bool _executing = true, _zero = false, _carry = false, _previousZero = false;
+	Token::Type _booleanOp = Token::UNKNOWN;
+	byte _variables[128] = {};
+	uint16 _changedVariables = 0;
+	byte _currentKey = 255;
+	byte _palette[4] = {};
+	byte _colorPatterns[15][4] = {};
+	byte _instruments[8][6] = {};
+	byte _textColor = 7, _movementMode = 1;
+	byte _climbHeight = 0, _fallHeight = 0, _walkSpeed = 0, _activationRange = 0;
+	byte _shotObject = 0, _hitObject = 0, _activatedObject = 0;
+	bool _fallen = false, _crushed = false, _crossVisible = true;
+	bool _timerTriggered = false, _pendingTimer = false, _soundWarning = false;
+	uint32 _lastTime = 0, _timerTicks = 0, _timerInterval = 0, _delayUntil = 0;
+	byte _fontData[96][8] = {};
+	bool _hasFont = false;
+	Graphics::ManagedSurface _scriptSurface, _overlaySurface, _borderSurface;
+};
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/games/3dck/8bitUI.cpp b/engines/freescape/games/3dck/8bitUI.cpp
new file mode 100644
index 00000000000..fb0f707e75f
--- /dev/null
+++ b/engines/freescape/games/3dck/8bitUI.cpp
@@ -0,0 +1,234 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/endian.h"
+#include "graphics/fonts/dosfont.h"
+#include "math/utils.h"
+
+#include "freescape/games/3dck/8bit.h"
+
+namespace Freescape {
+
+static const byte kCPCInks[27] = {
+	20, 4, 21, 28, 24, 29, 12, 5, 13, 22, 6, 23, 30, 0,
+	31, 14, 7, 15, 18, 2, 19, 26, 25, 27, 10, 3, 11
+};
+
+void Kit8Engine::loadPresentation() {
+	static const byte patterns[15][4] = {
+		{0x0f, 0x0f, 0x0f, 0x0f}, {0xf0, 0xf0, 0xf0, 0xf0}, {0xff, 0xff, 0xff, 0xff},
+		{0x0a, 0x05, 0x0a, 0x05}, {0xa0, 0x50, 0xa0, 0x50}, {0xaa, 0x55, 0xaa, 0x55},
+		{0xa5, 0x5a, 0xa5, 0x5a}, {0xaf, 0x5f, 0xaf, 0x5f}, {0xfa, 0xf5, 0xfa, 0xf5},
+		{0x02, 0x08, 0x02, 0x08}, {0x20, 0x80, 0x20, 0x80}, {0x22, 0x88, 0x22, 0x88},
+		{0x2d, 0x87, 0x2d, 0x87}, {0x2f, 0x8f, 0x2f, 0x8f}, {0xff, 0x66, 0x66, 0xff}
+	};
+	memcpy(_colorPatterns, patterns, sizeof(_colorPatterns));
+	_colorMap.resize(ARRAYSIZE(_colorPatterns));
+	for (uint i = 0; i < _colorMap.size(); i++)
+		_colorMap[i] = _colorPatterns[i];
+	_gfx->_keyColor = 0;
+	_scriptSurface.create(_screenW, _screenH, Graphics::PixelFormat::createFormatCLUT8());
+	_scriptSurface.fillRect(_fullscreenViewArea, 255);
+	_overlaySurface.create(_screenW, _screenH, _gfx->_texturePixelFormat);
+	_borderSurface.create(_screenW, _screenH, Graphics::PixelFormat::createFormatCLUT8());
+	_borderSurface.fillRect(_fullscreenViewArea, 0);
+
+	Common::File file;
+	if (file.open("BORDER.DAT")) {
+		if (file.size() != 16384 && file.size() != 16512)
+			error("Invalid 3D Construction Kit CPC border size");
+		file.seek(file.size() - 16384);
+		Common::Array<byte> screen;
+		screen.resize(16384);
+		file.read(screen.data(), screen.size());
+		for (int y = 0; y < _screenH; y++) {
+			for (int x = 0; x < _screenW; x++)
+				_borderSurface.setPixel(x, y, getCPCPixelMode1(screen[(y & 7) * 2048 + (y >> 3) * 80 + (x >> 2)], x & 3));
+		}
+		file.close();
+	}
+	_borderSurface.fillRect(_viewArea, 255);
+	// A saved editor data file omits the runner's font and border.
+	if (file.open("DISC.BIN") && file.size() == 25216) {
+		byte header[128];
+		file.read(header, sizeof(header));
+		if (READ_LE_UINT16(header + 21) == 0x3e00 && READ_LE_UINT16(header + 24) == 25088) {
+			file.seek(128 + 0x9c00 - 0x3e00);
+			_hasFont = file.read(_fontData, sizeof(_fontData)) == sizeof(_fontData);
+		}
+	}
+}
+
+void Kit8Engine::applyPalette() {
+	_gfx->_fourColorBackground = kCPCInks[_palette[0]];
+	_gfx->_underFireBackgroundColor = kCPCInks[_palette[2]];
+	_gfx->_paperColor = kCPCInks[_palette[1]];
+	_gfx->_inkColor = kCPCInks[_palette[3]];
+	_currentArea->_usualBackgroundColor = encodeCPCDirectColor(kCPCInks[_palette[0]]);
+	_currentArea->_skyColor = _currentArea->_usualBackgroundColor;
+	_currentArea->_underFireBackgroundColor = encodeCPCDirectColor(kCPCInks[_palette[3]]);
+}
+
+void Kit8Engine::printText(const Common::String &text, byte x, byte y, byte color) {
+	if (x >= 40 || y >= 25)
+		return;
+	Graphics::DosFont font;
+	byte background = ((color >> 3) & 1) | ((color >> 1) & 2);
+	for (uint i = 0; i < text.size() && x < 40; i++, x++) {
+		byte chr = text[i];
+		_scriptSurface.fillRect(Common::Rect(8 * x, 8 * y, 8 * x + 8, 8 * y + 8), background);
+		if (_hasFont && chr >= 32 && chr < 128) {
+			for (int row = 0; row < 8; row++) {
+				for (int col = 0; col < 8; col++) {
+					if (_fontData[chr - 32][row] & (0x80 >> col))
+						_scriptSurface.setPixel(8 * x + col, 8 * y + row, color & 3);
+				}
+			}
+		} else
+			font.drawChar(_scriptSurface.surfacePtr(), chr, 8 * x, 8 * y, color & 3);
+	}
+}
+
+void Kit8Engine::printMessage(byte id, byte x, byte y) {
+	if (_kitMessages.contains(id))
+		printText(_kitMessages[id], x, y, _textColor);
+}
+
+void Kit8Engine::updateInstruments() {
+	for (const auto &instrument : _instruments) {
+		byte type = instrument[0], x = instrument[1], y = instrument[2], length = instrument[3];
+		byte variable = instrument[4] & 127, color = instrument[5];
+		if (!type || type > 3 || x >= 40 || y >= 25 || !length)
+			continue;
+		uint16 value = _variables[variable];
+		if (type == 1) {
+			if (length > 5 || x + length > 40)
+				continue;
+			if (length > 3)
+				value |= _variables[(variable + 1) & 127] << 8;
+			printText(Common::String::format("%0*u", length, value), x, y, color);
+		} else {
+			if ((type == 2 && x + length > 40) || (type == 3 && y + length > 25))
+				continue;
+			Common::Rect bar(8 * x, 8 * y, 8 * (x + (type == 2 ? length : 1)), 8 * (y + (type == 3 ? length : 1)));
+			_scriptSurface.fillRect(bar, (color >> 2) & 3);
+			int filled = MIN<int>(value, 8 * length);
+			if (type == 2)
+				bar.right = bar.left + filled;
+			else
+				bar.top = bar.bottom - filled;
+			if (!bar.isEmpty())
+				_scriptSurface.fillRect(bar, color & 3);
+		}
+	}
+}
+
+void Kit8Engine::drawUI() {
+	uint32 colors[4];
+	for (uint i = 0; i < 4; i++) {
+		byte r, g, b;
+		_gfx->selectColorFromFourColorPalette(i, r, g, b);
+		colors[i] = _overlaySurface.format.ARGBToColor(255, r, g, b);
+	}
+	for (int y = 0; y < _screenH; y++) {
+		for (int x = 0; x < _screenW; x++) {
+			byte pen = _scriptSurface.getPixel(x, y);
+			if (pen == 255)
+				pen = _borderSurface.getPixel(x, y);
+			_overlaySurface.setPixel(x, y, pen == 255 ? 0 : colors[pen]);
+		}
+	}
+	drawFullscreenSurface(_overlaySurface.surfacePtr());
+	if (_crossVisible) {
+		_gfx->setViewport(_fullscreenViewArea);
+		_gfx->renderCrossair(_crossairPosition);
+	}
+}
+
+bool Kit8Engine::handleInput(const Common::Event &event) {
+	if (event.type == Common::EVENT_KEYDOWN || event.type == Common::EVENT_KEYUP) {
+		byte key = event.kbd.ascii < 128 ? event.kbd.ascii : 255;
+		if (key >= 'a' && key <= 'z')
+			key -= 'a' - 'A';
+		if (event.type == Common::EVENT_KEYDOWN)
+			_currentKey = key;
+		else if (_currentKey == key)
+			_currentKey = 255;
+		if (!_scriptFrameActive)
+			_variables[121] = _currentKey;
+	} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
+		switch (event.customType) {
+		case kActionShoot:
+		case kActionActivate:
+			if (!_scriptFrameActive)
+				interact(event.customType == kActionShoot);
+			return true;
+		case kActionSkip:
+			_variables[121] = ' ';
+			return true;
+		case kActionInfoMenu:
+			_variables[121] = 'I';
+			return true;
+		case kActionEscape:
+		case kActionChangeMode:
+			return false;
+		default:
+			break;
+		}
+		bool movement = event.customType == kActionMoveUp || event.customType == kActionMoveDown ||
+			event.customType == kActionMoveLeft || event.customType == kActionMoveRight;
+		return _scriptFrameActive && !movement;
+	} else if (event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN) {
+		if (_shootMode)
+			_crossairPosition = getNormalizedPosition(event.mouse);
+		if (!_scriptFrameActive)
+			interact(event.type == Common::EVENT_LBUTTONDOWN);
+		return true;
+	} else if (_scriptFrameActive && event.type == Common::EVENT_MOUSEMOVE)
+		return true;
+	return false;
+}
+
+void Kit8Engine::interact(bool shot) {
+	if (!_viewArea.contains(_crossairPosition) || (shot && !_variables[125]))
+		return;
+	float x = 2.0f * (_crossairPosition.x - _viewArea.left) / _viewArea.width() - 1;
+	float y = 1 - 2.0f * (_crossairPosition.y - _viewArea.top) / _viewArea.height();
+	float projection = tan(Math::deg2rad(_fieldOfView) / 2);
+	Math::Vector3d direction = directionToVector(_pitch + Math::rad2deg(atan(y * projection / _viewAspectRatio)),
+		_yaw - Math::rad2deg(atan(x * projection)), false);
+	Object *object = _currentArea->checkCollisionRay(Math::Ray(_position, direction), 8192, true);
+	if (shot) {
+		if (_variables[125] != 255)
+			_variables[125]--;
+		_shootingFrames = 3;
+	}
+	if (!object || !object->isGeometric())
+		return;
+	if (!shot) {
+		Math::Vector3d diff = object->getOrigin() + object->getSize() / 2 - _position;
+		if (ABS(diff.x()) + ABS(diff.y()) + ABS(diff.z()) > 32.0f * _activationRange / _currentArea->getScale())
+			return;
+	}
+	executeObjectConditions(static_cast<GeometricObject *>(object), shot, false, !shot);
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/gfx.cpp b/engines/freescape/gfx.cpp
index bcfa6e3c884..df4021d3b84 100644
--- a/engines/freescape/gfx.cpp
+++ b/engines/freescape/gfx.cpp
@@ -42,6 +42,7 @@ Renderer::Renderer(int screenW, int screenH, Common::RenderMode renderMode, bool
 	_screenW = screenW;
 	_screenH = screenH;
 	_keyColor = -1;
+	_fourColorBackground = -1;
 	_inkColor = -1;
 	_paperColor = -1;
 	_underFireBackgroundColor = -1;
@@ -517,9 +518,10 @@ bool Renderer::getRGBAtHercules(uint8 index, uint8 &r1, uint8 &g1, uint8 &b1, ui
 
 void Renderer::selectColorFromFourColorPalette(uint8 index, uint8 &r1, uint8 &g1, uint8 &b1) {
 	if (index == 0) {
-		r1 = 0;
-		g1 = 0;
-		b1 = 0;
+		if (_fourColorBackground >= 0)
+			readFromPalette(_fourColorBackground, r1, g1, b1);
+		else
+			r1 = g1 = b1 = 0;
 	} else if (index == 1) {
 		readFromPalette(_underFireBackgroundColor, r1, g1, b1);
 	} else if (index == 2) {
diff --git a/engines/freescape/gfx.h b/engines/freescape/gfx.h
index cdf0bded4ad..efbea62f0a9 100644
--- a/engines/freescape/gfx.h
+++ b/engines/freescape/gfx.h
@@ -304,6 +304,7 @@ public:
 	int _inkColor;
 	int _paperColor;
 	int _underFireBackgroundColor;
+	int _fourColorBackground;
 	Common::Point _shakeOffset;
 	byte _stipples[16][128];
 
diff --git a/engines/freescape/language/8bitKitDetokeniser.cpp b/engines/freescape/language/8bitKitDetokeniser.cpp
new file mode 100644
index 00000000000..05e38de0d34
--- /dev/null
+++ b/engines/freescape/language/8bitKitDetokeniser.cpp
@@ -0,0 +1,130 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/textconsole.h"
+#include "freescape/language/8bitKitDetokeniser.h"
+
+namespace Freescape {
+
+struct KitOpcode {
+	byte opcode;
+	Token::Type token;
+	const char *name;
+	byte minArgs, maxArgs;
+	byte event;
+};
+
+static const KitOpcode kKitOpcodes[] = {
+	{0x00, Token::SETVAR, "SETV", 2, 2, 0},
+	{0x01, Token::ADDVAR, "ADDV", 2, 2, 0},
+	{0x02, Token::ADCV, "ADCV", 2, 2, 0},
+	{0x03, Token::ANDV, "ANDV", 2, 2, 0},
+	{0x04, Token::SUBVAR, "SUBV", 2, 2, 0},
+	{0x05, Token::SBCV, "SBCV", 2, 2, 0},
+	{0x06, Token::ORV, "ORV", 2, 2, 0},
+	{0x07, Token::XORV, "XORV", 2, 2, 0},
+	{0x08, Token::TESTV, "TESTV", 2, 2, 0},
+	{0x09, Token::CMPV, "CMPV", 2, 2, 0},
+	{0x0a, Token::VIS, "VIS", 1, 2, 0},
+	{0x0b, Token::INVIS, "INVIS", 1, 2, 0},
+	{0x0c, Token::TOGVIS, "TOGVIS", 1, 2, 0},
+	{0x0d, Token::DESTROY, "DESTROY", 1, 2, 0},
+	{0x0e, Token::GOTO, "GOTO", 1, 2, 0},
+	{0x0f, Token::MODE, "MODE", 1, 1, 0},
+	{0x20, Token::CONDITIONAL, "IFSHOT", 1, 2, kConditionalShot},
+	{0x21, Token::CONDITIONAL, "IFHIT", 1, 2, kConditionalCollided},
+	{0x22, Token::CONDITIONAL, "IFACTIVE", 1, 2, kConditionalActivated},
+	{0x23, Token::CONDITIONAL, "IFSENSED", 1, 2, kConditionalSensed},
+	{0x24, Token::VISQ, "IFVIS", 1, 2, 0},
+	{0x25, Token::CONDITIONAL, "IFFALL", 0, 0, kConditionalFallen},
+	{0x26, Token::CONDITIONAL, "IFCRUSH", 0, 0, kConditionalCrushed},
+	{0x27, Token::CONDITIONAL, "IFTIMER", 0, 0, kConditionalTimeout},
+	{0x28, Token::IFEQ, "IFEQ", 0, 0, 0},
+	{0x29, Token::IFGT, "IFGT", 0, 0, 0},
+	{0x2a, Token::IFLT, "IFLT", 0, 0, 0},
+	{0x2b, Token::AND, "AND", 0, 0, 0},
+	{0x2c, Token::OR, "OR", 0, 0, 0},
+	{0x2d, Token::ELSE, "ELSE", 0, 0, 0},
+	{0x2e, Token::THEN, "THEN", 0, 0, 0},
+	{0x2f, Token::ENDIF, "ENDIF", 0, 0, 0},
+	{0x30, Token::TIMER, "TIMER", 1, 1, 0},
+	{0x31, Token::CROSS, "CROSS", 1, 1, 0},
+	{0x32, Token::ENDGAME, "ENDGAME", 0, 0, 0},
+	{0x33, Token::COLOUR, "COLOUR", 2, 2, 0},
+	{0x34, Token::SOUND, "SOUND", 1, 1, 0},
+	{0x35, Token::SYNCSND, "SYNCSND", 1, 1, 0},
+	{0x36, Token::DELAY, "DELAY", 1, 1, 0},
+	{0x37, Token::TEXTCOL, "TEXTCOL", 1, 1, 0},
+	{0x38, Token::PRINT, "PRINT", 3, 3, 0},
+	{0x39, Token::REDRAW, "REDRAW", 0, 0, 0},
+	{0x3a, Token::EXECUTE, "CALL", 1, 1, 0},
+	{0x3f, Token::END, "END", 0, 0, 0}
+};
+
+Common::String detokenise8bitKitCondition(const Common::Array<byte> &code, FCLInstructionVector &instructions) {
+	Common::String source;
+	for (uint pos = 0; pos < code.size();) {
+		byte raw = code[pos++];
+		if (raw == 0xff) {
+			instructions.push_back(FCLInstruction(Token::ENDOFFILE));
+			return source;
+		}
+		byte opcode = raw & 0x3f;
+		bool variableSource = opcode >= 0x10 && opcode <= 0x19;
+		if (variableSource)
+			opcode &= ~0x10;
+		const KitOpcode *entry = nullptr;
+		for (const auto &candidate : kKitOpcodes) {
+			if (candidate.opcode == opcode) {
+				entry = &candidate;
+				break;
+			}
+		}
+		if (!entry)
+			error("Unsupported 8-bit 3D Construction Kit opcode %02x", opcode);
+		uint count = raw >> 6;
+		if (count < entry->minArgs || count > entry->maxArgs || count > code.size() - pos)
+			error("Invalid 8-bit 3D Construction Kit %s operands", entry->name);
+		FCLInstruction instruction(entry->token);
+		if (count > 0)
+			instruction.setSource(code[pos], variableSource ? Token::VARIABLE : Token::CONSTANT);
+		if (count > 1)
+			instruction.setDestination(code[pos + 1], opcode <= 9 ? Token::VARIABLE : Token::CONSTANT);
+		if (count > 2)
+			instruction.setAdditional(code[pos + 2]);
+		if (entry->event) {
+			if (count > 1)
+				instruction.setAdditional(code[pos + 1]);
+			if (count > 0)
+				instruction.setDestination(code[pos]);
+			instruction.setSource(entry->event);
+		}
+		instructions.push_back(instruction);
+		source += entry->name;
+		for (uint arg = 0; arg < count; arg++)
+			source += Common::String::format(" %u", code[pos + arg]);
+		source += '\n';
+		pos += count;
+	}
+	error("Unterminated 8-bit 3D Construction Kit condition");
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/8bitKitDetokeniser.h b/engines/freescape/language/8bitKitDetokeniser.h
new file mode 100644
index 00000000000..dab26bb8abc
--- /dev/null
+++ b/engines/freescape/language/8bitKitDetokeniser.h
@@ -0,0 +1,33 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_8BIT_KIT_DETOKENISER_H
+#define FREESCAPE_8BIT_KIT_DETOKENISER_H
+
+#include "freescape/language/instruction.h"
+
+namespace Freescape {
+
+Common::String detokenise8bitKitCondition(const Common::Array<byte> &code, FCLInstructionVector &instructions);
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/language/instruction.h b/engines/freescape/language/instruction.h
index c3feb0e0dec..0be0997da41 100644
--- a/engines/freescape/language/instruction.h
+++ b/engines/freescape/language/instruction.h
@@ -36,6 +36,9 @@ enum {
 	kConditionalTimeout = 1 << 1,
 	kConditionalCollided = 1 << 2,
 	kConditionalActivated = 1 << 3,
+	kConditionalSensed = 1 << 4,
+	kConditionalFallen = 1 << 5,
+	kConditionalCrushed = 1 << 6,
 };
 
 class FCLInstruction;
diff --git a/engines/freescape/language/instruction8bitKit.cpp b/engines/freescape/language/instruction8bitKit.cpp
new file mode 100644
index 00000000000..65fca4ee461
--- /dev/null
+++ b/engines/freescape/language/instruction8bitKit.cpp
@@ -0,0 +1,423 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "math/utils.h"
+
+#include "freescape/games/3dck/8bit.h"
+
+namespace Freescape {
+
+void Kit8Engine::readSystemVariables() {
+	float scale = 2 * _currentArea->getScale();
+	for (int axis = 0; axis < 3; axis++) {
+		uint16 position = int32(round(_position.getValue(axis) * scale));
+		_variables[112 + 2 * axis] = position;
+		_variables[113 + 2 * axis] = position >> 8;
+	}
+	_variables[118] = (int(round(_pitch / 5)) % 72 + 72) % 72;
+	_variables[119] = (int(round((90 - _yaw) / 5)) % 72 + 72) % 72;
+	_variables[120] = (int(round(_roll / 5)) % 72 + 72) % 72;
+	_variables[121] = _currentKey;
+	_variables[124] = _currentArea->getAreaID();
+	_changedVariables = 0;
+}
+
+void Kit8Engine::writeSystemVariables() {
+	float scale = 2 * _currentArea->getScale();
+	for (int axis = 0; axis < 3; axis++) {
+		if (_changedVariables & (3 << (2 * axis)))
+			_position.setValue(axis, (_variables[112 + 2 * axis] | (_variables[113 + 2 * axis] << 8)) / scale);
+	}
+	if (_changedVariables & (1 << 6)) {
+		_pitch = 5 * (_variables[118] % 72);
+		if (_pitch > 180)
+			_pitch -= 360;
+	}
+	if (_changedVariables & (1 << 7))
+		_yaw = 90 - 5 * _variables[119];
+	if (_changedVariables & (1 << 8))
+		_roll = 5 * _variables[120];
+	if (_changedVariables & 0x1c0)
+		updateCamera();
+	if (_changedVariables & 0x3f)
+		_lastPosition = _position;
+	_changedVariables = 0;
+}
+
+void Kit8Engine::updateTimeVariables() {
+	uint32 now = g_system->getMillis();
+	uint32 elapsed = (now - _lastTime) / 20;
+	_lastTime += 20 * elapsed;
+	uint16 counter = (_variables[122] | (_variables[123] << 8)) + elapsed;
+	_variables[122] = counter;
+	_variables[123] = counter >> 8;
+	_timerTicks += elapsed;
+	uint32 interval = MAX<uint32>(1, _timerInterval);
+	if (_timerTicks >= interval) {
+		_pendingTimer = true;
+		_timerTicks %= interval;
+	}
+}
+
+void Kit8Engine::startScript(const FCLInstructionVector &code) {
+	if (_scriptStack.size() >= 64)
+		error("8-bit 3D Construction Kit procedure stack overflow");
+	_scriptStack.push_back(ScriptFrame(code));
+	_executing = true;
+	_booleanOp = Token::UNKNOWN;
+}
+
+void Kit8Engine::updateScripts() {
+	_fallen |= _hasFallen;
+	_crushed |= _playerWasCrushed;
+	_hasFallen = _playerWasCrushed = false;
+	_avoidRenderingFrames = 0;
+	if (_delayUntil && int32(_delayUntil - g_system->getMillis()) > 0)
+		return;
+	_delayUntil = 0;
+	if (!_scriptFrameActive) {
+		_scriptFrameActive = true;
+		readSystemVariables();
+		_timerTriggered = _pendingTimer;
+		_pendingTimer = false;
+		_scriptSurface.fillRect(_viewArea, 255);
+		_conditions = &_globalConditions;
+		_conditionIndex = 0;
+		_globalPhase = true;
+		if (_initialScriptPending) {
+			_initialScriptPending = false;
+			for (const auto &condition : _globalConditions) {
+				if (condition.id == 1) {
+					startScript(condition.code);
+					break;
+				}
+			}
+		}
+	}
+	uint budget = 4096;
+	while (budget) {
+		if (_scriptStack.empty()) {
+			if (_conditionIndex == _conditions->size()) {
+				if (!_globalPhase)
+					break;
+				_globalPhase = false;
+				_conditions = &_areaData[_currentArea->getAreaID()].conditions;
+				_conditionIndex = 0;
+				continue;
+			}
+			const ConditionData &condition = (*_conditions)[_conditionIndex++];
+			if (_globalPhase && condition.id == 1)
+				continue;
+			startScript(condition.code);
+		}
+		if (!executeCode(budget))
+			return;
+	}
+	writeSystemVariables();
+	if (!_scriptStack.empty() || _globalPhase || _conditionIndex < _conditions->size())
+		return;
+	updateInstruments();
+	_scriptFrameActive = false;
+	_shotObject = _hitObject = _activatedObject = 0;
+}
+
+bool Kit8Engine::executeCode(uint &budget) {
+	while (!_scriptStack.empty() && budget) {
+		ScriptFrame &frame = _scriptStack.back();
+		if (frame.ip == frame.code->size()) {
+			_scriptStack.pop_back();
+			continue;
+		}
+		const FCLInstruction &instruction = (*frame.code)[frame.ip++];
+		Token::Type op = instruction.getType();
+		budget--;
+		// Condition statements also execute while a THEN/ELSE branch is skipped.
+		switch (op) {
+		case Token::ENDOFFILE:
+			_scriptStack.pop_back();
+			continue;
+		case Token::CONDITIONAL:
+			executeConditional(instruction);
+			continue;
+		case Token::VISQ:
+		case Token::IFEQ:
+		case Token::IFGT:
+		case Token::IFLT:
+			executeComparison(instruction);
+			continue;
+		case Token::AND:
+		case Token::OR:
+			_previousZero = _zero;
+			_booleanOp = op;
+			continue;
+		case Token::THEN:
+			_executing = _zero;
+			continue;
+		case Token::ELSE:
+			_executing = !_executing;
+			continue;
+		case Token::ENDIF:
+			_executing = true;
+			continue;
+		default:
+			break;
+		}
+		if (!_executing)
+			continue;
+		switch (op) {
+		case Token::SETVAR:
+		case Token::ADDVAR:
+		case Token::ADCV:
+		case Token::SUBVAR:
+		case Token::SBCV:
+		case Token::ANDV:
+		case Token::ORV:
+		case Token::XORV:
+		case Token::TESTV:
+		case Token::CMPV:
+			executeArithmetic(instruction);
+			break;
+		case Token::VIS:
+		case Token::INVIS:
+		case Token::TOGVIS:
+		case Token::DESTROY:
+			executeObjectStatus(instruction);
+			break;
+		case Token::GOTO:
+			executeGoto(instruction);
+			break;
+		case Token::MODE:
+			executeMode(instruction);
+			break;
+		case Token::EXECUTE:
+			executeCall(instruction);
+			break;
+		case Token::END:
+			_scriptStack.pop_back();
+			break;
+		case Token::ENDGAME:
+			_gameStateControl = kFreescapeGameStateRestart;
+			_scriptStack.clear();
+			return false;
+		case Token::TIMER:
+			_timerInterval = instruction._source;
+			break;
+		case Token::CROSS:
+			_crossVisible = instruction._source != 0;
+			break;
+		case Token::COLOUR:
+			executeColour(instruction);
+			break;
+		case Token::SOUND:
+		case Token::SYNCSND:
+			executeSound(instruction);
+			break;
+		case Token::DELAY:
+			_delayUntil = g_system->getMillis() + 20 * (instruction._source ? instruction._source : 256);
+			return false;
+		case Token::TEXTCOL:
+			_textColor = instruction._source;
+			break;
+		case Token::PRINT:
+			printMessage(instruction._source, instruction._destination, instruction._additional);
+			break;
+		case Token::REDRAW:
+			writeSystemVariables();
+			_scriptSurface.fillRect(_viewArea, 255);
+			updateInstruments();
+			return false;
+		default:
+			error("Unsupported 8-bit 3D Construction Kit instruction %u", op);
+		}
+	}
+	return _scriptStack.empty();
+}
+
+void Kit8Engine::setPredicate(bool value) {
+	if (_booleanOp == Token::AND)
+		value = _previousZero && value;
+	else if (_booleanOp == Token::OR)
+		value = _previousZero || value;
+	_zero = value;
+	_booleanOp = Token::UNKNOWN;
+}
+
+void Kit8Engine::executeArithmetic(const FCLInstruction &instruction) {
+	byte index = instruction._destination & 127;
+	byte source = instruction._sourceType == Token::VARIABLE ? _variables[instruction._source & 127] : instruction._source;
+	int destination = _variables[index];
+	int result = destination;
+	bool store = true;
+	switch (instruction.getType()) {
+	case Token::SETVAR: result = source; break;
+	case Token::ADDVAR: result += source; break;
+	case Token::ADCV: result += source + (_carry ? 1 : 0); break;
+	case Token::SUBVAR: result -= source; break;
+	case Token::SBCV: result -= source + (_carry ? 1 : 0); break;
+	case Token::ANDV: result &= source; break;
+	case Token::ORV: result |= source; break;
+	case Token::XORV: result ^= source; break;
+	case Token::TESTV:
+		result &= source;
+		store = false;
+		break;
+	case Token::CMPV:
+		result = source - destination;
+		store = false;
+		break;
+	default:
+		break;
+	}
+	if (store) {
+		_variables[index] = result;
+		if (index >= 112 && index <= 120)
+			_changedVariables |= 1 << (index - 112);
+	}
+	_carry = result < 0 || result > 255;
+	if (instruction.getType() == Token::SETVAR) {
+		// SETV retains the CPC operand decoder's zero flag.
+		setPredicate(instruction._sourceType != Token::VARIABLE);
+	} else
+		setPredicate(byte(result) == 0);
+}
+
+Object *Kit8Engine::scriptObject(uint16 area, uint16 id) {
+	if (!_areaMap.contains(area))
+		return nullptr;
+	Object *object = _areaMap[area]->objectWithID(id);
+	if (!object)
+		object = _areaMap[area]->entranceWithID(id);
+	return object;
+}
+
+void Kit8Engine::executeComparison(const FCLInstruction &instruction) {
+	if (instruction.getType() == Token::VISQ) {
+		uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
+		Object *object = scriptObject(area, instruction._source);
+		_carry = false;
+		setPredicate(!object || (!object->isInvisible() && !object->isDestroyed()));
+	} else if (instruction.getType() == Token::IFGT)
+		setPredicate(!_zero && _carry);
+	else if (instruction.getType() == Token::IFLT)
+		setPredicate(!_zero && !_carry);
+	else
+		setPredicate(_zero);
+}
+
+void Kit8Engine::executeConditional(const FCLInstruction &instruction) {
+	int area = instruction._additionalType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._additional;
+	int id = instruction._destination;
+	int value = 0;
+	switch (instruction._source) {
+	case kConditionalShot: value = _shotObject; break;
+	case kConditionalCollided: value = _hitObject; break;
+	case kConditionalActivated: value = _activatedObject; break;
+	case kConditionalTimeout:
+		_carry = false;
+		setPredicate(_timerTriggered);
+		return;
+	case kConditionalFallen:
+		setPredicate(_fallen);
+		_fallen = false;
+		return;
+	case kConditionalCrushed:
+		setPredicate(_crushed);
+		_crushed = false;
+		return;
+	case kConditionalSensed: {
+		Object *object = scriptObject(area, id);
+		_carry = false;
+		setPredicate(!object || (object->getType() == kSensorType && static_cast<Sensor *>(object)->isShooting()));
+		return;
+	}
+	default:
+		break;
+	}
+	if (area != _currentArea->getAreaID()) {
+		value = _currentArea->getAreaID();
+		id = area;
+	}
+	_carry = value < id;
+	setPredicate(value == id);
+}
+
+void Kit8Engine::executeObjectStatus(const FCLInstruction &instruction) {
+	uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
+	Object *object = scriptObject(area, instruction._source);
+	if (!object || object->isDestroyed())
+		return;
+	switch (instruction.getType()) {
+	case Token::VIS: object->makeVisible(); break;
+	case Token::INVIS: object->makeInvisible(); break;
+	case Token::TOGVIS: object->toggleVisibility(); break;
+	case Token::DESTROY:
+		object->makeInvisible();
+		object->destroy();
+		break;
+	default:
+		break;
+	}
+}
+
+void Kit8Engine::executeGoto(const FCLInstruction &instruction) {
+	uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
+	writeSystemVariables();
+	gotoArea(area, instruction._source);
+}
+
+void Kit8Engine::executeMode(const FCLInstruction &instruction) {
+	writeSystemVariables();
+	setMovementMode(instruction._source);
+	readSystemVariables();
+}
+
+void Kit8Engine::executeCall(const FCLInstruction &instruction) {
+	for (const auto &procedure : _procedures) {
+		if (procedure.id == instruction._source) {
+			startScript(procedure.code);
+			return;
+		}
+	}
+}
+
+void Kit8Engine::executeColour(const FCLInstruction &instruction) {
+	_palette[instruction._source & 3] = MIN<int>(26, instruction._destination);
+	applyPalette();
+}
+
+void Kit8Engine::executeSound(const FCLInstruction &instruction) {
+	if (instruction._source && !_soundWarning) {
+		warning("8-bit 3D Construction Kit sound effects are not implemented");
+		_soundWarning = true;
+	}
+}
+
+bool Kit8Engine::executeObjectConditions(GeometricObject *object, bool shot, bool collided, bool activated) {
+	if (shot)
+		_shotObject = object->getObjectID();
+	if (collided)
+		_hitObject = object->getObjectID();
+	if (activated)
+		_activatedObject = object->getObjectID();
+	return false;
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/token.h b/engines/freescape/language/token.h
index f3992c743cc..82712ec7a77 100644
--- a/engines/freescape/language/token.h
+++ b/engines/freescape/language/token.h
@@ -99,7 +99,19 @@ public:
 		TOGGLEBIT,
 		SWAPJET,
 		BITNOTEQ,
-		VARNOTEQ
+		VARNOTEQ,
+		ADCV,
+		SBCV,
+		XORV,
+		TESTV,
+		CMPV,
+		IFEQ,
+		IFGT,
+		IFLT,
+		CROSS,
+		COLOUR,
+		TEXTCOL,
+		TIMER
 	};
 
 	Type getType();
diff --git a/engines/freescape/metaengine.cpp b/engines/freescape/metaengine.cpp
index 7ca7b9ed389..9359f73db00 100644
--- a/engines/freescape/metaengine.cpp
+++ b/engines/freescape/metaengine.cpp
@@ -32,6 +32,7 @@
 #include "freescape/games/driller/driller.h"
 #include "freescape/games/eclipse/eclipse.h"
 #include "freescape/games/3dck/3dck.h"
+#include "freescape/games/3dck/8bit.h"
 #include "freescape/detection.h"
 
 
@@ -210,7 +211,10 @@ Common::Error FreescapeMetaEngine::createInstance(OSystem *syst, Engine **engine
 	} else if (Common::String(gd->gameId) == "castlemaster" || Common::String(gd->gameId) == "castlemaster2") {
 		*engine = (Engine *)new Freescape::CastleEngine(syst, gd);
 	} else if (Common::String(gd->gameId) == "3dkit") {
-		*engine = new Freescape::KitEngine(syst, gd);
+		if (gd->platform == Common::kPlatformAmstradCPC)
+			*engine = new Freescape::Kit8Engine(syst, gd);
+		else
+			*engine = new Freescape::KitEngine(syst, gd);
 	} else
 		*engine = new Freescape::FreescapeEngine(syst, gd);
 
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index 16915b5fa6c..528f17feda9 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -53,6 +53,8 @@ MODULE_OBJS := \
 	games/eclipse/cpc.o \
 	games/eclipse/zx.o \
 	games/3dck/3dck.o \
+	games/3dck/8bit.o \
+	games/3dck/8bitUI.o \
 	games/3dck/ui.o \
 	games/palettes.o \
 	gfx.o \
@@ -60,8 +62,10 @@ MODULE_OBJS := \
 	loaders/8bitBinaryLoader.o \
 	loaders/c64.o \
 	language/8bitDetokeniser.o \
+	language/8bitKitDetokeniser.o \
 	language/16bitDetokeniser.o \
 	language/instruction.o \
+	language/instruction8bitKit.o \
 	language/instruction16bit.o \
 	metaengine.o \
 	movement.o \


Commit: ce36579e2f48f3b7aeb9e306792f53e639f8a601
    https://github.com/scummvm/scummvm/commit/ce36579e2f48f3b7aeb9e306792f53e639f8a601
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: refactored language scripting for freescape/3dck

Changed paths:
  A engines/freescape/language/detokeniser.cpp
  A engines/freescape/language/detokeniser.h
  A engines/freescape/language/detokeniser_3dck16.cpp
  A engines/freescape/language/detokeniser_3dck8.cpp
  A engines/freescape/language/detokeniser_freescape.cpp
  A engines/freescape/language/execution.h
  A engines/freescape/language/execution_3dck16.cpp
  A engines/freescape/language/execution_3dck16.h
  A engines/freescape/language/execution_3dck8.cpp
  A engines/freescape/language/execution_3dck8.h
  A engines/freescape/language/execution_freescape.cpp
  A engines/freescape/language/variables.h
  R engines/freescape/language/16bitDetokeniser.cpp
  R engines/freescape/language/16bitDetokeniser.h
  R engines/freescape/language/8bitDetokeniser.cpp
  R engines/freescape/language/8bitDetokeniser.h
  R engines/freescape/language/8bitKitDetokeniser.cpp
  R engines/freescape/language/8bitKitDetokeniser.h
  R engines/freescape/language/instruction16bit.cpp
  R engines/freescape/language/instruction16bit.h
  R engines/freescape/language/instruction8bitKit.cpp
    engines/freescape/freescape.cpp
    engines/freescape/freescape.h
    engines/freescape/games/3dck/3dck.cpp
    engines/freescape/games/3dck/3dck.h
    engines/freescape/games/3dck/8bit.cpp
    engines/freescape/games/3dck/8bit.h
    engines/freescape/games/3dck/8bitUI.cpp
    engines/freescape/games/castle/amiga.cpp
    engines/freescape/games/castle/atari.cpp
    engines/freescape/games/castle/c64.cpp
    engines/freescape/games/castle/castle.cpp
    engines/freescape/games/castle/cpc.cpp
    engines/freescape/games/castle/dos.cpp
    engines/freescape/games/castle/zx.cpp
    engines/freescape/games/dark/amiga.cpp
    engines/freescape/games/dark/atari.cpp
    engines/freescape/games/dark/c64.cpp
    engines/freescape/games/dark/cpc.cpp
    engines/freescape/games/dark/dark.cpp
    engines/freescape/games/dark/dos.cpp
    engines/freescape/games/dark/zx.cpp
    engines/freescape/games/driller/amiga.cpp
    engines/freescape/games/driller/atari.cpp
    engines/freescape/games/driller/c64.cpp
    engines/freescape/games/driller/cpc.cpp
    engines/freescape/games/driller/dos.cpp
    engines/freescape/games/driller/driller.cpp
    engines/freescape/games/driller/zx.cpp
    engines/freescape/games/eclipse/amiga.cpp
    engines/freescape/games/eclipse/atari.cpp
    engines/freescape/games/eclipse/c64.cpp
    engines/freescape/games/eclipse/cpc.cpp
    engines/freescape/games/eclipse/dos.cpp
    engines/freescape/games/eclipse/eclipse.cpp
    engines/freescape/games/eclipse/zx.cpp
    engines/freescape/language/instruction.cpp
    engines/freescape/language/instruction.h
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/module.mk
    engines/freescape/objects/entrance.h
    engines/freescape/objects/geometricobject.cpp
    engines/freescape/objects/group.cpp


diff --git a/engines/freescape/freescape.cpp b/engines/freescape/freescape.cpp
index 09d618480ef..bfbed6299a5 100644
--- a/engines/freescape/freescape.cpp
+++ b/engines/freescape/freescape.cpp
@@ -29,7 +29,7 @@
 #include "math/utils.h"
 
 #include "freescape/freescape.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/objects/sensor.h"
 #include "freescape/sweepAABB.h"
 #include "freescape/doodle.h"
diff --git a/engines/freescape/freescape.h b/engines/freescape/freescape.h
index c73ef2591ab..d5e4775752a 100644
--- a/engines/freescape/freescape.h
+++ b/engines/freescape/freescape.h
@@ -39,7 +39,7 @@
 #include "freescape/area.h"
 #include "freescape/font.h"
 #include "freescape/gfx.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/objects/entrance.h"
 #include "freescape/objects/geometricobject.h"
 #include "freescape/objects/sensor.h"
@@ -488,12 +488,11 @@ public:
 	bool checkConditional(const FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated);
 	bool checkIfGreaterOrEqual(FCLInstruction &instruction);
 	bool checkIfLessOrEqual(FCLInstruction &instruction);
-	void executeExecute(FCLInstruction &instruction);
+	void executeCall(FCLInstruction &instruction);
 	void executeIncrementVariable(FCLInstruction &instruction);
 	void executeDecrementVariable(FCLInstruction &instruction);
 	void executeSetVariable(FCLInstruction &instruction);
 	void executeGoto(FCLInstruction &instruction);
-	void executeIfThenElse(FCLInstruction &instruction);
 	virtual void executeMakeInvisible(FCLInstruction &instruction);
 	void executeMakeVisible(FCLInstruction &instruction);
 	void executeToggleVisibility(FCLInstruction &instruction);
diff --git a/engines/freescape/games/3dck/3dck.cpp b/engines/freescape/games/3dck/3dck.cpp
index 6c80aafa23c..24c178415e2 100644
--- a/engines/freescape/games/3dck/3dck.cpp
+++ b/engines/freescape/games/3dck/3dck.cpp
@@ -24,7 +24,7 @@
 #include "math/utils.h"
 
 #include "freescape/games/3dck/3dck.h"
-#include "freescape/language/16bitDetokeniser.h"
+#include "freescape/language/detokeniser.h"
 
 namespace Freescape {
 
@@ -235,7 +235,7 @@ Common::Array<KitEngine::ConditionData> KitEngine::loadConditions(Common::Seekab
 		uint16 words = file.readUint16BE() & 0x7fff;
 		ConditionData condition;
 		condition.name = name;
-		Common::String source = detokenise16bitCondition(readCode(file, 2 * words), condition.condition);
+		Common::String source = detokeniseKit16Condition(readCode(file, 2 * words), condition.condition);
 		debugC(1, kFreescapeDebugParser, "3DCK condition %s:\n%s", name, source.c_str());
 		conditions.push_back(condition);
 	}
@@ -362,7 +362,7 @@ Object *KitEngine::loadObject(Common::SeekableReadStream &file, ObjectData &data
 		// Entrances can retain editor data after their header.
 		data.extra = readWords(payload, (payload.size() - payload.pos()) / 2);
 	} else {
-		Common::String source = detokenise16bitCondition(readCode(payload, payload.size() - payload.pos()), data.condition);
+		Common::String source = detokeniseKit16Condition(readCode(payload, payload.size() - payload.pos()), data.condition);
 		debugC(1, kFreescapeDebugParser, "3DCK object %u condition:\n%s", data.id, source.c_str());
 	}
 	file.seek(end);
diff --git a/engines/freescape/games/3dck/3dck.h b/engines/freescape/games/3dck/3dck.h
index 421bd1dd373..e09933f47be 100644
--- a/engines/freescape/games/3dck/3dck.h
+++ b/engines/freescape/games/3dck/3dck.h
@@ -23,7 +23,7 @@
 #define FREESCAPE_GAMES_3DCK_H
 
 #include "freescape/freescape.h"
-#include "freescape/language/instruction16bit.h"
+#include "freescape/language/execution_3dck16.h"
 
 namespace Freescape {
 
@@ -49,7 +49,7 @@ public:
 
 private:
 	struct ObjectData;
-	struct ScriptState : FCLExecutionState {
+	struct ScriptState : FCLKit16ExecutionState {
 		ObjectData *object = nullptr;
 		uint16 area = 0;
 		byte events = 0;
@@ -116,7 +116,7 @@ private:
 	void executeObjectStatus(const FCLInstruction &instruction);
 	bool checkObjectStatus(const FCLInstruction &instruction);
 	void executeGetPosition(const FCLInstruction &instruction);
-	bool executeExecute(const FCLInstruction &instruction, ScriptState &script);
+	bool executeCall(const FCLInstruction &instruction, ScriptState &script);
 	bool executeGoto(const FCLInstruction &instruction);
 	void executeMode(const FCLInstruction &instruction);
 	bool executeDelay(const FCLInstruction &instruction);
@@ -135,7 +135,6 @@ private:
 	void setScriptVariable(byte index, uint32 value);
 	void setVariableResult(const FCLInstruction &instruction, ScriptState &script, uint32 value);
 	int32 getVariableOrConstant(int32 operand, Token::Type type) const;
-	void setScriptPredicate(ScriptState &script, bool value);
 	void getObjectReference(const FCLInstruction &instruction, uint16 &area, uint16 &id) const;
 	ObjectData *scriptObject(uint16 area, uint16 id);
 	ObjectData *scriptAnimator(const FCLInstruction &instruction);
diff --git a/engines/freescape/games/3dck/8bit.cpp b/engines/freescape/games/3dck/8bit.cpp
index aac773e5d14..a23aa372257 100644
--- a/engines/freescape/games/3dck/8bit.cpp
+++ b/engines/freescape/games/3dck/8bit.cpp
@@ -24,7 +24,7 @@
 #include "math/utils.h"
 
 #include "freescape/games/3dck/8bit.h"
-#include "freescape/language/8bitKitDetokeniser.h"
+#include "freescape/language/detokeniser.h"
 
 namespace Freescape {
 
@@ -153,7 +153,7 @@ Common::Array<Kit8Engine::ConditionData> Kit8Engine::loadConditions(Common::Seek
 		Common::Array<byte> code;
 		code.resize(length);
 		file.read(code.data(), length);
-		Common::String source = detokenise8bitKitCondition(code, condition.code);
+		Common::String source = detokeniseKit8Condition(code, condition.condition);
 		debugC(1, kFreescapeDebugParser, "Condition %u:\n%s", condition.id, source.c_str());
 		conditions.push_back(condition);
 	}
@@ -270,23 +270,7 @@ GeometricObject *Kit8Engine::loadGeometricObject(Common::SeekableReadStream &fil
 
 void Kit8Engine::initGameState() {
 	FreescapeEngine::initGameState();
-	memset(_variables, 0, sizeof(_variables));
-	_changedVariables = 0;
-	_currentKey = 255;
-	_textColor = 7;
-	_variables[121] = _variables[125] = 255;
-	_variables[127] = 0x9c;
-	_scriptStack.clear();
-	_conditions = nullptr;
-	_initialScriptPending = true;
-	_scriptFrameActive = false;
-	_zero = _carry = _previousZero = false;
-	_shotObject = _hitObject = _activatedObject = 0;
-	_fallen = _crushed = _pendingTimer = _timerTriggered = false;
-	_crossVisible = true;
-	_timerTicks = _timerInterval = _delayUntil = 0;
-	_lastTime = g_system->getMillis();
-	_scriptSurface.fillRect(_fullscreenViewArea, 255);
+	resetScripts();
 	_currentArea = nullptr;
 	_movementMode = 1;
 	_playerHeight = 0;
diff --git a/engines/freescape/games/3dck/8bit.h b/engines/freescape/games/3dck/8bit.h
index 541b460b2ab..0bb35b8c734 100644
--- a/engines/freescape/games/3dck/8bit.h
+++ b/engines/freescape/games/3dck/8bit.h
@@ -23,6 +23,7 @@
 #define FREESCAPE_GAMES_3DCK_8BIT_H
 
 #include "freescape/freescape.h"
+#include "freescape/language/execution_3dck8.h"
 
 namespace Freescape {
 
@@ -48,9 +49,11 @@ public:
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override { return false; }
 
 private:
+	typedef FCLKit8ExecutionState ScriptState;
+
 	struct ConditionData {
 		byte id;
-		FCLInstructionVector code;
+		FCLInstructionVector condition;
 	};
 
 	struct AreaData {
@@ -59,12 +62,6 @@ private:
 		Common::Array<ConditionData> conditions;
 	};
 
-	struct ScriptFrame {
-		const FCLInstructionVector *code;
-		uint ip;
-		ScriptFrame(const FCLInstructionVector &instructions) : code(&instructions), ip(0) {}
-	};
-
 	Common::Array<ConditionData> loadConditions(Common::SeekableReadStream &file);
 	Area *loadArea(Common::SeekableReadStream &file);
 	GeometricObject *loadGeometricObject(Common::SeekableReadStream &file, const byte header[9]);
@@ -73,18 +70,20 @@ private:
 	void setMovementMode(byte mode);
 	void readSystemVariables();
 	void writeSystemVariables();
-	void startScript(const FCLInstructionVector &code);
-	bool executeCode(uint &budget);
-	void executeArithmetic(const FCLInstruction &instruction);
-	void executeComparison(const FCLInstruction &instruction);
-	void executeConditional(const FCLInstruction &instruction);
+	void resetScripts();
+	void beginScriptFrame();
+	void startScript(ScriptState &script, const FCLInstructionVector &code);
+	FCLExecutionResult executeCode(ScriptState &script, uint &budget);
+	void executeArithmetic(const FCLInstruction &instruction, ScriptState &script);
+	void executeComparison(const FCLInstruction &instruction, ScriptState &script);
+	void executeConditional(const FCLInstruction &instruction, ScriptState &script);
 	void executeObjectStatus(const FCLInstruction &instruction);
 	void executeGoto(const FCLInstruction &instruction);
 	void executeMode(const FCLInstruction &instruction);
-	void executeCall(const FCLInstruction &instruction);
+	void executeCall(const FCLInstruction &instruction, ScriptState &script);
 	void executeSound(const FCLInstruction &instruction);
 	void executeColour(const FCLInstruction &instruction);
-	void setPredicate(bool value);
+	void getObjectReference(const FCLInstruction &instruction, uint16 &area, uint16 &id) const;
 	Object *scriptObject(uint16 area, uint16 id);
 	void interact(bool shot);
 	void printMessage(byte id, byte x, byte y);
@@ -94,13 +93,11 @@ private:
 	Common::HashMap<uint16, AreaData> _areaData;
 	Common::Array<ConditionData> _globalConditions, _procedures;
 	Common::HashMap<byte, Common::String> _kitMessages;
-	Common::Array<ScriptFrame> _scriptStack;
-	const Common::Array<ConditionData> *_conditions = nullptr;
+	ScriptState _script;
+	const Common::Array<ConditionData> *_activeConditions = nullptr;
 	uint _conditionIndex = 0;
 	bool _initialScriptPending = true, _scriptFrameActive = false, _globalPhase = false;
-	bool _executing = true, _zero = false, _carry = false, _previousZero = false;
-	Token::Type _booleanOp = Token::UNKNOWN;
-	byte _variables[128] = {};
+	byte _kitVariables[128] = {};
 	uint16 _changedVariables = 0;
 	byte _currentKey = 255;
 	byte _palette[4] = {};
diff --git a/engines/freescape/games/3dck/8bitUI.cpp b/engines/freescape/games/3dck/8bitUI.cpp
index fb0f707e75f..06a87a06ca5 100644
--- a/engines/freescape/games/3dck/8bitUI.cpp
+++ b/engines/freescape/games/3dck/8bitUI.cpp
@@ -118,12 +118,12 @@ void Kit8Engine::updateInstruments() {
 		byte variable = instrument[4] & 127, color = instrument[5];
 		if (!type || type > 3 || x >= 40 || y >= 25 || !length)
 			continue;
-		uint16 value = _variables[variable];
+		uint16 value = _kitVariables[variable];
 		if (type == 1) {
 			if (length > 5 || x + length > 40)
 				continue;
 			if (length > 3)
-				value |= _variables[(variable + 1) & 127] << 8;
+				value |= _kitVariables[(variable + 1) & 127] << 8;
 			printText(Common::String::format("%0*u", length, value), x, y, color);
 		} else {
 			if ((type == 2 && x + length > 40) || (type == 3 && y + length > 25))
@@ -173,7 +173,7 @@ bool Kit8Engine::handleInput(const Common::Event &event) {
 		else if (_currentKey == key)
 			_currentKey = 255;
 		if (!_scriptFrameActive)
-			_variables[121] = _currentKey;
+			_kitVariables[121] = _currentKey;
 	} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
 		switch (event.customType) {
 		case kActionShoot:
@@ -182,10 +182,10 @@ bool Kit8Engine::handleInput(const Common::Event &event) {
 				interact(event.customType == kActionShoot);
 			return true;
 		case kActionSkip:
-			_variables[121] = ' ';
+			_kitVariables[121] = ' ';
 			return true;
 		case kActionInfoMenu:
-			_variables[121] = 'I';
+			_kitVariables[121] = 'I';
 			return true;
 		case kActionEscape:
 		case kActionChangeMode:
@@ -208,7 +208,7 @@ bool Kit8Engine::handleInput(const Common::Event &event) {
 }
 
 void Kit8Engine::interact(bool shot) {
-	if (!_viewArea.contains(_crossairPosition) || (shot && !_variables[125]))
+	if (!_viewArea.contains(_crossairPosition) || (shot && !_kitVariables[125]))
 		return;
 	float x = 2.0f * (_crossairPosition.x - _viewArea.left) / _viewArea.width() - 1;
 	float y = 1 - 2.0f * (_crossairPosition.y - _viewArea.top) / _viewArea.height();
@@ -217,8 +217,8 @@ void Kit8Engine::interact(bool shot) {
 		_yaw - Math::rad2deg(atan(x * projection)), false);
 	Object *object = _currentArea->checkCollisionRay(Math::Ray(_position, direction), 8192, true);
 	if (shot) {
-		if (_variables[125] != 255)
-			_variables[125]--;
+		if (_kitVariables[125] != 255)
+			_kitVariables[125]--;
 		_shootingFrames = 3;
 	}
 	if (!object || !object->isGeometric())
diff --git a/engines/freescape/games/castle/amiga.cpp b/engines/freescape/games/castle/amiga.cpp
index 81edcd87f09..553738357e3 100644
--- a/engines/freescape/games/castle/amiga.cpp
+++ b/engines/freescape/games/castle/amiga.cpp
@@ -27,7 +27,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/castle/castle.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/castle/atari.cpp b/engines/freescape/games/castle/atari.cpp
index 142c7c80fe6..c4183cf2be2 100644
--- a/engines/freescape/games/castle/atari.cpp
+++ b/engines/freescape/games/castle/atari.cpp
@@ -25,7 +25,7 @@
 #include "freescape/copylock.h"
 #include "freescape/freescape.h"
 #include "freescape/games/castle/castle.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/castle/c64.cpp b/engines/freescape/games/castle/c64.cpp
index c024a0e105f..6cf1461de86 100644
--- a/engines/freescape/games/castle/c64.cpp
+++ b/engines/freescape/games/castle/c64.cpp
@@ -27,7 +27,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/castle/c64.music.h"
 #include "freescape/games/castle/castle.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/castle/castle.cpp b/engines/freescape/games/castle/castle.cpp
index 5a1c88b47fc..a5bcca3a1bb 100644
--- a/engines/freescape/games/castle/castle.cpp
+++ b/engines/freescape/games/castle/castle.cpp
@@ -36,7 +36,7 @@
 #include "freescape/gfx.h"
 #include "freescape/games/castle/castle.h"
 #include "freescape/games/castle/c64.music.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/music.h"
 
 namespace Freescape {
@@ -1600,7 +1600,7 @@ void CastleEngine::drawFullscreenGameOverAndWait() {
 	}
 }
 
-// Same as FreescapeEngine::executeExecute but updates the spirits destroyed counter
+// Same as FreescapeEngine::executeCall but updates the spirits destroyed counter
 void CastleEngine::executeDestroy(FCLInstruction &instruction) {
 	uint16 objectID = 0;
 	uint16 areaID = _currentArea->getAreaID();
diff --git a/engines/freescape/games/castle/cpc.cpp b/engines/freescape/games/castle/cpc.cpp
index 922d4e60982..e8ed9c1a5fc 100644
--- a/engines/freescape/games/castle/cpc.cpp
+++ b/engines/freescape/games/castle/cpc.cpp
@@ -26,7 +26,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/castle/ay.music.h"
 #include "freescape/games/castle/castle.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/castle/dos.cpp b/engines/freescape/games/castle/dos.cpp
index 871a1e11be5..df1635e2144 100644
--- a/engines/freescape/games/castle/dos.cpp
+++ b/engines/freescape/games/castle/dos.cpp
@@ -26,7 +26,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/castle/castle.h"
 #include "freescape/games/castle/opl.music.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/castle/zx.cpp b/engines/freescape/games/castle/zx.cpp
index d3d1abedeb1..a1330cd40d8 100644
--- a/engines/freescape/games/castle/zx.cpp
+++ b/engines/freescape/games/castle/zx.cpp
@@ -23,7 +23,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/castle/castle.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/dark/amiga.cpp b/engines/freescape/games/dark/amiga.cpp
index 7833ea42ebd..50e70684017 100644
--- a/engines/freescape/games/dark/amiga.cpp
+++ b/engines/freescape/games/dark/amiga.cpp
@@ -26,7 +26,7 @@
 #include "freescape/copylock.h"
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/dark/atari.cpp b/engines/freescape/games/dark/atari.cpp
index 3ed35c03197..fc3af8daadd 100644
--- a/engines/freescape/games/dark/atari.cpp
+++ b/engines/freescape/games/dark/atari.cpp
@@ -24,7 +24,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/wb.h"
 
 namespace Freescape {
diff --git a/engines/freescape/games/dark/c64.cpp b/engines/freescape/games/dark/c64.cpp
index 67470fb8aea..b872c054e94 100644
--- a/engines/freescape/games/dark/c64.cpp
+++ b/engines/freescape/games/dark/c64.cpp
@@ -24,7 +24,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/dark/cpc.cpp b/engines/freescape/games/dark/cpc.cpp
index 1e6cf14c7cb..95de8547a03 100644
--- a/engines/freescape/games/dark/cpc.cpp
+++ b/engines/freescape/games/dark/cpc.cpp
@@ -24,7 +24,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/dark/dark.cpp b/engines/freescape/games/dark/dark.cpp
index 3132718f8c7..1a1348feaf5 100644
--- a/engines/freescape/games/dark/dark.cpp
+++ b/engines/freescape/games/dark/dark.cpp
@@ -26,7 +26,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/objects/global.h"
 #include "freescape/wb.h"
 #include "freescape/objects/connections.h"
diff --git a/engines/freescape/games/dark/dos.cpp b/engines/freescape/games/dark/dos.cpp
index ff06bf4cea9..ca0a3588861 100644
--- a/engines/freescape/games/dark/dos.cpp
+++ b/engines/freescape/games/dark/dos.cpp
@@ -25,7 +25,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
 #include "freescape/games/dark/opl.music.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/dark/zx.cpp b/engines/freescape/games/dark/zx.cpp
index 63e843dee59..512513bc3c0 100644
--- a/engines/freescape/games/dark/zx.cpp
+++ b/engines/freescape/games/dark/zx.cpp
@@ -23,7 +23,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/dark/dark.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/amiga.cpp b/engines/freescape/games/driller/amiga.cpp
index 0eb595d7e7c..88863517853 100644
--- a/engines/freescape/games/driller/amiga.cpp
+++ b/engines/freescape/games/driller/amiga.cpp
@@ -23,7 +23,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/atari.cpp b/engines/freescape/games/driller/atari.cpp
index a64a251bd06..771f05102df 100644
--- a/engines/freescape/games/driller/atari.cpp
+++ b/engines/freescape/games/driller/atari.cpp
@@ -24,7 +24,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/c64.cpp b/engines/freescape/games/driller/c64.cpp
index d36fb058e68..7d82ada1112 100644
--- a/engines/freescape/games/driller/c64.cpp
+++ b/engines/freescape/games/driller/c64.cpp
@@ -23,7 +23,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/cpc.cpp b/engines/freescape/games/driller/cpc.cpp
index ba1f861c8c3..a0a38e08d7f 100644
--- a/engines/freescape/games/driller/cpc.cpp
+++ b/engines/freescape/games/driller/cpc.cpp
@@ -24,7 +24,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/dos.cpp b/engines/freescape/games/driller/dos.cpp
index c487684e2de..4fcf7a81256 100644
--- a/engines/freescape/games/driller/dos.cpp
+++ b/engines/freescape/games/driller/dos.cpp
@@ -25,7 +25,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
 #include "freescape/games/driller/opl.music.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/driller/driller.cpp b/engines/freescape/games/driller/driller.cpp
index 5b061e73995..9a64ae2a8f5 100644
--- a/engines/freescape/games/driller/driller.cpp
+++ b/engines/freescape/games/driller/driller.cpp
@@ -31,7 +31,8 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/detokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
@@ -327,7 +328,7 @@ void DrillerEngine::loadAssets() {
 		conditionArray.push_back(0x7f);
 		conditionArray.push_back(0x0);
 
-		Common::String conditionSource = detokenise8bitCondition(conditionArray, instructions, false);
+		Common::String conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, false);
 		debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
 		_areaMap[18]->_conditions.push_back(instructions);
 		_areaMap[18]->_conditionSources.push_back(conditionSource);
diff --git a/engines/freescape/games/driller/zx.cpp b/engines/freescape/games/driller/zx.cpp
index 3d61cbcef66..d76197d1fa0 100644
--- a/engines/freescape/games/driller/zx.cpp
+++ b/engines/freescape/games/driller/zx.cpp
@@ -22,7 +22,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/driller/driller.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/amiga.cpp b/engines/freescape/games/eclipse/amiga.cpp
index 0602c5b670a..8f98cb2ae50 100644
--- a/engines/freescape/games/eclipse/amiga.cpp
+++ b/engines/freescape/games/eclipse/amiga.cpp
@@ -25,7 +25,7 @@
 
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/eclipse.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/atari.cpp b/engines/freescape/games/eclipse/atari.cpp
index ec9f23cf361..0490151db1f 100644
--- a/engines/freescape/games/eclipse/atari.cpp
+++ b/engines/freescape/games/eclipse/atari.cpp
@@ -26,7 +26,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/eclipse.h"
 #include "freescape/wb.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/c64.cpp b/engines/freescape/games/eclipse/c64.cpp
index a6ff892308f..30ef6458d62 100644
--- a/engines/freescape/games/eclipse/c64.cpp
+++ b/engines/freescape/games/eclipse/c64.cpp
@@ -25,7 +25,7 @@
 #include "freescape/games/eclipse/c64.music.h"
 #include "freescape/games/eclipse/c64.sfx.h"
 #include "freescape/games/eclipse/eclipse.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/cpc.cpp b/engines/freescape/games/eclipse/cpc.cpp
index bf50e68e373..386787a0a9c 100644
--- a/engines/freescape/games/eclipse/cpc.cpp
+++ b/engines/freescape/games/eclipse/cpc.cpp
@@ -26,7 +26,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/ay.music.h"
 #include "freescape/games/eclipse/eclipse.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/dos.cpp b/engines/freescape/games/eclipse/dos.cpp
index f342960b15f..1a35418e301 100644
--- a/engines/freescape/games/eclipse/dos.cpp
+++ b/engines/freescape/games/eclipse/dos.cpp
@@ -26,7 +26,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/eclipse.h"
 #include "freescape/games/eclipse/opl.music.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/eclipse.cpp b/engines/freescape/games/eclipse/eclipse.cpp
index efc5d2b81d2..0eeac037612 100644
--- a/engines/freescape/games/eclipse/eclipse.cpp
+++ b/engines/freescape/games/eclipse/eclipse.cpp
@@ -37,7 +37,7 @@
 #include "freescape/games/eclipse/opl.music.h"
 #include "freescape/games/eclipse/eclipse.h"
 #include "freescape/objects/entrance.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/games/eclipse/zx.cpp b/engines/freescape/games/eclipse/zx.cpp
index 512639cfa2e..2a06e8d08b0 100644
--- a/engines/freescape/games/eclipse/zx.cpp
+++ b/engines/freescape/games/eclipse/zx.cpp
@@ -25,7 +25,7 @@
 #include "freescape/freescape.h"
 #include "freescape/games/eclipse/ay.music.h"
 #include "freescape/games/eclipse/eclipse.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
diff --git a/engines/freescape/language/16bitDetokeniser.cpp b/engines/freescape/language/16bitDetokeniser.cpp
deleted file mode 100644
index 4e8585e2f12..00000000000
--- a/engines/freescape/language/16bitDetokeniser.cpp
+++ /dev/null
@@ -1,181 +0,0 @@
-/* ScummVM - Graphic Adventure Engine
- *
- * ScummVM is the legal property of its developers, whose names
- * are too numerous to list here. Please refer to the COPYRIGHT
- * file distributed with this source distribution.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-#include "common/endian.h"
-#include "common/textconsole.h"
-
-#include "freescape/language/16bitDetokeniser.h"
-
-namespace Freescape {
-
-static const struct {
-	byte opcode;
-	Token::Type type;
-	byte minArgs, maxArgs;
-	const char *name;
-} opcodeTable[] = {
-	{0x00, Token::NOP,          0, 0, "NOP"},
-	{0x01, Token::CONDITIONAL,  0, 0, "ACTIVATED?"},
-	{0x02, Token::CONDITIONAL,  0, 0, "COLLIDED?"},
-	{0x03, Token::CONDITIONAL,  0, 0, "SHOT?"},
-	{0x04, Token::CONDITIONAL,  0, 0, "TIMER?"},
-	{0x10, Token::SETVAR,       2, 2, "SETVAR"},
-	{0x11, Token::ADDVAR,       2, 2, "ADDVAR"},
-	{0x12, Token::SUBVAR,       2, 2, "SUBVAR"},
-	{0x13, Token::ANDV,         2, 2, "ANDV"},
-	{0x14, Token::ORV,          2, 2, "ORV"},
-	{0x15, Token::NOTV,         1, 1, "NOTV"},
-	{0x16, Token::VAREQ,        2, 2, "VAR=?"},
-	{0x17, Token::VARGT,        2, 2, "VAR>?"},
-	{0x18, Token::VARLT,        2, 2, "VAR<?"},
-	{0x2f, Token::DESTROYEDQ,   1, 2, "DESTROYED?"},
-	{0x30, Token::INVIS,        1, 2, "INVIS"},
-	{0x31, Token::VIS,          1, 2, "VIS"},
-	{0x32, Token::TOGVIS,       1, 2, "TOGVIS"},
-	{0x33, Token::DESTROY,      1, 2, "DESTROY"},
-	{0x34, Token::INVISQ,       1, 2, "INVIS?"},
-	{0x35, Token::VISQ,         1, 2, "VIS?"},
-	{0x36, Token::MOVE,         3, 3, "MOVE"},
-	{0x37, Token::GETXPOS,      2, 3, "GETXPOS"},
-	{0x38, Token::GETYPOS,      2, 3, "GETYPOS"},
-	{0x39, Token::GETZPOS,      2, 3, "GETZPOS"},
-	{0x3a, Token::MOVETO,       3, 3, "MOVETO"},
-	{0x40, Token::IF,           0, 0, "IF"},
-	{0x41, Token::THEN,         0, 0, "THEN"},
-	{0x42, Token::ELSE,         0, 0, "ELSE"},
-	{0x43, Token::ENDIF,        0, 0, "ENDIF"},
-	{0x44, Token::AND,          0, 0, "AND"},
-	{0x45, Token::OR,           0, 0, "OR"},
-	{0x50, Token::STARTANIM,    1, 2, "STARTANIM"},
-	{0x51, Token::STOPANIM,     1, 2, "STOPANIM"},
-	{0x52, Token::START,        0, 0, "START"},
-	{0x53, Token::RESTART,      0, 0, "RESTART"},
-	{0x54, Token::INCLUDE,      1, 1, "INCLUDE"},
-	{0x55, Token::WAITTRIG,     0, 0, "WAITTRIG"},
-	{0x56, Token::TRIGANIM,     1, 2, "TRIGANIM"},
-	{0x57, Token::REMOVE,       1, 1, "REMOVE"},
-	{0x60, Token::LOOP,         1, 1, "LOOP"},
-	{0x61, Token::AGAIN,        2, 2, "AGAIN"},
-	{0x70, Token::SOUND,        1, 1, "SOUND"},
-	{0x71, Token::SYNCSND,      1, 1, "SYNCSND"},
-	{0x80, Token::WAIT,         0, 0, "WAIT"},
-	{0x81, Token::DELAY,        1, 1, "DELAY"},
-	{0x82, Token::UPDATEI,      1, 1, "UPDATEI"},
-	{0x83, Token::PRINT,        2, 255, "PRINT"},
-	{0x84, Token::REDRAW,       0, 0, "REDRAW"},
-	{0x85, Token::MODE,         1, 1, "MODE"},
-	{0x86, Token::ENDGAME,      0, 0, "ENDGAME"},
-	{0x87, Token::EXECUTE,      1, 1, "EXECUTE"},
-	{0x90, Token::GOTO,         1, 2, "GOTO"},
-	{0xff, Token::END,          0, 0, "END"}
-};
-
-Common::String detokenise16bitCondition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions) {
-	Common::String detokenisedStream;
-	int loops = 0;
-	for (uint32 bytePointer = 0; bytePointer < tokenisedCondition.size();) {
-		if (tokenisedCondition.size() - bytePointer < 2)
-			error("Truncated 16-bit FCL instruction at %u", bytePointer);
-		byte count = tokenisedCondition[bytePointer], opcode = tokenisedCondition[bytePointer + 1];
-		if (tokenisedCondition.size() - bytePointer < 2U + 2U * count)
-			error("Truncated 16-bit FCL instruction at %u", bytePointer);
-		uint index = 0;
-		while (index < ARRAYSIZE(opcodeTable) && opcodeTable[index].opcode != opcode)
-			index++;
-		if (index == ARRAYSIZE(opcodeTable))
-			error("Unknown 16-bit FCL opcode %02x at %u", opcode, bytePointer);
-		const auto &entry = opcodeTable[index];
-		if (count < entry.minArgs || count > entry.maxArgs)
-			error("Invalid argument count for 16-bit FCL opcode %02x at %u", opcode, bytePointer);
-
-		FCLInstruction instruction(entry.type);
-		int32 operands[3] = {};
-		Token::Type types[3] = {Token::UNKNOWN, Token::UNKNOWN, Token::UNKNOWN};
-		uint32 argumentPointer = bytePointer + 2;
-		uint argumentCount = count;
-		detokenisedStream += entry.name;
-		if (entry.type == Token::PRINT) {
-			uint16 length = READ_BE_UINT16(&tokenisedCondition[argumentPointer]);
-			if (count != 2 + (length + 1) / 2)
-				error("Invalid 16-bit FCL PRINT instruction at %u", bytePointer);
-			instruction._text = Common::String(reinterpret_cast<const char *>(&tokenisedCondition[argumentPointer + 2]), length);
-			detokenisedStream += Common::String::format(" (\"%s\", ", instruction._text.c_str());
-			argumentPointer += 2 + ((length + 1) & ~1);
-			argumentCount = 1;
-		} else if (entry.type == Token::AGAIN) {
-			argumentCount = 0; // The two words hold the runner's loop state.
-		} else if (count) {
-			detokenisedStream += " (";
-		}
-		for (uint i = 0; i < argumentCount; i++) {
-			uint16 operand = READ_BE_UINT16(&tokenisedCondition[argumentPointer + 2 * i]);
-			types[i] = operand & 0x8000 ? Token::VARIABLE : Token::CONSTANT;
-			operands[i] = types[i] == Token::VARIABLE ? operand & 0xff : (operand & 0x4000 ? int32(operand) - 0x8000 : operand);
-			if (i)
-				detokenisedStream += ", ";
-			detokenisedStream += Common::String::format(types[i] == Token::VARIABLE ? "v%d" : "%d", operands[i]);
-		}
-		if (argumentCount)
-			detokenisedStream += ")";
-		detokenisedStream += "\n";
-
-		// Match the operand order used by the 8-bit detokeniser.
-		switch (entry.type) {
-		case Token::SETVAR: case Token::ADDVAR: case Token::SUBVAR: case Token::ANDV: case Token::ORV:
-		case Token::GOTO:
-			SWAP(operands[0], operands[1]);
-			SWAP(types[0], types[1]);
-			break;
-		case Token::INVIS: case Token::VIS: case Token::TOGVIS: case Token::DESTROY:
-		case Token::INVISQ: case Token::VISQ: case Token::DESTROYEDQ:
-		case Token::STARTANIM: case Token::STOPANIM: case Token::TRIGANIM:
-			if (count == 2) {
-				SWAP(operands[0], operands[1]);
-				SWAP(types[0], types[1]);
-			}
-			break;
-		case Token::CONDITIONAL:
-			operands[0] = opcode == 1 ? kConditionalActivated : opcode == 2 ? kConditionalCollided :
-				opcode == 3 ? kConditionalShot : kConditionalTimeout;
-			types[0] = Token::CONSTANT;
-			break;
-		case Token::LOOP:
-			loops++;
-			break;
-		case Token::AGAIN:
-			if (--loops < 0)
-				error("16-bit FCL AGAIN without LOOP");
-			break;
-		default:
-			break;
-		}
-		instruction.setSource(operands[0], types[0]);
-		instruction.setDestination(operands[1], types[1]);
-		instruction.setAdditional(operands[2], types[2]);
-		instructions.push_back(instruction);
-		bytePointer += 2 + 2 * count;
-	}
-	if (loops)
-		error("Unterminated 16-bit FCL LOOP");
-	return detokenisedStream;
-}
-
-} // namespace Freescape
diff --git a/engines/freescape/language/detokeniser.cpp b/engines/freescape/language/detokeniser.cpp
new file mode 100644
index 00000000000..ee7f5d8112b
--- /dev/null
+++ b/engines/freescape/language/detokeniser.cpp
@@ -0,0 +1,62 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "freescape/language/detokeniser.h"
+
+namespace Freescape {
+
+void normaliseKitOperands(FCLInstruction &instruction) {
+	switch (instruction.getType()) {
+	case Token::SETVAR:
+	case Token::ADDVAR:
+	case Token::ADCV:
+	case Token::SUBVAR:
+	case Token::SBCV:
+	case Token::ANDV:
+	case Token::ORV:
+	case Token::XORV:
+	case Token::TESTV:
+	case Token::CMPV:
+	case Token::GOTO:
+		break;
+	case Token::INVIS:
+	case Token::VIS:
+	case Token::TOGVIS:
+	case Token::DESTROY:
+	case Token::INVISQ:
+	case Token::VISQ:
+	case Token::DESTROYEDQ:
+	case Token::STARTANIM:
+	case Token::STOPANIM:
+	case Token::TRIGANIM:
+		if (instruction._destinationType != Token::UNKNOWN)
+			break;
+		return;
+	default:
+		return;
+	}
+	int32 source = instruction._source;
+	Token::Type sourceType = instruction._sourceType;
+	instruction.setSource(instruction._destination, instruction._destinationType);
+	instruction.setDestination(source, sourceType);
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/16bitDetokeniser.h b/engines/freescape/language/detokeniser.h
similarity index 54%
rename from engines/freescape/language/16bitDetokeniser.h
rename to engines/freescape/language/detokeniser.h
index 08b63b214ca..b31e54cd732 100644
--- a/engines/freescape/language/16bitDetokeniser.h
+++ b/engines/freescape/language/detokeniser.h
@@ -19,14 +19,36 @@
  *
  */
 
-#ifndef FREESCAPE_16BITDETOKENISER_H
-#define FREESCAPE_16BITDETOKENISER_H
+#ifndef FREESCAPE_LANGUAGE_DETOKENISER_H
+#define FREESCAPE_LANGUAGE_DETOKENISER_H
 
 #include "freescape/language/instruction.h"
 
 namespace Freescape {
 
-Common::String detokenise16bitCondition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions);
+// Classic games retain their byte-oriented dialect on DOS, Amiga and Atari ST.
+Common::String detokeniseFreescapeCondition(const Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool isAmigaAtari);
+Common::String detokeniseKit8Condition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions);
+Common::String detokeniseKit16Condition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions);
+
+void normaliseKitOperands(FCLInstruction &instruction);
+
+struct FCLOpcode {
+	byte opcode;
+	Token::Type type;
+	const char *name;
+	byte minArgs, maxArgs;
+	byte event;
+};
+
+template<uint N>
+const FCLOpcode *findFCLOpcode(const FCLOpcode (&opcodes)[N], byte opcode) {
+	for (const auto &entry : opcodes) {
+		if (entry.opcode == opcode)
+			return &entry;
+	}
+	return nullptr;
+}
 
 } // namespace Freescape
 
diff --git a/engines/freescape/language/detokeniser_3dck16.cpp b/engines/freescape/language/detokeniser_3dck16.cpp
new file mode 100644
index 00000000000..964f90b8fdf
--- /dev/null
+++ b/engines/freescape/language/detokeniser_3dck16.cpp
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/endian.h"
+#include "common/textconsole.h"
+
+#include "freescape/language/detokeniser.h"
+
+namespace Freescape {
+
+static const FCLOpcode kKitOpcodes[] = {
+	{0x00, Token::NOP, "NOP", 0, 0, 0},
+	{0x01, Token::CONDITIONAL, "ACTIVATED?", 0, 0, kConditionalActivated},
+	{0x02, Token::CONDITIONAL, "COLLIDED?", 0, 0, kConditionalCollided},
+	{0x03, Token::CONDITIONAL, "SHOT?", 0, 0, kConditionalShot},
+	{0x04, Token::CONDITIONAL, "TIMER?", 0, 0, kConditionalTimeout},
+	{0x10, Token::SETVAR, "SETVAR", 2, 2, 0},
+	{0x11, Token::ADDVAR, "ADDVAR", 2, 2, 0},
+	{0x12, Token::SUBVAR, "SUBVAR", 2, 2, 0},
+	{0x13, Token::ANDV, "ANDV", 2, 2, 0},
+	{0x14, Token::ORV, "ORV", 2, 2, 0},
+	{0x15, Token::NOTV, "NOTV", 1, 1, 0},
+	{0x16, Token::VAREQ, "VAR=?", 2, 2, 0},
+	{0x17, Token::VARGT, "VAR>?", 2, 2, 0},
+	{0x18, Token::VARLT, "VAR<?", 2, 2, 0},
+	{0x2f, Token::DESTROYEDQ, "DESTROYED?", 1, 2, 0},
+	{0x30, Token::INVIS, "INVIS", 1, 2, 0},
+	{0x31, Token::VIS, "VIS", 1, 2, 0},
+	{0x32, Token::TOGVIS, "TOGVIS", 1, 2, 0},
+	{0x33, Token::DESTROY, "DESTROY", 1, 2, 0},
+	{0x34, Token::INVISQ, "INVIS?", 1, 2, 0},
+	{0x35, Token::VISQ, "VIS?", 1, 2, 0},
+	{0x36, Token::MOVE, "MOVE", 3, 3, 0},
+	{0x37, Token::GETXPOS, "GETXPOS", 2, 3, 0},
+	{0x38, Token::GETYPOS, "GETYPOS", 2, 3, 0},
+	{0x39, Token::GETZPOS, "GETZPOS", 2, 3, 0},
+	{0x3a, Token::MOVETO, "MOVETO", 3, 3, 0},
+	{0x40, Token::IF, "IF", 0, 0, 0},
+	{0x41, Token::THEN, "THEN", 0, 0, 0},
+	{0x42, Token::ELSE, "ELSE", 0, 0, 0},
+	{0x43, Token::ENDIF, "ENDIF", 0, 0, 0},
+	{0x44, Token::AND, "AND", 0, 0, 0},
+	{0x45, Token::OR, "OR", 0, 0, 0},
+	{0x50, Token::STARTANIM, "STARTANIM", 1, 2, 0},
+	{0x51, Token::STOPANIM, "STOPANIM", 1, 2, 0},
+	{0x52, Token::START, "START", 0, 0, 0},
+	{0x53, Token::RESTART, "RESTART", 0, 0, 0},
+	{0x54, Token::INCLUDE, "INCLUDE", 1, 1, 0},
+	{0x55, Token::WAITTRIG, "WAITTRIG", 0, 0, 0},
+	{0x56, Token::TRIGANIM, "TRIGANIM", 1, 2, 0},
+	{0x57, Token::REMOVE, "REMOVE", 1, 1, 0},
+	{0x60, Token::LOOP, "LOOP", 1, 1, 0},
+	{0x61, Token::AGAIN, "AGAIN", 2, 2, 0},
+	{0x70, Token::SOUND, "SOUND", 1, 1, 0},
+	{0x71, Token::SYNCSND, "SYNCSND", 1, 1, 0},
+	{0x80, Token::WAIT, "WAIT", 0, 0, 0},
+	{0x81, Token::DELAY, "DELAY", 1, 1, 0},
+	{0x82, Token::UPDATEI, "UPDATEI", 1, 1, 0},
+	{0x83, Token::PRINT, "PRINT", 2, 255, 0},
+	{0x84, Token::REDRAW, "REDRAW", 0, 0, 0},
+	{0x85, Token::MODE, "MODE", 1, 1, 0},
+	{0x86, Token::ENDGAME, "ENDGAME", 0, 0, 0},
+	{0x87, Token::EXECUTE, "EXECUTE", 1, 1, 0},
+	{0x90, Token::GOTO, "GOTO", 1, 2, 0},
+	{0xff, Token::END, "END", 0, 0, 0}
+};
+
+Common::String detokeniseKit16Condition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions) {
+	Common::String detokenisedStream;
+	int loops = 0;
+	for (uint32 bytePointer = 0; bytePointer < tokenisedCondition.size();) {
+		if (tokenisedCondition.size() - bytePointer < 2)
+			error("Truncated 16-bit FCL instruction at %u", bytePointer);
+		byte count = tokenisedCondition[bytePointer], opcode = tokenisedCondition[bytePointer + 1];
+		if (tokenisedCondition.size() - bytePointer < 2U + 2U * count)
+			error("Truncated 16-bit FCL instruction at %u", bytePointer);
+		const FCLOpcode *entry = findFCLOpcode(kKitOpcodes, opcode);
+		if (!entry)
+			error("Unknown 16-bit FCL opcode %02x at %u", opcode, bytePointer);
+		if (count < entry->minArgs || count > entry->maxArgs)
+			error("Invalid argument count for 16-bit FCL opcode %02x at %u", opcode, bytePointer);
+
+		FCLInstruction instruction(entry->type);
+		int32 operands[3] = {};
+		Token::Type types[3] = {Token::UNKNOWN, Token::UNKNOWN, Token::UNKNOWN};
+		uint32 argumentPointer = bytePointer + 2;
+		uint argumentCount = count;
+		detokenisedStream += entry->name;
+		if (entry->type == Token::PRINT) {
+			uint16 length = READ_BE_UINT16(&tokenisedCondition[argumentPointer]);
+			if (count != 2 + (length + 1) / 2)
+				error("Invalid 16-bit FCL PRINT instruction at %u", bytePointer);
+			instruction._text = Common::String(reinterpret_cast<const char *>(&tokenisedCondition[argumentPointer + 2]), length);
+			detokenisedStream += Common::String::format(" (\"%s\", ", instruction._text.c_str());
+			argumentPointer += 2 + ((length + 1) & ~1);
+			argumentCount = 1;
+		} else if (entry->type == Token::AGAIN) {
+			argumentCount = 0; // The two words hold the runner's loop state.
+		} else if (count) {
+			detokenisedStream += " (";
+		}
+		for (uint i = 0; i < argumentCount; i++) {
+			uint16 operand = READ_BE_UINT16(&tokenisedCondition[argumentPointer + 2 * i]);
+			types[i] = operand & 0x8000 ? Token::VARIABLE : Token::CONSTANT;
+			operands[i] = types[i] == Token::VARIABLE ? operand & 0xff : (operand & 0x4000 ? int32(operand) - 0x8000 : operand);
+			if (i)
+				detokenisedStream += ", ";
+			detokenisedStream += Common::String::format(types[i] == Token::VARIABLE ? "v%d" : "%d", operands[i]);
+		}
+		if (argumentCount)
+			detokenisedStream += ")";
+		detokenisedStream += "\n";
+
+		switch (entry->type) {
+		case Token::CONDITIONAL:
+			operands[0] = entry->event;
+			types[0] = Token::CONSTANT;
+			break;
+		case Token::LOOP:
+			loops++;
+			break;
+		case Token::AGAIN:
+			if (--loops < 0)
+				error("16-bit FCL AGAIN without LOOP");
+			break;
+		default:
+			break;
+		}
+		instruction.setSource(operands[0], types[0]);
+		instruction.setDestination(operands[1], types[1]);
+		instruction.setAdditional(operands[2], types[2]);
+		normaliseKitOperands(instruction);
+		instructions.push_back(instruction);
+		bytePointer += 2 + 2 * count;
+	}
+	if (loops)
+		error("Unterminated 16-bit FCL LOOP");
+	return detokenisedStream;
+}
+
+} // namespace Freescape
diff --git a/engines/freescape/language/8bitKitDetokeniser.cpp b/engines/freescape/language/detokeniser_3dck8.cpp
similarity index 79%
rename from engines/freescape/language/8bitKitDetokeniser.cpp
rename to engines/freescape/language/detokeniser_3dck8.cpp
index 05e38de0d34..be390e31052 100644
--- a/engines/freescape/language/8bitKitDetokeniser.cpp
+++ b/engines/freescape/language/detokeniser_3dck8.cpp
@@ -20,19 +20,12 @@
  */
 
 #include "common/textconsole.h"
-#include "freescape/language/8bitKitDetokeniser.h"
 
-namespace Freescape {
+#include "freescape/language/detokeniser.h"
 
-struct KitOpcode {
-	byte opcode;
-	Token::Type token;
-	const char *name;
-	byte minArgs, maxArgs;
-	byte event;
-};
+namespace Freescape {
 
-static const KitOpcode kKitOpcodes[] = {
+static const FCLOpcode kKitOpcodes[] = {
 	{0x00, Token::SETVAR, "SETV", 2, 2, 0},
 	{0x01, Token::ADDVAR, "ADDV", 2, 2, 0},
 	{0x02, Token::ADCV, "ADCV", 2, 2, 0},
@@ -79,10 +72,10 @@ static const KitOpcode kKitOpcodes[] = {
 	{0x3f, Token::END, "END", 0, 0, 0}
 };
 
-Common::String detokenise8bitKitCondition(const Common::Array<byte> &code, FCLInstructionVector &instructions) {
+Common::String detokeniseKit8Condition(const Common::Array<byte> &tokenisedCondition, FCLInstructionVector &instructions) {
 	Common::String source;
-	for (uint pos = 0; pos < code.size();) {
-		byte raw = code[pos++];
+	for (uint pos = 0; pos < tokenisedCondition.size();) {
+		byte raw = tokenisedCondition[pos++];
 		if (raw == 0xff) {
 			instructions.push_back(FCLInstruction(Token::ENDOFFILE));
 			return source;
@@ -91,36 +84,31 @@ Common::String detokenise8bitKitCondition(const Common::Array<byte> &code, FCLIn
 		bool variableSource = opcode >= 0x10 && opcode <= 0x19;
 		if (variableSource)
 			opcode &= ~0x10;
-		const KitOpcode *entry = nullptr;
-		for (const auto &candidate : kKitOpcodes) {
-			if (candidate.opcode == opcode) {
-				entry = &candidate;
-				break;
-			}
-		}
+		const FCLOpcode *entry = findFCLOpcode(kKitOpcodes, opcode);
 		if (!entry)
 			error("Unsupported 8-bit 3D Construction Kit opcode %02x", opcode);
 		uint count = raw >> 6;
-		if (count < entry->minArgs || count > entry->maxArgs || count > code.size() - pos)
+		if (count < entry->minArgs || count > entry->maxArgs || count > tokenisedCondition.size() - pos)
 			error("Invalid 8-bit 3D Construction Kit %s operands", entry->name);
-		FCLInstruction instruction(entry->token);
+		FCLInstruction instruction(entry->type);
 		if (count > 0)
-			instruction.setSource(code[pos], variableSource ? Token::VARIABLE : Token::CONSTANT);
+			instruction.setSource(tokenisedCondition[pos], variableSource ? Token::VARIABLE : Token::CONSTANT);
 		if (count > 1)
-			instruction.setDestination(code[pos + 1], opcode <= 9 ? Token::VARIABLE : Token::CONSTANT);
+			instruction.setDestination(tokenisedCondition[pos + 1], opcode <= 9 ? Token::VARIABLE : Token::CONSTANT);
 		if (count > 2)
-			instruction.setAdditional(code[pos + 2]);
+			instruction.setAdditional(tokenisedCondition[pos + 2]);
 		if (entry->event) {
 			if (count > 1)
-				instruction.setAdditional(code[pos + 1]);
+				instruction.setAdditional(tokenisedCondition[pos + 1]);
 			if (count > 0)
-				instruction.setDestination(code[pos]);
+				instruction.setDestination(tokenisedCondition[pos]);
 			instruction.setSource(entry->event);
 		}
+		normaliseKitOperands(instruction);
 		instructions.push_back(instruction);
 		source += entry->name;
 		for (uint arg = 0; arg < count; arg++)
-			source += Common::String::format(" %u", code[pos + arg]);
+			source += Common::String::format(" %u", tokenisedCondition[pos + arg]);
 		source += '\n';
 		pos += count;
 	}
diff --git a/engines/freescape/language/8bitDetokeniser.cpp b/engines/freescape/language/detokeniser_freescape.cpp
similarity index 98%
rename from engines/freescape/language/8bitDetokeniser.cpp
rename to engines/freescape/language/detokeniser_freescape.cpp
index 80a2f653346..b41d9f096fb 100644
--- a/engines/freescape/language/8bitDetokeniser.cpp
+++ b/engines/freescape/language/detokeniser_freescape.cpp
@@ -25,13 +25,12 @@
 // https://web.archive.org/web/20200116141513/http://www.seasip.demon.co.uk/ZX/Driller/
 
 #include "freescape/freescape.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/detokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 
-uint8 k8bitVariableShield = 63;
-
-Common::String detokenise8bitCondition(Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool isAmigaAtari) {
+Common::String detokeniseFreescapeCondition(const Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool isAmigaAtari) {
 	Common::String detokenisedStream;
 	Common::Array<uint8>::size_type bytePointer = 0;
 	Common::Array<uint8>::size_type sizeOfTokenisedContent = tokenisedCondition.size();
diff --git a/engines/freescape/language/execution.h b/engines/freescape/language/execution.h
new file mode 100644
index 00000000000..fc44a51c1ae
--- /dev/null
+++ b/engines/freescape/language/execution.h
@@ -0,0 +1,63 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef FREESCAPE_LANGUAGE_EXECUTION_H
+#define FREESCAPE_LANGUAGE_EXECUTION_H
+
+#include "freescape/language/instruction.h"
+
+namespace Freescape {
+
+enum FCLExecutionResult {
+	kFCLFinished,
+	kFCLYielded, // Resume this script after the other scripts in the frame.
+	kFCLPaused   // Resume this script before advancing the frame.
+};
+
+struct FCLExecutionFrame {
+	const FCLInstructionVector *code;
+	uint32 ip = 0;
+	explicit FCLExecutionFrame(const FCLInstructionVector *instructions = nullptr) : code(instructions) {}
+};
+
+struct FCLPredicateState {
+	bool value, previousValue;
+	Token::Type operation = Token::UNKNOWN;
+	explicit FCLPredicateState(bool initialValue = false) : value(initialValue), previousValue(initialValue) {}
+
+	void combine(Token::Type op) {
+		previousValue = value;
+		operation = op;
+	}
+
+	void set(bool result) {
+		if (operation == Token::AND)
+			result = previousValue && result;
+		else if (operation == Token::OR)
+			result = previousValue || result;
+		value = result;
+		operation = Token::UNKNOWN;
+	}
+};
+
+} // namespace Freescape
+
+#endif
diff --git a/engines/freescape/language/instruction16bit.cpp b/engines/freescape/language/execution_3dck16.cpp
similarity index 95%
rename from engines/freescape/language/instruction16bit.cpp
rename to engines/freescape/language/execution_3dck16.cpp
index d923c963486..271aa4ad2c6 100644
--- a/engines/freescape/language/instruction16bit.cpp
+++ b/engines/freescape/language/execution_3dck16.cpp
@@ -75,8 +75,7 @@ void KitEngine::startScript(ScriptState &script) {
 	script.code = script.source;
 	script.ip = script.restart = 0;
 	script.loops.clear();
-	script.predicate = script.previousPredicate = true;
-	script.booleanOp = Token::UNKNOWN;
+	script.predicate = FCLPredicateState(true);
 	script.events = script.object ? script.object->flags & 0x38 : 0;
 	if (script.object)
 		script.object->flags &= ~0x38;
@@ -245,15 +244,6 @@ int32 KitEngine::getVariableOrConstant(int32 operand, Token::Type type) const {
 	return type == Token::VARIABLE ? int32(_kitVariables[operand]) : operand;
 }
 
-void KitEngine::setScriptPredicate(ScriptState &script, bool value) {
-	if (script.booleanOp == Token::AND)
-		value = script.previousPredicate && value;
-	else if (script.booleanOp == Token::OR)
-		value = script.previousPredicate || value;
-	script.predicate = value;
-	script.booleanOp = Token::UNKNOWN;
-}
-
 KitEngine::ObjectData *KitEngine::scriptObject(uint16 area, uint16 id) {
 	if (!area)
 		area = _currentArea->getAreaID();
@@ -323,13 +313,12 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 		case Token::ENDIF:
 			break;
 		case Token::IF:
-			script.predicate = true;
-			script.booleanOp = Token::UNKNOWN;
+			script.predicate.value = true;
+			script.predicate.operation = Token::UNKNOWN;
 			break;
 		case Token::AND:
 		case Token::OR:
-			script.previousPredicate = script.predicate;
-			script.booleanOp = instruction.getType();
+			script.predicate.combine(instruction.getType());
 			break;
 		case Token::THEN:
 		case Token::ELSE:
@@ -370,7 +359,7 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 		case Token::INVISQ:
 		case Token::VISQ:
 		case Token::DESTROYEDQ:
-			setScriptPredicate(script, checkObjectStatus(instruction));
+			script.predicate.set(checkObjectStatus(instruction));
 			break;
 		case Token::GETXPOS:
 		case Token::GETYPOS:
@@ -378,7 +367,7 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 			executeGetPosition(instruction);
 			break;
 		case Token::EXECUTE:
-			if (!executeExecute(instruction, script))
+			if (!executeCall(instruction, script))
 				return kFCLFinished;
 			break;
 		case Token::GOTO:
@@ -465,7 +454,7 @@ FCLExecutionResult KitEngine::executeCode(ScriptState &script, uint &budget) {
 }
 
 void KitEngine::executeIfThenElse(const FCLInstruction &instruction, ScriptState &script) {
-	if (instruction.getType() == Token::THEN && script.predicate)
+	if (instruction.getType() == Token::THEN && script.predicate.value)
 		return;
 	const FCLInstructionVector &code = *script.code;
 	int depth = 0;
@@ -485,14 +474,14 @@ void KitEngine::executeIfThenElse(const FCLInstruction &instruction, ScriptState
 
 void KitEngine::executeConditional(const FCLInstruction &instruction, ScriptState &script) {
 	// RUNVGA retains these flags until the object's execution yields or ends.
-	setScriptPredicate(script, checkConditional(instruction,
+	script.predicate.set(checkConditional(instruction,
 		script.events & 16, script.events & 32, _timerTriggered, script.events & 8));
 }
 
 void KitEngine::setVariableResult(const FCLInstruction &instruction, ScriptState &script, uint32 value) {
 	if (instruction._sourceType == Token::VARIABLE)
 		setScriptVariable(instruction._source, value);
-	setScriptPredicate(script, value != 0);
+	script.predicate.set(value != 0);
 }
 
 void KitEngine::executeSetVariable(const FCLInstruction &instruction, ScriptState &script) {
@@ -534,13 +523,13 @@ void KitEngine::executeVariableComparison(const FCLInstruction &instruction, Scr
 	int32 destination = getVariableOrConstant(instruction._destination, instruction._destinationType);
 	switch (instruction.getType()) {
 	case Token::VAREQ:
-		setScriptPredicate(script, source == destination);
+		script.predicate.set(source == destination);
 		break;
 	case Token::VARGT:
-		setScriptPredicate(script, source > destination);
+		script.predicate.set(source > destination);
 		break;
 	case Token::VARLT:
-		setScriptPredicate(script, source < destination);
+		script.predicate.set(source < destination);
 		break;
 	default:
 		break;
@@ -587,7 +576,7 @@ void KitEngine::executeGetPosition(const FCLInstruction &instruction) {
 	}
 }
 
-bool KitEngine::executeExecute(const FCLInstruction &instruction, ScriptState &script) {
+bool KitEngine::executeCall(const FCLInstruction &instruction, ScriptState &script) {
 	uint16 id = getVariableOrConstant(instruction._source, instruction._sourceType);
 	ObjectData *object = scriptObject(0, id);
 	if (!object || object->type == kGroupType)
@@ -643,7 +632,7 @@ void KitEngine::executeLoop(const FCLInstruction &instruction, ScriptState &scri
 		}
 		end++;
 	}
-	FCLLoop &loop = script.loops[end];
+	FCLKit16Loop &loop = script.loops[end];
 	loop.start = script.ip;
 	loop.remaining = getVariableOrConstant(instruction._source, instruction._sourceType);
 }
@@ -730,7 +719,7 @@ void KitEngine::executeMove(const FCLInstruction &instruction, ScriptState &scri
 	int16 y = getVariableOrConstant(instruction._destination, instruction._destinationType);
 	int16 z = getVariableOrConstant(instruction._additional, instruction._additionalType);
 	bool absolute = instruction.getType() == Token::MOVETO;
-	setScriptPredicate(script, moveAnimation(script, Math::Vector3d(x, y, z), absolute));
+	script.predicate.set(moveAnimation(script, Math::Vector3d(x, y, z), absolute));
 }
 
 void KitEngine::executeSound(const FCLInstruction &instruction) {
diff --git a/engines/freescape/language/instruction16bit.h b/engines/freescape/language/execution_3dck16.h
similarity index 69%
rename from engines/freescape/language/instruction16bit.h
rename to engines/freescape/language/execution_3dck16.h
index 7b1795c3a9b..0e9c92a9367 100644
--- a/engines/freescape/language/instruction16bit.h
+++ b/engines/freescape/language/execution_3dck16.h
@@ -19,30 +19,28 @@
  *
  */
 
-#ifndef FREESCAPE_INSTRUCTION16BIT_H
-#define FREESCAPE_INSTRUCTION16BIT_H
+#ifndef FREESCAPE_LANGUAGE_EXECUTION_3DCK16_H
+#define FREESCAPE_LANGUAGE_EXECUTION_3DCK16_H
 
 #include "common/hashmap.h"
-#include "freescape/language/instruction.h"
+#include "freescape/language/execution.h"
 
 namespace Freescape {
 
-struct FCLLoop {
+struct FCLKit16Loop {
 	uint32 start = 0;
 	uint16 remaining = 0;
 };
 
-struct FCLExecutionState {
-	const FCLInstructionVector *source = nullptr, *code = nullptr;
-	uint32 ip = 0, restart = 0;
-	Common::HashMap<uint32, FCLLoop> loops;
+// Each condition or animator keeps its own continuation and predicate.
+struct FCLKit16ExecutionState : FCLExecutionFrame {
+	const FCLInstructionVector *source = nullptr;
+	uint32 restart = 0;
+	Common::HashMap<uint32, FCLKit16Loop> loops;
 	bool running = false;
-	bool predicate = true, previousPredicate = true;
-	Token::Type booleanOp = Token::UNKNOWN;
+	FCLPredicateState predicate = FCLPredicateState(true);
 };
 
-enum FCLExecutionResult { kFCLFinished, kFCLYielded, kFCLPaused };
-
 } // namespace Freescape
 
 #endif
diff --git a/engines/freescape/language/instruction8bitKit.cpp b/engines/freescape/language/execution_3dck8.cpp
similarity index 58%
rename from engines/freescape/language/instruction8bitKit.cpp
rename to engines/freescape/language/execution_3dck8.cpp
index 65fca4ee461..5e4e31d73cf 100644
--- a/engines/freescape/language/instruction8bitKit.cpp
+++ b/engines/freescape/language/execution_3dck8.cpp
@@ -25,18 +25,37 @@
 
 namespace Freescape {
 
+void Kit8Engine::resetScripts() {
+	memset(_kitVariables, 0, sizeof(_kitVariables));
+	_changedVariables = 0;
+	_currentKey = 255;
+	_textColor = 7;
+	_kitVariables[121] = _kitVariables[125] = 255;
+	_kitVariables[127] = 0x9c;
+	_script = ScriptState();
+	_activeConditions = nullptr;
+	_initialScriptPending = true;
+	_scriptFrameActive = false;
+	_shotObject = _hitObject = _activatedObject = 0;
+	_fallen = _crushed = _pendingTimer = _timerTriggered = false;
+	_crossVisible = true;
+	_timerTicks = _timerInterval = _delayUntil = 0;
+	_lastTime = g_system->getMillis();
+	_scriptSurface.fillRect(_fullscreenViewArea, 255);
+}
+
 void Kit8Engine::readSystemVariables() {
 	float scale = 2 * _currentArea->getScale();
 	for (int axis = 0; axis < 3; axis++) {
 		uint16 position = int32(round(_position.getValue(axis) * scale));
-		_variables[112 + 2 * axis] = position;
-		_variables[113 + 2 * axis] = position >> 8;
+		_kitVariables[112 + 2 * axis] = position;
+		_kitVariables[113 + 2 * axis] = position >> 8;
 	}
-	_variables[118] = (int(round(_pitch / 5)) % 72 + 72) % 72;
-	_variables[119] = (int(round((90 - _yaw) / 5)) % 72 + 72) % 72;
-	_variables[120] = (int(round(_roll / 5)) % 72 + 72) % 72;
-	_variables[121] = _currentKey;
-	_variables[124] = _currentArea->getAreaID();
+	_kitVariables[118] = (int(round(_pitch / 5)) % 72 + 72) % 72;
+	_kitVariables[119] = (int(round((90 - _yaw) / 5)) % 72 + 72) % 72;
+	_kitVariables[120] = (int(round(_roll / 5)) % 72 + 72) % 72;
+	_kitVariables[121] = _currentKey;
+	_kitVariables[124] = _currentArea->getAreaID();
 	_changedVariables = 0;
 }
 
@@ -44,17 +63,17 @@ void Kit8Engine::writeSystemVariables() {
 	float scale = 2 * _currentArea->getScale();
 	for (int axis = 0; axis < 3; axis++) {
 		if (_changedVariables & (3 << (2 * axis)))
-			_position.setValue(axis, (_variables[112 + 2 * axis] | (_variables[113 + 2 * axis] << 8)) / scale);
+			_position.setValue(axis, (_kitVariables[112 + 2 * axis] | (_kitVariables[113 + 2 * axis] << 8)) / scale);
 	}
 	if (_changedVariables & (1 << 6)) {
-		_pitch = 5 * (_variables[118] % 72);
+		_pitch = 5 * (_kitVariables[118] % 72);
 		if (_pitch > 180)
 			_pitch -= 360;
 	}
 	if (_changedVariables & (1 << 7))
-		_yaw = 90 - 5 * _variables[119];
+		_yaw = 90 - 5 * _kitVariables[119];
 	if (_changedVariables & (1 << 8))
-		_roll = 5 * _variables[120];
+		_roll = 5 * _kitVariables[120];
 	if (_changedVariables & 0x1c0)
 		updateCamera();
 	if (_changedVariables & 0x3f)
@@ -66,9 +85,9 @@ void Kit8Engine::updateTimeVariables() {
 	uint32 now = g_system->getMillis();
 	uint32 elapsed = (now - _lastTime) / 20;
 	_lastTime += 20 * elapsed;
-	uint16 counter = (_variables[122] | (_variables[123] << 8)) + elapsed;
-	_variables[122] = counter;
-	_variables[123] = counter >> 8;
+	uint16 counter = (_kitVariables[122] | (_kitVariables[123] << 8)) + elapsed;
+	_kitVariables[122] = counter;
+	_kitVariables[123] = counter >> 8;
 	_timerTicks += elapsed;
 	uint32 interval = MAX<uint32>(1, _timerInterval);
 	if (_timerTicks >= interval) {
@@ -77,12 +96,32 @@ void Kit8Engine::updateTimeVariables() {
 	}
 }
 
-void Kit8Engine::startScript(const FCLInstructionVector &code) {
-	if (_scriptStack.size() >= 64)
+void Kit8Engine::startScript(ScriptState &script, const FCLInstructionVector &code) {
+	if (script.stack.size() >= 64)
 		error("8-bit 3D Construction Kit procedure stack overflow");
-	_scriptStack.push_back(ScriptFrame(code));
-	_executing = true;
-	_booleanOp = Token::UNKNOWN;
+	script.stack.push_back(FCLExecutionFrame(&code));
+	script.executing = true;
+	script.predicate.operation = Token::UNKNOWN;
+}
+
+void Kit8Engine::beginScriptFrame() {
+	_scriptFrameActive = true;
+	readSystemVariables();
+	_timerTriggered = _pendingTimer;
+	_pendingTimer = false;
+	_scriptSurface.fillRect(_viewArea, 255);
+	_activeConditions = &_globalConditions;
+	_conditionIndex = 0;
+	_globalPhase = true;
+	if (_initialScriptPending) {
+		_initialScriptPending = false;
+		for (const auto &condition : _globalConditions) {
+			if (condition.id == 1) {
+				startScript(_script, condition.condition);
+				break;
+			}
+		}
+	}
 }
 
 void Kit8Engine::updateScripts() {
@@ -93,57 +132,40 @@ void Kit8Engine::updateScripts() {
 	if (_delayUntil && int32(_delayUntil - g_system->getMillis()) > 0)
 		return;
 	_delayUntil = 0;
-	if (!_scriptFrameActive) {
-		_scriptFrameActive = true;
-		readSystemVariables();
-		_timerTriggered = _pendingTimer;
-		_pendingTimer = false;
-		_scriptSurface.fillRect(_viewArea, 255);
-		_conditions = &_globalConditions;
-		_conditionIndex = 0;
-		_globalPhase = true;
-		if (_initialScriptPending) {
-			_initialScriptPending = false;
-			for (const auto &condition : _globalConditions) {
-				if (condition.id == 1) {
-					startScript(condition.code);
-					break;
-				}
-			}
-		}
-	}
+	if (!_scriptFrameActive)
+		beginScriptFrame();
 	uint budget = 4096;
 	while (budget) {
-		if (_scriptStack.empty()) {
-			if (_conditionIndex == _conditions->size()) {
+		if (_script.stack.empty()) {
+			if (_conditionIndex == _activeConditions->size()) {
 				if (!_globalPhase)
 					break;
 				_globalPhase = false;
-				_conditions = &_areaData[_currentArea->getAreaID()].conditions;
+				_activeConditions = &_areaData[_currentArea->getAreaID()].conditions;
 				_conditionIndex = 0;
 				continue;
 			}
-			const ConditionData &condition = (*_conditions)[_conditionIndex++];
+			const ConditionData &condition = (*_activeConditions)[_conditionIndex++];
 			if (_globalPhase && condition.id == 1)
 				continue;
-			startScript(condition.code);
+			startScript(_script, condition.condition);
 		}
-		if (!executeCode(budget))
+		if (executeCode(_script, budget) != kFCLFinished)
 			return;
 	}
 	writeSystemVariables();
-	if (!_scriptStack.empty() || _globalPhase || _conditionIndex < _conditions->size())
+	if (!_script.stack.empty() || _globalPhase || _conditionIndex < _activeConditions->size())
 		return;
 	updateInstruments();
 	_scriptFrameActive = false;
 	_shotObject = _hitObject = _activatedObject = 0;
 }
 
-bool Kit8Engine::executeCode(uint &budget) {
-	while (!_scriptStack.empty() && budget) {
-		ScriptFrame &frame = _scriptStack.back();
+FCLExecutionResult Kit8Engine::executeCode(ScriptState &script, uint &budget) {
+	while (!script.stack.empty() && budget) {
+		FCLExecutionFrame &frame = script.stack.back();
 		if (frame.ip == frame.code->size()) {
-			_scriptStack.pop_back();
+			script.stack.pop_back();
 			continue;
 		}
 		const FCLInstruction &instruction = (*frame.code)[frame.ip++];
@@ -152,35 +174,34 @@ bool Kit8Engine::executeCode(uint &budget) {
 		// Condition statements also execute while a THEN/ELSE branch is skipped.
 		switch (op) {
 		case Token::ENDOFFILE:
-			_scriptStack.pop_back();
+			script.stack.pop_back();
 			continue;
 		case Token::CONDITIONAL:
-			executeConditional(instruction);
+			executeConditional(instruction, script);
 			continue;
 		case Token::VISQ:
 		case Token::IFEQ:
 		case Token::IFGT:
 		case Token::IFLT:
-			executeComparison(instruction);
+			executeComparison(instruction, script);
 			continue;
 		case Token::AND:
 		case Token::OR:
-			_previousZero = _zero;
-			_booleanOp = op;
+			script.predicate.combine(op);
 			continue;
 		case Token::THEN:
-			_executing = _zero;
+			script.executing = script.predicate.value;
 			continue;
 		case Token::ELSE:
-			_executing = !_executing;
+			script.executing = !script.executing;
 			continue;
 		case Token::ENDIF:
-			_executing = true;
+			script.executing = true;
 			continue;
 		default:
 			break;
 		}
-		if (!_executing)
+		if (!script.executing)
 			continue;
 		switch (op) {
 		case Token::SETVAR:
@@ -193,7 +214,7 @@ bool Kit8Engine::executeCode(uint &budget) {
 		case Token::XORV:
 		case Token::TESTV:
 		case Token::CMPV:
-			executeArithmetic(instruction);
+			executeArithmetic(instruction, script);
 			break;
 		case Token::VIS:
 		case Token::INVIS:
@@ -208,15 +229,15 @@ bool Kit8Engine::executeCode(uint &budget) {
 			executeMode(instruction);
 			break;
 		case Token::EXECUTE:
-			executeCall(instruction);
+			executeCall(instruction, script);
 			break;
 		case Token::END:
-			_scriptStack.pop_back();
+			script.stack.pop_back();
 			break;
 		case Token::ENDGAME:
 			_gameStateControl = kFreescapeGameStateRestart;
-			_scriptStack.clear();
-			return false;
+			script.stack.clear();
+			return kFCLPaused;
 		case Token::TIMER:
 			_timerInterval = instruction._source;
 			break;
@@ -232,7 +253,7 @@ bool Kit8Engine::executeCode(uint &budget) {
 			break;
 		case Token::DELAY:
 			_delayUntil = g_system->getMillis() + 20 * (instruction._source ? instruction._source : 256);
-			return false;
+			return kFCLPaused;
 		case Token::TEXTCOL:
 			_textColor = instruction._source;
 			break;
@@ -243,60 +264,60 @@ bool Kit8Engine::executeCode(uint &budget) {
 			writeSystemVariables();
 			_scriptSurface.fillRect(_viewArea, 255);
 			updateInstruments();
-			return false;
+			return kFCLPaused;
 		default:
 			error("Unsupported 8-bit 3D Construction Kit instruction %u", op);
 		}
 	}
-	return _scriptStack.empty();
-}
-
-void Kit8Engine::setPredicate(bool value) {
-	if (_booleanOp == Token::AND)
-		value = _previousZero && value;
-	else if (_booleanOp == Token::OR)
-		value = _previousZero || value;
-	_zero = value;
-	_booleanOp = Token::UNKNOWN;
+	return script.stack.empty() ? kFCLFinished : kFCLPaused;
 }
 
-void Kit8Engine::executeArithmetic(const FCLInstruction &instruction) {
-	byte index = instruction._destination & 127;
-	byte source = instruction._sourceType == Token::VARIABLE ? _variables[instruction._source & 127] : instruction._source;
-	int destination = _variables[index];
-	int result = destination;
+void Kit8Engine::executeArithmetic(const FCLInstruction &instruction, ScriptState &script) {
+	byte index = instruction._source & 127;
+	byte operand = instruction._destinationType == Token::VARIABLE ? _kitVariables[instruction._destination & 127] : instruction._destination;
+	int value = _kitVariables[index];
+	int result = value;
 	bool store = true;
 	switch (instruction.getType()) {
-	case Token::SETVAR: result = source; break;
-	case Token::ADDVAR: result += source; break;
-	case Token::ADCV: result += source + (_carry ? 1 : 0); break;
-	case Token::SUBVAR: result -= source; break;
-	case Token::SBCV: result -= source + (_carry ? 1 : 0); break;
-	case Token::ANDV: result &= source; break;
-	case Token::ORV: result |= source; break;
-	case Token::XORV: result ^= source; break;
+	case Token::SETVAR: result = operand; break;
+	case Token::ADDVAR: result += operand; break;
+	case Token::ADCV: result += operand + (script.carry ? 1 : 0); break;
+	case Token::SUBVAR: result -= operand; break;
+	case Token::SBCV: result -= operand + (script.carry ? 1 : 0); break;
+	case Token::ANDV: result &= operand; break;
+	case Token::ORV: result |= operand; break;
+	case Token::XORV: result ^= operand; break;
 	case Token::TESTV:
-		result &= source;
+		result &= operand;
 		store = false;
 		break;
 	case Token::CMPV:
-		result = source - destination;
+		result = operand - value;
 		store = false;
 		break;
 	default:
 		break;
 	}
 	if (store) {
-		_variables[index] = result;
+		_kitVariables[index] = result;
 		if (index >= 112 && index <= 120)
 			_changedVariables |= 1 << (index - 112);
 	}
-	_carry = result < 0 || result > 255;
+	script.carry = result < 0 || result > 255;
 	if (instruction.getType() == Token::SETVAR) {
 		// SETV retains the CPC operand decoder's zero flag.
-		setPredicate(instruction._sourceType != Token::VARIABLE);
+		script.predicate.set(instruction._destinationType != Token::VARIABLE);
 	} else
-		setPredicate(byte(result) == 0);
+		script.predicate.set(byte(result) == 0);
+}
+
+void Kit8Engine::getObjectReference(const FCLInstruction &instruction, uint16 &area, uint16 &id) const {
+	area = _currentArea->getAreaID();
+	id = instruction._source;
+	if (instruction._destinationType != Token::UNKNOWN) {
+		area = instruction._source;
+		id = instruction._destination;
+	}
 }
 
 Object *Kit8Engine::scriptObject(uint16 area, uint16 id) {
@@ -308,21 +329,22 @@ Object *Kit8Engine::scriptObject(uint16 area, uint16 id) {
 	return object;
 }
 
-void Kit8Engine::executeComparison(const FCLInstruction &instruction) {
+void Kit8Engine::executeComparison(const FCLInstruction &instruction, ScriptState &script) {
 	if (instruction.getType() == Token::VISQ) {
-		uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
-		Object *object = scriptObject(area, instruction._source);
-		_carry = false;
-		setPredicate(!object || (!object->isInvisible() && !object->isDestroyed()));
+		uint16 area, id;
+		getObjectReference(instruction, area, id);
+		Object *object = scriptObject(area, id);
+		script.carry = false;
+		script.predicate.set(!object || (!object->isInvisible() && !object->isDestroyed()));
 	} else if (instruction.getType() == Token::IFGT)
-		setPredicate(!_zero && _carry);
+		script.predicate.set(!script.predicate.value && script.carry);
 	else if (instruction.getType() == Token::IFLT)
-		setPredicate(!_zero && !_carry);
+		script.predicate.set(!script.predicate.value && !script.carry);
 	else
-		setPredicate(_zero);
+		script.predicate.set(script.predicate.value);
 }
 
-void Kit8Engine::executeConditional(const FCLInstruction &instruction) {
+void Kit8Engine::executeConditional(const FCLInstruction &instruction, ScriptState &script) {
 	int area = instruction._additionalType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._additional;
 	int id = instruction._destination;
 	int value = 0;
@@ -331,21 +353,21 @@ void Kit8Engine::executeConditional(const FCLInstruction &instruction) {
 	case kConditionalCollided: value = _hitObject; break;
 	case kConditionalActivated: value = _activatedObject; break;
 	case kConditionalTimeout:
-		_carry = false;
-		setPredicate(_timerTriggered);
+		script.carry = false;
+		script.predicate.set(_timerTriggered);
 		return;
 	case kConditionalFallen:
-		setPredicate(_fallen);
+		script.predicate.set(_fallen);
 		_fallen = false;
 		return;
 	case kConditionalCrushed:
-		setPredicate(_crushed);
+		script.predicate.set(_crushed);
 		_crushed = false;
 		return;
 	case kConditionalSensed: {
 		Object *object = scriptObject(area, id);
-		_carry = false;
-		setPredicate(!object || (object->getType() == kSensorType && static_cast<Sensor *>(object)->isShooting()));
+		script.carry = false;
+		script.predicate.set(!object || (object->getType() == kSensorType && static_cast<Sensor *>(object)->isShooting()));
 		return;
 	}
 	default:
@@ -355,13 +377,14 @@ void Kit8Engine::executeConditional(const FCLInstruction &instruction) {
 		value = _currentArea->getAreaID();
 		id = area;
 	}
-	_carry = value < id;
-	setPredicate(value == id);
+	script.carry = value < id;
+	script.predicate.set(value == id);
 }
 
 void Kit8Engine::executeObjectStatus(const FCLInstruction &instruction) {
-	uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
-	Object *object = scriptObject(area, instruction._source);
+	uint16 area, id;
+	getObjectReference(instruction, area, id);
+	Object *object = scriptObject(area, id);
 	if (!object || object->isDestroyed())
 		return;
 	switch (instruction.getType()) {
@@ -378,9 +401,9 @@ void Kit8Engine::executeObjectStatus(const FCLInstruction &instruction) {
 }
 
 void Kit8Engine::executeGoto(const FCLInstruction &instruction) {
-	uint16 area = instruction._destinationType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._destination;
+	uint16 area = instruction._sourceType == Token::UNKNOWN ? _currentArea->getAreaID() : instruction._source;
 	writeSystemVariables();
-	gotoArea(area, instruction._source);
+	gotoArea(area, instruction._destination);
 }
 
 void Kit8Engine::executeMode(const FCLInstruction &instruction) {
@@ -389,10 +412,10 @@ void Kit8Engine::executeMode(const FCLInstruction &instruction) {
 	readSystemVariables();
 }
 
-void Kit8Engine::executeCall(const FCLInstruction &instruction) {
+void Kit8Engine::executeCall(const FCLInstruction &instruction, ScriptState &script) {
 	for (const auto &procedure : _procedures) {
 		if (procedure.id == instruction._source) {
-			startScript(procedure.code);
+			startScript(script, procedure.condition);
 			return;
 		}
 	}
diff --git a/engines/freescape/language/8bitKitDetokeniser.h b/engines/freescape/language/execution_3dck8.h
similarity index 72%
rename from engines/freescape/language/8bitKitDetokeniser.h
rename to engines/freescape/language/execution_3dck8.h
index dab26bb8abc..c80d5222ef5 100644
--- a/engines/freescape/language/8bitKitDetokeniser.h
+++ b/engines/freescape/language/execution_3dck8.h
@@ -19,14 +19,20 @@
  *
  */
 
-#ifndef FREESCAPE_8BIT_KIT_DETOKENISER_H
-#define FREESCAPE_8BIT_KIT_DETOKENISER_H
+#ifndef FREESCAPE_LANGUAGE_EXECUTION_3DCK8_H
+#define FREESCAPE_LANGUAGE_EXECUTION_3DCK8_H
 
-#include "freescape/language/instruction.h"
+#include "freescape/language/execution.h"
 
 namespace Freescape {
 
-Common::String detokenise8bitKitCondition(const Common::Array<byte> &code, FCLInstructionVector &instructions);
+// The byte interpreter shares its zero/carry flags across procedure calls.
+struct FCLKit8ExecutionState {
+	Common::Array<FCLExecutionFrame> stack;
+	FCLPredicateState predicate;
+	bool executing = true;
+	bool carry = false;
+};
 
 } // namespace Freescape
 
diff --git a/engines/freescape/language/execution_freescape.cpp b/engines/freescape/language/execution_freescape.cpp
new file mode 100644
index 00000000000..7ef56674c3c
--- /dev/null
+++ b/engines/freescape/language/execution_freescape.cpp
@@ -0,0 +1,870 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Based on Phantasma code by Thomas Harte (2013),
+// available at https://github.com/TomHarte/Phantasma/ (MIT)
+
+#include "freescape/freescape.h"
+#include "freescape/language/variables.h"
+#include "freescape/sweepAABB.h"
+
+namespace Freescape {
+
+uint8 k8bitVariableShield = 63;
+
+static bool isFreescapeConditional(const FCLInstruction &instruction) {
+	Token::Type type = instruction.getType();
+	return type == Token::BITNOTEQ || type == Token::VARNOTEQ ||
+		type == Token::IFGTEQ || type == Token::IFLTEQ ||
+		type == Token::VAREQ || type == Token::INVISQ;
+}
+
+void FreescapeEngine::executeEntranceConditions(Entrance *entrance) {
+	if (!entrance->_conditionSource.empty()) {
+		_firstSound = true;
+		_syncSound = false;
+
+		debugC(1, kFreescapeDebugCode, "Executing entrance condition with collision flag: %s", entrance->_conditionSource.c_str());
+		executeCode(entrance->_condition, false, true, false, false);
+	}
+}
+
+bool FreescapeEngine::executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) {
+	bool executed = false;
+	assert(obj != nullptr);
+	if (!obj->_conditionSource.empty()) {
+		_firstSound = true;
+		_syncSound = false;
+		_objExecutingCodeSize = collided ? obj->getSize() : Math::Vector3d();
+		if (collided) {
+			if (!isCastle())
+				clearGameBit(31); // We collided with something that has code
+			debugC(1, kFreescapeDebugCode, "Executing with collision flag: %s", obj->_conditionSource.c_str());
+		} else if (shot)
+			debugC(1, kFreescapeDebugCode, "Executing with shot flag: %s", obj->_conditionSource.c_str());
+		else if (activated) {
+			if (isCastle()) // TODO: add a 3DCK check here
+				clearTemporalMessages();
+			debugC(1, kFreescapeDebugCode, "Executing with activated flag: %s", obj->_conditionSource.c_str());
+		} else
+			error("Neither shot or collided flag is set!");
+		executed = executeCode(obj->_condition, shot, collided, false, activated); // TODO: check this last parameter
+	}
+	if (activated && !executed)
+		if (!_noEffectMessage.empty())
+			insertTemporaryMessage(_noEffectMessage, _countdown - 2);
+
+	return executed;
+}
+
+void FreescapeEngine::executeLocalGlobalConditions(bool shot, bool collided, bool timer) {
+	debugC(1, kFreescapeDebugCode, "Executing room conditions");
+	Common::Array<FCLInstructionVector> conditions = _currentArea->_conditions;
+	Common::Array<Common::String> conditionSources = _currentArea->_conditionSources;
+
+	for (uint i = 0; i < conditions.size(); i++) {
+		debugC(1, kFreescapeDebugCode, "%s", conditionSources[i].c_str());
+		executeCode(conditions[i], shot, collided, timer, false);
+	}
+
+	_executingGlobalCode = true;
+	debugC(1, kFreescapeDebugCode, "Executing global conditions (%d)", _conditions.size());
+	for (uint i = 0; i < _conditions.size(); i++) {
+		debugC(1, kFreescapeDebugCode, "%s", _conditionSources[i].c_str());
+		executeCode(_conditions[i], shot, collided, timer, false);
+	}
+	_executingGlobalCode = false;
+}
+
+bool FreescapeEngine::executeCode(FCLInstructionVector &code, bool shot, bool collided, bool timer, bool activated) {
+	int ip = 0;
+	bool skip = false;
+	int skipDepth = 0;
+	int conditionalDepth = 0;
+	bool executed = false;
+	int loopIterations = 0;
+	int loopHead = -1;
+	int codeSize = code.size();
+
+	if (codeSize == 0) {
+		assert(isCastle()); // Only seems to happen in Castle Master (magister room)
+		debugC(1, kFreescapeDebugCode, "Code is empty!");
+		return false;
+	}
+
+	while (ip <= codeSize - 1) {
+		FCLInstruction &instruction = code[ip];
+		debugC(1, kFreescapeDebugCode, "Executing ip: %d with type %d in code with size: %d. Skip flag is: %d", ip, instruction.getType(), codeSize, skip);
+
+		if (isFreescapeConditional(instruction)) {
+			conditionalDepth++;
+			debugC(1, kFreescapeDebugCode, "Conditional depth increased to: %d", conditionalDepth);
+		} else if (instruction.getType() == Token::ENDIF) {
+			conditionalDepth--;
+			debugC(1, kFreescapeDebugCode, "Conditional depth decreased to: %d", conditionalDepth);
+		}
+
+		if (skip) {
+			if (instruction.getType() == Token::ELSE) {
+				debugC(1, kFreescapeDebugCode, "Else found, skip depth: %d, conditional depth: %d", skipDepth, conditionalDepth);
+				if (skipDepth == conditionalDepth - 1) {
+					skip = false;
+				}
+			} else if (instruction.getType() == Token::ENDIF) {
+				debugC(1, kFreescapeDebugCode, "Endif found, skip depth: %d, conditional depth: %d", skipDepth, conditionalDepth);
+				if (skipDepth == conditionalDepth) {
+					skip = false;
+				}
+			}
+			debugC(1, kFreescapeDebugCode, "Instruction skipped!");
+			ip++;
+			continue;
+		}
+
+		if (instruction.getType() != Token::CONDITIONAL && !isFreescapeConditional(instruction))
+			executed = true;
+
+		switch (instruction.getType()) {
+		default:
+			error("Instruction %x at ip: %d not implemented!", instruction.getType(), ip);
+			break;
+		case Token::NOP:
+			debugC(1, kFreescapeDebugCode, "Executing NOP at ip: %d", ip);
+			break;
+
+		case Token::LOOP:
+			loopHead = ip;
+			loopIterations = instruction._source;
+			debugC(1, kFreescapeDebugCode, "Starting loop with %d iterations at ip: %d", loopIterations, ip);
+			break;
+
+		case Token::AGAIN:
+			if (loopIterations > 1) {
+				loopIterations--;
+				ip = loopHead;
+				debugC(1, kFreescapeDebugCode, "Looping again, %d iterations left, jumping to ip: %d", loopIterations, ip);
+			} else if (loopIterations == 1) {
+				loopIterations--;
+				debugC(1, kFreescapeDebugCode, "Loop finished");
+			} else {
+				error("AGAIN found without a matching LOOP!");
+			}
+			break;
+
+		case Token::CONDITIONAL:
+			if (checkConditional(instruction, shot, collided, timer, activated))
+				executed = executeCode(*instruction._thenInstructions, shot, collided, timer, activated);
+			// else branch is always empty
+			assert(instruction._elseInstructions == nullptr);
+			break;
+
+		case Token::VARNOTEQ:
+			if (executeEndIfNotEqual(instruction)) {
+				if (isCastle()) {
+					skip = true;
+					skipDepth = conditionalDepth - 1;
+				} else
+					ip = codeSize;
+			}
+			break;
+		case Token::IFGTEQ:
+			skip = !checkIfGreaterOrEqual(instruction);
+			if (skip)
+				skipDepth = conditionalDepth - 1;
+			break;
+
+		case Token::IFLTEQ:
+			skip = !checkIfLessOrEqual(instruction);
+			if (skip)
+				skipDepth = conditionalDepth - 1;
+			break;
+
+
+		case Token::ELSE:
+			skip = !skip;
+			if (skip)
+				skipDepth = conditionalDepth - 1;
+			break;
+
+		case Token::ENDIF:
+			skip = false;
+			break;
+
+		case Token::SWAPJET:
+			executeSwapJet(instruction);
+			break;
+		case Token::ADDVAR:
+			executeIncrementVariable(instruction);
+			break;
+		case Token::SUBVAR:
+			executeDecrementVariable(instruction);
+			break;
+		case Token::SETVAR:
+			executeSetVariable(instruction);
+			break;
+		case Token::GOTO:
+			executeGoto(instruction);
+			break;
+		case Token::TOGVIS:
+			executeToggleVisibility(instruction);
+			break;
+		case Token::INVIS:
+			executeMakeInvisible(instruction);
+			break;
+		case Token::VIS:
+			executeMakeVisible(instruction);
+			break;
+		case Token::DESTROY:
+			executeDestroy(instruction);
+			break;
+		case Token::REDRAW:
+			executeRedraw(instruction);
+			break;
+		case Token::EXECUTE:
+			executeCall(instruction);
+			ip = codeSize;
+			break;
+		case Token::DELAY:
+			executeDelay(instruction);
+			break;
+		case Token::SOUND:
+			executeSound(instruction);
+			break;
+		case Token::SETBIT:
+			executeSetBit(instruction);
+			break;
+		case Token::CLEARBIT:
+			executeClearBit(instruction);
+			break;
+		case Token::TOGGLEBIT:
+			executeToggleBit(instruction);
+			break;
+		case Token::PRINT:
+			executePrint(instruction);
+			break;
+		case Token::SPFX:
+			executeSPFX(instruction);
+			break;
+		case Token::SCREEN:
+			// TODO
+			break;
+		case Token::SETFLAGS:
+			// TODO
+			break;
+		case Token::STARTANIM:
+			executeStartAnim(instruction);
+			break;
+		case Token::BITNOTEQ:
+			if (executeEndIfBitNotEqual(instruction)) {
+				if (isCastle()) {
+					skip = true;
+					skipDepth = conditionalDepth - 1;
+				} else
+					ip = codeSize;
+			}
+			break;
+		case Token::INVISQ:
+			if (executeEndIfVisibilityIsEqual(instruction)) {
+				if (isCastle()) {
+					skip = true;
+					skipDepth = conditionalDepth - 1;
+				} else
+					ip = codeSize;
+			}
+			break;
+		}
+		ip++;
+	}
+	return executed;
+}
+
+void FreescapeEngine::executeRedraw(FCLInstruction &instruction) {
+	debugC(1, kFreescapeDebugCode, "Redrawing screen");
+	uint32 delay = (100 / 15) + 1;
+	if (isEclipse2() && _currentArea->getAreaID() == _startArea && _gameStateControl == kFreescapeGameStateStart)
+		delay = delay * 10;
+
+	if (isCastle() && (isSpectrum() || isCPC() || isC64()) && getGameBit(31))
+		delay = delay * 15; // Slow down redraws when the final cutscene is playing
+
+	if (isDriller() && (isSpectrum() || isCPC() || isC64()) && _gameStateVars[32] == 18)
+		delay = delay * 15; // Slow down redraws when the final cutscene is playing
+
+	if (isEclipse() && _currentArea->getAreaID() == 37 && getGameBit(6))
+		delay = delay * 10; // Slow down redraws in the final area of Eclipse
+
+	waitInLoop(delay);
+}
+
+void FreescapeEngine::executeCall(FCLInstruction &instruction) {
+	uint16 objId = instruction._source;
+	debugC(1, kFreescapeDebugCode, "Executing instructions from object %d", objId);
+	Object *obj = _currentArea->objectWithID(objId);
+	if (!obj) {
+		obj = _areaMap[255]->objectWithID(objId);
+		if (!obj) {
+			obj = _areaMap[255]->entranceWithID(objId);
+			if (!obj) {
+				debugC(1, kFreescapeDebugCode, "WARNING: executing instructions from a non-existent object %d", objId);
+				return;
+			}
+			assert(obj);
+			FCLInstructionVector &condition = ((Entrance *)obj)->_condition;
+			executeCode(condition, true, true, true, true);
+			return;
+		}
+	}
+	executeObjectConditions((GeometricObject *)obj, true, true, true);
+}
+
+void FreescapeEngine::executeSound(FCLInstruction &instruction) {
+	uint16 index = instruction._source;
+	bool sync = instruction._additional;
+	// An undefined sound index is a no-op in the original engines (e.g.
+	// start_speaker_sound returns early when the DOS table index is 0xFF) and
+	// must NOT disturb the sound that is already playing. Otherwise the
+	// stopAllSounds() below cuts the previous sound and then plays nothing
+	// (e.g. SOUND 15; SOUND 16 where sound 16 is undefined in the data).
+	if (_sound && !_sound->isSoundAvailable(index)) {
+		debugC(1, kFreescapeDebugCode, "Sound %d not available, keeping current sound", index);
+		return;
+	}
+	stopAllSounds(Sound::kTypeMovement);
+	_firstSound = false;
+	debugC(1, kFreescapeDebugCode, "Playing sound %d", index);
+	playSound(index, sync);
+}
+
+void FreescapeEngine::executeDelay(FCLInstruction &instruction) {
+	uint16 delay = instruction._source;
+	debugC(1, kFreescapeDebugCode, "Delaying %d * 1/50 seconds", delay);
+	waitInLoop(((20 * delay) / 15) + 1);
+}
+
+void FreescapeEngine::executePrint(FCLInstruction &instruction) {
+	uint16 index = instruction._source - 1;
+	debugC(1, kFreescapeDebugCode, "Printing message %d: \"%s\"", index, _messagesList[index].c_str());
+	_currentAreaMessages.clear();
+	_currentAreaMessages.push_back(_messagesList[index]);
+}
+
+uint32 spfxBasePaletteColor(FreescapeEngine *engine, uint8 index) {
+	index &= 0x0f;
+	uint8 r = engine->_gfx->_palette[3 * index + 0];
+	uint8 g = engine->_gfx->_palette[3 * index + 1];
+	uint8 b = engine->_gfx->_palette[3 * index + 2];
+	return engine->_gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
+}
+
+uint32 spfxDirectPaletteColor(FreescapeEngine *engine, uint16 value) {
+	uint8 r = (value >> 8) & 0x0f;
+	uint8 g = (value >> 4) & 0x0f;
+	uint8 b = value & 0x0f;
+
+	if (engine->isAtariST()) {
+		r = ((r & 0x07) << 1) | ((r & 0x07) >> 2);
+		g = ((g & 0x07) << 1) | ((g & 0x07) >> 2);
+		b = ((b & 0x07) << 1) | ((b & 0x07) >> 2);
+	}
+
+	r = (r << 4) | r;
+	g = (g << 4) | g;
+	b = (b << 4) | b;
+	return engine->_gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
+}
+
+uint32 spfxActivePaletteColor(FreescapeEngine *engine, uint8 index) {
+	index &= 0x0f;
+	if (engine->_currentArea->_colorRemaps.contains(index))
+		return (uint32)engine->_currentArea->_colorRemaps[index];
+	return spfxBasePaletteColor(engine, index);
+}
+
+void spfxSetActivePaletteColor(FreescapeEngine *engine, uint8 index, uint32 color) {
+	index &= 0x0f;
+	if (color == spfxBasePaletteColor(engine, index))
+		engine->_currentArea->unremapColor(index);
+	else
+		engine->_currentArea->remapColor(index, color);
+}
+
+void spfxFillRange(FreescapeEngine *engine, uint8 start, uint8 end, uint32 color) {
+	if (end < start)
+		return;
+
+	for (int i = start; i <= end; i++)
+		spfxSetActivePaletteColor(engine, i, color);
+}
+
+void spfxRestoreRange(FreescapeEngine *engine, uint8 start, uint8 end) {
+	if (end < start)
+		return;
+
+	for (int i = start; i <= end; i++)
+		engine->_currentArea->unremapColor(i);
+}
+
+void spfxRotateLeft(FreescapeEngine *engine, uint8 start, uint8 end) {
+	if (end <= start)
+		return;
+
+	uint32 color = spfxActivePaletteColor(engine, start);
+	for (int i = start; i < end; i++)
+		spfxSetActivePaletteColor(engine, i, spfxActivePaletteColor(engine, i + 1));
+	spfxSetActivePaletteColor(engine, end, color);
+}
+
+void spfxRotateRight(FreescapeEngine *engine, uint8 start, uint8 end) {
+	if (end <= start)
+		return;
+
+	uint32 color = spfxActivePaletteColor(engine, end);
+	for (int i = end; i > start; i--)
+		spfxSetActivePaletteColor(engine, i, spfxActivePaletteColor(engine, i - 1));
+	spfxSetActivePaletteColor(engine, start, color);
+}
+
+void FreescapeEngine::executeSPFX(FCLInstruction &instruction) {
+	uint16 src = instruction._source;
+	uint16 dst = instruction._destination;
+	if (isAmiga() || isAtariST()) {
+		uint16 raw = ((src & 0xff) << 8) | (dst & 0xff);
+		if (raw & 0x8000) {
+			uint16 color = raw & 0x7770;
+			if (isAmiga())
+				color >>= 3;
+			else
+				color >>= 4;
+
+			spfxSetActivePaletteColor(this, raw & 0x0f, spfxDirectPaletteColor(this, color));
+		} else if ((raw & 0xf000) == 0x1000) {
+			spfxFillRange(this, (raw >> 4) & 0x0f, raw & 0x0f, spfxBasePaletteColor(this, (raw >> 8) & 0x0f));
+		} else {
+			switch (raw & 0x0f00) {
+			case 0x0000:
+				spfxSetActivePaletteColor(this, raw & 0x0f, spfxBasePaletteColor(this, (raw >> 4) & 0x0f));
+				break;
+			case 0x0100:
+				spfxFillRange(this, 0, 14, spfxBasePaletteColor(this, raw & 0x0f));
+				break;
+			case 0x0200:
+				_currentArea->_colorRemaps.clear();
+				break;
+			case 0x0300:
+				spfxRestoreRange(this, (raw >> 4) & 0x0f, raw & 0x0f);
+				break;
+			case 0x0400:
+				spfxRotateLeft(this, (raw >> 4) & 0x0f, raw & 0x0f);
+				break;
+			case 0x0500:
+				spfxRotateRight(this, (raw >> 4) & 0x0f, raw & 0x0f);
+				break;
+			default:
+				break;
+			}
+		}
+	} else {
+		debugC(1, kFreescapeDebugCode, "Switching palette from position %d to %d", src, dst);
+		if (src == 0 && dst == 1) {
+
+			src = _currentArea->_usualBackgroundColor;
+			dst = _currentArea->_underFireBackgroundColor;
+
+			if (_renderMode == Common::kRenderCGA)
+				dst = 1;
+			else if (isC64()) {
+				src %= 16;
+				dst %= 16;
+			}
+
+			_currentArea->remapColor(src, dst);
+		} else if (src == 0 && dst == 0)
+			_currentArea->unremapColor(_currentArea->_usualBackgroundColor);
+		else if (src == 15 && dst == 15) // Found in Total Eclipse (DOS)
+			_currentArea->unremapColor(_currentArea->_usualBackgroundColor);
+		else
+			_currentArea->remapColor(src, dst);
+	}
+	_gfx->setColorRemaps(&_currentArea->_colorRemaps);
+	executeRedraw(instruction);
+}
+
+
+bool FreescapeEngine::executeEndIfVisibilityIsEqual(FCLInstruction &instruction) {
+	uint16 source = instruction._source;
+	uint16 additional = instruction._additional;
+	uint16 value = instruction._destination;
+
+	Object *obj = nullptr;
+	if (additional == 0) {
+		obj = _currentArea->objectWithID(source);
+		if (!obj && isCastle())
+			return (true == (value != 0));
+		assert(obj);
+		debugC(1, kFreescapeDebugCode, "End condition if visibility of obj with id %d is %d!", source, value);
+	} else {
+		debugC(1, kFreescapeDebugCode, "End condition if visibility of obj with id %d in area %d is %d!", additional, source, value);
+		if (_areaMap.contains(source)) {
+			obj = _areaMap[source]->objectWithID(additional);
+			assert(obj);
+		} else {
+			assert(isDOS() && isDemo()); // Should only happen in the DOS demo
+			return (value == false);
+		}
+	}
+
+	return (obj->isInvisible() == (value != 0));
+}
+
+bool FreescapeEngine::checkConditional(const FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated) {
+	uint16 conditional = instruction._source;
+	bool result = false;
+
+	if (conditional & kConditionalShot)
+		result |= shot;
+	if (conditional & kConditionalTimeout)
+		result |= timer;
+	if (conditional & kConditionalCollided)
+		result |= collided;
+	if (conditional & kConditionalActivated)
+		result |= activated;
+
+	debugC(1, kFreescapeDebugCode, "Check if conditional %x is true: %d!", conditional, result);
+	return result;
+}
+
+bool FreescapeEngine::checkIfGreaterOrEqual(FCLInstruction &instruction) {
+	assert(instruction._destination <= 128);
+
+	uint16 variable = instruction._source;
+	int8 value = instruction._destination;
+	debugC(1, kFreescapeDebugCode, "Check if variable %d with value %d is greater or equal to %d!", variable, (int8)_gameStateVars[variable], value);
+	return ((int8)_gameStateVars[variable] >= value);
+}
+
+bool FreescapeEngine::checkIfLessOrEqual(FCLInstruction &instruction) {
+	assert(instruction._destination <= 128);
+
+	uint16 variable = instruction._source;
+	int8 value = instruction._destination;
+	debugC(1, kFreescapeDebugCode, "Check if variable %d with value %d is less or equal to %d!", variable, (int8)_gameStateVars[variable], value);
+	return ((int8)_gameStateVars[variable] <= value);
+}
+
+
+bool FreescapeEngine::executeEndIfNotEqual(FCLInstruction &instruction) {
+	uint16 variable = instruction._source;
+	uint16 value = instruction._destination;
+	debugC(1, kFreescapeDebugCode, "End condition if variable %d with value %d is not equal to %d!", variable, (int8)_gameStateVars[variable], value);
+	return (_gameStateVars[variable] != value);
+}
+
+void FreescapeEngine::executeIncrementVariable(FCLInstruction &instruction) {
+	int32 variable = instruction._source;
+	int32 increment = instruction._destination;
+	_gameStateVars[variable] = _gameStateVars[variable] + increment;
+	if (variable == k8bitVariableScore) {
+		debugC(1, kFreescapeDebugCode, "Score incremented by %d up to %d", increment, _gameStateVars[variable]);
+	} else if (variable == k8bitVariableEnergy) {
+		if (_gameStateVars[variable] > _maxEnergy)
+			_gameStateVars[variable] = _maxEnergy;
+		else if (_gameStateVars[variable] < 0)
+			_gameStateVars[variable] = 0;
+		debugC(1, kFreescapeDebugCode, "Energy incremented by %d up to %d", increment, _gameStateVars[variable]);
+	} else if (variable == k8bitVariableShield) {
+		if (_gameStateVars[variable] > _maxShield)
+			_gameStateVars[variable] = _maxShield;
+		else if (_gameStateVars[variable] < 0)
+			_gameStateVars[variable] = 0;
+
+		if (increment < 0 && !isCastle())
+			flashScreen(_renderMode == Common::kRenderCGA ? 1 :_currentArea->_underFireBackgroundColor);
+
+		debugC(1, kFreescapeDebugCode, "Shield incremented by %d up to %d", increment, _gameStateVars[variable]);
+	} else {
+		debugC(1, kFreescapeDebugCode, "Variable %d by %d incremented up to %d!", variable, increment, _gameStateVars[variable]);
+	}
+}
+
+void FreescapeEngine::executeDecrementVariable(FCLInstruction &instruction) {
+	uint16 variable = instruction._source;
+	uint16 decrement = instruction._destination;
+	_gameStateVars[variable] = _gameStateVars[variable] - decrement;
+	if (variable == k8bitVariableEnergy) {
+		debugC(1, kFreescapeDebugCode, "Energy decrement by %d up to %d", decrement, _gameStateVars[variable]);
+	} else
+		debugC(1, kFreescapeDebugCode, "Variable %d by %d incremented up to %d!", variable, decrement, _gameStateVars[variable]);
+}
+
+void FreescapeEngine::executeSetVariable(FCLInstruction &instruction) {
+	uint16 variable = instruction._source;
+	uint16 value = instruction._destination;
+	_gameStateVars[variable] = value;
+	if (variable == k8bitVariableEnergy)
+		debugC(1, kFreescapeDebugCode, "Energy set to %d", value);
+	else
+		debugC(1, kFreescapeDebugCode, "Variable %d by set to %d!", variable, value);
+}
+
+void FreescapeEngine::executeDestroy(FCLInstruction &instruction) {
+	uint16 objectID = 0;
+	uint16 areaID = _currentArea->getAreaID();
+
+	if (instruction._destination > 0) {
+		objectID = instruction._destination;
+		areaID = instruction._source;
+	} else {
+		objectID = instruction._source;
+	}
+
+	debugC(1, kFreescapeDebugCode, "Destroying obj %d in area %d!", objectID, areaID);
+	assert(_areaMap.contains(areaID));
+	Object *obj = _areaMap[areaID]->objectWithID(objectID);
+	assert(obj); // We know that an object should be there
+	if (obj->isDestroyed())
+		debugC(1, kFreescapeDebugCode, "WARNING: Destroying obj %d in area %d already destroyed!", objectID, areaID);
+
+	obj->destroy();
+	obj->makeInvisible();
+}
+
+void FreescapeEngine::executeMakeInvisible(FCLInstruction &instruction) {
+	uint16 objectID = 0;
+	uint16 areaID = _currentArea->getAreaID();
+
+	if (instruction._destination > 0) {
+		objectID = instruction._destination;
+		areaID = instruction._source;
+	} else {
+		objectID = instruction._source;
+	}
+
+	debugC(1, kFreescapeDebugCode, "Making obj %d invisible in area %d!", objectID, areaID);
+	if (_areaMap.contains(areaID)) {
+		Object *obj = _areaMap[areaID]->objectWithID(objectID);
+
+		if (!obj) {
+			// Object is not in the area, but it should be invisible so we can return immediately
+			return;
+			/*obj = _areaMap[255]->objectWithID(objectID);
+			if (!obj) {
+				error("obj %d does not exists in area %d nor in the global one!", objectID, areaID);
+				return;
+			}
+			_currentArea->addObjectFromArea(objectID, _areaMap[255]);
+			obj = _areaMap[areaID]->objectWithID(objectID);*/
+		}
+
+		assert(obj); // We assume the object was there
+		obj->makeInvisible();
+	} else {
+		assert(isDriller() && isDOS() && isDemo());
+	}
+
+}
+
+void FreescapeEngine::executeMakeVisible(FCLInstruction &instruction) {
+	uint16 objectID = 0;
+	uint16 areaID = _currentArea->getAreaID();
+
+	if (instruction._destination > 0) {
+		objectID = instruction._destination;
+		areaID = instruction._source;
+	} else {
+		objectID = instruction._source;
+	}
+
+	debugC(1, kFreescapeDebugCode, "Making obj %d visible in area %d!", objectID, areaID);
+	if (_areaMap.contains(areaID)) {
+		Object *obj = _areaMap[areaID]->objectWithID(objectID);
+		if (!obj) {
+			obj = _areaMap[255]->objectWithID(objectID);
+			if (!obj) {
+				if (isCastleMaster2()) {
+					// CM2 Z80 code (Lb286_find_object_by_id) returns silently
+					// when object is not found — the caller skips the rule.
+					debugC(1, kFreescapeDebugCode, "obj %d not found in area %d nor in global area, skipping", objectID, areaID);
+					return;
+				}
+				if (!isCastle() || !isDemo())
+					error("obj %d does not exists in area %d nor in the global one!", objectID, areaID);
+				return;
+			}
+
+			if (obj->getType() != kGroupType)
+				_currentArea->addObjectFromArea(objectID, _areaMap[255]);
+			else if (obj->_partOfGroup)
+				_currentArea->addGroupFromArea(objectID, _areaMap[255]);
+			obj = _areaMap[areaID]->objectWithID(objectID);
+			assert(obj); // We know that an object should be there
+		}
+
+		obj->makeVisible();
+		if (!isDriller()) {
+			Math::AABB boundingBox = createPlayerAABB(_position, _playerHeight);
+			if (obj->_boundingBox.collides(boundingBox)) {
+				_playerWasCrushed = true;
+				_avoidRenderingFrames = 60 * 3;
+				if (isEclipse())
+					playSoundFx(2, true);
+				_shootingFrames = 0;
+			}
+		}
+	} else {
+		assert(isDOS() && isDemo()); // Should only happen in the DOS demo
+	}
+}
+
+void FreescapeEngine::executeToggleVisibility(FCLInstruction &instruction) {
+	uint16 objectID = 0;
+	uint16 areaID = _currentArea->getAreaID();
+
+	if (instruction._destination > 0) {
+		objectID = instruction._destination;
+		areaID = instruction._source;
+	} else {
+		objectID = instruction._source;
+	}
+
+	debugC(1, kFreescapeDebugCode, "Toggling obj %d visibility in area %d!", objectID, areaID);
+	Object *obj = _areaMap[areaID]->objectWithID(objectID);
+	if (obj)
+		obj->toggleVisibility();
+	else {
+		obj = _areaMap[255]->objectWithID(objectID);
+		if (!obj) {
+			// This happens in Driller, the ketar hangar
+			warning("ERROR!: obj %d does not exists in area %d nor in the global one!", objectID, areaID);
+			return;
+		}
+		// If an object is not in the area, it is considered to be invisible
+		_currentArea->addObjectFromArea(objectID, _areaMap[255]);
+		obj = _areaMap[areaID]->objectWithID(objectID);
+		assert(obj); // We know that an object should be there
+		obj->makeVisible();
+	}
+	if (!obj->isInvisible()) {
+		if (!isDriller()) {
+			Math::AABB boundingBox = createPlayerAABB(_position, _playerHeight);
+			if (obj->_boundingBox.collides(boundingBox)) {
+				_playerWasCrushed = true;
+				_avoidRenderingFrames = 60 * 3;
+				_shootingFrames = 0;
+			}
+		}
+	}
+}
+
+void FreescapeEngine::executeGoto(FCLInstruction &instruction) {
+	uint16 areaID = instruction._source;
+	uint16 entranceID = instruction._destination;
+	gotoArea(areaID, entranceID);
+	_gotoExecuted = true;
+}
+
+void FreescapeEngine::executeSetBit(FCLInstruction &instruction) {
+	uint16 index = instruction._source; // Starts at 1
+	assert(index > 0 && index <= 32);
+	setGameBit(index);
+	debugC(1, kFreescapeDebugCode, "Setting bit %d", index);
+}
+
+void FreescapeEngine::executeClearBit(FCLInstruction &instruction) {
+	uint16 index = instruction._source; // Starts at 1
+	assert(index > 0 && index <= 32);
+	clearGameBit(index);
+	debugC(1, kFreescapeDebugCode, "Clearing bit %d", index);
+}
+
+void FreescapeEngine::executeToggleBit(FCLInstruction &instruction) {
+	uint16 index = instruction._source; // Starts at 1
+	assert(index > 0 && index <= 32);
+	toggleGameBit(index);
+	debugC(1, kFreescapeDebugCode, "Toggling bit %d", index);
+}
+
+bool FreescapeEngine::executeEndIfBitNotEqual(FCLInstruction &instruction) {
+	uint16 index = instruction._source;
+	uint16 value = instruction._destination;
+	assert(index <= 32);
+	debugC(1, kFreescapeDebugCode, "End condition if bit %d is not equal to %d!", index, value);
+	return (getGameBit(index) != value);
+}
+
+void FreescapeEngine::executeSwapJet(FCLInstruction &instruction) {
+	//playSound(15, false);
+	_flyMode = !_flyMode;
+	uint16 areaID = _currentArea->getAreaID();
+
+	if (_flyMode) {
+		debugC(1, kFreescapeDebugCode, "Swaping to ship mode");
+		if (areaID == 27 && !(isAmiga() || isAtariST())) {
+			traverseEntrance(26);
+			_lastPosition = _position;
+		}
+		_playerHeight = 2;
+		_playerHeightNumber = -1;
+
+		// Save tank energy and shield
+		_gameStateVars[k8bitVariableEnergyDrillerTank] = _gameStateVars[k8bitVariableEnergy];
+		_gameStateVars[k8bitVariableShieldDrillerTank] = _gameStateVars[k8bitVariableShield];
+
+		// Restore ship energy and shield
+		_gameStateVars[k8bitVariableEnergy] = _gameStateVars[k8bitVariableEnergyDrillerJet];
+		_gameStateVars[k8bitVariableShield] = _gameStateVars[k8bitVariableShieldDrillerJet];
+	} else {
+		debugC(1, kFreescapeDebugCode, "Swaping to tank mode");
+		_playerHeightNumber = 0;
+		if (areaID == 27 && !(isAmiga() || isAtariST())) {
+			traverseEntrance(27);
+			_lastPosition = _position;
+		}
+
+		// Save shield energy and shield
+		_gameStateVars[k8bitVariableEnergyDrillerJet] = _gameStateVars[k8bitVariableEnergy];
+		_gameStateVars[k8bitVariableShieldDrillerJet] = _gameStateVars[k8bitVariableShield];
+
+		// Restore ship energy and shield
+		_gameStateVars[k8bitVariableEnergy] = _gameStateVars[k8bitVariableEnergyDrillerTank];
+		_gameStateVars[k8bitVariableShield] = _gameStateVars[k8bitVariableShieldDrillerTank];
+	}
+	// TODO: implement the rest of the changes (e.g. border)
+}
+
+void FreescapeEngine::executeStartAnim(FCLInstruction &instruction) {
+	uint16 objID = instruction._source;
+	debugC(1, kFreescapeDebugCode, "Staring animation of object %d", objID);
+	Object *obj = _currentArea->objectWithID(objID);
+	assert(obj);
+	Group *group = nullptr;
+	if (obj->getType() == kGroupType) {
+		group = (Group *)obj;
+	} else {
+		assert(obj->_partOfGroup);
+		group = (Group *)obj->_partOfGroup;
+	}
+	debugC(1, kFreescapeDebugCode, "From group %d", group->getObjectID());
+	if (!group->isDestroyed())
+		group->start();
+}
+
+
+} // End of namespace Freescape
diff --git a/engines/freescape/language/instruction.cpp b/engines/freescape/language/instruction.cpp
index eae6d37fcad..c5c7fb228a4 100644
--- a/engines/freescape/language/instruction.cpp
+++ b/engines/freescape/language/instruction.cpp
@@ -22,13 +22,11 @@
 // Based on Phantasma code by Thomas Harte (2013),
 // available at https://github.com/TomHarte/Phantasma/ (MIT)
 
-#include "freescape/freescape.h"
-#include "freescape/language/8bitDetokeniser.h"
-#include "freescape/sweepAABB.h"
+#include "freescape/language/instruction.h"
 
 namespace Freescape {
 
-FCLInstructionVector *duplicateCondition(FCLInstructionVector *condition) {
+FCLInstructionVector *duplicateCondition(const FCLInstructionVector *condition) {
 	if (!condition)
 		return nullptr;
 
@@ -39,7 +37,7 @@ FCLInstructionVector *duplicateCondition(FCLInstructionVector *condition) {
 	return copy;
 }
 
-FCLInstruction FCLInstruction::duplicate() {
+FCLInstruction FCLInstruction::duplicate() const {
 	FCLInstruction copy(_type);
 	copy.setSource(_source, _sourceType);
 	copy.setDestination(_destination, _destinationType);
@@ -62,15 +60,7 @@ FCLInstruction::FCLInstruction(Token::Type type_) {
 	_elseInstructions = nullptr;
 }
 
-FCLInstruction::FCLInstruction() {
-	_source = 0;
-	_destination = 0;
-	_additional = 0;
-	_sourceType = _destinationType = _additionalType = Token::UNKNOWN;
-	_type = Token::UNKNOWN;
-	_thenInstructions = nullptr;
-	_elseInstructions = nullptr;
-}
+FCLInstruction::FCLInstruction() : FCLInstruction(Token::UNKNOWN) {}
 
 void FCLInstruction::setSource(int32 source_, Token::Type type) {
 	_source = source_;
@@ -96,834 +86,4 @@ Token::Type FCLInstruction::getType() const {
 	return _type;
 }
 
-void FreescapeEngine::executeEntranceConditions(Entrance *entrance) {
-	if (!entrance->_conditionSource.empty()) {
-		_firstSound = true;
-		_syncSound = false;
-
-		debugC(1, kFreescapeDebugCode, "Executing entrance condition with collision flag: %s", entrance->_conditionSource.c_str());
-		executeCode(entrance->_condition, false, true, false, false);
-	}
-}
-
-bool FreescapeEngine::executeObjectConditions(GeometricObject *obj, bool shot, bool collided, bool activated) {
-	bool executed = false;
-	assert(obj != nullptr);
-	if (!obj->_conditionSource.empty()) {
-		_firstSound = true;
-		_syncSound = false;
-		_objExecutingCodeSize = collided ? obj->getSize() : Math::Vector3d();
-		if (collided) {
-			if (!isCastle())
-				clearGameBit(31); // We collided with something that has code
-			debugC(1, kFreescapeDebugCode, "Executing with collision flag: %s", obj->_conditionSource.c_str());
-		} else if (shot)
-			debugC(1, kFreescapeDebugCode, "Executing with shot flag: %s", obj->_conditionSource.c_str());
-		else if (activated) {
-			if (isCastle()) // TODO: add a 3DCK check here
-				clearTemporalMessages();
-			debugC(1, kFreescapeDebugCode, "Executing with activated flag: %s", obj->_conditionSource.c_str());
-		} else
-			error("Neither shot or collided flag is set!");
-		executed = executeCode(obj->_condition, shot, collided, false, activated); // TODO: check this last parameter
-	}
-	if (activated && !executed)
-		if (!_noEffectMessage.empty())
-			insertTemporaryMessage(_noEffectMessage, _countdown - 2);
-
-	return executed;
-}
-
-void FreescapeEngine::executeLocalGlobalConditions(bool shot, bool collided, bool timer) {
-	debugC(1, kFreescapeDebugCode, "Executing room conditions");
-	Common::Array<FCLInstructionVector> conditions = _currentArea->_conditions;
-	Common::Array<Common::String> conditionSources = _currentArea->_conditionSources;
-
-	for (uint i = 0; i < conditions.size(); i++) {
-		debugC(1, kFreescapeDebugCode, "%s", conditionSources[i].c_str());
-		executeCode(conditions[i], shot, collided, timer, false);
-	}
-
-	_executingGlobalCode = true;
-	debugC(1, kFreescapeDebugCode, "Executing global conditions (%d)", _conditions.size());
-	for (uint i = 0; i < _conditions.size(); i++) {
-		debugC(1, kFreescapeDebugCode, "%s", _conditionSources[i].c_str());
-		executeCode(_conditions[i], shot, collided, timer, false);
-	}
-	_executingGlobalCode = false;
-}
-
-bool FreescapeEngine::executeCode(FCLInstructionVector &code, bool shot, bool collided, bool timer, bool activated) {
-	int ip = 0;
-	bool skip = false;
-	int skipDepth = 0;
-	int conditionalDepth = 0;
-	bool executed = false;
-	int loopIterations = 0;
-	int loopHead = -1;
-	int codeSize = code.size();
-
-	if (codeSize == 0) {
-		assert(isCastle()); // Only seems to happen in Castle Master (magister room)
-		debugC(1, kFreescapeDebugCode, "Code is empty!");
-		return false;
-	}
-
-	while (ip <= codeSize - 1) {
-		FCLInstruction &instruction = code[ip];
-		debugC(1, kFreescapeDebugCode, "Executing ip: %d with type %d in code with size: %d. Skip flag is: %d", ip, instruction.getType(), codeSize, skip);
-
-		if (instruction.isConditional()) {
-			conditionalDepth++;
-			debugC(1, kFreescapeDebugCode, "Conditional depth increased to: %d", conditionalDepth);
-		} else if (instruction.getType() == Token::ENDIF) {
-			conditionalDepth--;
-			debugC(1, kFreescapeDebugCode, "Conditional depth decreased to: %d", conditionalDepth);
-		}
-
-		if (skip) {
-			if (instruction.getType() == Token::ELSE) {
-				debugC(1, kFreescapeDebugCode, "Else found, skip depth: %d, conditional depth: %d", skipDepth, conditionalDepth);
-				if (skipDepth == conditionalDepth - 1) {
-					skip = false;
-				}
-			} else if (instruction.getType() == Token::ENDIF) {
-				debugC(1, kFreescapeDebugCode, "Endif found, skip depth: %d, conditional depth: %d", skipDepth, conditionalDepth);
-				if (skipDepth == conditionalDepth) {
-					skip = false;
-				}
-			}
-			debugC(1, kFreescapeDebugCode, "Instruction skipped!");
-			ip++;
-			continue;
-		}
-
-		if (instruction.getType() != Token::CONDITIONAL && !instruction.isConditional())
-			executed = true;
-
-		switch (instruction.getType()) {
-		default:
-			error("Instruction %x at ip: %d not implemented!", instruction.getType(), ip);
-			break;
-		case Token::NOP:
-			debugC(1, kFreescapeDebugCode, "Executing NOP at ip: %d", ip);
-			break;
-
-		case Token::LOOP:
-			loopHead = ip;
-			loopIterations = instruction._source;
-			debugC(1, kFreescapeDebugCode, "Starting loop with %d iterations at ip: %d", loopIterations, ip);
-			break;
-
-		case Token::AGAIN:
-			if (loopIterations > 1) {
-				loopIterations--;
-				ip = loopHead;
-				debugC(1, kFreescapeDebugCode, "Looping again, %d iterations left, jumping to ip: %d", loopIterations, ip);
-			} else if (loopIterations == 1) {
-				loopIterations--;
-				debugC(1, kFreescapeDebugCode, "Loop finished");
-			} else {
-				error("AGAIN found without a matching LOOP!");
-			}
-			break;
-
-		case Token::CONDITIONAL:
-			if (checkConditional(instruction, shot, collided, timer, activated))
-				executed = executeCode(*instruction._thenInstructions, shot, collided, timer, activated);
-			// else branch is always empty
-			assert(instruction._elseInstructions == nullptr);
-			break;
-
-		case Token::VARNOTEQ:
-			if (executeEndIfNotEqual(instruction)) {
-				if (isCastle()) {
-					skip = true;
-					skipDepth = conditionalDepth - 1;
-				} else
-					ip = codeSize;
-			}
-			break;
-		case Token::IFGTEQ:
-			skip = !checkIfGreaterOrEqual(instruction);
-			if (skip)
-				skipDepth = conditionalDepth - 1;
-			break;
-
-		case Token::IFLTEQ:
-			skip = !checkIfLessOrEqual(instruction);
-			if (skip)
-				skipDepth = conditionalDepth - 1;
-			break;
-
-
-		case Token::ELSE:
-			skip = !skip;
-			if (skip)
-				skipDepth = conditionalDepth - 1;
-			break;
-
-		case Token::ENDIF:
-			skip = false;
-			break;
-
-		case Token::SWAPJET:
-			executeSwapJet(instruction);
-			break;
-		case Token::ADDVAR:
-			executeIncrementVariable(instruction);
-			break;
-		case Token::SUBVAR:
-			executeDecrementVariable(instruction);
-			break;
-		case Token::SETVAR:
-			executeSetVariable(instruction);
-			break;
-		case Token::GOTO:
-			executeGoto(instruction);
-			break;
-		case Token::TOGVIS:
-			executeToggleVisibility(instruction);
-			break;
-		case Token::INVIS:
-			executeMakeInvisible(instruction);
-			break;
-		case Token::VIS:
-			executeMakeVisible(instruction);
-			break;
-		case Token::DESTROY:
-			executeDestroy(instruction);
-			break;
-		case Token::REDRAW:
-			executeRedraw(instruction);
-			break;
-		case Token::EXECUTE:
-			executeExecute(instruction);
-			ip = codeSize;
-			break;
-		case Token::DELAY:
-			executeDelay(instruction);
-			break;
-		case Token::SOUND:
-			executeSound(instruction);
-			break;
-		case Token::SETBIT:
-			executeSetBit(instruction);
-			break;
-		case Token::CLEARBIT:
-			executeClearBit(instruction);
-			break;
-		case Token::TOGGLEBIT:
-			executeToggleBit(instruction);
-			break;
-		case Token::PRINT:
-			executePrint(instruction);
-			break;
-		case Token::SPFX:
-			executeSPFX(instruction);
-			break;
-		case Token::SCREEN:
-			// TODO
-			break;
-		case Token::SETFLAGS:
-			// TODO
-			break;
-		case Token::STARTANIM:
-			executeStartAnim(instruction);
-			break;
-		case Token::BITNOTEQ:
-			if (executeEndIfBitNotEqual(instruction)) {
-				if (isCastle()) {
-					skip = true;
-					skipDepth = conditionalDepth - 1;
-				} else
-					ip = codeSize;
-			}
-			break;
-		case Token::INVISQ:
-			if (executeEndIfVisibilityIsEqual(instruction)) {
-				if (isCastle()) {
-					skip = true;
-					skipDepth = conditionalDepth - 1;
-				} else
-					ip = codeSize;
-			}
-			break;
-		}
-		ip++;
-	}
-	return executed;
-}
-
-void FreescapeEngine::executeRedraw(FCLInstruction &instruction) {
-	debugC(1, kFreescapeDebugCode, "Redrawing screen");
-	uint32 delay = (100 / 15) + 1;
-	if (isEclipse2() && _currentArea->getAreaID() == _startArea && _gameStateControl == kFreescapeGameStateStart)
-		delay = delay * 10;
-
-	if (isCastle() && (isSpectrum() || isCPC() || isC64()) && getGameBit(31))
-		delay = delay * 15; // Slow down redraws when the final cutscene is playing
-
-	if (isDriller() && (isSpectrum() || isCPC() || isC64()) && _gameStateVars[32] == 18)
-		delay = delay * 15; // Slow down redraws when the final cutscene is playing
-
-	if (isEclipse() && _currentArea->getAreaID() == 37 && getGameBit(6))
-		delay = delay * 10; // Slow down redraws in the final area of Eclipse
-
-	waitInLoop(delay);
-}
-
-void FreescapeEngine::executeExecute(FCLInstruction &instruction) {
-	uint16 objId = instruction._source;
-	debugC(1, kFreescapeDebugCode, "Executing instructions from object %d", objId);
-	Object *obj = _currentArea->objectWithID(objId);
-	if (!obj) {
-		obj = _areaMap[255]->objectWithID(objId);
-		if (!obj) {
-			obj = _areaMap[255]->entranceWithID(objId);
-			if (!obj) {
-				debugC(1, kFreescapeDebugCode, "WARNING: executing instructions from a non-existent object %d", objId);
-				return;
-			}
-			assert(obj);
-			FCLInstructionVector &condition = ((Entrance *)obj)->_condition;
-			executeCode(condition, true, true, true, true);
-			return;
-		}
-	}
-	executeObjectConditions((GeometricObject *)obj, true, true, true);
-}
-
-void FreescapeEngine::executeSound(FCLInstruction &instruction) {
-	uint16 index = instruction._source;
-	bool sync = instruction._additional;
-	// An undefined sound index is a no-op in the original engines (e.g.
-	// start_speaker_sound returns early when the DOS table index is 0xFF) and
-	// must NOT disturb the sound that is already playing. Otherwise the
-	// stopAllSounds() below cuts the previous sound and then plays nothing
-	// (e.g. SOUND 15; SOUND 16 where sound 16 is undefined in the data).
-	if (_sound && !_sound->isSoundAvailable(index)) {
-		debugC(1, kFreescapeDebugCode, "Sound %d not available, keeping current sound", index);
-		return;
-	}
-	stopAllSounds(Sound::kTypeMovement);
-	_firstSound = false;
-	debugC(1, kFreescapeDebugCode, "Playing sound %d", index);
-	playSound(index, sync);
-}
-
-void FreescapeEngine::executeDelay(FCLInstruction &instruction) {
-	uint16 delay = instruction._source;
-	debugC(1, kFreescapeDebugCode, "Delaying %d * 1/50 seconds", delay);
-	waitInLoop(((20 * delay) / 15) + 1);
-}
-
-void FreescapeEngine::executePrint(FCLInstruction &instruction) {
-	uint16 index = instruction._source - 1;
-	debugC(1, kFreescapeDebugCode, "Printing message %d: \"%s\"", index, _messagesList[index].c_str());
-	_currentAreaMessages.clear();
-	_currentAreaMessages.push_back(_messagesList[index]);
-}
-
-uint32 spfxBasePaletteColor(FreescapeEngine *engine, uint8 index) {
-	index &= 0x0f;
-	uint8 r = engine->_gfx->_palette[3 * index + 0];
-	uint8 g = engine->_gfx->_palette[3 * index + 1];
-	uint8 b = engine->_gfx->_palette[3 * index + 2];
-	return engine->_gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
-}
-
-uint32 spfxDirectPaletteColor(FreescapeEngine *engine, uint16 value) {
-	uint8 r = (value >> 8) & 0x0f;
-	uint8 g = (value >> 4) & 0x0f;
-	uint8 b = value & 0x0f;
-
-	if (engine->isAtariST()) {
-		r = ((r & 0x07) << 1) | ((r & 0x07) >> 2);
-		g = ((g & 0x07) << 1) | ((g & 0x07) >> 2);
-		b = ((b & 0x07) << 1) | ((b & 0x07) >> 2);
-	}
-
-	r = (r << 4) | r;
-	g = (g << 4) | g;
-	b = (b << 4) | b;
-	return engine->_gfx->_texturePixelFormat.ARGBToColor(0xFF, r, g, b);
-}
-
-uint32 spfxActivePaletteColor(FreescapeEngine *engine, uint8 index) {
-	index &= 0x0f;
-	if (engine->_currentArea->_colorRemaps.contains(index))
-		return (uint32)engine->_currentArea->_colorRemaps[index];
-	return spfxBasePaletteColor(engine, index);
-}
-
-void spfxSetActivePaletteColor(FreescapeEngine *engine, uint8 index, uint32 color) {
-	index &= 0x0f;
-	if (color == spfxBasePaletteColor(engine, index))
-		engine->_currentArea->unremapColor(index);
-	else
-		engine->_currentArea->remapColor(index, color);
-}
-
-void spfxFillRange(FreescapeEngine *engine, uint8 start, uint8 end, uint32 color) {
-	if (end < start)
-		return;
-
-	for (int i = start; i <= end; i++)
-		spfxSetActivePaletteColor(engine, i, color);
-}
-
-void spfxRestoreRange(FreescapeEngine *engine, uint8 start, uint8 end) {
-	if (end < start)
-		return;
-
-	for (int i = start; i <= end; i++)
-		engine->_currentArea->unremapColor(i);
-}
-
-void spfxRotateLeft(FreescapeEngine *engine, uint8 start, uint8 end) {
-	if (end <= start)
-		return;
-
-	uint32 color = spfxActivePaletteColor(engine, start);
-	for (int i = start; i < end; i++)
-		spfxSetActivePaletteColor(engine, i, spfxActivePaletteColor(engine, i + 1));
-	spfxSetActivePaletteColor(engine, end, color);
-}
-
-void spfxRotateRight(FreescapeEngine *engine, uint8 start, uint8 end) {
-	if (end <= start)
-		return;
-
-	uint32 color = spfxActivePaletteColor(engine, end);
-	for (int i = end; i > start; i--)
-		spfxSetActivePaletteColor(engine, i, spfxActivePaletteColor(engine, i - 1));
-	spfxSetActivePaletteColor(engine, start, color);
-}
-
-void FreescapeEngine::executeSPFX(FCLInstruction &instruction) {
-	uint16 src = instruction._source;
-	uint16 dst = instruction._destination;
-	if (isAmiga() || isAtariST()) {
-		uint16 raw = ((src & 0xff) << 8) | (dst & 0xff);
-		if (raw & 0x8000) {
-			uint16 color = raw & 0x7770;
-			if (isAmiga())
-				color >>= 3;
-			else
-				color >>= 4;
-
-			spfxSetActivePaletteColor(this, raw & 0x0f, spfxDirectPaletteColor(this, color));
-		} else if ((raw & 0xf000) == 0x1000) {
-			spfxFillRange(this, (raw >> 4) & 0x0f, raw & 0x0f, spfxBasePaletteColor(this, (raw >> 8) & 0x0f));
-		} else {
-			switch (raw & 0x0f00) {
-			case 0x0000:
-				spfxSetActivePaletteColor(this, raw & 0x0f, spfxBasePaletteColor(this, (raw >> 4) & 0x0f));
-				break;
-			case 0x0100:
-				spfxFillRange(this, 0, 14, spfxBasePaletteColor(this, raw & 0x0f));
-				break;
-			case 0x0200:
-				_currentArea->_colorRemaps.clear();
-				break;
-			case 0x0300:
-				spfxRestoreRange(this, (raw >> 4) & 0x0f, raw & 0x0f);
-				break;
-			case 0x0400:
-				spfxRotateLeft(this, (raw >> 4) & 0x0f, raw & 0x0f);
-				break;
-			case 0x0500:
-				spfxRotateRight(this, (raw >> 4) & 0x0f, raw & 0x0f);
-				break;
-			default:
-				break;
-			}
-		}
-	} else {
-		debugC(1, kFreescapeDebugCode, "Switching palette from position %d to %d", src, dst);
-		if (src == 0 && dst == 1) {
-
-			src = _currentArea->_usualBackgroundColor;
-			dst = _currentArea->_underFireBackgroundColor;
-
-			if (_renderMode == Common::kRenderCGA)
-				dst = 1;
-			else if (isC64()) {
-				src %= 16;
-				dst %= 16;
-			}
-
-			_currentArea->remapColor(src, dst);
-		} else if (src == 0 && dst == 0)
-			_currentArea->unremapColor(_currentArea->_usualBackgroundColor);
-		else if (src == 15 && dst == 15) // Found in Total Eclipse (DOS)
-			_currentArea->unremapColor(_currentArea->_usualBackgroundColor);
-		else
-			_currentArea->remapColor(src, dst);
-	}
-	_gfx->setColorRemaps(&_currentArea->_colorRemaps);
-	executeRedraw(instruction);
-}
-
-
-bool FreescapeEngine::executeEndIfVisibilityIsEqual(FCLInstruction &instruction) {
-	uint16 source = instruction._source;
-	uint16 additional = instruction._additional;
-	uint16 value = instruction._destination;
-
-	Object *obj = nullptr;
-	if (additional == 0) {
-		obj = _currentArea->objectWithID(source);
-		if (!obj && isCastle())
-			return (true == (value != 0));
-		assert(obj);
-		debugC(1, kFreescapeDebugCode, "End condition if visibility of obj with id %d is %d!", source, value);
-	} else {
-		debugC(1, kFreescapeDebugCode, "End condition if visibility of obj with id %d in area %d is %d!", additional, source, value);
-		if (_areaMap.contains(source)) {
-			obj = _areaMap[source]->objectWithID(additional);
-			assert(obj);
-		} else {
-			assert(isDOS() && isDemo()); // Should only happen in the DOS demo
-			return (value == false);
-		}
-	}
-
-	return (obj->isInvisible() == (value != 0));
-}
-
-bool FreescapeEngine::checkConditional(const FCLInstruction &instruction, bool shot, bool collided, bool timer, bool activated) {
-	uint16 conditional = instruction._source;
-	bool result = false;
-
-	if (conditional & kConditionalShot)
-		result |= shot;
-	if (conditional & kConditionalTimeout)
-		result |= timer;
-	if (conditional & kConditionalCollided)
-		result |= collided;
-	if (conditional & kConditionalActivated)
-		result |= activated;
-
-	debugC(1, kFreescapeDebugCode, "Check if conditional %x is true: %d!", conditional, result);
-	return result;
-}
-
-bool FreescapeEngine::checkIfGreaterOrEqual(FCLInstruction &instruction) {
-	assert(instruction._destination <= 128);
-
-	uint16 variable = instruction._source;
-	int8 value = instruction._destination;
-	debugC(1, kFreescapeDebugCode, "Check if variable %d with value %d is greater or equal to %d!", variable, (int8)_gameStateVars[variable], value);
-	return ((int8)_gameStateVars[variable] >= value);
-}
-
-bool FreescapeEngine::checkIfLessOrEqual(FCLInstruction &instruction) {
-	assert(instruction._destination <= 128);
-
-	uint16 variable = instruction._source;
-	int8 value = instruction._destination;
-	debugC(1, kFreescapeDebugCode, "Check if variable %d with value %d is less or equal to %d!", variable, (int8)_gameStateVars[variable], value);
-	return ((int8)_gameStateVars[variable] <= value);
-}
-
-
-bool FreescapeEngine::executeEndIfNotEqual(FCLInstruction &instruction) {
-	uint16 variable = instruction._source;
-	uint16 value = instruction._destination;
-	debugC(1, kFreescapeDebugCode, "End condition if variable %d with value %d is not equal to %d!", variable, (int8)_gameStateVars[variable], value);
-	return (_gameStateVars[variable] != value);
-}
-
-void FreescapeEngine::executeIncrementVariable(FCLInstruction &instruction) {
-	int32 variable = instruction._source;
-	int32 increment = instruction._destination;
-	_gameStateVars[variable] = _gameStateVars[variable] + increment;
-	if (variable == k8bitVariableScore) {
-		debugC(1, kFreescapeDebugCode, "Score incremented by %d up to %d", increment, _gameStateVars[variable]);
-	} else if (variable == k8bitVariableEnergy) {
-		if (_gameStateVars[variable] > _maxEnergy)
-			_gameStateVars[variable] = _maxEnergy;
-		else if (_gameStateVars[variable] < 0)
-			_gameStateVars[variable] = 0;
-		debugC(1, kFreescapeDebugCode, "Energy incremented by %d up to %d", increment, _gameStateVars[variable]);
-	} else if (variable == k8bitVariableShield) {
-		if (_gameStateVars[variable] > _maxShield)
-			_gameStateVars[variable] = _maxShield;
-		else if (_gameStateVars[variable] < 0)
-			_gameStateVars[variable] = 0;
-
-		if (increment < 0 && !isCastle())
-			flashScreen(_renderMode == Common::kRenderCGA ? 1 :_currentArea->_underFireBackgroundColor);
-
-		debugC(1, kFreescapeDebugCode, "Shield incremented by %d up to %d", increment, _gameStateVars[variable]);
-	} else {
-		debugC(1, kFreescapeDebugCode, "Variable %d by %d incremented up to %d!", variable, increment, _gameStateVars[variable]);
-	}
-}
-
-void FreescapeEngine::executeDecrementVariable(FCLInstruction &instruction) {
-	uint16 variable = instruction._source;
-	uint16 decrement = instruction._destination;
-	_gameStateVars[variable] = _gameStateVars[variable] - decrement;
-	if (variable == k8bitVariableEnergy) {
-		debugC(1, kFreescapeDebugCode, "Energy decrement by %d up to %d", decrement, _gameStateVars[variable]);
-	} else
-		debugC(1, kFreescapeDebugCode, "Variable %d by %d incremented up to %d!", variable, decrement, _gameStateVars[variable]);
-}
-
-void FreescapeEngine::executeSetVariable(FCLInstruction &instruction) {
-	uint16 variable = instruction._source;
-	uint16 value = instruction._destination;
-	_gameStateVars[variable] = value;
-	if (variable == k8bitVariableEnergy)
-		debugC(1, kFreescapeDebugCode, "Energy set to %d", value);
-	else
-		debugC(1, kFreescapeDebugCode, "Variable %d by set to %d!", variable, value);
-}
-
-void FreescapeEngine::executeDestroy(FCLInstruction &instruction) {
-	uint16 objectID = 0;
-	uint16 areaID = _currentArea->getAreaID();
-
-	if (instruction._destination > 0) {
-		objectID = instruction._destination;
-		areaID = instruction._source;
-	} else {
-		objectID = instruction._source;
-	}
-
-	debugC(1, kFreescapeDebugCode, "Destroying obj %d in area %d!", objectID, areaID);
-	assert(_areaMap.contains(areaID));
-	Object *obj = _areaMap[areaID]->objectWithID(objectID);
-	assert(obj); // We know that an object should be there
-	if (obj->isDestroyed())
-		debugC(1, kFreescapeDebugCode, "WARNING: Destroying obj %d in area %d already destroyed!", objectID, areaID);
-
-	obj->destroy();
-	obj->makeInvisible();
-}
-
-void FreescapeEngine::executeMakeInvisible(FCLInstruction &instruction) {
-	uint16 objectID = 0;
-	uint16 areaID = _currentArea->getAreaID();
-
-	if (instruction._destination > 0) {
-		objectID = instruction._destination;
-		areaID = instruction._source;
-	} else {
-		objectID = instruction._source;
-	}
-
-	debugC(1, kFreescapeDebugCode, "Making obj %d invisible in area %d!", objectID, areaID);
-	if (_areaMap.contains(areaID)) {
-		Object *obj = _areaMap[areaID]->objectWithID(objectID);
-
-		if (!obj) {
-			// Object is not in the area, but it should be invisible so we can return immediately
-			return;
-			/*obj = _areaMap[255]->objectWithID(objectID);
-			if (!obj) {
-				error("obj %d does not exists in area %d nor in the global one!", objectID, areaID);
-				return;
-			}
-			_currentArea->addObjectFromArea(objectID, _areaMap[255]);
-			obj = _areaMap[areaID]->objectWithID(objectID);*/
-		}
-
-		assert(obj); // We assume the object was there
-		obj->makeInvisible();
-	} else {
-		assert(isDriller() && isDOS() && isDemo());
-	}
-
-}
-
-void FreescapeEngine::executeMakeVisible(FCLInstruction &instruction) {
-	uint16 objectID = 0;
-	uint16 areaID = _currentArea->getAreaID();
-
-	if (instruction._destination > 0) {
-		objectID = instruction._destination;
-		areaID = instruction._source;
-	} else {
-		objectID = instruction._source;
-	}
-
-	debugC(1, kFreescapeDebugCode, "Making obj %d visible in area %d!", objectID, areaID);
-	if (_areaMap.contains(areaID)) {
-		Object *obj = _areaMap[areaID]->objectWithID(objectID);
-		if (!obj) {
-			obj = _areaMap[255]->objectWithID(objectID);
-			if (!obj) {
-				if (isCastleMaster2()) {
-					// CM2 Z80 code (Lb286_find_object_by_id) returns silently
-					// when object is not found — the caller skips the rule.
-					debugC(1, kFreescapeDebugCode, "obj %d not found in area %d nor in global area, skipping", objectID, areaID);
-					return;
-				}
-				if (!isCastle() || !isDemo())
-					error("obj %d does not exists in area %d nor in the global one!", objectID, areaID);
-				return;
-			}
-
-			if (obj->getType() != kGroupType)
-				_currentArea->addObjectFromArea(objectID, _areaMap[255]);
-			else if (obj->_partOfGroup)
-				_currentArea->addGroupFromArea(objectID, _areaMap[255]);
-			obj = _areaMap[areaID]->objectWithID(objectID);
-			assert(obj); // We know that an object should be there
-		}
-
-		obj->makeVisible();
-		if (!isDriller()) {
-			Math::AABB boundingBox = createPlayerAABB(_position, _playerHeight);
-			if (obj->_boundingBox.collides(boundingBox)) {
-				_playerWasCrushed = true;
-				_avoidRenderingFrames = 60 * 3;
-				if (isEclipse())
-					playSoundFx(2, true);
-				_shootingFrames = 0;
-			}
-		}
-	} else {
-		assert(isDOS() && isDemo()); // Should only happen in the DOS demo
-	}
-}
-
-void FreescapeEngine::executeToggleVisibility(FCLInstruction &instruction) {
-	uint16 objectID = 0;
-	uint16 areaID = _currentArea->getAreaID();
-
-	if (instruction._destination > 0) {
-		objectID = instruction._destination;
-		areaID = instruction._source;
-	} else {
-		objectID = instruction._source;
-	}
-
-	debugC(1, kFreescapeDebugCode, "Toggling obj %d visibility in area %d!", objectID, areaID);
-	Object *obj = _areaMap[areaID]->objectWithID(objectID);
-	if (obj)
-		obj->toggleVisibility();
-	else {
-		obj = _areaMap[255]->objectWithID(objectID);
-		if (!obj) {
-			// This happens in Driller, the ketar hangar
-			warning("ERROR!: obj %d does not exists in area %d nor in the global one!", objectID, areaID);
-			return;
-		}
-		// If an object is not in the area, it is considered to be invisible
-		_currentArea->addObjectFromArea(objectID, _areaMap[255]);
-		obj = _areaMap[areaID]->objectWithID(objectID);
-		assert(obj); // We know that an object should be there
-		obj->makeVisible();
-	}
-	if (!obj->isInvisible()) {
-		if (!isDriller()) {
-			Math::AABB boundingBox = createPlayerAABB(_position, _playerHeight);
-			if (obj->_boundingBox.collides(boundingBox)) {
-				_playerWasCrushed = true;
-				_avoidRenderingFrames = 60 * 3;
-				_shootingFrames = 0;
-			}
-		}
-	}
-}
-
-void FreescapeEngine::executeGoto(FCLInstruction &instruction) {
-	uint16 areaID = instruction._source;
-	uint16 entranceID = instruction._destination;
-	gotoArea(areaID, entranceID);
-	_gotoExecuted = true;
-}
-
-void FreescapeEngine::executeSetBit(FCLInstruction &instruction) {
-	uint16 index = instruction._source; // Starts at 1
-	assert(index > 0 && index <= 32);
-	setGameBit(index);
-	debugC(1, kFreescapeDebugCode, "Setting bit %d", index);
-}
-
-void FreescapeEngine::executeClearBit(FCLInstruction &instruction) {
-	uint16 index = instruction._source; // Starts at 1
-	assert(index > 0 && index <= 32);
-	clearGameBit(index);
-	debugC(1, kFreescapeDebugCode, "Clearing bit %d", index);
-}
-
-void FreescapeEngine::executeToggleBit(FCLInstruction &instruction) {
-	uint16 index = instruction._source; // Starts at 1
-	assert(index > 0 && index <= 32);
-	toggleGameBit(index);
-	debugC(1, kFreescapeDebugCode, "Toggling bit %d", index);
-}
-
-bool FreescapeEngine::executeEndIfBitNotEqual(FCLInstruction &instruction) {
-	uint16 index = instruction._source;
-	uint16 value = instruction._destination;
-	assert(index <= 32);
-	debugC(1, kFreescapeDebugCode, "End condition if bit %d is not equal to %d!", index, value);
-	return (getGameBit(index) != value);
-}
-
-void FreescapeEngine::executeSwapJet(FCLInstruction &instruction) {
-	//playSound(15, false);
-	_flyMode = !_flyMode;
-	uint16 areaID = _currentArea->getAreaID();
-
-	if (_flyMode) {
-		debugC(1, kFreescapeDebugCode, "Swaping to ship mode");
-		if (areaID == 27 && !(isAmiga() || isAtariST())) {
-			traverseEntrance(26);
-			_lastPosition = _position;
-		}
-		_playerHeight = 2;
-		_playerHeightNumber = -1;
-
-		// Save tank energy and shield
-		_gameStateVars[k8bitVariableEnergyDrillerTank] = _gameStateVars[k8bitVariableEnergy];
-		_gameStateVars[k8bitVariableShieldDrillerTank] = _gameStateVars[k8bitVariableShield];
-
-		// Restore ship energy and shield
-		_gameStateVars[k8bitVariableEnergy] = _gameStateVars[k8bitVariableEnergyDrillerJet];
-		_gameStateVars[k8bitVariableShield] = _gameStateVars[k8bitVariableShieldDrillerJet];
-	} else {
-		debugC(1, kFreescapeDebugCode, "Swaping to tank mode");
-		_playerHeightNumber = 0;
-		if (areaID == 27 && !(isAmiga() || isAtariST())) {
-			traverseEntrance(27);
-			_lastPosition = _position;
-		}
-
-		// Save shield energy and shield
-		_gameStateVars[k8bitVariableEnergyDrillerJet] = _gameStateVars[k8bitVariableEnergy];
-		_gameStateVars[k8bitVariableShieldDrillerJet] = _gameStateVars[k8bitVariableShield];
-
-		// Restore ship energy and shield
-		_gameStateVars[k8bitVariableEnergy] = _gameStateVars[k8bitVariableEnergyDrillerTank];
-		_gameStateVars[k8bitVariableShield] = _gameStateVars[k8bitVariableShieldDrillerTank];
-	}
-	// TODO: implement the rest of the changes (e.g. border)
-}
-
-void FreescapeEngine::executeStartAnim(FCLInstruction &instruction) {
-	uint16 objID = instruction._source;
-	debugC(1, kFreescapeDebugCode, "Staring animation of object %d", objID);
-	Object *obj = _currentArea->objectWithID(objID);
-	assert(obj);
-	Group *group = nullptr;
-	if (obj->getType() == kGroupType) {
-		group = (Group *)obj;
-	} else {
-		assert(obj->_partOfGroup);
-		group = (Group *)obj->_partOfGroup;
-	}
-	debugC(1, kFreescapeDebugCode, "From group %d", group->getObjectID());
-	if (!group->isDestroyed())
-		group->start();
-}
-
-
 } // End of namespace Freescape
diff --git a/engines/freescape/language/instruction.h b/engines/freescape/language/instruction.h
index 0be0997da41..4c25d2fb392 100644
--- a/engines/freescape/language/instruction.h
+++ b/engines/freescape/language/instruction.h
@@ -44,6 +44,8 @@ enum {
 class FCLInstruction;
 typedef Common::Array<FCLInstruction> FCLInstructionVector;
 
+FCLInstructionVector *duplicateCondition(const FCLInstructionVector *condition);
+
 class FCLInstruction {
 public:
 	FCLInstruction();
@@ -54,21 +56,16 @@ public:
 
 	Token::Type getType() const;
 
-	bool isConditional() const {
-		Token::Type type = getType();
-		return 	type == Token::Type::BITNOTEQ || type == Token::Type::VARNOTEQ || \
-				type == Token::Type::IFGTEQ || type == Token::Type::IFLTEQ || \
-				type == Token::Type::VAREQ || _type == Token::Type::INVISQ;
-	}
-
 	void setBranches(FCLInstructionVector *thenBranch, FCLInstructionVector *elseBranch);
 
-	FCLInstruction duplicate();
+	FCLInstruction duplicate() const;
 
+	// Source/destination: arithmetic uses (variable, value); GOTO uses (area, entrance).
+	// Object commands use (object) or (area, object).
 	int32 _source;
 	int32 _additional;
 	int32 _destination;
-	// UNKNOWN denotes an omitted operand.
+	// Kit decoders mark omitted operands as UNKNOWN.
 	Token::Type _sourceType;
 	Token::Type _additionalType;
 	Token::Type _destinationType;
@@ -78,7 +75,7 @@ public:
 	FCLInstructionVector *_elseInstructions;
 
 private:
-	enum Token::Type _type;
+	Token::Type _type;
 };
 
 } // End of namespace Freescape
diff --git a/engines/freescape/language/8bitDetokeniser.h b/engines/freescape/language/variables.h
similarity index 81%
rename from engines/freescape/language/8bitDetokeniser.h
rename to engines/freescape/language/variables.h
index c06d5b73c5b..7c4bdba2c67 100644
--- a/engines/freescape/language/8bitDetokeniser.h
+++ b/engines/freescape/language/variables.h
@@ -19,13 +19,14 @@
  *
  */
 
-#ifndef FREESCAPE_8BITDETOKENIZER_H
-#define FREESCAPE_8BITDETOKENIZER_H
+#ifndef FREESCAPE_LANGUAGE_VARIABLES_H
+#define FREESCAPE_LANGUAGE_VARIABLES_H
 
-#include "freescape/language/instruction.h"
+#include "common/scummsys.h"
 
 namespace Freescape {
 
+// Variable and bit assignments used by the classic Freescape games.
 enum {
 	k8bitGameBitTravelRock = 30
 };
@@ -44,8 +45,6 @@ enum {
 
 extern uint8 k8bitVariableShield;
 
-Common::String detokenise8bitCondition(Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool enableActivated);
-
 } // End of namespace Freescape
 
-#endif // FREESCAPE_8BITDETOKENIZER_H
+#endif // FREESCAPE_LANGUAGE_VARIABLES_H
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index f1f99c47df6..11207328071 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -26,7 +26,8 @@
 #include "common/file.h"
 
 #include "freescape/freescape.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/detokeniser.h"
+#include "freescape/language/variables.h"
 #include "freescape/objects/connections.h"
 #include "freescape/objects/global.h"
 #include "freescape/objects/group.h"
@@ -170,7 +171,7 @@ Group *FreescapeEngine::load8bitGroupV1(Common::SeekableReadStream *file, byte r
 			debugC(1, kFreescapeDebugParser, "Length of condition: %d at %lx", lengthOfCondition, long(file->pos()));
 			// get the condition
 			Common::Array<uint16> conditionArray = readArray(file, lengthOfCondition);
-			operation->conditionSource = detokenise8bitCondition(conditionArray, operation->condition, isAmiga() || isAtariST());
+			operation->conditionSource = detokeniseFreescapeCondition(conditionArray, operation->condition, isAmiga() || isAtariST());
 			debugC(1, kFreescapeDebugParser, "%s", operation->conditionSource.c_str());
 			byteSizeOfObject = byteSizeOfObject - lengthOfCondition;
 		} else {
@@ -276,7 +277,7 @@ Group *FreescapeEngine::load8bitGroupV2(Common::SeekableReadStream *file, byte r
 			debugC(1, kFreescapeDebugParser, "Length of condition: %d at %lx", lengthOfCondition, long(file->pos()));
 			// get the condition
 			Common::Array<uint16> conditionArray = readArray(file, lengthOfCondition);
-			operation->conditionSource = detokenise8bitCondition(conditionArray, operation->condition, isAmiga() || isAtariST());
+			operation->conditionSource = detokeniseFreescapeCondition(conditionArray, operation->condition, isAmiga() || isAtariST());
 			debugC(1, kFreescapeDebugParser, "%s", operation->conditionSource.c_str());
 			byteSizeOfObject = byteSizeOfObject - lengthOfCondition;
 		} else {
@@ -455,7 +456,7 @@ Object *FreescapeEngine::load8bitObject(Common::SeekableReadStream *file) {
 		Common::String conditionSource;
 		if (byteSizeOfObject) {
 			Common::Array<uint16> conditionArray = readArray(file, byteSizeOfObject);
-			conditionSource = detokenise8bitCondition(conditionArray, instructions, isAmiga() || isAtariST());
+			conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, isAmiga() || isAtariST());
 			// instructions = getInstructions(conditionSource);
 			debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
 		}
@@ -486,7 +487,7 @@ Object *FreescapeEngine::load8bitObject(Common::SeekableReadStream *file) {
 					debugC(1, kFreescapeDebugParser, "b: %x", readField(file, 8));
 			} else {
 				Common::Array<uint16> conditionArray = readArray(file, byteSizeOfObject);
-				conditionSource = detokenise8bitCondition(conditionArray, instructions, isAmiga() || isAtariST());
+				conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, isAmiga() || isAtariST());
 				debugC(1, kFreescapeDebugParser, "Entrance condition:");
 				debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
 			}
@@ -573,7 +574,7 @@ Object *FreescapeEngine::load8bitObject(Common::SeekableReadStream *file) {
 		// grab the object condition, if there is one
 		if (byteSizeOfObject) {
 			Common::Array<uint16> conditionArray = readArray(file, byteSizeOfObject);
-			conditionSource = detokenise8bitCondition(conditionArray, instructions, isAmiga() || isAtariST());
+			conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, isAmiga() || isAtariST());
 			debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
 		}
 		debugC(1, kFreescapeDebugParser, "End of object at %lx", long(file->pos()));
@@ -860,7 +861,7 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 		// get the condition
 		if (lengthOfCondition > 0) {
 			Common::Array<uint16> conditionArray = readArray(file, lengthOfCondition);
-			Common::String conditionSource = detokenise8bitCondition(conditionArray, instructions, isAmiga() || isAtariST());
+			Common::String conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, isAmiga() || isAtariST());
 			area->_conditions.push_back(instructions);
 			area->_conditionSources.push_back(conditionSource);
 			debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
@@ -976,7 +977,7 @@ void FreescapeEngine::load8bitBinary(Common::SeekableReadStream *file, int offse
 		// get the condition
 		if (lengthOfCondition > 0) {
 			Common::Array<uint16> conditionArray = readArray(file, lengthOfCondition);
-			Common::String conditionSource = detokenise8bitCondition(conditionArray, instructions, isAmiga() || isAtariST());
+			Common::String conditionSource = detokeniseFreescapeCondition(conditionArray, instructions, isAmiga() || isAtariST());
 			_conditions.push_back(instructions);
 			_conditionSources.push_back(conditionSource);
 			debugC(1, kFreescapeDebugParser, "%s", conditionSource.c_str());
diff --git a/engines/freescape/module.mk b/engines/freescape/module.mk
index 528f17feda9..9cdd6b53917 100644
--- a/engines/freescape/module.mk
+++ b/engines/freescape/module.mk
@@ -61,12 +61,14 @@ MODULE_OBJS := \
 	loaders/8bitImage.o \
 	loaders/8bitBinaryLoader.o \
 	loaders/c64.o \
-	language/8bitDetokeniser.o \
-	language/8bitKitDetokeniser.o \
-	language/16bitDetokeniser.o \
+	language/detokeniser.o \
+	language/detokeniser_freescape.o \
+	language/detokeniser_3dck8.o \
+	language/detokeniser_3dck16.o \
 	language/instruction.o \
-	language/instruction8bitKit.o \
-	language/instruction16bit.o \
+	language/execution_freescape.o \
+	language/execution_3dck8.o \
+	language/execution_3dck16.o \
 	metaengine.o \
 	movement.o \
 	objects/geometricobject.o \
diff --git a/engines/freescape/objects/entrance.h b/engines/freescape/objects/entrance.h
index 7303d6c34ba..7608969f86c 100644
--- a/engines/freescape/objects/entrance.h
+++ b/engines/freescape/objects/entrance.h
@@ -25,12 +25,11 @@
 #ifndef FREESCAPE_ENTRANCE_H
 #define FREESCAPE_ENTRANCE_H
 
+#include "freescape/language/instruction.h"
 #include "freescape/objects/object.h"
 
 namespace Freescape {
 
-extern FCLInstructionVector *duplicateCondition(FCLInstructionVector *condition);
-
 class Entrance : public Object {
 public:
 	Entrance(
diff --git a/engines/freescape/objects/geometricobject.cpp b/engines/freescape/objects/geometricobject.cpp
index 16042386de4..7a0e16a7fc0 100644
--- a/engines/freescape/objects/geometricobject.cpp
+++ b/engines/freescape/objects/geometricobject.cpp
@@ -28,8 +28,6 @@
 
 namespace Freescape {
 
-extern FCLInstructionVector *duplicateCondition(FCLInstructionVector *condition);
-
 int GeometricObject::numberOfColoursForObjectOfType(ObjectType type) {
 	switch (type) {
 	default:
diff --git a/engines/freescape/objects/group.cpp b/engines/freescape/objects/group.cpp
index 47cfb9db1a2..7383564135c 100644
--- a/engines/freescape/objects/group.cpp
+++ b/engines/freescape/objects/group.cpp
@@ -21,7 +21,7 @@
 #include "freescape/freescape.h"
 #include "freescape/objects/group.h"
 #include "freescape/objects/geometricobject.h"
-#include "freescape/language/8bitDetokeniser.h"
+#include "freescape/language/variables.h"
 
 namespace Freescape {
 


Commit: 241b138f5e385b5d8f2f25e3ab58564f7e70d5e9
    https://github.com/scummvm/scummvm/commit/241b138f5e385b5d8f2f25e3ab58564f7e70d5e9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-09-07T07:23:53+02:00

Commit Message:
FREESCAPE: refactored freescape script detokenizer to match other similar functions

Changed paths:
    engines/freescape/language/detokeniser_freescape.cpp


diff --git a/engines/freescape/language/detokeniser_freescape.cpp b/engines/freescape/language/detokeniser_freescape.cpp
index b41d9f096fb..f894fd1a063 100644
--- a/engines/freescape/language/detokeniser_freescape.cpp
+++ b/engines/freescape/language/detokeniser_freescape.cpp
@@ -30,459 +30,198 @@
 
 namespace Freescape {
 
-Common::String detokeniseFreescapeCondition(const Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool isAmigaAtari) {
-	Common::String detokenisedStream;
-	Common::Array<uint8>::size_type bytePointer = 0;
-	Common::Array<uint8>::size_type sizeOfTokenisedContent = tokenisedCondition.size();
-
-	if (sizeOfTokenisedContent == 0)
-		error("No tokenised content");
-
-	// on the 8bit platforms, all instructions have a conditional flag;
-	// we'll want to convert them into runs of "if shot? then", "if collided? then" or "if timer? then",
-	// and we'll want to start that from the top
-	FCLInstructionVector *conditionalInstructions = new FCLInstructionVector();
-	FCLInstruction currentInstruction = FCLInstruction(Token::UNKNOWN);
-
-	// this lookup table tells us how many argument bytes to read per opcode
-	uint8 argumentsRequiredByOpcode[49] =
-		{0, 3, 1, 1, 1, 1, 2, 2,
-		 2, 1, 1, 2, 1, 1, 2, 1,
-		 1, 2, 2, 1, 2, 0, 0, 0,
-		 1, 1, 0, 1, 1, 1, 1, 1,
-		 2, 2, 1, 1, 1, 1, 0, 0,
-		 0, 1, 0, 0, 0, 0, 2, 2,
-		 1};
-
-	if (sizeOfTokenisedContent > 0)
-		detokenisedStream += Common::String::format("CONDITION FLAG: %x\n", tokenisedCondition[0]);
-	uint16 newConditional = 0;
-	uint16 oldConditional = 0;
+static const FCLOpcode kFreescapeOpcodes[] = {
+	{0x00, Token::NOP, "NOP", 0, 0, 0},
+	{0x01, Token::ADDVAR, "ADDVAR", 3, 3, 0},
+	{0x02, Token::ADDVAR, "ADDVAR", 1, 1, 0},
+	{0x03, Token::TOGVIS, "TOGVIS", 1, 1, 0},
+	{0x04, Token::VIS, "VIS", 1, 1, 0},
+	{0x05, Token::INVIS, "INVIS", 1, 1, 0},
+	{0x06, Token::TOGVIS, "TOGVIS", 2, 2, 0},
+	{0x07, Token::VIS, "VIS", 2, 2, 0},
+	{0x08, Token::INVIS, "INVIS", 2, 2, 0},
+	{0x09, Token::ADDVAR, "ADDVAR", 1, 1, 0},
+	{0x0a, Token::SUBVAR, "SUBVAR", 1, 1, 0},
+	{0x0b, Token::VARNOTEQ, "IF VAR!=?", 2, 2, 0},
+	{0x0c, Token::SETBIT, "SETBIT", 1, 1, 0},
+	{0x0d, Token::CLEARBIT, "CLRBIT", 1, 1, 0},
+	{0x0e, Token::BITNOTEQ, "IF BIT!=?", 2, 2, 0},
+	{0x0f, Token::SOUND, "SOUND", 1, 1, 0},
+	{0x10, Token::DESTROY, "DESTROY", 1, 1, 0},
+	{0x11, Token::DESTROY, "DESTROY", 2, 2, 0},
+	{0x12, Token::GOTO, "GOTO", 2, 2, 0},
+	{0x13, Token::ADDVAR, "ADDVAR", 1, 1, 0},
+	{0x14, Token::SETVAR, "SETVAR", 2, 2, 0},
+	{0x15, Token::SWAPJET, "SWAPJET", 0, 1, 0},
+	{0x16, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x17, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x18, Token::UNKNOWN, "UNKNOWN", 1, 1, 0},
+	{0x19, Token::SPFX, "SPFX", 1, 1, 0},
+	{0x1a, Token::REDRAW, "REDRAW", 0, 0, 0},
+	{0x1b, Token::DELAY, "DELAY", 1, 1, 0},
+	{0x1c, Token::SOUND, "SYNCSND", 1, 1, 0},
+	{0x1d, Token::TOGGLEBIT, "TOGGLEBIT", 1, 1, 0},
+	{0x1e, Token::INVISQ, "IF INVIS?", 1, 1, 0},
+	{0x1f, Token::INVISQ, "IF VIS?", 1, 1, 0},
+	{0x20, Token::INVISQ, "IF RINVIS?", 2, 2, 0},
+	{0x21, Token::INVISQ, "IF RVIS?", 2, 2, 0},
+	{0x22, Token::PRINT, "PRINT", 1, 1, 0},
+	{0x23, Token::SCREEN, "SCREEN", 1, 1, 0},
+	{0x24, Token::SETFLAGS, "SETFLAGS", 1, 1, 0},
+	{0x25, Token::STARTANIM, "STARTANIM", 1, 1, 0},
+	{0x26, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x27, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x28, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x29, Token::LOOP, "LOOP", 1, 1, 0},
+	{0x2a, Token::AGAIN, "AGAIN", 0, 0, 0},
+	{0x2b, Token::UNKNOWN, "UNKNOWN", 0, 0, 0},
+	{0x2c, Token::ELSE, "ELSE", 0, 0, 0},
+	{0x2d, Token::ENDIF, "ENDIF", 0, 0, 0},
+	{0x2e, Token::IFGTEQ, "IFGTE", 2, 2, 0},
+	{0x2f, Token::IFLTEQ, "IFLTE", 2, 2, 0},
+	{0x30, Token::EXECUTE, "EXECUTE", 1, 1, 0}
+};
+
+static const struct {
+	byte flag;
+	const char *name;
+} kFreescapeEvents[] = {
+	{kConditionalCollided, "COLLIDED?"},
+	{kConditionalTimeout, "TIMER?"},
+	{kConditionalShot, "SHOT?"},
+	{kConditionalActivated, "ACTIVATED?"}
+};
+
+static FCLInstruction decodeFreescapeInstruction(const FCLOpcode &entry, const uint16 *operands, bool isAmigaAtari) {
+	FCLInstruction instruction(entry.type);
+	if (entry.minArgs > 0)
+		instruction.setSource(operands[0]);
+	if (entry.minArgs > 1)
+		instruction.setDestination(operands[1]);
+
+	switch (entry.type) {
+	case Token::ADDVAR:
+		if (entry.opcode == 0x01) {
+			instruction.setSource(k8bitVariableScore);
+			instruction.setDestination(operands[0] | (operands[1] << 8) | (operands[2] << 16));
+		} else if (entry.opcode == 0x02 || entry.opcode == 0x13) {
+			instruction.setSource(entry.opcode == 0x02 ? k8bitVariableEnergy : k8bitVariableShield);
+			instruction.setDestination(int8(operands[0]));
+		} else {
+			instruction.setDestination(1);
+		}
+		break;
+	case Token::SUBVAR:
+		instruction.setDestination(1);
+		break;
+	case Token::TOGVIS:
+	case Token::VIS:
+	case Token::INVIS:
+		if (entry.minArgs == 1)
+			instruction.setDestination(0);
+		break;
+	case Token::INVISQ:
+		if (entry.minArgs == 2)
+			instruction.setAdditional(operands[1]);
+		instruction.setDestination(entry.opcode == 0x1e || entry.opcode == 0x20);
+		break;
+	case Token::SOUND:
+		instruction.setAdditional(entry.opcode == 0x1c);
+		break;
+	case Token::SPFX:
+		instruction.setSource(operands[0] >> (isAmigaAtari ? 8 : 4));
+		instruction.setDestination(operands[0] & (isAmigaAtari ? 0xff : 0xf));
+		break;
+	default:
+		break;
+	}
+	return instruction;
+}
 
-	while (bytePointer < sizeOfTokenisedContent) {
-		// get the conditional type of the next operation
-		uint8 conditionalByte = tokenisedCondition[bytePointer] & 0xc0;
-		//detokenisedStream += Common::String::format("CONDITION FLAG: %x\n", conditionalByte);
-		newConditional = 0;
+static Common::String formatFreescapeInstruction(const FCLOpcode &entry, const FCLInstruction &instruction) {
+	int32 operands[2] = {instruction._source, instruction._destination};
+	Token::Type types[2] = {Token::CONSTANT, Token::CONSTANT};
+	byte count = entry.minArgs;
+	switch (entry.type) {
+	case Token::ADDVAR:
+	case Token::SUBVAR:
+		operands[0] = instruction._destination;
+		operands[1] = instruction._source;
+		count = 2;
+		types[1] = Token::VARIABLE;
+		break;
+	case Token::SETVAR:
+	case Token::VARNOTEQ:
+	case Token::IFGTEQ:
+	case Token::IFLTEQ:
+		types[0] = Token::VARIABLE;
+		break;
+	case Token::INVISQ:
+		operands[1] = instruction._additional;
+		break;
+	case Token::SPFX:
+		count = 2;
+		break;
+	default:
+		break;
+	}
 
-		if (conditionalByte == 0x40)
-			newConditional = kConditionalTimeout;
-		else if (conditionalByte == 0x80)
-			newConditional = kConditionalShot;
-		else if (conditionalByte == 0xc0)
-			newConditional = kConditionalActivated;
-		else
-			newConditional = kConditionalCollided;
+	Common::String source = entry.name;
+	if (count)
+		source += " (";
+	for (uint i = 0; i < count; i++) {
+		if (i)
+			source += ", ";
+		source += Common::String::format(types[i] == Token::VARIABLE ? "v%d" : "%d", operands[i]);
+	}
+	if (count)
+		source += ")";
+	if (entry.type == Token::VARNOTEQ || entry.type == Token::BITNOTEQ || entry.type == Token::INVISQ)
+		source += " THEN END ENDIF";
+	return source + '\n';
+}
 
-		// if the conditional type has changed then end the old conditional,
-		// if we were in one, and begin a new one
-		if (bytePointer == 0 || newConditional != oldConditional) {
-			oldConditional = newConditional;
-			FCLInstruction branch;
-			branch = FCLInstruction(Token::CONDITIONAL);
+Common::String detokeniseFreescapeCondition(const Common::Array<uint16> &tokenisedCondition, FCLInstructionVector &instructions, bool isAmigaAtari) {
+	if (tokenisedCondition.empty())
+		error("No tokenised content");
 
-			if (bytePointer > 0) {
+	Common::String detokenisedStream = Common::String::format("CONDITION FLAG: %x\n", tokenisedCondition[0]);
+	FCLInstructionVector *conditionalInstructions = nullptr;
+	byte conditional = 0;
+	for (uint bytePointer = 0; bytePointer < tokenisedCondition.size();) {
+		uint16 raw = tokenisedCondition[bytePointer++];
+		const auto &event = kFreescapeEvents[(raw >> 6) & 3];
+		// Consecutive instructions with the same event flag share a branch.
+		if (event.flag != conditional) {
+			if (conditionalInstructions) {
 				detokenisedStream += "ENDIF\n";
-				assert(conditionalInstructions->size() > 0);
-				// Allocate the next vector of instructions
-				conditionalInstructions = new FCLInstructionVector();
+				assert(!conditionalInstructions->empty());
 			}
-
+			conditional = event.flag;
+			conditionalInstructions = new FCLInstructionVector();
+			FCLInstruction branch(Token::CONDITIONAL);
+			branch.setSource(conditional);
 			branch.setBranches(conditionalInstructions, nullptr);
-			branch.setSource(oldConditional); // conditional flag
 			instructions.push_back(branch);
-
-			detokenisedStream += "IF ";
-
-			if (oldConditional & kConditionalShot)
-				detokenisedStream += "SHOT? ";
-			else if (oldConditional & kConditionalTimeout)
-				detokenisedStream += "TIMER? ";
-			else if (oldConditional & kConditionalCollided)
-				detokenisedStream += "COLLIDED? ";
-			else if (oldConditional & kConditionalActivated)
-				detokenisedStream += "ACTIVATED? ";
-			else
-				error("Invalid conditional: %x", oldConditional);
-
-			detokenisedStream += "THEN\n";
-		}
-
-		// get the actual operation
-		uint16 opcode = tokenisedCondition[bytePointer] & 0x3f;
-		bytePointer++;
-
-		// figure out how many argument bytes we're going to need,
-		// check we have enough bytes left to read
-		if (opcode > 48) {
-			debugC(1, kFreescapeDebugParser, "%s", detokenisedStream.c_str());
-			error("ERROR: failed to read opcode: %x", opcode);
-			break;
+			detokenisedStream += Common::String::format("IF %s THEN\n", event.name);
 		}
 
-		uint8 numberOfArguments = argumentsRequiredByOpcode[opcode];
-		if (bytePointer + numberOfArguments > sizeOfTokenisedContent)
+		byte opcode = raw & 0x3f;
+		const FCLOpcode *entry = findFCLOpcode(kFreescapeOpcodes, opcode);
+		if (entry && entry->minArgs > tokenisedCondition.size() - bytePointer)
 			break;
-
-		// generate the string
-		switch (opcode) {
-		default:
-			detokenisedStream += "<UNKNOWN 8 bit: ";
-			detokenisedStream += Common::String::format("%x", (int)opcode);
-			detokenisedStream += " > ";
+		if (!entry || entry->type == Token::UNKNOWN) {
 			debugC(1, kFreescapeDebugParser, "%s", detokenisedStream.c_str());
-			error("ERROR: failed to read opcode: %x", opcode);
-			break;
-
-		case 0:
-			detokenisedStream += "NOP ";
-			currentInstruction = FCLInstruction(Token::NOP);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			break; // NOP
-		case 1:    // add three-byte value to score
-		{
-			int32 additionValue =
-				tokenisedCondition[bytePointer] |
-				(tokenisedCondition[bytePointer + 1] << 8) |
-				(tokenisedCondition[bytePointer + 2] << 16);
-			detokenisedStream += "ADDVAR";
-			detokenisedStream += Common::String::format("(%d, v%d)", additionValue, k8bitVariableScore);
-			currentInstruction = FCLInstruction(Token::ADDVAR);
-			currentInstruction.setSource(k8bitVariableScore);
-			currentInstruction.setDestination(additionValue);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer += 3;
-			numberOfArguments = 0;
-		} break;
-		case 2: // add one-byte value to energy
-			detokenisedStream += "ADDVAR ";
-			detokenisedStream += Common::String::format("(%d, v%d)", (int8)tokenisedCondition[bytePointer], k8bitVariableEnergy);
-			currentInstruction = FCLInstruction(Token::ADDVAR);
-			currentInstruction.setSource(k8bitVariableEnergy);
-			currentInstruction.setDestination((int8)tokenisedCondition[bytePointer]);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-		case 19: // add one-byte value to shield
-			detokenisedStream += "ADDVAR ";
-			detokenisedStream += Common::String::format("(%d, v%d)", (int8)tokenisedCondition[bytePointer], k8bitVariableShield);
-			currentInstruction = FCLInstruction(Token::ADDVAR);
-			currentInstruction.setSource(k8bitVariableShield);
-			currentInstruction.setDestination((int8)tokenisedCondition[bytePointer]);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-
-		case 6:
-		case 3:
-			detokenisedStream += "TOGVIS (";
-			currentInstruction = FCLInstruction(Token::TOGVIS);
-			currentInstruction.setSource(0);
-			currentInstruction.setDestination(0);
-			break; // these all come in unary and binary versions,
-		case 7:
-		case 4:
-			detokenisedStream += "VIS (";
-			currentInstruction = FCLInstruction(Token::VIS);
-			currentInstruction.setSource(0);
-			currentInstruction.setDestination(0);
-			break; // hence each getting two case statement entries
-		case 8:
-		case 5:
-			detokenisedStream += "INVIS (";
-			currentInstruction = FCLInstruction(Token::INVIS);
-			currentInstruction.setSource(0);
-			currentInstruction.setDestination(0);
-			break;
-
-		case 9:
-			detokenisedStream += "ADDVAR (1, v";
-			detokenisedStream += Common::String::format("%d)", tokenisedCondition[bytePointer]);
-			currentInstruction = FCLInstruction(Token::ADDVAR);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(1);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-		case 10:
-			detokenisedStream += "SUBVAR (1, v";
-			detokenisedStream += Common::String::format("%d)", tokenisedCondition[bytePointer]);
-			currentInstruction = FCLInstruction(Token::SUBVAR);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(1);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-
-		case 11: // end condition if a variable doesn't have a particular value
-			detokenisedStream += "IF VAR!=? ";
-			detokenisedStream += Common::String::format("(v%d, %d)", (int)tokenisedCondition[bytePointer], (int)tokenisedCondition[bytePointer + 1]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::VARNOTEQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(tokenisedCondition[bytePointer + 1]);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer += 2;
-			numberOfArguments = 0;
-			break;
-		case 14: // end condition if a bit doesn't have a particular value
-			detokenisedStream += "IF BIT!=? ";
-			detokenisedStream += Common::String::format("(%d, %d)", (int)tokenisedCondition[bytePointer], (int)tokenisedCondition[bytePointer + 1]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::BITNOTEQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(tokenisedCondition[bytePointer + 1]);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer += 2;
-			numberOfArguments = 0;
-			break;
-		case 30: // end condition if an object is invisible
-			detokenisedStream += "IF INVIS? ";
-			detokenisedStream += Common::String::format("(%d)", (int)tokenisedCondition[bytePointer]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::INVISQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(true); // invisible
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-		case 31: // end condition if an object is visible
-			detokenisedStream += "IF VIS? ";
-			detokenisedStream += Common::String::format("(%d)", (int)tokenisedCondition[bytePointer]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::INVISQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setDestination(false); // visible
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-
-		case 32: // end condition if an object is visible in another area
-			detokenisedStream += "IF RINVIS? ";
-			detokenisedStream += Common::String::format("(%d, %d)", (int)tokenisedCondition[bytePointer], (int)tokenisedCondition[bytePointer + 1]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::INVISQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setAdditional(tokenisedCondition[bytePointer + 1]);
-			currentInstruction.setDestination(true); // invisible
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer += 2;
-			numberOfArguments = 0;
-			break;
-
-		case 33: // end condition if an object is invisible in another area
-			detokenisedStream += "IF RVIS? ";
-			detokenisedStream += Common::String::format("(%d, %d)", (int)tokenisedCondition[bytePointer], (int)tokenisedCondition[bytePointer + 1]);
-			detokenisedStream += " THEN END ENDIF";
-			currentInstruction = FCLInstruction(Token::INVISQ);
-			currentInstruction.setSource(tokenisedCondition[bytePointer]);
-			currentInstruction.setAdditional(tokenisedCondition[bytePointer + 1]);
-			currentInstruction.setDestination(false); // visible
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer += 2;
-			numberOfArguments = 0;
-			break;
-
-		case 34: // show a message on screen
-			detokenisedStream += "PRINT (";
-			currentInstruction = FCLInstruction(Token::PRINT);
-			break;
-
-		case 35:
-			detokenisedStream += "SCREEN (";
-			currentInstruction = FCLInstruction(Token::SCREEN);
-			break;
-
-		case 36: // Only used in Dark Side to keep track of cristals and letters collected
-			detokenisedStream += "SETFLAGS (";
-			currentInstruction = FCLInstruction(Token::SETFLAGS);
-			break;
-
-		case 37:
-			detokenisedStream += "STARTANIM (";
-			currentInstruction = FCLInstruction(Token::STARTANIM);
-			break;
-
-		case 41: // Not sure about this one
-			detokenisedStream += "LOOP (";
-			currentInstruction = FCLInstruction(Token::LOOP);
-			break;
-
-		case 42: // Not sure about this one
-			detokenisedStream += "AGAIN";
-			currentInstruction = FCLInstruction(Token::AGAIN);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			numberOfArguments = 0;
-			break;
-
-		case 12:
-			detokenisedStream += "SETBIT (";
-			currentInstruction = FCLInstruction(Token::SETBIT);
-			break;
-		case 13:
-			detokenisedStream += "CLRBIT (";
-			currentInstruction = FCLInstruction(Token::CLEARBIT);
-			break;
-
-		case 15:
-			detokenisedStream += "SOUND (";
-			currentInstruction = FCLInstruction(Token::SOUND);
-			currentInstruction.setAdditional(false);
-			break;
-		case 17:
-		case 16:
-			detokenisedStream += "DESTROY (";
-			currentInstruction = FCLInstruction(Token::DESTROY);
-			break;
-		case 18:
-			detokenisedStream += "GOTO (";
-			currentInstruction = FCLInstruction(Token::GOTO);
-			break;
-
-		case 21:
-			detokenisedStream += "SWAPJET";
-			currentInstruction = FCLInstruction(Token::SWAPJET);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			// The 16-bit Amiga/Atari token stream stores SWAPJET without a
-			// padding argument. The 8-bit data has one unused byte here.
-			if (!isAmigaAtari)
-				bytePointer++;
-			numberOfArguments = 0;
-			break;
-
-		/*
-		case 22:
-		case 23:
-		case 24:
-			UNUSED
-		*/
-
-		case 26:
-			detokenisedStream += "REDRAW";
-			currentInstruction = FCLInstruction(Token::REDRAW);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			break;
-		case 27:
-			detokenisedStream += "DELAY (";
-			currentInstruction = FCLInstruction(Token::DELAY);
-			break;
-		case 28:
-			detokenisedStream += "SYNCSND (";
-			currentInstruction = FCLInstruction(Token::SOUND);
-			currentInstruction.setAdditional(true);
-			break;
-		case 29:
-			detokenisedStream += "TOGGLEBIT (";
-			currentInstruction = FCLInstruction(Token::TOGGLEBIT);
-			break;
-
-		case 25:
-			// this should toggle border colour or the room palette
-			detokenisedStream += "SPFX (";
-			currentInstruction = FCLInstruction(Token::SPFX);
-			if (isAmigaAtari) {
-				currentInstruction.setSource(tokenisedCondition[bytePointer] >> 8);
-				currentInstruction.setDestination(tokenisedCondition[bytePointer] & 0xff);
-			} else {
-				currentInstruction.setSource(tokenisedCondition[bytePointer] >> 4);
-				currentInstruction.setDestination(tokenisedCondition[bytePointer] & 0xf);
-			}
-			detokenisedStream += Common::String::format("%d, %d)", currentInstruction._source, currentInstruction._destination);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			bytePointer++;
-			numberOfArguments = 0;
-			break;
-
-		case 20:
-			detokenisedStream += "SETVAR (v";
-			currentInstruction = FCLInstruction(Token::SETVAR);
-			break;
-
-		case 44:
-			detokenisedStream += "ELSE ";
-			currentInstruction = FCLInstruction(Token::ELSE);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			numberOfArguments = 0;
-			break;
-
-		case 45:
-			detokenisedStream += "ENDIF ";
-			currentInstruction = FCLInstruction(Token::ENDIF);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-			numberOfArguments = 0;
-			break;
-
-		case 46:
-			detokenisedStream += "IFGTE (v";
-			currentInstruction = FCLInstruction(Token::IFGTEQ);
-			break;
-
-		case 47:
-			detokenisedStream += "IFLTE (v";
-			currentInstruction = FCLInstruction(Token::IFLTEQ);
-			break;
-
-		case 48:
-			detokenisedStream += "EXECUTE (";
-			currentInstruction = FCLInstruction(Token::EXECUTE);
-			break;
+			error("Unknown Freescape opcode %02x at %u", opcode, bytePointer - 1);
 		}
 
-		// if there are any regular arguments to add, do so
-		if (numberOfArguments) {
-			for (uint8 argumentNumber = 0; argumentNumber < numberOfArguments; argumentNumber++) {
-				if (argumentNumber == 0)
-					currentInstruction.setSource(tokenisedCondition[bytePointer]);
-				else if (argumentNumber == 1)
-					currentInstruction.setDestination(tokenisedCondition[bytePointer]);
-				else
-					error("Unexpected number of arguments!");
-
-				detokenisedStream += Common::String::format("%d", (int)tokenisedCondition[bytePointer]);
-				bytePointer++;
-
-				if (argumentNumber < numberOfArguments - 1)
-					detokenisedStream += ", ";
-			}
-
-			detokenisedStream += ")";
-			assert(currentInstruction.getType() != Token::UNKNOWN);
-			conditionalInstructions->push_back(currentInstruction);
-			currentInstruction = FCLInstruction(Token::UNKNOWN);
-		}
-
-		// throw in a newline
-		detokenisedStream += "\n";
+		FCLInstruction instruction = decodeFreescapeInstruction(*entry, tokenisedCondition.data() + bytePointer, isAmigaAtari);
+		conditionalInstructions->push_back(instruction);
+		detokenisedStream += formatFreescapeInstruction(*entry, instruction);
+		// SWAPJET has unused padding outside the Amiga/Atari stream.
+		bytePointer += isAmigaAtari ? entry->minArgs : entry->maxArgs;
 	}
-
-	// This fails in Castle Master
-	//assert(conditionalInstructions->size() > 0);
-
 	return detokenisedStream;
 }
 
-} // End of namespace Freescape
+} // namespace Freescape




More information about the Scummvm-git-logs mailing list