[Scummvm-git-logs] scummvm master -> 81d3f76d302b9c28be438dfab4f76a93b2e9c662

neuromancer noreply at scummvm.org
Wed Oct 14 18:59:22 UTC 2026


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

Summary:
65ddda86b4 SCUMM: MORTIMER: first POC to support demo
82d9ef19a4 SCUMM: MORTIMER: improved flight progression code and removed POC naming
322730bb23 SCUMM: MORTIMER: implemented UI details like shooting, status bars, targets among others
af9e3bf7ee SCUMM: MORTIMER: implemented intro video decoding and playback
bbb9d83c86 SCUMM: MORTIMER: implemented basic interview handling
1c2a61c56e SCUMM: MORTIMER: improved video playback to avoid slowdowns
c866c4ade3 SCUMM: MORTIMER: added full game detection
8b1b2cc97c SCUMM: MORTIMER: full game intro implemented
cce591bdbf SCUMM: MORTIMER: implement campaign progression
4f4543fa81 SCUMM: MORTIMER: use copyRectToSurfaceWithKey for sprite drawing
5996c0daa7 SCUMM: MORTIMER: shared SAUD/SAD reader for RA1 and mortimer
6a4ad1273e SCUMM: MORTIMER: implement shellshock-related code and indicators
625e660068 SCUMM: MORTIMER: added restore content option for the demo with some extra unused videos
eaa45832a0 SCUMM: MORTIMER: implemented dificulty levels
972d6b1a9a SCUMM: MORTIMER: enable view jolt when taking damage in demo
09f67f2a8e SCUMM: MORTIMER: abstracted SAUD code and use it for Mortimer and RA
f8a95c9e4c SCUMM: MORTIMER: initial implementation of castle gameplay
e5c3219592 SCUMM: MORTIMER: implemented cheats and easter eggs
30cdd215a4 SCUMM: MORTIMER: adjusted the position of some elements in the full game
bc691b52ca SCUMM: MORTIMER: automatic saving per profile
81d3f76d30 SCUMM: MORTIMER: added support for high score save/load


Commit: 65ddda86b4232af98f55745e8d248091c0c7a46a
    https://github.com/scummvm/scummvm/commit/65ddda86b4232af98f55745e8d248091c0c7a46a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: first POC to support demo

Changed paths:
  A engines/scumm/insane/mortimer/flight.cpp
  A engines/scumm/insane/mortimer/flight.h
  A engines/scumm/insane/mortimer/mortimer.cpp
  A engines/scumm/insane/mortimer/mortimer.h
    devtools/scumm-md5.txt
    engines/scumm/detection.h
    engines/scumm/detection_tables.h
    engines/scumm/metaengine.cpp
    engines/scumm/module.mk
    engines/scumm/scumm-md5.h


diff --git a/devtools/scumm-md5.txt b/devtools/scumm-md5.txt
index fd97698ba56..7a0e29c97f3 100644
--- a/devtools/scumm-md5.txt
+++ b/devtools/scumm-md5.txt
@@ -449,6 +449,9 @@ ft	Full Throttle
 	9b7452b5cd6d3ffb2b2f5118010af84f	116463537	en	Mac	Demo	Demo	Mac bundle	Fingolfin, Joachim Eberhard
 	8ead2c01c48c2e3d15d6a89b86cd84ab	19697	he	DOS	-	-	-	Hebrew fan translation
 
+mortimer	Mortimer and the Riddles of the Medallion
+	3450a722d29c150a4bdac5ba9b5f3b46	290304	en	Windows	-	Demo (flight POC)	Windows demo launcher; first Savanna flight only	-
+
 rebel1	Star Wars: Rebel Assault
 	0ca9c07185b4969764eaf3ecd0de06e9	111530	en	DOS	Demo v0.67	Demo v0.67	Non-interactive demo	-
 	a29730a42dfcff7bc2e10acfca1e76a2	212951	en	DOS	Demo v1.5	Demo v1.5	CD-ROM demo (chapters 2 and 10)	-
diff --git a/engines/scumm/detection.h b/engines/scumm/detection.h
index 2214472fef6..4cb7b85d199 100644
--- a/engines/scumm/detection.h
+++ b/engines/scumm/detection.h
@@ -274,7 +274,8 @@ enum ScummGameId {
 	GID_BASKETBALL,
 	GID_MOONBASE,
 	GID_PJGAMES,
-	GID_HECUP		// CUP demos
+	GID_HECUP,		// CUP demos
+	GID_MORTIMER
 };
 
 struct RuScummPatcher {
diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index 222ca6f7bbb..b6bd4f14739 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -77,6 +77,7 @@ static const PlainGameDescriptor gameDescriptions[] = {
 	{ "ft", "Full Throttle" },
 	{ "rebel1", "Star Wars: Rebel Assault" },
 	{ "rebel2", "Star Wars: Rebel Assault II: The Hidden Empire" },
+	{ "mortimer", "Mortimer and the Riddles of the Medallion" },
 	{ "dig", "The Dig" },
 	{ "comi", "The Curse of Monkey Island" },
 
@@ -250,6 +251,8 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
+	{"mortimer", 0, "flight-poc", GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Demo", 0, GID_DIG, 7, 0, MDT_NONE, GF_DEMO, UNK, GUIO4(GUIO_NOMIDI, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Steam", "steam", GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
@@ -518,6 +521,8 @@ static const GameFilenamePattern gameFilenamesTable[] = {
 	{ "ft", "Vollgas Data", kGenUnchanged, Common::DE_DEU, Common::kPlatformMacintosh, 0 },
 	{ "ft", "Vollgas Demo Data", kGenUnchanged, Common::DE_DEU, Common::kPlatformMacintosh, "Demo" },
 
+	{ "mortimer", "MORTIMER.EXE", kGenUnchanged, Common::EN_ANY, Common::kPlatformWindows, 0 },
+
 	{ "rebel1", "ASSAULT.EXE", kGenUnchanged, UNK_LANG, Common::kPlatformDOS, "" },
 	{ "rebel1", "ASSAULT.EXE", kGenUnchanged, UNK_LANG, Common::kPlatformDOS, "Demo v1.51" },
 	{ "rebel1", "REBEL.EXE", kGenUnchanged, UNK_LANG, Common::kPlatformDOS, "Demo 1994-04-13" },
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
new file mode 100644
index 00000000000..f0cddef206c
--- /dev/null
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -0,0 +1,529 @@
+/* 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 "scumm/insane/mortimer/flight.h"
+#include "scumm/smush/codec37.h"
+#include "scumm/smush/rebel/codec_ra2.h"
+
+#include "common/endian.h"
+#include "common/stream.h"
+#include "common/util.h"
+
+namespace Scumm {
+
+namespace {
+
+struct Chunk {
+	uint32 tag, offset, size;
+};
+
+bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk, bool padded = true) {
+	if (pos > end || end - pos < 8)
+		return false;
+	chunk.tag = READ_BE_UINT32(data + pos);
+	chunk.size = READ_BE_UINT32(data + pos + 4);
+	chunk.offset = pos + 8;
+	if (chunk.size > end - chunk.offset)
+		return false;
+	pos = chunk.offset + chunk.size;
+	if (padded && (chunk.size & 1)) {
+		if (pos == end)
+			return false;
+		++pos;
+	}
+	return true;
+}
+
+bool readData(Common::SeekableReadStream &stream, Common::Array<byte> &data) {
+	const int64 size = stream.size();
+	if (size < 8 || size > 64 * 1024 * 1024 || !stream.seek(0))
+		return false;
+	data.resize(size);
+	return stream.read(data.begin(), data.size()) == data.size();
+}
+
+bool isContainer(const Common::Array<byte> &data, uint32 tag) {
+	return data.size() >= 8 && READ_BE_UINT32(data.begin()) == tag &&
+		READ_BE_UINT32(data.begin() + 4) == data.size() - 8;
+}
+
+bool validRows(const byte *src, uint32 size, int height, bool remap) {
+	uint32 pos = 0;
+	// Remap masks can include additional rows below the FOBJ clipping height.
+	for (int y = 0; y < height || (remap && pos + 1 < size); ++y) {
+		if (size - pos < 2)
+			return false;
+		const uint len = READ_LE_UINT16(src + pos);
+		pos += 2;
+		if (len > size - pos)
+			return false;
+		const uint32 end = pos + len;
+		if (remap) {
+			// Skip/run pairs; a final skip without a run is also permitted.
+			pos = end;
+		} else {
+			while (pos < end) {
+				const byte code = src[pos++];
+				const uint count = (code & 1) ? 1 : (code >> 1) + 1;
+				if (count > end - pos)
+					return false;
+				pos += count;
+			}
+		}
+	}
+	// FOBJ includes the encoder's final byte of alignment in its payload size.
+	return pos == size || (pos + 1 == size && src[pos] == 0);
+}
+
+// Codec37's existing decoder has no input-length argument. Validate the two
+// encodings actually present in DEMO_S1 before passing any data to it.
+bool validBackground(const byte *src, uint32 size) {
+	if (size < 16 || src[1] != 1 || src[12] != 4)
+		return false;
+	uint32 pos = 16;
+	if (src[0] == 2) {
+		if (READ_LE_UINT32(src + 4) != MortimerFlight::kWidth * MortimerFlight::kHeight)
+			return false;
+		uint remaining = MortimerFlight::kWidth * MortimerFlight::kHeight;
+		while (remaining) {
+			if (pos == size)
+				return false;
+			const byte code = src[pos++];
+			const uint run = (code >> 1) + 1;
+			const uint count = (code & 1) ? 1 : run;
+			if (count > size - pos || run > remaining)
+				return false;
+			pos += count;
+			remaining -= run;
+		}
+	} else if (src[0] == 3) {
+		for (uint block = 0; block < MortimerFlight::kWidth * MortimerFlight::kHeight / 16; ++block) {
+			if (pos == size)
+				return false;
+			const byte code = src[pos++];
+			const uint count = code == 0xff ? 16 : code == 0xfe ? 4 : code == 0xfd ? 1 : 0;
+			if (count > size - pos)
+				return false;
+			pos += count;
+		}
+	} else {
+		return false;
+	}
+	return pos <= size;
+}
+
+bool validObject(const byte *data, uint32 size, bool sprite = false) {
+	if (size < 14)
+		return false;
+	const int width = READ_LE_INT16(data + 6), height = READ_LE_INT16(data + 8);
+	if (width <= 0 || height <= 0 || width > 640 || height > 480)
+		return false;
+	if (READ_LE_INT16(data + 2) + width > 32767 || READ_LE_INT16(data + 4) + height > 32767)
+		return false;
+	const byte codec = data[0];
+	if (sprite)
+		return codec == 1 && validRows(data + 14, size - 14, height, false);
+	if (codec == 37)
+		return width == MortimerFlight::kWidth && height == MortimerFlight::kHeight &&
+			READ_LE_UINT32(data + 2) == 0 && validBackground(data + 14, size - 14);
+	if (codec != 3 && codec != 23)
+		return false;
+	uint32 offset = 14;
+	if (codec == 23) {
+		const uint16 mode = READ_LE_UINT16(data + 12);
+		if (mode != 0x100 && mode != 0x101)
+			return false;
+		if (mode == 0x100)
+			offset += 256;
+	}
+	return offset <= size && validRows(data + offset, size - offset, height, codec == 23);
+}
+
+bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
+	if (!isContainer(data, MKTAG('S', 'A', 'U', 'D')))
+		return false;
+	sound.samples.clear();
+	uint32 pos = 8;
+	while (pos < data.size()) {
+		Chunk chunk;
+		// SAUD chunks are not word-aligned, unlike the surrounding SMUSH file.
+		if (!nextChunk(data.begin(), data.size(), pos, chunk, false))
+			return false;
+		const byte *src = data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('S', 'D', 'A', 'T')) {
+			if (!sound.samples.empty())
+				return false;
+			sound.samples.assign(src, src + chunk.size);
+		} else if (chunk.tag == MKTAG('S', 'H', 'D', 'R') && chunk.size == 4) {
+			sound.rate = READ_LE_UINT32(src);
+		} else if (chunk.tag == MKTAG('S', 'T', 'R', 'K') && chunk.size >= 14 && src[0] == 6 && src[1] == 12) {
+			sound.rate = READ_BE_UINT32(src + 10);
+		}
+	}
+	return !sound.samples.empty() && sound.rate >= 4000 && sound.rate <= 48000;
+}
+
+} // End of anonymous namespace
+
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites) {
+	Common::Array<byte> data;
+	sprites.clear();
+	if (!readData(stream, data) || !isContainer(data, MKTAG('A', 'N', 'I', 'M')))
+		return false;
+	uint32 pos = 8;
+	while (pos < data.size()) {
+		Chunk frame;
+		if (!nextChunk(data.begin(), data.size(), pos, frame))
+			return false;
+		if (frame.tag != MKTAG('F', 'R', 'M', 'E'))
+			continue;
+		uint32 subpos = frame.offset;
+		const uint before = sprites.size();
+		while (subpos < frame.offset + frame.size) {
+			Chunk chunk;
+			if (!nextChunk(data.begin(), frame.offset + frame.size, subpos, chunk))
+				return false;
+			if (chunk.tag != MKTAG('F', 'O', 'B', 'J'))
+				continue;
+			const byte *src = data.begin() + chunk.offset;
+			if (!validObject(src, chunk.size, true))
+				return false;
+			MortimerSprite sprite;
+			sprite.x = READ_LE_INT16(src + 2);
+			sprite.y = READ_LE_INT16(src + 4);
+			sprite.width = READ_LE_INT16(src + 6);
+			sprite.height = READ_LE_INT16(src + 8);
+			sprite.pixels.resize(sprite.width * sprite.height);
+			memset(sprite.pixels.begin(), 0, sprite.pixels.size());
+			smushDecodeRLEOpaque(sprite.pixels.begin(), src + 14, 0, 0, sprite.width, sprite.height, sprite.width, chunk.size - 14);
+			sprites.push_back(sprite);
+		}
+		if (sprites.size() != before + 1)
+			return false;
+	}
+	return !sprites.empty();
+}
+
+bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound) {
+	Common::Array<byte> data;
+	return readData(stream, data) && decodeSound(data, sound);
+}
+
+MortimerFlight::MortimerFlight() : _decoder(nullptr), _nextFrame(0), _frameRate(10), _rescuedCount(0), _rescuedSpecies(0) {
+	_background.resize(kWidth * kHeight);
+	_pixels.resize(kWidth * kHeight);
+	memset(_palette, 0, sizeof(_palette));
+	reset();
+}
+
+MortimerFlight::~MortimerFlight() {
+	delete _decoder;
+}
+
+bool MortimerFlight::fail(const char *message) {
+	_error = message;
+	_frames.clear();
+	return false;
+}
+
+bool MortimerFlight::load(Common::SeekableReadStream &stream) {
+	_frames.clear();
+	_sounds.clear();
+	_error.clear();
+	reset();
+	if (!readData(stream, _data) || !isContainer(_data, MKTAG('A', 'N', 'I', 'M')))
+		return fail("Invalid Mortimer flight ANIM container");
+
+	struct Track {
+		uint16 id, nextBlock;
+		MortimerSound sound;
+		Common::Array<byte> bytes;
+	};
+	Common::Array<Track> tracks;
+	uint32 pos = 8;
+	uint expectedFrames = 0;
+	while (pos < _data.size()) {
+		Chunk chunk;
+		if (!nextChunk(_data.begin(), _data.size(), pos, chunk))
+			return fail("Truncated Mortimer flight chunk");
+		const byte *src = _data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('A', 'H', 'D', 'R')) {
+			if (expectedFrames || chunk.size < 778 || READ_LE_UINT16(src) != 2)
+				return fail("Unsupported Mortimer flight header");
+			expectedFrames = READ_LE_UINT16(src + 2);
+			_frameRate = READ_LE_UINT32(src + 774);
+			if (!expectedFrames || _frameRate != 10)
+				return fail("Unsupported Mortimer flight timing");
+			memcpy(_palette, src + 6, sizeof(_palette));
+		} else if (chunk.tag == MKTAG('F', 'R', 'M', 'E')) {
+			if (!expectedFrames)
+				return fail("Mortimer flight has no header");
+			uint32 subpos = chunk.offset;
+			uint backgrounds = 0;
+			while (subpos < chunk.offset + chunk.size) {
+				Chunk sub;
+				if (!nextChunk(_data.begin(), chunk.offset + chunk.size, subpos, sub))
+					return fail("Truncated Mortimer flight frame");
+				const byte *body = _data.begin() + sub.offset;
+				if (sub.tag == MKTAG('F', 'O', 'B', 'J')) {
+					if (!validObject(body, sub.size))
+						return fail("Unsupported or truncated Mortimer flight image");
+					if (body[0] == 37) {
+						++backgrounds;
+						if (_frames.empty() && body[14] != 2)
+							return fail("Mortimer flight does not start with a keyframe");
+					}
+				} else if (sub.tag == MKTAG('S', 'K', 'I', 'P')) {
+					if (sub.size != 4 || READ_LE_UINT16(body) >= 256 || READ_LE_UINT16(body + 2) >= 256)
+						return fail("Invalid Mortimer object toggle");
+				} else if (sub.tag == MKTAG('I', 'A', 'C', 'T')) {
+					if (sub.size < 8)
+						return fail("Truncated Mortimer flight action");
+					const uint16 code = READ_LE_UINT16(body);
+					if ((code >= 2 && code <= 4 && (sub.size < (code == 4 ? 18U : 12U) || READ_LE_UINT16(body + 8) >= 256)) ||
+						(code == 5 && sub.size < 28))
+						return fail("Invalid Mortimer flight action");
+				} else if (sub.tag == MKTAG('P', 'S', 'A', 'D')) {
+					if (sub.size < 10)
+						return fail("Truncated Mortimer speech block");
+					const uint16 id = READ_LE_UINT16(body), block = READ_LE_UINT16(body + 2);
+					uint index = 0;
+					while (index < tracks.size() && tracks[index].id != id)
+						++index;
+					if (index == tracks.size()) {
+						Track track;
+						track.id = id;
+						track.nextBlock = 0;
+						track.sound.frame = _frames.size();
+						track.sound.volume = MIN(255, body[8] * 2);
+						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
+						tracks.push_back(track);
+					}
+					if (tracks[index].nextBlock++ != block)
+						return fail("Missing Mortimer speech block");
+					Common::Array<byte> &bytes = tracks[index].bytes;
+					const uint oldSize = bytes.size();
+					bytes.resize(oldSize + sub.size - 10);
+					memcpy(bytes.begin() + oldSize, body + 10, sub.size - 10);
+				}
+			}
+			if (backgrounds != 1)
+				return fail("Mortimer flight frame needs one background");
+			_frames.push_back({chunk.offset, chunk.size});
+		}
+	}
+	if (_frames.size() != expectedFrames || !expectedFrames)
+		return fail("Incomplete Mortimer flight");
+	for (uint i = 0; i < tracks.size(); ++i) {
+		if (!decodeSound(tracks[i].bytes, tracks[i].sound))
+			return fail("Unsupported or incomplete Mortimer speech");
+		_sounds.push_back(tracks[i].sound);
+	}
+	return true;
+}
+
+void MortimerFlight::reset() {
+	delete _decoder;
+	_decoder = new SmushDeltaBlocksDecoder(kWidth, kHeight);
+	_nextFrame = _rescuedCount = _rescuedSpecies = 0;
+	_targets.clear();
+	_objects.clear();
+	_obstacles.clear();
+	memset(_toggles, 0, sizeof(_toggles));
+	memset(_background.begin(), 0, _background.size());
+	memset(_pixels.begin(), 0, _pixels.size());
+	for (uint i = 0; i < 256; ++i)
+		_remap[i] = i;
+}
+
+MortimerFlight::Target *MortimerFlight::findTarget(uint16 id) {
+	for (uint i = 0; i < _targets.size(); ++i)
+		if (_targets[i].id == id)
+			return &_targets[i];
+	return nullptr;
+}
+
+void MortimerFlight::handleAction(const byte *data, uint32 size) {
+	const uint16 code = READ_LE_UINT16(data), flags = READ_LE_UINT16(data + 2);
+	const uint16 mode = READ_LE_UINT16(data + 4), id = READ_LE_UINT16(data + 6);
+	if (code >= 2 && code <= 4 && mode == 1) {
+		const uint16 toggle = READ_LE_UINT16(data + 8);
+		if (id == 2000) {
+			// Offscreen control object: the remap SKIPs compare against this
+			// false toggle, rather than against the individual stone objects.
+			_toggles[toggle] = false;
+			return;
+		}
+		if (id >= 1000) {
+			Target *target = findTarget(id - 1000);
+			if (target)
+				target->remapToggle = toggle;
+			// Briefly show the rescued animal's silhouette before removing it
+			// from the scene. Flash duration is POC tuning.
+			_toggles[toggle] = !target || !target->rescued || frame() - target->rescuedAt >= 5;
+			return;
+		}
+		Target *target = findTarget(id);
+		if (!target && code == 2) {
+			Target fresh;
+			fresh.id = id;
+			fresh.toggle = toggle;
+			fresh.remapToggle = 0xffff;
+			fresh.rescuedAt = 0;
+			fresh.active = fresh.rescued = false;
+			_targets.push_back(fresh);
+			target = &_targets.back();
+		}
+		if (!target)
+			return;
+		target->toggle = toggle;
+		_toggles[toggle] = target->rescued;
+		if (code == 4) {
+			const int x = READ_LE_INT16(data + 10), y = READ_LE_INT16(data + 12);
+			const int w = READ_LE_INT16(data + 14), h = READ_LE_INT16(data + 16);
+			target->rect = Common::Rect(CLIP<int>(x, 0, kWidth), CLIP<int>(y, 0, kHeight),
+				CLIP<int>(x + MAX(0, w), 0, kWidth), CLIP<int>(y + MAX(0, h), 0, kHeight));
+			target->active = !target->rect.isEmpty();
+		} else if (code == 3) {
+			target->active = false;
+		}
+	} else if (code == 5 && flags == 14 && size >= 28) {
+		Obstacle obstacle;
+		for (uint i = 0; i < 4; ++i)
+			obstacle.corners[i] = Common::Point(READ_LE_INT16(data + 12 + i * 4), READ_LE_INT16(data + 14 + i * 4));
+		_obstacles.push_back(obstacle);
+	}
+	// Flight transitions (8/46), prefetches and shell/interface callbacks are
+	// deliberately outside this single-segment POC.
+}
+
+bool MortimerFlight::advance() {
+	if (_nextFrame >= _frames.size())
+		return false;
+	_objects.clear();
+	_obstacles.clear();
+	for (uint i = 0; i < _targets.size(); ++i)
+		_targets[i].active = false;
+	const Frame &frame = _frames[_nextFrame++];
+	uint32 pos = frame.offset;
+	uint16 skipA = 0xffff, skipB = 0;
+	while (pos < frame.offset + frame.size) {
+		Chunk chunk;
+		if (!nextChunk(_data.begin(), frame.offset + frame.size, pos, chunk))
+			return fail("Truncated Mortimer flight frame");
+		const byte *src = _data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('S', 'K', 'I', 'P')) {
+			skipA = READ_LE_UINT16(src);
+			skipB = READ_LE_UINT16(src + 2);
+			continue;
+		}
+		if (chunk.tag == MKTAG('I', 'A', 'C', 'T')) {
+			handleAction(src, chunk.size);
+		} else if (chunk.tag == MKTAG('F', 'O', 'B', 'J')) {
+			if (src[0] == 37) {
+				_decoder->decode(_background.begin(), src + 14);
+			} else {
+				if (src[0] == 23 && READ_LE_UINT16(src + 12) == 0x100)
+					memcpy(_remap, src + 14, sizeof(_remap));
+				_objects.push_back({chunk.offset, chunk.size, skipA, skipB});
+			}
+		}
+		skipA = 0xffff;
+		skipB = 0;
+	}
+	render();
+	return true;
+}
+
+void MortimerFlight::render() {
+	memcpy(_pixels.begin(), _background.begin(), _pixels.size());
+	for (uint i = 0; i < _objects.size(); ++i) {
+		const Object &object = _objects[i];
+		if (object.skipA != 0xffff && (object.skipB ? _toggles[object.skipA] != _toggles[object.skipB] : _toggles[object.skipA]))
+			continue;
+		const byte *src = _data.begin() + object.offset;
+		const int x = READ_LE_INT16(src + 2), y = READ_LE_INT16(src + 4);
+		const int width = READ_LE_INT16(src + 6), height = READ_LE_INT16(src + 8);
+		Common::Rect clip(x, y, x + width, y + height);
+		clip.clip(Common::Rect(kWidth, kHeight));
+		if (clip.isEmpty())
+			continue;
+		// Decode into an object-sized buffer so offscreen sprites cannot write
+		// beyond the flight surface. Remap objects must start with its pixels.
+		_scratch.resize(width * height);
+		memset(_scratch.begin(), 0, _scratch.size());
+		if (src[0] == 23) {
+			for (int row = clip.top; row < clip.bottom; ++row)
+				memcpy(_scratch.begin() + (row - y) * width + clip.left - x,
+					_pixels.begin() + row * kWidth + clip.left, clip.width());
+			const uint offset = READ_LE_UINT16(src + 12) == 0x100 ? 270 : 14;
+			smushDecodeRA2SkipRemap(_scratch.begin(), src + offset, 0, 0, width, height, width, object.size - offset, _remap, 0);
+		} else {
+			smushDecodeRLEOpaque(_scratch.begin(), src + 14, 0, 0, width, height, width, object.size - 14);
+		}
+		for (int row = clip.top; row < clip.bottom; ++row) {
+			const byte *line = _scratch.begin() + (row - y) * width + clip.left - x;
+			byte *dest = _pixels.begin() + row * kWidth + clip.left;
+			for (int col = 0; col < clip.width(); ++col) {
+				// Mortimer treats zero as transparent in literal runs as well.
+				if (src[0] == 23 || line[col])
+					dest[col] = line[col];
+			}
+		}
+	}
+}
+
+bool MortimerFlight::rescue(const Common::Point &point) {
+	// Later objects are drawn on top of earlier ones.
+	for (int i = int(_targets.size()) - 1; i >= 0; --i) {
+		Target &target = _targets[i];
+		if (!target.active || target.rescued || !target.rect.contains(point))
+			continue;
+		target.rescued = true;
+		target.rescuedAt = frame();
+		_toggles[target.toggle] = true;
+		if (target.remapToggle != 0xffff)
+			_toggles[target.remapToggle] = false;
+		++_rescuedCount;
+		if (target.id / 100 < 7)
+			_rescuedSpecies |= 1 << (target.id / 100);
+		render();
+		return true;
+	}
+	return false;
+}
+
+bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
+	for (uint n = 0; n < _obstacles.size(); ++n) {
+		const Obstacle &obstacle = _obstacles[n];
+		bool inside = false;
+		for (uint i = 0, j = 3; i < 4; j = i++) {
+			const Common::Point &a = obstacle.corners[i], &b = obstacle.corners[j];
+			if ((a.y > point.y) != (b.y > point.y) &&
+				point.x < a.x + int64(b.x - a.x) * (point.y - a.y) / (b.y - a.y))
+				inside = !inside;
+		}
+		if (inside)
+			return true;
+	}
+	return false;
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
new file mode 100644
index 00000000000..23a25475ec2
--- /dev/null
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -0,0 +1,115 @@
+/* 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 SCUMM_INSANE_MORTIMER_FLIGHT_H
+#define SCUMM_INSANE_MORTIMER_FLIGHT_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Scumm {
+
+class SmushDeltaBlocksDecoder;
+
+struct MortimerSprite {
+	int x, y, width, height;
+	Common::Array<byte> pixels;
+};
+
+struct MortimerSound {
+	uint frame = 0;
+	uint rate = 11025;
+	byte volume = 255;
+	int8 balance = 0;
+	Common::Array<byte> samples;
+};
+
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites);
+bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
+
+// The first Windows demo flight only. No ScummEngine, InsaneRebel or SmushPlayer
+// state is used here; only the existing, stateless RLE helpers and a private
+// codec37 decoder are shared with the other SMUSH games.
+class MortimerFlight {
+public:
+	enum { kWidth = 384, kHeight = 288 };
+
+	MortimerFlight();
+	~MortimerFlight();
+	bool load(Common::SeekableReadStream &stream);
+	void reset();
+	bool advance();
+	bool rescue(const Common::Point &point);
+	bool hitsObstacle(const Common::Point &point) const;
+
+	const byte *pixels() const { return _pixels.begin(); }
+	const byte *palette() const { return _palette; }
+	uint frame() const { return _nextFrame ? _nextFrame - 1 : 0; }
+	uint frameCount() const { return _frames.size(); }
+	uint frameRate() const { return _frameRate; }
+	uint rescuedCount() const { return _rescuedCount; }
+	uint rescuedSpecies() const { return _rescuedSpecies; }
+	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
+	const Common::String &errorMessage() const { return _error; }
+
+private:
+	struct Frame {
+		uint32 offset, size;
+	};
+	struct Object {
+		uint32 offset, size;
+		uint16 skipA, skipB;
+	};
+	struct Target {
+		uint16 id, toggle, remapToggle;
+		uint rescuedAt;
+		Common::Rect rect;
+		bool active, rescued;
+	};
+	struct Obstacle {
+		Common::Point corners[4];
+	};
+
+	bool fail(const char *message);
+	void handleAction(const byte *data, uint32 size);
+	void render();
+	Target *findTarget(uint16 id);
+
+	Common::Array<byte> _data, _background, _pixels, _scratch;
+	Common::Array<Frame> _frames;
+	Common::Array<Object> _objects;
+	Common::Array<Target> _targets;
+	Common::Array<Obstacle> _obstacles;
+	Common::Array<MortimerSound> _sounds;
+	SmushDeltaBlocksDecoder *_decoder;
+	Common::String _error;
+	byte _palette[768], _remap[256];
+	bool _toggles[256];
+	uint _nextFrame, _frameRate, _rescuedCount, _rescuedSpecies;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
new file mode 100644
index 00000000000..eef05d64aef
--- /dev/null
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -0,0 +1,279 @@
+/* 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 "scumm/insane/mortimer/mortimer.h"
+
+#include "audio/audiostream.h"
+#include "audio/decoders/raw.h"
+#include "common/events.h"
+#include "common/file.h"
+#include "common/system.h"
+#include "common/util.h"
+#include "engines/util.h"
+#include "graphics/cursorman.h"
+#include "graphics/font.h"
+#include "graphics/fontman.h"
+#include "graphics/paletteman.h"
+
+namespace Scumm {
+
+MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
+	_mouse(320, 180), _view(32, 24), _nextFrameTime(0), _pauseTime(0),
+	_health(100), _lastDamageFrame(-10), _finished(false) {
+}
+
+MortimerEngine::~MortimerEngine() {
+	stopSounds();
+	_screen.free();
+}
+
+bool MortimerEngine::hasFeature(EngineFeature feature) const {
+	return feature == kSupportsReturnToLauncher;
+}
+
+bool MortimerEngine::loadResources() {
+	const char *flightName = "RESOURCE/DEMO/DEMO_S1.SAN";
+	Common::File file;
+	if (!file.open(Common::Path(flightName))) {
+		_loadError = Common::String::format("Missing %s", flightName);
+		return false;
+	}
+	if (!_flight.load(file)) {
+		_loadError = _flight.errorMessage();
+		return false;
+	}
+	file.close();
+
+	struct SpriteBank {
+		const char *name;
+		Common::Array<MortimerSprite> *sprites;
+		uint minFrames;
+	};
+	const SpriteBank banks[] = {
+		{"RESOURCE/GENERAL/COCKPT_H.NUT", &_cockpit, 3},
+		{"RESOURCE/GENERAL/DASH_H.NUT", &_dashboard, 36},
+		{"RESOURCE/GENERAL/RETICLEH.NUT", &_reticles, 1},
+		{"RESOURCE/SAVANNA/S_ICON_H.NUT", &_icons, 14}
+	};
+	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
+		if (!file.open(Common::Path(banks[i].name)) || !loadMortimerSprites(file, *banks[i].sprites) || banks[i].sprites->size() < banks[i].minFrames) {
+			_loadError = Common::String::format("Missing or invalid %s", banks[i].name);
+			return false;
+		}
+		file.close();
+	}
+	struct Effect {
+		const char *name;
+		MortimerSound *sound;
+	};
+	const Effect effects[] = {
+		{"RESOURCE/GENERAL/SFX/REVITAL2.SAD", &_scopeSound},
+		{"RESOURCE/GENERAL/SFX/SCOPHIT3.SAD", &_rescueSound},
+		{"RESOURCE/GENERAL/SFX/SHELHIT1.SAD", &_collisionSound}
+	};
+	for (uint i = 0; i < ARRAYSIZE(effects); ++i) {
+		if (!file.open(Common::Path(effects[i].name)) || !loadMortimerSound(file, *effects[i].sound)) {
+			_loadError = Common::String::format("Missing or invalid %s", effects[i].name);
+			return false;
+		}
+		file.close();
+	}
+	_speechHandles.resize(_flight.sounds().size());
+	return true;
+}
+
+void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type) {
+	_mixer->stopHandle(handle);
+	// The engine owns the samples until all its handles have been stopped.
+	Audio::AudioStream *stream = Audio::makeRawStream(sound.samples.begin(), sound.samples.size(), sound.rate,
+		Audio::FLAG_UNSIGNED, DisposeAfterUse::NO);
+	_mixer->playStream(type, &handle, stream, -1, sound.volume, sound.balance);
+}
+
+void MortimerEngine::playSpeech() {
+	for (uint i = 0; i < _flight.sounds().size(); ++i)
+		if (_flight.sounds()[i].frame == _flight.frame())
+			playSound(_flight.sounds()[i], _speechHandles[i], Audio::Mixer::kSpeechSoundType);
+}
+
+void MortimerEngine::stopSounds() {
+	for (uint i = 0; i < _speechHandles.size(); ++i)
+		_mixer->stopHandle(_speechHandles[i]);
+	_mixer->stopHandle(_scopeHandle);
+	_mixer->stopHandle(_rescueHandle);
+	_mixer->stopHandle(_collisionHandle);
+}
+
+void MortimerEngine::restart() {
+	stopSounds();
+	_flight.reset();
+	_flight.advance();
+	_health = 100;
+	_lastDamageFrame = -int(_flight.frameRate());
+	_finished = false;
+	setMouse(Common::Point(320, 180));
+	_system->warpMouse(_mouse.x, _mouse.y);
+	_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
+	playSpeech();
+}
+
+void MortimerEngine::setMouse(const Common::Point &position) {
+	_mouse.x = CLIP<int>(position.x, 0, 639);
+	_mouse.y = CLIP<int>(position.y, 0, 479);
+	// A bounded view into the overscanned movie supplies steering. Exact
+	// acceleration, collision damage and scope behaviour remain POC rules.
+	_view.x = CLIP<int>(32 + (_mouse.x - 320) / 8, 0, MortimerFlight::kWidth - 320);
+	_view.y = CLIP<int>(24 + (_mouse.y - 180) / 4, 0, MortimerFlight::kHeight - 240);
+}
+
+void MortimerEngine::pauseEngineIntern(bool pause) {
+	Engine::pauseEngineIntern(pause);
+	if (pause)
+		_pauseTime = _system->getMillis();
+	else
+		_nextFrameTime += _system->getMillis() - _pauseTime;
+}
+
+Common::Error MortimerEngine::run() {
+	if (!loadResources())
+		return Common::Error(Common::kReadingFailed, _loadError);
+	initGraphics(640, 480);
+	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
+	_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
+	syncSoundSettings();
+	const bool oldCursor = CursorMan.showMouse(false);
+	restart();
+	PauseToken pause;
+	bool exit = false;
+	while (!shouldQuit() && !exit) {
+		Common::Event event;
+		while (_eventMan->pollEvent(event)) {
+			if (event.type == Common::EVENT_MOUSEMOVE) {
+				if (!isPaused())
+					setMouse(event.mouse);
+			} else if (event.type == Common::EVENT_LBUTTONDOWN && !isPaused() && !_finished && _health > 0) {
+				setMouse(event.mouse);
+				if (_mouse.x >= 54 && _mouse.x < 567 && _mouse.y >= 37 && _mouse.y < 320) {
+					playSound(_scopeSound, _scopeHandle, Audio::Mixer::kSFXSoundType);
+					if (_flight.rescue(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2)))
+						playSound(_rescueSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+				}
+			} else if (event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat) {
+				switch (event.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
+				case Common::KEYCODE_q:
+					exit = true;
+					break;
+				case Common::KEYCODE_SPACE:
+					if (pause.isActive())
+						pause.clear();
+					else
+						pause = pauseEngine();
+					break;
+				case Common::KEYCODE_r:
+					if (pause.isActive())
+						pause.clear();
+					restart();
+					break;
+				case Common::KEYCODE_F5:
+					openMainMenuDialog();
+					break;
+				default:
+					break;
+				}
+			}
+		}
+		if (!isPaused() && !_finished && _health > 0 && int32(_system->getMillis() - _nextFrameTime) >= 0) {
+			if (!_flight.advance()) {
+				_finished = true;
+				stopSounds();
+			} else {
+				playSpeech();
+				if (int(_flight.frame()) - _lastDamageFrame >= int(_flight.frameRate()) &&
+					_flight.hitsObstacle(Common::Point(_view.x + 160, _view.y + 120))) {
+					_health = MAX(0, _health - 10);
+					_lastDamageFrame = _flight.frame();
+					playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
+				}
+			}
+			_nextFrameTime += 1000 / _flight.frameRate();
+			// Avoid racing through queued frames following a slow backend call.
+			if (int32(_system->getMillis() - _nextFrameTime) > 250)
+				_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
+		}
+		draw();
+		_system->delayMillis(10);
+	}
+	stopSounds();
+	CursorMan.showMouse(oldCursor);
+	return Common::kNoError;
+}
+
+void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y) {
+	Common::Rect clip(x, y, x + sprite.width, y + sprite.height);
+	clip.clip(Common::Rect(_screen.w, _screen.h));
+	if (clip.isEmpty())
+		return;
+	for (int row = clip.top; row < clip.bottom; ++row) {
+		const byte *src = sprite.pixels.begin() + (row - y) * sprite.width + clip.left - x;
+		byte *dst = (byte *)_screen.getBasePtr(clip.left, row);
+		for (int col = clip.left; col < clip.right; ++col, ++src, ++dst)
+			if (*src)
+				*dst = *src;
+	}
+}
+
+void MortimerEngine::draw() {
+	for (int y = 0; y < 480; ++y) {
+		const byte *src = _flight.pixels() + (_view.y + y / 2) * MortimerFlight::kWidth + _view.x;
+		byte *dst = (byte *)_screen.getBasePtr(0, y);
+		for (int x = 0; x < 640; ++x)
+			dst[x] = src[x / 2];
+	}
+	drawSprite(_cockpit[0], 0, _cockpit[0].y);
+	drawSprite(_cockpit[1], 640 - _cockpit[1].width, _cockpit[1].y);
+	drawSprite(_cockpit[2], 0, 0);
+	drawSprite(_dashboard[35], 0, 320);
+	const int iconCenterY[] = {384, 379, 377, 376, 378, 380, 384};
+	for (uint species = 0; species < 7; ++species) {
+		if (_flight.rescuedSpecies() & (1 << species)) {
+			const MortimerSprite &icon = _icons[species * 2];
+			drawSprite(icon, 176 + species * 45 - icon.width / 2, iconCenterY[species] - icon.height / 2);
+		}
+	}
+	if (_mouse.y < 320)
+		drawSprite(_reticles[0], _mouse.x - _reticles[0].width / 2, _mouse.y - _reticles[0].height / 2);
+
+	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
+	_screen.fillRect(Common::Rect(0, 449, 640, 480), 10);
+	font->drawString(&_screen, Common::String::format("Animals rescued: %u   Shell: %d%%   Flight: %u / %u",
+		_flight.rescuedCount(), _health, _flight.frame() + 1, _flight.frameCount()), 8, 451, 624, 20);
+	font->drawString(&_screen, "Mouse: steer/aim  Click: rescue  Space: pause  R: restart  Esc: exit", 8, 467, 624, 20);
+	if (_finished || !_health || isPaused()) {
+		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
+		const char *message = isPaused() ? "Paused - Space to continue" :
+			_finished ? "Flight complete - R to restart" : "Shell exhausted - R to restart";
+		font->drawString(&_screen, message, 90, 155, 460, 20, Graphics::kTextAlignCenter);
+	}
+	_system->copyRectToScreen(_screen.getPixels(), _screen.pitch, 0, 0, 640, 480);
+	_system->updateScreen();
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
new file mode 100644
index 00000000000..eed9d377868
--- /dev/null
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -0,0 +1,68 @@
+/* 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 SCUMM_INSANE_MORTIMER_MORTIMER_H
+#define SCUMM_INSANE_MORTIMER_MORTIMER_H
+
+#include "engines/engine.h"
+#include "audio/mixer.h"
+#include "graphics/surface.h"
+#include "scumm/insane/mortimer/flight.h"
+
+namespace Scumm {
+
+// Registered by the SCUMM plugin, but deliberately independent of ScummEngine
+// and both Rebel Assault engines while the Mortimer flight POC is developed.
+class MortimerEngine : public Engine {
+public:
+	explicit MortimerEngine(OSystem *system);
+	~MortimerEngine() override;
+	Common::Error run() override;
+	bool hasFeature(EngineFeature feature) const override;
+
+protected:
+	void pauseEngineIntern(bool pause) override;
+
+private:
+	bool loadResources();
+	void restart();
+	void setMouse(const Common::Point &position);
+	void draw();
+	void drawSprite(const MortimerSprite &sprite, int x, int y);
+	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type);
+	void playSpeech();
+	void stopSounds();
+
+	MortimerFlight _flight;
+	Graphics::Surface _screen;
+	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons;
+	MortimerSound _scopeSound, _rescueSound, _collisionSound;
+	Common::Array<Audio::SoundHandle> _speechHandles;
+	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle;
+	Common::String _loadError;
+	Common::Point _mouse, _view;
+	uint32 _nextFrameTime, _pauseTime;
+	int _health, _lastDamageFrame;
+	bool _finished;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index d6bf12f63d6..5f474054257 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -48,6 +48,10 @@
 #include "scumm/file.h"
 #include "scumm/file_nes.h"
 
+#ifdef ENABLE_SCUMM_7_8
+#include "scumm/insane/mortimer/mortimer.h"
+#endif
+
 #if defined(ENABLE_REBEL2_PSX) && !defined(ENABLE_SCUMM_7_8)
 #error "SCUMM v7 & v8 games must be enabled for Rebel Assault II PlayStation. Specify --enable-engine=scumm-7-8,rebel2-psx"
 #endif
@@ -467,6 +471,12 @@ Common::Error ScummMetaEngine::createInstance(OSystem *syst, Engine **engine,
 
 	// Finally, we have massaged the GameDescriptor to our satisfaction, and can
 	// instantiate the appropriate game engine. Hooray!
+#ifdef ENABLE_SCUMM_7_8
+	if (res.game.id == GID_MORTIMER) {
+		*engine = new MortimerEngine(syst);
+		return Common::kNoError;
+	}
+#endif
 	switch (res.game.version) {
 	case 0:
 		*engine = new ScummEngine_v0(syst, res);
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index b0f7e3eb3aa..6c37ae4de2f 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -147,6 +147,8 @@ MODULE_OBJS += \
 	insane/insane_enemy.o \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
+	insane/mortimer/flight.o \
+	insane/mortimer/mortimer.o \
 	insane/rebel/rebel_audio.o \
 	insane/rebel/rebel_gamepad.o \
 	insane/rebel/rebel_touch.o \
diff --git a/engines/scumm/scumm-md5.h b/engines/scumm/scumm-md5.h
index f2a5530f9d0..5db6e273c88 100644
--- a/engines/scumm/scumm-md5.h
+++ b/engines/scumm/scumm-md5.h
@@ -1,5 +1,5 @@
 /*
-  This file was generated by the md5table tool on Sun Sep 27 19:37:35 2026
+  This file was generated by the md5table tool on Sun Sep 27 20:08:36 2026
   DO NOT EDIT MANUALLY!
  */
 
@@ -196,6 +196,7 @@ static const MD5Table md5table[] = {
 	{ "330f631502e381a4e199a3f7cb483c20", "indy3", "EGA", "EGA", -1, Common::DE_DEU, Common::kPlatformAmiga },
 	{ "33e989f85da700e2014d00f345cab3d7", "puttrace", "HE 98.5", "", 32754, Common::FR_FRA, Common::kPlatformWindows },
 	{ "3433be9866ca4261b2d5d25374e3f243", "monkey", "VGA", "", 8347, Common::FR_FRA, Common::kPlatformAmiga },
+	{ "3450a722d29c150a4bdac5ba9b5f3b46", "mortimer", "", "Demo (flight POC)", 290304, Common::EN_ANY, Common::kPlatformWindows },
 	{ "3486ede0f904789267d4bcc5537a46d4", "puttzoo", "", "Demo", 14337, Common::EN_ANY, Common::kPlatformMacintosh },
 	{ "35183fc18b1e04cdc9070078260b2c03", "zak", "V1", "", 1914, Common::EN_ANY, Common::kPlatformC64 },
 	{ "356fb5f680b68251333016175935d126", "BluesABCTime", "HE CUP", "Preview", 4133436, Common::UNK_LANG, Common::kPlatformUnknown },


Commit: 82d9ef19a4009c4525a06a9e80255f33d8b5af80
    https://github.com/scummvm/scummvm/commit/82d9ef19a4009c4525a06a9e80255f33d8b5af80
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: improved flight progression code and removed POC naming

Changed paths:
    devtools/scumm-md5.txt
    engines/scumm/detection_tables.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/scumm-md5.h


diff --git a/devtools/scumm-md5.txt b/devtools/scumm-md5.txt
index 7a0e29c97f3..98fbe672899 100644
--- a/devtools/scumm-md5.txt
+++ b/devtools/scumm-md5.txt
@@ -450,7 +450,7 @@ ft	Full Throttle
 	8ead2c01c48c2e3d15d6a89b86cd84ab	19697	he	DOS	-	-	-	Hebrew fan translation
 
 mortimer	Mortimer and the Riddles of the Medallion
-	3450a722d29c150a4bdac5ba9b5f3b46	290304	en	Windows	-	Demo (flight POC)	Windows demo launcher; first Savanna flight only	-
+	3450a722d29c150a4bdac5ba9b5f3b46	290304	en	Windows	-	Demo	Windows demo launcher	-
 
 rebel1	Star Wars: Rebel Assault
 	0ca9c07185b4969764eaf3ecd0de06e9	111530	en	DOS	Demo v0.67	Demo v0.67	Non-interactive demo	-
diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index b6bd4f14739..d94ce23f1bb 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -251,7 +251,7 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
-	{"mortimer", 0, "flight-poc", GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+	{"mortimer", 0, 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Demo", 0, GID_DIG, 7, 0, MDT_NONE, GF_DEMO, UNK, GUIO4(GUIO_NOMIDI, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index f0cddef206c..cff31c40452 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -93,7 +93,7 @@ bool validRows(const byte *src, uint32 size, int height, bool remap) {
 }
 
 // Codec37's existing decoder has no input-length argument. Validate the two
-// encodings actually present in DEMO_S1 before passing any data to it.
+// encodings present in the demo flights before passing any data to it.
 bool validBackground(const byte *src, uint32 size) {
 	if (size < 16 || src[1] != 1 || src[12] != 4)
 		return false;
@@ -143,7 +143,7 @@ bool validObject(const byte *data, uint32 size, bool sprite = false) {
 	if (codec == 37)
 		return width == MortimerFlight::kWidth && height == MortimerFlight::kHeight &&
 			READ_LE_UINT32(data + 2) == 0 && validBackground(data + 14, size - 14);
-	if (codec != 3 && codec != 23)
+	if (codec != 1 && codec != 3 && codec != 23)
 		return false;
 	uint32 offset = 14;
 	if (codec == 23) {
@@ -156,6 +156,22 @@ bool validObject(const byte *data, uint32 size, bool sprite = false) {
 	return offset <= size && validRows(data + offset, size - offset, height, codec == 23);
 }
 
+bool validPalette(const byte *data, uint32 size) {
+	return (size == 2308 && READ_BE_UINT32(data) == 2) ||
+		(size == 6 && READ_BE_UINT32(data) == 1);
+}
+
+bool validTransition(const byte *data, uint32 size) {
+	// IACT 8/46 contains a length-prefixed filename followed by CR/LF/EOF.
+	if (size < 22 || READ_LE_UINT16(data + 14) != size - 18 ||
+		data[size - 3] != '\r' || data[size - 2] != '\n' || data[size - 1] != 0x1a)
+		return false;
+	for (uint i = 18; i < size - 3; ++i)
+		if (data[i] < 32 || data[i] > 126)
+			return false;
+	return true;
+}
+
 bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
 	if (!isContainer(data, MKTAG('S', 'A', 'U', 'D')))
 		return false;
@@ -229,7 +245,7 @@ bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound)
 MortimerFlight::MortimerFlight() : _decoder(nullptr), _nextFrame(0), _frameRate(10), _rescuedCount(0), _rescuedSpecies(0) {
 	_background.resize(kWidth * kHeight);
 	_pixels.resize(kWidth * kHeight);
-	memset(_palette, 0, sizeof(_palette));
+	memset(_initialPalette, 0, sizeof(_initialPalette));
 	reset();
 }
 
@@ -271,7 +287,8 @@ bool MortimerFlight::load(Common::SeekableReadStream &stream) {
 			_frameRate = READ_LE_UINT32(src + 774);
 			if (!expectedFrames || _frameRate != 10)
 				return fail("Unsupported Mortimer flight timing");
-			memcpy(_palette, src + 6, sizeof(_palette));
+			memcpy(_initialPalette, src + 6, sizeof(_initialPalette));
+			memcpy(_palette, _initialPalette, sizeof(_palette));
 		} else if (chunk.tag == MKTAG('F', 'R', 'M', 'E')) {
 			if (!expectedFrames)
 				return fail("Mortimer flight has no header");
@@ -300,6 +317,13 @@ bool MortimerFlight::load(Common::SeekableReadStream &stream) {
 					if ((code >= 2 && code <= 4 && (sub.size < (code == 4 ? 18U : 12U) || READ_LE_UINT16(body + 8) >= 256)) ||
 						(code == 5 && sub.size < 28))
 						return fail("Invalid Mortimer flight action");
+					if (code == 2 && READ_LE_UINT16(body + 4) == 3 && READ_LE_UINT16(body + 10) >= 256)
+						return fail("Invalid Mortimer conditional object");
+					if (code == 8 && READ_LE_UINT16(body + 2) == 46 && !validTransition(body, sub.size))
+						return fail("Invalid Mortimer flight transition");
+				} else if (sub.tag == MKTAG('X', 'P', 'A', 'L')) {
+					if (!validPalette(body, sub.size))
+						return fail("Invalid Mortimer palette change");
 				} else if (sub.tag == MKTAG('P', 'S', 'A', 'D')) {
 					if (sub.size < 10)
 						return fail("Truncated Mortimer speech block");
@@ -312,6 +336,7 @@ bool MortimerFlight::load(Common::SeekableReadStream &stream) {
 						track.id = id;
 						track.nextBlock = 0;
 						track.sound.frame = _frames.size();
+						track.sound.music = (READ_LE_UINT16(body + 6) & 64) != 0;
 						track.sound.volume = MIN(255, body[8] * 2);
 						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
 						tracks.push_back(track);
@@ -324,8 +349,9 @@ bool MortimerFlight::load(Common::SeekableReadStream &stream) {
 					memcpy(bytes.begin() + oldSize, body + 10, sub.size - 10);
 				}
 			}
-			if (backgrounds != 1)
-				return fail("Mortimer flight frame needs one background");
+			// The final Big Tree frame contains an opaque RLE image only.
+			if (backgrounds > 1 || (_frames.empty() && backgrounds != 1))
+				return fail("Invalid Mortimer flight background");
 			_frames.push_back({chunk.offset, chunk.size});
 		}
 	}
@@ -343,6 +369,11 @@ void MortimerFlight::reset() {
 	delete _decoder;
 	_decoder = new SmushDeltaBlocksDecoder(kWidth, kHeight);
 	_nextFrame = _rescuedCount = _rescuedSpecies = 0;
+	_nextFlight.clear();
+	_weapon = kRevitascope;
+	memcpy(_palette, _initialPalette, sizeof(_palette));
+	memset(_paletteDelta, 0, sizeof(_paletteDelta));
+	memset(_shiftedPalette, 0, sizeof(_shiftedPalette));
 	_targets.clear();
 	_objects.clear();
 	_obstacles.clear();
@@ -377,7 +408,7 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 				target->remapToggle = toggle;
 			// Briefly show the rescued animal's silhouette before removing it
 			// from the scene. Flash duration is POC tuning.
-			_toggles[toggle] = !target || !target->rescued || frame() - target->rescuedAt >= 5;
+			_toggles[toggle] = !target || !target->hit || frame() - target->rescuedAt >= 5;
 			return;
 		}
 		Target *target = findTarget(id);
@@ -387,14 +418,20 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 			fresh.toggle = toggle;
 			fresh.remapToggle = 0xffff;
 			fresh.rescuedAt = 0;
-			fresh.active = fresh.rescued = false;
+			fresh.active = fresh.hit = false;
+			// Salt belongs to the hornet whose ID is 100 lower. Destroying a
+			// hornet also disables its subsequent salt objects (SHELL 407c5f).
+			if (id >= 800 && id < 900) {
+				const Target *hornet = findTarget(id - 100);
+				fresh.hit = hornet && hornet->hit;
+			}
 			_targets.push_back(fresh);
 			target = &_targets.back();
 		}
 		if (!target)
 			return;
 		target->toggle = toggle;
-		_toggles[toggle] = target->rescued;
+		_toggles[toggle] = target->hit;
 		if (code == 4) {
 			const int x = READ_LE_INT16(data + 10), y = READ_LE_INT16(data + 12);
 			const int w = READ_LE_INT16(data + 14), h = READ_LE_INT16(data + 16);
@@ -404,14 +441,37 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 		} else if (code == 3) {
 			target->active = false;
 		}
+	} else if (code == 2 && mode == 3) {
+		_toggles[READ_LE_UINT16(data + 8)] = !_toggles[READ_LE_UINT16(data + 10)];
 	} else if (code == 5 && flags == 14 && size >= 28) {
 		Obstacle obstacle;
 		for (uint i = 0; i < 4; ++i)
 			obstacle.corners[i] = Common::Point(READ_LE_INT16(data + 12 + i * 4), READ_LE_INT16(data + 14 + i * 4));
 		_obstacles.push_back(obstacle);
+	} else if (code == 6 && (flags == 9 || flags == 10)) {
+		// SHELL 407c5f calls 40b844/40b7ce at the scripted weapon changes.
+		_weapon = flags == 9 ? kSlimeGun : kRevitascope;
+	} else if (code == 8 && flags == 46) {
+		_nextFlight = Common::String((const char *)data + 18, size - 21);
+		for (uint i = 0; i < _nextFlight.size(); ++i)
+			if (_nextFlight[i] == '\\')
+				_nextFlight.setChar('/', i);
+	}
+}
+
+void MortimerFlight::handlePalette(const byte *data, uint32 size) {
+	if (size == 2308) {
+		for (uint i = 0; i < 768; ++i) {
+			_shiftedPalette[i] = _palette[i] << 7;
+			_paletteDelta[i] = READ_LE_INT16(data + 4 + i * 2);
+		}
+		memcpy(_palette, data + 1540, sizeof(_palette));
+	} else {
+		for (uint i = 0; i < 768; ++i) {
+			_shiftedPalette[i] += _paletteDelta[i];
+			_palette[i] = CLIP<int32>(_shiftedPalette[i] >> 7, 0, 255);
+		}
 	}
-	// Flight transitions (8/46), prefetches and shell/interface callbacks are
-	// deliberately outside this single-segment POC.
 }
 
 bool MortimerFlight::advance() {
@@ -436,6 +496,8 @@ bool MortimerFlight::advance() {
 		}
 		if (chunk.tag == MKTAG('I', 'A', 'C', 'T')) {
 			handleAction(src, chunk.size);
+		} else if (chunk.tag == MKTAG('X', 'P', 'A', 'L')) {
+			handlePalette(src, chunk.size);
 		} else if (chunk.tag == MKTAG('F', 'O', 'B', 'J')) {
 			if (src[0] == 37) {
 				_decoder->decode(_background.begin(), src + 14);
@@ -483,34 +545,50 @@ void MortimerFlight::render() {
 			byte *dest = _pixels.begin() + row * kWidth + clip.left;
 			for (int col = 0; col < clip.width(); ++col) {
 				// Mortimer treats zero as transparent in literal runs as well.
-				if (src[0] == 23 || line[col])
+				if (src[0] == 1 || src[0] == 23 || line[col])
 					dest[col] = line[col];
 			}
 		}
 	}
 }
 
-bool MortimerFlight::rescue(const Common::Point &point) {
+MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 	// Later objects are drawn on top of earlier ones.
 	for (int i = int(_targets.size()) - 1; i >= 0; --i) {
 		Target &target = _targets[i];
-		if (!target.active || target.rescued || !target.rect.contains(point))
+		if (!target.active || target.hit || !target.rect.contains(point))
+			continue;
+		const uint species = target.id / 100;
+		if (!target.id || species > 9 || species == 8 ||
+			(species < 7 && _weapon != kRevitascope) || (species == 7 && _weapon != kSlimeGun))
 			continue;
-		target.rescued = true;
+		target.hit = true;
 		target.rescuedAt = frame();
 		_toggles[target.toggle] = true;
 		if (target.remapToggle != 0xffff)
 			_toggles[target.remapToggle] = false;
-		++_rescuedCount;
-		if (target.id / 100 < 7)
-			_rescuedSpecies |= 1 << (target.id / 100);
+		if (species < 7) {
+			++_rescuedCount;
+			_rescuedSpecies |= 1 << species;
+		} else if (species == 7) {
+			Target *salt = findTarget(target.id + 100);
+			if (salt) {
+				salt->hit = true;
+				_toggles[salt->toggle] = true;
+			}
+		}
 		render();
-		return true;
+		return species == 9 ? kShellRestored : species == 7 ? kHornetHit : kAnimalRescued;
 	}
-	return false;
+	return kMiss;
 }
 
 bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
+	for (uint i = 0; i < _targets.size(); ++i) {
+		const Target &target = _targets[i];
+		if (target.id >= 800 && target.id < 900 && target.active && !target.hit && target.rect.contains(point))
+			return true;
+	}
 	for (uint n = 0; n < _obstacles.size(); ++n) {
 		const Obstacle &obstacle = _obstacles[n];
 		bool inside = false;
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 23a25475ec2..5a9abb0e968 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -43,25 +43,28 @@ struct MortimerSound {
 	uint rate = 11025;
 	byte volume = 255;
 	int8 balance = 0;
+	bool music = false;
 	Common::Array<byte> samples;
 };
 
 bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites);
 bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
 
-// The first Windows demo flight only. No ScummEngine, InsaneRebel or SmushPlayer
+// The Windows demo flights. No ScummEngine, InsaneRebel or SmushPlayer
 // state is used here; only the existing, stateless RLE helpers and a private
 // codec37 decoder are shared with the other SMUSH games.
 class MortimerFlight {
 public:
 	enum { kWidth = 384, kHeight = 288 };
+	enum Weapon { kRevitascope, kSlimeGun };
+	enum ShotResult { kMiss, kAnimalRescued, kHornetHit, kShellRestored };
 
 	MortimerFlight();
 	~MortimerFlight();
 	bool load(Common::SeekableReadStream &stream);
 	void reset();
 	bool advance();
-	bool rescue(const Common::Point &point);
+	ShotResult shoot(const Common::Point &point);
 	bool hitsObstacle(const Common::Point &point) const;
 
 	const byte *pixels() const { return _pixels.begin(); }
@@ -71,6 +74,8 @@ public:
 	uint frameRate() const { return _frameRate; }
 	uint rescuedCount() const { return _rescuedCount; }
 	uint rescuedSpecies() const { return _rescuedSpecies; }
+	Weapon weapon() const { return _weapon; }
+	const Common::String &nextFlight() const { return _nextFlight; }
 	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
 	const Common::String &errorMessage() const { return _error; }
 
@@ -86,7 +91,7 @@ private:
 		uint16 id, toggle, remapToggle;
 		uint rescuedAt;
 		Common::Rect rect;
-		bool active, rescued;
+		bool active, hit;
 	};
 	struct Obstacle {
 		Common::Point corners[4];
@@ -94,6 +99,7 @@ private:
 
 	bool fail(const char *message);
 	void handleAction(const byte *data, uint32 size);
+	void handlePalette(const byte *data, uint32 size);
 	void render();
 	Target *findTarget(uint16 id);
 
@@ -104,9 +110,12 @@ private:
 	Common::Array<Obstacle> _obstacles;
 	Common::Array<MortimerSound> _sounds;
 	SmushDeltaBlocksDecoder *_decoder;
-	Common::String _error;
-	byte _palette[768], _remap[256];
+	Common::String _error, _nextFlight;
+	byte _palette[768], _initialPalette[768], _remap[256];
+	int16 _paletteDelta[768];
+	int32 _shiftedPalette[768];
 	bool _toggles[256];
+	Weapon _weapon;
 	uint _nextFrame, _frameRate, _rescuedCount, _rescuedSpecies;
 };
 
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index eef05d64aef..3a6dd97fe9e 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -25,18 +25,26 @@
 #include "common/events.h"
 #include "common/file.h"
 #include "common/system.h"
+#include "common/translation.h"
 #include "common/util.h"
 #include "engines/util.h"
 #include "graphics/cursorman.h"
 #include "graphics/font.h"
 #include "graphics/fontman.h"
 #include "graphics/paletteman.h"
+#include "gui/message.h"
 
 namespace Scumm {
 
+static const char *const kDemoFlights[] = {
+	"RESOURCE/DEMO/DEMO_S1.SAN",
+	"RESOURCE/DEMO/DEMO_PL.SAN",
+	"RESOURCE/DEMO/DEMO_BT.SAN"
+};
+
 MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
 	_mouse(320, 180), _view(32, 24), _nextFrameTime(0), _pauseTime(0),
-	_health(100), _lastDamageFrame(-10), _finished(false) {
+	_segment(0), _completedRescues(0), _completedSpecies(0), _shellShocks(0), _finished(false) {
 }
 
 MortimerEngine::~MortimerEngine() {
@@ -49,17 +57,7 @@ bool MortimerEngine::hasFeature(EngineFeature feature) const {
 }
 
 bool MortimerEngine::loadResources() {
-	const char *flightName = "RESOURCE/DEMO/DEMO_S1.SAN";
 	Common::File file;
-	if (!file.open(Common::Path(flightName))) {
-		_loadError = Common::String::format("Missing %s", flightName);
-		return false;
-	}
-	if (!_flight.load(file)) {
-		_loadError = _flight.errorMessage();
-		return false;
-	}
-	file.close();
 
 	struct SpriteBank {
 		const char *name;
@@ -69,7 +67,7 @@ bool MortimerEngine::loadResources() {
 	const SpriteBank banks[] = {
 		{"RESOURCE/GENERAL/COCKPT_H.NUT", &_cockpit, 3},
 		{"RESOURCE/GENERAL/DASH_H.NUT", &_dashboard, 36},
-		{"RESOURCE/GENERAL/RETICLEH.NUT", &_reticles, 1},
+		{"RESOURCE/GENERAL/RETICLEH.NUT", &_reticles, 2},
 		{"RESOURCE/SAVANNA/S_ICON_H.NUT", &_icons, 14}
 	};
 	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
@@ -86,7 +84,9 @@ bool MortimerEngine::loadResources() {
 	const Effect effects[] = {
 		{"RESOURCE/GENERAL/SFX/REVITAL2.SAD", &_scopeSound},
 		{"RESOURCE/GENERAL/SFX/SCOPHIT3.SAD", &_rescueSound},
-		{"RESOURCE/GENERAL/SFX/SHELHIT1.SAD", &_collisionSound}
+		{"RESOURCE/GENERAL/SFX/SHELHIT1.SAD", &_collisionSound},
+		{"RESOURCE/GENERAL/SFX/SLMGUN2.SAD", &_slimeSound},
+		{"RESOURCE/GENERAL/SFX/MUCKHIT1.SAD", &_slimeHitSound}
 	};
 	for (uint i = 0; i < ARRAYSIZE(effects); ++i) {
 		if (!file.open(Common::Path(effects[i].name)) || !loadMortimerSound(file, *effects[i].sound)) {
@@ -95,7 +95,24 @@ bool MortimerEngine::loadResources() {
 		}
 		file.close();
 	}
-	_speechHandles.resize(_flight.sounds().size());
+	return loadFlight(0);
+}
+
+bool MortimerEngine::loadFlight(uint segment) {
+	// The mixer borrows the samples owned by the flight being replaced.
+	stopSounds();
+	_flightHandles.clear();
+	Common::File file;
+	if (!file.open(Common::Path(kDemoFlights[segment]))) {
+		_loadError = Common::String::format("Missing %s", kDemoFlights[segment]);
+		return false;
+	}
+	if (!_flight.load(file)) {
+		_loadError = Common::String::format("%s: %s", kDemoFlights[segment], _flight.errorMessage().c_str());
+		return false;
+	}
+	_segment = segment;
+	_flightHandles.resize(_flight.sounds().size());
 	return true;
 }
 
@@ -107,31 +124,62 @@ void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &h
 	_mixer->playStream(type, &handle, stream, -1, sound.volume, sound.balance);
 }
 
-void MortimerEngine::playSpeech() {
+void MortimerEngine::playFlightSounds() {
 	for (uint i = 0; i < _flight.sounds().size(); ++i)
 		if (_flight.sounds()[i].frame == _flight.frame())
-			playSound(_flight.sounds()[i], _speechHandles[i], Audio::Mixer::kSpeechSoundType);
+			playSound(_flight.sounds()[i], _flightHandles[i],
+				_flight.sounds()[i].music ? Audio::Mixer::kMusicSoundType : Audio::Mixer::kSpeechSoundType);
 }
 
 void MortimerEngine::stopSounds() {
-	for (uint i = 0; i < _speechHandles.size(); ++i)
-		_mixer->stopHandle(_speechHandles[i]);
+	for (uint i = 0; i < _flightHandles.size(); ++i)
+		_mixer->stopHandle(_flightHandles[i]);
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
 	_mixer->stopHandle(_collisionHandle);
 }
 
-void MortimerEngine::restart() {
+bool MortimerEngine::startFlight() {
 	stopSounds();
 	_flight.reset();
-	_flight.advance();
-	_health = 100;
-	_lastDamageFrame = -int(_flight.frameRate());
+	if (!_flight.advance()) {
+		_loadError = _flight.errorMessage();
+		return false;
+	}
 	_finished = false;
 	setMouse(Common::Point(320, 180));
 	_system->warpMouse(_mouse.x, _mouse.y);
+	_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
 	_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
-	playSpeech();
+	playFlightSounds();
+	return true;
+}
+
+bool MortimerEngine::restart(bool all) {
+	_shellShocks = 0;
+	if (all) {
+		_completedRescues = _completedSpecies = 0;
+		if (!loadFlight(0))
+			return false;
+	}
+	return startFlight();
+}
+
+bool MortimerEngine::nextFlight(bool skip) {
+	stopSounds();
+	if (_segment + 1 == ARRAYSIZE(kDemoFlights)) {
+		_finished = true;
+		return true;
+	}
+	// Only follow the demo's two flight branches, never an interview or a
+	// full-game resource named by another SAN file.
+	if (!skip && !_flight.nextFlight().equalsIgnoreCase(kDemoFlights[_segment + 1])) {
+		_loadError = Common::String::format("Unexpected flight transition in %s", kDemoFlights[_segment]);
+		return false;
+	}
+	_completedRescues += _flight.rescuedCount();
+	_completedSpecies |= _flight.rescuedSpecies();
+	return loadFlight(_segment + 1) && startFlight();
 }
 
 void MortimerEngine::setMouse(const Common::Point &position) {
@@ -156,28 +204,37 @@ Common::Error MortimerEngine::run() {
 		return Common::Error(Common::kReadingFailed, _loadError);
 	initGraphics(640, 480);
 	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
-	_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
 	syncSoundSettings();
 	const bool oldCursor = CursorMan.showMouse(false);
-	restart();
 	PauseToken pause;
-	bool exit = false;
+	bool exit = !restart();
 	while (!shouldQuit() && !exit) {
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
 			if (event.type == Common::EVENT_MOUSEMOVE) {
 				if (!isPaused())
 					setMouse(event.mouse);
-			} else if (event.type == Common::EVENT_LBUTTONDOWN && !isPaused() && !_finished && _health > 0) {
+			} else if (event.type == Common::EVENT_LBUTTONDOWN && !isPaused() && !_finished) {
 				setMouse(event.mouse);
 				if (_mouse.x >= 54 && _mouse.x < 567 && _mouse.y >= 37 && _mouse.y < 320) {
-					playSound(_scopeSound, _scopeHandle, Audio::Mixer::kSFXSoundType);
-					if (_flight.rescue(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2)))
+					playSound(_flight.weapon() == MortimerFlight::kSlimeGun ? _slimeSound : _scopeSound,
+						_scopeHandle, Audio::Mixer::kSFXSoundType);
+					const MortimerFlight::ShotResult result = _flight.shoot(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2));
+					if (result == MortimerFlight::kAnimalRescued || result == MortimerFlight::kShellRestored)
 						playSound(_rescueSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+					else if (result == MortimerFlight::kHornetHit)
+						playSound(_slimeHitSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+					if (result == MortimerFlight::kShellRestored)
+						_shellShocks = 0;
 				}
 			} else if (event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat) {
 				switch (event.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
+					if (pause.isActive())
+						pause.clear();
+					else
+						exit = !nextFlight(true);
+					break;
 				case Common::KEYCODE_q:
 					exit = true;
 					break;
@@ -190,7 +247,7 @@ Common::Error MortimerEngine::run() {
 				case Common::KEYCODE_r:
 					if (pause.isActive())
 						pause.clear();
-					restart();
+					exit = !restart();
 					break;
 				case Common::KEYCODE_F5:
 					openMainMenuDialog();
@@ -200,30 +257,43 @@ Common::Error MortimerEngine::run() {
 				}
 			}
 		}
-		if (!isPaused() && !_finished && _health > 0 && int32(_system->getMillis() - _nextFrameTime) >= 0) {
+		if (exit)
+			break;
+		if (!isPaused() && !_finished && int32(_system->getMillis() - _nextFrameTime) >= 0) {
+			_nextFrameTime += 1000 / _flight.frameRate();
 			if (!_flight.advance()) {
-				_finished = true;
-				stopSounds();
+				if (!_flight.errorMessage().empty()) {
+					_loadError = _flight.errorMessage();
+					break;
+				}
+				if (!nextFlight())
+					break;
 			} else {
-				playSpeech();
-				if (int(_flight.frame()) - _lastDamageFrame >= int(_flight.frameRate()) &&
-					_flight.hitsObstacle(Common::Point(_view.x + 160, _view.y + 120))) {
-					_health = MAX(0, _health - 10);
-					_lastDamageFrame = _flight.frame();
+				playFlightSounds();
+				_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
+				if (_flight.hitsObstacle(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2))) {
+					++_shellShocks;
 					playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
+					// SHELL 40be36 resets the flight after eighteen shell shocks.
+					if (_shellShocks >= 18 && !restart())
+						break;
 				}
 			}
-			_nextFrameTime += 1000 / _flight.frameRate();
 			// Avoid racing through queued frames following a slow backend call.
 			if (int32(_system->getMillis() - _nextFrameTime) > 250)
 				_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
 		}
 		draw();
+		if (_finished) {
+			GUI::MessageDialog dialog(_("Play again?"), _("Restart"), _("Quit"));
+			if (dialog.runModal() != GUI::kMessageOK || !restart(true))
+				break;
+		}
 		_system->delayMillis(10);
 	}
 	stopSounds();
 	CursorMan.showMouse(oldCursor);
-	return Common::kNoError;
+	return _loadError.empty() ? Common::kNoError : Common::Error(Common::kReadingFailed, _loadError);
 }
 
 void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y) {
@@ -253,24 +323,23 @@ void MortimerEngine::draw() {
 	drawSprite(_dashboard[35], 0, 320);
 	const int iconCenterY[] = {384, 379, 377, 376, 378, 380, 384};
 	for (uint species = 0; species < 7; ++species) {
-		if (_flight.rescuedSpecies() & (1 << species)) {
+		if ((_completedSpecies | _flight.rescuedSpecies()) & (1 << species)) {
 			const MortimerSprite &icon = _icons[species * 2];
 			drawSprite(icon, 176 + species * 45 - icon.width / 2, iconCenterY[species] - icon.height / 2);
 		}
 	}
-	if (_mouse.y < 320)
-		drawSprite(_reticles[0], _mouse.x - _reticles[0].width / 2, _mouse.y - _reticles[0].height / 2);
+	if (_mouse.y < 320) {
+		const MortimerSprite &reticle = _reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1];
+		drawSprite(reticle, _mouse.x - reticle.width / 2, _mouse.y - reticle.height / 2);
+	}
 
 	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
-	_screen.fillRect(Common::Rect(0, 449, 640, 480), 10);
-	font->drawString(&_screen, Common::String::format("Animals rescued: %u   Shell: %d%%   Flight: %u / %u",
-		_flight.rescuedCount(), _health, _flight.frame() + 1, _flight.frameCount()), 8, 451, 624, 20);
-	font->drawString(&_screen, "Mouse: steer/aim  Click: rescue  Space: pause  R: restart  Esc: exit", 8, 467, 624, 20);
-	if (_finished || !_health || isPaused()) {
+	_screen.fillRect(Common::Rect(0, 449, 640, 465), 10);
+	font->drawString(&_screen, Common::String::format("Animals rescued: %u   Shell shocks: %u / 18   Flight: %u / 3",
+		_completedRescues + _flight.rescuedCount(), _shellShocks, _segment + 1), 8, 451, 624, 20);
+	if (isPaused()) {
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
-		const char *message = isPaused() ? "Paused - Space to continue" :
-			_finished ? "Flight complete - R to restart" : "Shell exhausted - R to restart";
-		font->drawString(&_screen, message, 90, 155, 460, 20, Graphics::kTextAlignCenter);
+		font->drawString(&_screen, "Paused - Space to continue", 90, 155, 460, 20, Graphics::kTextAlignCenter);
 	}
 	_system->copyRectToScreen(_screen.getPixels(), _screen.pitch, 0, 0, 640, 480);
 	_system->updateScreen();
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index eed9d377868..62cd5144535 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -29,7 +29,7 @@
 namespace Scumm {
 
 // Registered by the SCUMM plugin, but deliberately independent of ScummEngine
-// and both Rebel Assault engines while the Mortimer flight POC is developed.
+// and both Rebel Assault engines.
 class MortimerEngine : public Engine {
 public:
 	explicit MortimerEngine(OSystem *system);
@@ -42,24 +42,27 @@ protected:
 
 private:
 	bool loadResources();
-	void restart();
+	bool loadFlight(uint segment);
+	bool startFlight();
+	bool nextFlight(bool skip = false);
+	bool restart(bool all = false);
 	void setMouse(const Common::Point &position);
 	void draw();
 	void drawSprite(const MortimerSprite &sprite, int x, int y);
 	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type);
-	void playSpeech();
+	void playFlightSounds();
 	void stopSounds();
 
 	MortimerFlight _flight;
 	Graphics::Surface _screen;
 	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons;
-	MortimerSound _scopeSound, _rescueSound, _collisionSound;
-	Common::Array<Audio::SoundHandle> _speechHandles;
+	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound;
+	Common::Array<Audio::SoundHandle> _flightHandles;
 	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle;
 	Common::String _loadError;
 	Common::Point _mouse, _view;
 	uint32 _nextFrameTime, _pauseTime;
-	int _health, _lastDamageFrame;
+	uint _segment, _completedRescues, _completedSpecies, _shellShocks;
 	bool _finished;
 };
 
diff --git a/engines/scumm/scumm-md5.h b/engines/scumm/scumm-md5.h
index 5db6e273c88..f5365db9848 100644
--- a/engines/scumm/scumm-md5.h
+++ b/engines/scumm/scumm-md5.h
@@ -1,5 +1,5 @@
 /*
-  This file was generated by the md5table tool on Sun Sep 27 20:08:36 2026
+  This file was generated by the md5table tool on Wed Sep 30 06:55:18 2026
   DO NOT EDIT MANUALLY!
  */
 
@@ -196,7 +196,7 @@ static const MD5Table md5table[] = {
 	{ "330f631502e381a4e199a3f7cb483c20", "indy3", "EGA", "EGA", -1, Common::DE_DEU, Common::kPlatformAmiga },
 	{ "33e989f85da700e2014d00f345cab3d7", "puttrace", "HE 98.5", "", 32754, Common::FR_FRA, Common::kPlatformWindows },
 	{ "3433be9866ca4261b2d5d25374e3f243", "monkey", "VGA", "", 8347, Common::FR_FRA, Common::kPlatformAmiga },
-	{ "3450a722d29c150a4bdac5ba9b5f3b46", "mortimer", "", "Demo (flight POC)", 290304, Common::EN_ANY, Common::kPlatformWindows },
+	{ "3450a722d29c150a4bdac5ba9b5f3b46", "mortimer", "", "Demo", 290304, Common::EN_ANY, Common::kPlatformWindows },
 	{ "3486ede0f904789267d4bcc5537a46d4", "puttzoo", "", "Demo", 14337, Common::EN_ANY, Common::kPlatformMacintosh },
 	{ "35183fc18b1e04cdc9070078260b2c03", "zak", "V1", "", 1914, Common::EN_ANY, Common::kPlatformC64 },
 	{ "356fb5f680b68251333016175935d126", "BluesABCTime", "HE CUP", "Preview", 4133436, Common::UNK_LANG, Common::kPlatformUnknown },


Commit: 322730bb230ae9567141bd684163aa6ed1ac13f1
    https://github.com/scummvm/scummvm/commit/322730bb230ae9567141bd684163aa6ed1ac13f1
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: implemented UI details like shooting, status bars, targets among others

Changed paths:
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/metaengine.cpp


diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index cff31c40452..bf4b728ad80 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -406,9 +406,9 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 			Target *target = findTarget(id - 1000);
 			if (target)
 				target->remapToggle = toggle;
-			// Briefly show the rescued animal's silhouette before removing it
-			// from the scene. Flash duration is POC tuning.
-			_toggles[toggle] = !target || !target->hit || frame() - target->rescuedAt >= 5;
+			// Keep the remapped silhouette for the duration of its NUT effect.
+			const uint duration = target && target->hitWeapon == kSlimeGun ? 7 : 11;
+			_toggles[toggle] = !target || !target->hit || frame() - target->rescuedAt >= duration;
 			return;
 		}
 		Target *target = findTarget(id);
@@ -418,6 +418,7 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 			fresh.toggle = toggle;
 			fresh.remapToggle = 0xffff;
 			fresh.rescuedAt = 0;
+			fresh.hitWeapon = kRevitascope;
 			fresh.active = fresh.hit = false;
 			// Salt belongs to the hornet whose ID is 100 lower. Destroying a
 			// hornet also disables its subsequent salt objects (SHELL 407c5f).
@@ -564,6 +565,7 @@ MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 			continue;
 		target.hit = true;
 		target.rescuedAt = frame();
+		target.hitWeapon = _weapon;
 		_toggles[target.toggle] = true;
 		if (target.remapToggle != 0xffff)
 			_toggles[target.remapToggle] = false;
@@ -584,21 +586,37 @@ MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 }
 
 bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
+	// SHELL 40e390 uses a 50 x 50 footprint in movie coordinates.
+	const Common::Rect footprint(point.x - 25, point.y - 25, point.x + 25, point.y + 25);
 	for (uint i = 0; i < _targets.size(); ++i) {
 		const Target &target = _targets[i];
-		if (target.id >= 800 && target.id < 900 && target.active && !target.hit && target.rect.contains(point))
+		if (target.id >= 800 && target.id < 900 && target.active && !target.hit && target.rect.intersects(footprint))
 			return true;
 	}
 	for (uint n = 0; n < _obstacles.size(); ++n) {
 		const Obstacle &obstacle = _obstacles[n];
-		bool inside = false;
-		for (uint i = 0, j = 3; i < 4; j = i++) {
-			const Common::Point &a = obstacle.corners[i], &b = obstacle.corners[j];
-			if ((a.y > point.y) != (b.y > point.y) &&
-				point.x < a.x + int64(b.x - a.x) * (point.y - a.y) / (b.y - a.y))
-				inside = !inside;
+		// Separating axes also handle vertical edges and degenerate quads
+		// without the divisions used by the original executable.
+		bool separated = false;
+		for (uint axis = 0; axis < 6 && !separated; ++axis) {
+			int dx = axis == 4 ? 1 : 0, dy = axis == 5 ? 1 : 0;
+			if (axis < 4) {
+				const Common::Point &a = obstacle.corners[axis], &b = obstacle.corners[(axis + 1) % 4];
+				dx = a.y - b.y;
+				dy = b.x - a.x;
+			}
+			int64 minimum = int64(dx) * obstacle.corners[0].x + int64(dy) * obstacle.corners[0].y;
+			int64 maximum = minimum;
+			for (uint i = 1; i < 4; ++i) {
+				const int64 projection = int64(dx) * obstacle.corners[i].x + int64(dy) * obstacle.corners[i].y;
+				minimum = MIN(minimum, projection);
+				maximum = MAX(maximum, projection);
+			}
+			const int64 center = int64(dx) * point.x + int64(dy) * point.y;
+			const int64 radius = 25 * (int64(ABS(dx)) + ABS(dy));
+			separated = center + radius < minimum || center - radius > maximum;
 		}
-		if (inside)
+		if (!separated)
 			return true;
 	}
 	return false;
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 5a9abb0e968..fa6f9cd1770 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -58,6 +58,13 @@ public:
 	enum { kWidth = 384, kHeight = 288 };
 	enum Weapon { kRevitascope, kSlimeGun };
 	enum ShotResult { kMiss, kAnimalRescued, kHornetHit, kShellRestored };
+	struct Target {
+		uint16 id, toggle, remapToggle;
+		uint rescuedAt;
+		Common::Rect rect;
+		Weapon hitWeapon;
+		bool active, hit;
+	};
 
 	MortimerFlight();
 	~MortimerFlight();
@@ -77,6 +84,7 @@ public:
 	Weapon weapon() const { return _weapon; }
 	const Common::String &nextFlight() const { return _nextFlight; }
 	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
+	const Common::Array<Target> &targets() const { return _targets; }
 	const Common::String &errorMessage() const { return _error; }
 
 private:
@@ -87,12 +95,6 @@ private:
 		uint32 offset, size;
 		uint16 skipA, skipB;
 	};
-	struct Target {
-		uint16 id, toggle, remapToggle;
-		uint rescuedAt;
-		Common::Rect rect;
-		bool active, hit;
-	};
 	struct Obstacle {
 		Common::Point corners[4];
 	};
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 3a6dd97fe9e..dbfe3d2d1e6 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -22,6 +22,7 @@
 
 #include "audio/audiostream.h"
 #include "audio/decoders/raw.h"
+#include "backends/keymapper/action.h"
 #include "common/events.h"
 #include "common/file.h"
 #include "common/system.h"
@@ -42,9 +43,68 @@ static const char *const kDemoFlights[] = {
 	"RESOURCE/DEMO/DEMO_BT.SAN"
 };
 
+enum MortimerAction {
+	kActionFire, kActionPause, kActionSkip, kActionRestart, kActionQuit,
+	kActionMenu, kActionMouse, kActionJoystick
+};
+
+Common::KeymapArray MortimerEngine::initKeymaps() {
+	Common::Keymap *keymap = new Common::Keymap(Common::Keymap::kKeymapTypeGame, "mortimer", "Mortimer");
+	keymap->setPartialMatchAllowed(false);
+	struct Binding {
+		const char *id, *label;
+		MortimerAction action;
+		const char *inputs[4];
+	};
+	const Binding bindings[] = {
+		{"FIRE", _s("Fire"), kActionFire, {"MOUSE_LEFT", "MOUSE_RIGHT", "JOY_A", "JOY_B"}},
+		{"PAUSE", _s("Pause"), kActionPause, {"SPACE", "JOY_START", nullptr, nullptr}},
+		{"SKIP", _s("Skip"), kActionSkip, {"ESCAPE", "JOY_Y", nullptr, nullptr}},
+		{"RESTART", _s("Restart"), kActionRestart, {"r", "JOY_X", nullptr, nullptr}},
+		{"QUIT", _s("Quit"), kActionQuit, {"q", "A+q", nullptr, nullptr}},
+		{"MENU", _s("Game menu"), kActionMenu, {"F5", "JOY_LEFT_SHOULDER", nullptr, nullptr}},
+		{"MOUSE", _s("Mouse control"), kActionMouse, {"A+m", nullptr, nullptr, nullptr}},
+		{"JOYSTICK", _s("Joystick control"), kActionJoystick, {"A+j", nullptr, nullptr, nullptr}}
+	};
+	for (uint i = 0; i < ARRAYSIZE(bindings); ++i) {
+		Common::Action *action = new Common::Action(bindings[i].id, _(bindings[i].label));
+		action->setCustomEngineActionEvent(bindings[i].action);
+		for (uint j = 0; j < ARRAYSIZE(bindings[i].inputs); ++j)
+			if (bindings[i].inputs[j])
+				action->addDefaultInputMapping(bindings[i].inputs[j]);
+		if (bindings[i].action == kActionFire)
+			action->addDefaultInputMapping("RETURN");
+		keymap->addAction(action);
+	}
+	const char *const directions[] = {"LEFT", "RIGHT", "UP", "DOWN"};
+	const char *const labels[] = {_s("Left"), _s("Right"), _s("Up"), _s("Down")};
+	const char *const axes[] = {"JOY_LEFT_STICK_X-", "JOY_LEFT_STICK_X+", "JOY_LEFT_STICK_Y-", "JOY_LEFT_STICK_Y+"};
+	const char *const keypad[] = {"KP4", "KP6", "KP8", "KP2"};
+	for (uint i = 0; i < ARRAYSIZE(directions); ++i) {
+		Common::Action *action = new Common::Action(directions[i], _(labels[i]));
+		Common::Event event;
+		event.type = Common::EVENT_JOYAXIS_MOTION;
+		event.joystick.axis = i;
+		event.joystick.position = 0;
+		action->setEvent(event);
+		action->addDefaultInputMapping(directions[i]);
+		action->addDefaultInputMapping(keypad[i]);
+		action->addDefaultInputMapping(Common::String("JOY_") + directions[i]);
+		action->addDefaultInputMapping(axes[i]);
+		keymap->addAction(action);
+	}
+	Common::KeymapArray keymaps;
+	keymaps.push_back(keymap);
+	return keymaps;
+}
+
 MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
-	_mouse(320, 180), _view(32, 24), _nextFrameTime(0), _pauseTime(0),
-	_segment(0), _completedRescues(0), _completedSpecies(0), _shellShocks(0), _finished(false) {
+	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
+	_nextFrameTime(0), _pauseTime(0), _segment(0), _completedRescues(0), _completedSpecies(0),
+	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
+	_pendingGlowStones(0), _finished(false), _controllerMode(false) {
+	memset(_steering, 0, sizeof(_steering));
+	memset(_iconFlash, 0, sizeof(_iconFlash));
 }
 
 MortimerEngine::~MortimerEngine() {
@@ -65,10 +125,17 @@ bool MortimerEngine::loadResources() {
 		uint minFrames;
 	};
 	const SpriteBank banks[] = {
-		{"RESOURCE/GENERAL/COCKPT_H.NUT", &_cockpit, 3},
+		{"RESOURCE/GENERAL/COCKPT_H.NUT", &_cockpit, 14},
 		{"RESOURCE/GENERAL/DASH_H.NUT", &_dashboard, 36},
 		{"RESOURCE/GENERAL/RETICLEH.NUT", &_reticles, 2},
-		{"RESOURCE/SAVANNA/S_ICON_H.NUT", &_icons, 14}
+		{"RESOURCE/SAVANNA/S_ICON_H.NUT", &_icons, 14},
+		{"RESOURCE/GENERAL/MHEAD_H.NUT", &_head, 4},
+		{"RESOURCE/GENERAL/REVITE.NUT", &_scopeHit, 11},
+		{"RESOURCE/GENERAL/SLIME_H.NUT", &_slimeHit, 7},
+		{"RESOURCE/GENERAL/LINE_BLU.NUT", &_scopeBeam, 1},
+		{"RESOURCE/GENERAL/SLMBALL.NUT", &_slimeBall, 1},
+		{"RESOURCE/GENERAL/NUMBER_H.NUT", &_numbers, 11},
+		{"RESOURCE/GENERAL/CORNER_H.NUT", &_corners, 2}
 	};
 	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
 		if (!file.open(Common::Path(banks[i].name)) || !loadMortimerSprites(file, *banks[i].sprites) || banks[i].sprites->size() < banks[i].minFrames) {
@@ -86,7 +153,9 @@ bool MortimerEngine::loadResources() {
 		{"RESOURCE/GENERAL/SFX/SCOPHIT3.SAD", &_rescueSound},
 		{"RESOURCE/GENERAL/SFX/SHELHIT1.SAD", &_collisionSound},
 		{"RESOURCE/GENERAL/SFX/SLMGUN2.SAD", &_slimeSound},
-		{"RESOURCE/GENERAL/SFX/MUCKHIT1.SAD", &_slimeHitSound}
+		{"RESOURCE/GENERAL/SFX/MUCKHIT1.SAD", &_slimeHitSound},
+		{"RESOURCE/GENERAL/SFX/GLOWIN1.SAD", &_glowSound},
+		{"RESOURCE/GENERAL/SFX/BURST1.SAD", &_balloonSound}
 	};
 	for (uint i = 0; i < ARRAYSIZE(effects); ++i) {
 		if (!file.open(Common::Path(effects[i].name)) || !loadMortimerSound(file, *effects[i].sound)) {
@@ -137,6 +206,7 @@ void MortimerEngine::stopSounds() {
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
 	_mixer->stopHandle(_collisionHandle);
+	_mixer->stopHandle(_glowHandle);
 }
 
 bool MortimerEngine::startFlight() {
@@ -147,7 +217,16 @@ bool MortimerEngine::startFlight() {
 		return false;
 	}
 	_finished = false;
-	setMouse(Common::Point(320, 180));
+	_uiFrame = 0;
+	_shotFrame = 3;
+	_glowFrame = 10;
+	_glowStones = MIN(_completedRescues, 999U);
+	_pendingGlowStones = 0;
+	memset(_iconFlash, 0, sizeof(_iconFlash));
+	for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
+		_steeringHistory[i] = Common::Point();
+	setMouse(Common::Point(320, 240));
+	updateSteering();
 	_system->warpMouse(_mouse.x, _mouse.y);
 	_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
 	_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
@@ -185,10 +264,90 @@ bool MortimerEngine::nextFlight(bool skip) {
 void MortimerEngine::setMouse(const Common::Point &position) {
 	_mouse.x = CLIP<int>(position.x, 0, 639);
 	_mouse.y = CLIP<int>(position.y, 0, 479);
-	// A bounded view into the overscanned movie supplies steering. Exact
-	// acceleration, collision damage and scope behaviour remain POC rules.
-	_view.x = CLIP<int>(32 + (_mouse.x - 320) / 8, 0, MortimerFlight::kWidth - 320);
-	_view.y = CLIP<int>(24 + (_mouse.y - 180) / 4, 0, MortimerFlight::kHeight - 240);
+}
+
+void MortimerEngine::updateSteering() {
+	int x = (_mouse.x - 320) / 2, y = 240 - _mouse.y;
+	if (_controllerMode) {
+		x = (_steering[1] - _steering[0]) * 128 / Common::JOYAXIS_MAX;
+		y = (_steering[2] - _steering[3]) * 128 / Common::JOYAXIS_MAX;
+	}
+	// SHELL 40db90 averages ten input samples before moving the viewport.
+	const int weights[] = {1, 1, 3, 3, 8, 8, 14, 16, 16, 14};
+	int averageX = 0, averageY = 0;
+	for (uint i = 0; i < ARRAYSIZE(weights); ++i) {
+		_steeringHistory[i] = i + 1 < ARRAYSIZE(weights) ? _steeringHistory[i + 1] : Common::Point(x, y);
+		averageX += _steeringHistory[i].x * weights[i];
+		averageY += _steeringHistory[i].y * weights[i];
+	}
+	averageX /= 84;
+	averageY /= 84;
+	// The movie is doubled, and its translation is aligned to four pixels.
+	_view.x = ((88 + CLIP(averageX * 88 / 128, -88, 88) + 3) & ~3) / 2;
+	_view.y = ((118 - CLIP(averageY * 118 / 128, -118, 118) + 3) & ~3) / 2;
+	if (ABS(x) < 3)
+		x = 0;
+	if (ABS(y) < 3)
+		y = 0;
+	_aim.x = 320 + CLIP(x * 296 / 109, -296, 295);
+	_aim.y = 190 - CLIP(y * 170 / 128, -169, 170);
+}
+
+Common::Point MortimerEngine::moviePoint(const Common::Point &point) const {
+	return Common::Point(_view.x + (point.x - _viewport.left) / 2, _view.y + (point.y - _viewport.top) / 2);
+}
+
+Common::Point MortimerEngine::screenPoint(const Common::Point &point) const {
+	return Common::Point(_viewport.left + (point.x - _view.x) * 2, _viewport.top + (point.y - _view.y) * 2);
+}
+
+void MortimerEngine::fire() {
+	if (_shotFrame >= 3) {
+		_shot = _aim;
+		_shotWeapon = _flight.weapon();
+		_shotFrame = 0;
+		playSound(_shotWeapon == MortimerFlight::kSlimeGun ? _slimeSound : _scopeSound,
+			_scopeHandle, Audio::Mixer::kSFXSoundType);
+	}
+	const MortimerFlight::ShotResult result = _flight.shoot(moviePoint(_aim));
+	if (result == MortimerFlight::kAnimalRescued) {
+		playSound(_rescueSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+		++_pendingGlowStones;
+		if (_glowFrame >= 10) {
+			_glowFrame = 0;
+			playSound(_glowSound, _glowHandle, Audio::Mixer::kSFXSoundType);
+		}
+		for (uint i = 0; i < _flight.targets().size(); ++i) {
+			const MortimerFlight::Target &target = _flight.targets()[i];
+			if (target.hit && target.rescuedAt == _flight.frame() && target.id / 100 < 7)
+				_iconFlash[target.id / 100] = 6;
+		}
+	} else if (result == MortimerFlight::kHornetHit) {
+		playSound(_slimeHitSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+	} else if (result == MortimerFlight::kShellRestored) {
+		_shellShocks = 0;
+		playSound(_balloonSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
+	}
+}
+
+void MortimerEngine::updateInterface() {
+	++_uiFrame;
+	if (_shotFrame < 3)
+		++_shotFrame;
+	for (uint i = 0; i < ARRAYSIZE(_iconFlash); ++i)
+		if (_iconFlash[i])
+			--_iconFlash[i];
+	if (_glowFrame < 10 && ++_glowFrame == 10) {
+		_glowStones = MIN(_glowStones + _pendingGlowStones, 999U);
+		_pendingGlowStones = 0;
+	}
+}
+
+bool MortimerEngine::speechPlaying() const {
+	for (uint i = 0; i < _flightHandles.size(); ++i)
+		if (!_flight.sounds()[i].music && _mixer->isSoundHandleActive(_flightHandles[i]))
+			return true;
+	return false;
 }
 
 void MortimerEngine::pauseEngineIntern(bool pause) {
@@ -212,46 +371,50 @@ Common::Error MortimerEngine::run() {
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
 			if (event.type == Common::EVENT_MOUSEMOVE) {
-				if (!isPaused())
+				if (!isPaused()) {
 					setMouse(event.mouse);
-			} else if (event.type == Common::EVENT_LBUTTONDOWN && !isPaused() && !_finished) {
-				setMouse(event.mouse);
-				if (_mouse.x >= 54 && _mouse.x < 567 && _mouse.y >= 37 && _mouse.y < 320) {
-					playSound(_flight.weapon() == MortimerFlight::kSlimeGun ? _slimeSound : _scopeSound,
-						_scopeHandle, Audio::Mixer::kSFXSoundType);
-					const MortimerFlight::ShotResult result = _flight.shoot(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2));
-					if (result == MortimerFlight::kAnimalRescued || result == MortimerFlight::kShellRestored)
-						playSound(_rescueSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
-					else if (result == MortimerFlight::kHornetHit)
-						playSound(_slimeHitSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
-					if (result == MortimerFlight::kShellRestored)
-						_shellShocks = 0;
+					_controllerMode = false;
 				}
-			} else if (event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat) {
-				switch (event.kbd.keycode) {
-				case Common::KEYCODE_ESCAPE:
+			} else if (event.type == Common::EVENT_JOYAXIS_MOTION && event.joystick.axis < ARRAYSIZE(_steering)) {
+				_steering[event.joystick.axis] = CLIP<int>(event.joystick.position, 0, Common::JOYAXIS_MAX);
+				if (event.joystick.position && !isPaused())
+					_controllerMode = true;
+			} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
+				switch (event.customType) {
+				case kActionFire:
+					if (!isPaused() && !_finished)
+						fire();
+					break;
+				case kActionSkip:
 					if (pause.isActive())
 						pause.clear();
 					else
 						exit = !nextFlight(true);
 					break;
-				case Common::KEYCODE_q:
+				case kActionQuit:
 					exit = true;
 					break;
-				case Common::KEYCODE_SPACE:
+				case kActionPause:
 					if (pause.isActive())
 						pause.clear();
 					else
 						pause = pauseEngine();
 					break;
-				case Common::KEYCODE_r:
+				case kActionRestart:
 					if (pause.isActive())
 						pause.clear();
 					exit = !restart();
 					break;
-				case Common::KEYCODE_F5:
+				case kActionMenu:
 					openMainMenuDialog();
 					break;
+				case kActionMouse:
+					_controllerMode = false;
+					setMouse(_eventMan->getMousePos());
+					break;
+				case kActionJoystick:
+					_controllerMode = true;
+					break;
 				default:
 					break;
 				}
@@ -261,6 +424,8 @@ Common::Error MortimerEngine::run() {
 			break;
 		if (!isPaused() && !_finished && int32(_system->getMillis() - _nextFrameTime) >= 0) {
 			_nextFrameTime += 1000 / _flight.frameRate();
+			updateSteering();
+			updateInterface();
 			if (!_flight.advance()) {
 				if (!_flight.errorMessage().empty()) {
 					_loadError = _flight.errorMessage();
@@ -271,7 +436,7 @@ Common::Error MortimerEngine::run() {
 			} else {
 				playFlightSounds();
 				_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
-				if (_flight.hitsObstacle(Common::Point(_view.x + _mouse.x / 2, _view.y + _mouse.y / 2))) {
+				if (_flight.hitsObstacle(moviePoint(_aim))) {
 					++_shellShocks;
 					playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
 					// SHELL 40be36 resets the flight after eighteen shell shocks.
@@ -296,9 +461,19 @@ Common::Error MortimerEngine::run() {
 	return _loadError.empty() ? Common::kNoError : Common::Error(Common::kReadingFailed, _loadError);
 }
 
-void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y) {
+void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y, bool centered, const Common::Rect *bounds) {
+	// NUT offsets are relative to the animation's origin, including when its
+	// frames have different bounding boxes (SHELL 402c83/4141bc).
+	if (centered) {
+		x -= (sprite.x + sprite.width) / 2;
+		y -= (sprite.y + sprite.height) / 2;
+	}
+	x += sprite.x;
+	y += sprite.y;
 	Common::Rect clip(x, y, x + sprite.width, y + sprite.height);
 	clip.clip(Common::Rect(_screen.w, _screen.h));
+	if (bounds)
+		clip.clip(*bounds);
 	if (clip.isEmpty())
 		return;
 	for (int row = clip.top; row < clip.bottom; ++row) {
@@ -310,36 +485,109 @@ void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y) {
 	}
 }
 
-void MortimerEngine::draw() {
-	for (int y = 0; y < 480; ++y) {
-		const byte *src = _flight.pixels() + (_view.y + y / 2) * MortimerFlight::kWidth + _view.x;
-		byte *dst = (byte *)_screen.getBasePtr(0, y);
-		for (int x = 0; x < 640; ++x)
-			dst[x] = src[x / 2];
+void MortimerEngine::drawTargets() {
+	for (uint i = 0; i < _flight.targets().size(); ++i) {
+		const MortimerFlight::Target &target = _flight.targets()[i];
+		if (!target.active || !target.id || target.id >= 800)
+			continue;
+		if (target.hit) {
+			const Common::Array<MortimerSprite> &effect = target.hitWeapon == MortimerFlight::kSlimeGun ? _slimeHit : _scopeHit;
+			const uint frame = _flight.frame() - target.rescuedAt;
+			if (frame < effect.size()) {
+				const Common::Point center = screenPoint(Common::Point((target.rect.left + target.rect.right) / 2,
+					(target.rect.top + target.rect.bottom) / 2));
+				drawSprite(effect[frame], center.x, center.y, true, &_viewport);
+			}
+		} else if ((target.id < 700 && _flight.weapon() == MortimerFlight::kRevitascope) ||
+			(target.id >= 700 && _flight.weapon() == MortimerFlight::kSlimeGun)) {
+			const Common::Point top = screenPoint(Common::Point(target.rect.left, target.rect.top));
+			const Common::Point bottom = screenPoint(Common::Point(target.rect.right, target.rect.bottom));
+			drawSprite(_corners[0], top.x, top.y, true, &_viewport);
+			drawSprite(_corners[1], bottom.x, bottom.y, true, &_viewport);
+		}
 	}
-	drawSprite(_cockpit[0], 0, _cockpit[0].y);
-	drawSprite(_cockpit[1], 640 - _cockpit[1].width, _cockpit[1].y);
-	drawSprite(_cockpit[2], 0, 0);
-	drawSprite(_dashboard[35], 0, 320);
-	const int iconCenterY[] = {384, 379, 377, 376, 378, 380, 384};
-	for (uint species = 0; species < 7; ++species) {
-		if ((_completedSpecies | _flight.rescuedSpecies()) & (1 << species)) {
-			const MortimerSprite &icon = _icons[species * 2];
-			drawSprite(icon, 176 + species * 45 - icon.width / 2, iconCenterY[species] - icon.height / 2);
+}
+
+void MortimerEngine::drawProjectile() {
+	if (_shotFrame >= 3)
+		return;
+	const bool slime = _shotWeapon == MortimerFlight::kSlimeGun;
+	const MortimerSprite &texture = slime ? _slimeBall[0] : _scopeBeam[0];
+	const Common::Point origin(slime ? 599 : 40, 400);
+	// SHELL 403b60 projects a textured streak in three steps from the weapon
+	// towards the sight. Its far endpoint is 7/6 of the distance to the aim.
+	const int dx = (_shot.x - origin.x) * 7 / 6, dy = (_shot.y - origin.y) * 7 / 6;
+	const int distance = 32 * _shotFrame + 16;
+	const Common::Point tail(origin.x + dx - dx * 16 / distance, origin.y + dy - dy * 16 / distance);
+	const Common::Point tip(origin.x + dx - dx * 16 / (distance + 25), origin.y + dy - dy * 16 / (distance + 25));
+	const int lengthX = ABS(tip.x - tail.x), lengthY = ABS(tip.y - tail.y);
+	const bool horizontal = lengthX > lengthY;
+	const int length = MAX(lengthX, lengthY);
+	const int thickness = MIN(horizontal ? 8 : texture.height, length * texture.height / texture.width + 2);
+	for (int row = 0; row < thickness; ++row) {
+		const byte *src = texture.pixels.begin() + (row * texture.height / thickness) * texture.width;
+		for (int step = 0; step <= length; ++step) {
+			const int x = tail.x + (tip.x - tail.x) * step / MAX(length, 1) + (horizontal ? 0 : row - thickness / 2);
+			const int y = tail.y + (tip.y - tail.y) * step / MAX(length, 1) + (horizontal ? row - thickness / 2 : 0);
+			const byte color = src[step * texture.width / (length + 1)];
+			if (color && x >= 0 && x < _screen.w && y >= 0 && y < _screen.h)
+				*(byte *)_screen.getBasePtr(x, y) = color;
 		}
 	}
-	if (_mouse.y < 320) {
-		const MortimerSprite &reticle = _reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1];
-		drawSprite(reticle, _mouse.x - reticle.width / 2, _mouse.y - reticle.height / 2);
+}
+
+void MortimerEngine::drawDashboard() {
+	// Origins and frame ranges are from SHELL's animation/position tables
+	// at 441240, 442d20 and 444958. drawSprite adds each frame's own offset.
+	drawSprite(_dashboard[35], 0, 0);
+	drawSprite(_dashboard[29 + _uiFrame % 6], 0, 439);
+	drawSprite(_dashboard[speechPlaying() ? 16 + _uiFrame % 3 : 16], 21, 312);
+	const uint gauge = _shellShocks < 16 ? 19 + _shellShocks / 2 : 27 + _uiFrame % 2;
+	drawSprite(_dashboard[gauge], 180, 442);
+	const Common::Point iconOrigins[] = {{158, 364}, {203, 358}, {248, 358}, {293, 354}, {339, 355}, {383, 360}, {428, 361}};
+	const Common::Point flashOrigins[] = {{154, 345}, {198, 338}, {243, 334}, {288, 332}, {333, 334}, {378, 338}, {422, 345}};
+	for (uint species = 0; species < 7; ++species) {
+		if (_iconFlash[species] & 1)
+			drawSprite(_dashboard[species], flashOrigins[species].x, flashOrigins[species].y);
+		const bool rescued = (_completedSpecies | _flight.rescuedSpecies()) & (1 << species);
+		drawSprite(_icons[species * 2 + (rescued ? 0 : 1)], iconOrigins[species].x, iconOrigins[species].y);
 	}
+	const uint thresholds[] = {1, 2, 3, 4, 7, 20, 30, 40, 50};
+	for (int i = ARRAYSIZE(thresholds) - 1; i >= 0; --i) {
+		if (_glowStones >= thresholds[i]) {
+			drawSprite(_dashboard[7 + i], 468, 368);
+			break;
+		}
+	}
+	drawSprite(_numbers[_glowStones < 100 ? 10 : _glowStones / 100], 504, 360);
+	drawSprite(_numbers[_glowStones < 10 ? 10 : (_glowStones / 10) % 10], 517, 361);
+	drawSprite(_numbers[_glowStones % 10], 529, 363);
+	if (_glowFrame < 10)
+		drawSprite(_cockpit[4 + _glowFrame], 392, 272);
+	if (_flight.weapon() == MortimerFlight::kSlimeGun)
+		drawSprite(_cockpit[3], 306, 465, true);
+}
 
-	const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
-	_screen.fillRect(Common::Rect(0, 449, 640, 465), 10);
-	font->drawString(&_screen, Common::String::format("Animals rescued: %u   Shell shocks: %u / 18   Flight: %u / 3",
-		_completedRescues + _flight.rescuedCount(), _shellShocks, _segment + 1), 8, 451, 624, 20);
+void MortimerEngine::draw() {
+	_screen.fillRect(Common::Rect(640, 480), 10);
+	for (int y = _viewport.top; y < _viewport.bottom; ++y) {
+		const byte *src = _flight.pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerFlight::kWidth + _view.x;
+		byte *dst = (byte *)_screen.getBasePtr(_viewport.left, y);
+		for (int x = 0; x < _viewport.width(); ++x)
+			dst[x] = src[x / 2];
+	}
+	drawTargets();
+	drawSprite(_head[_uiFrame % 4], 270, 200);
+	drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
+	drawSprite(_cockpit[0], 0, 0);
+	drawSprite(_cockpit[1], 567, 0);
+	drawSprite(_cockpit[2], 0, 0);
+	drawDashboard();
+	drawProjectile();
 	if (isPaused()) {
+		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
-		font->drawString(&_screen, "Paused - Space to continue", 90, 155, 460, 20, Graphics::kTextAlignCenter);
+		font->drawString(&_screen, _("Paused"), 90, 155, 460, 20, Graphics::kTextAlignCenter);
 	}
 	_system->copyRectToScreen(_screen.getPixels(), _screen.pitch, 0, 0, 640, 480);
 	_system->updateScreen();
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 62cd5144535..336b002243e 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -22,6 +22,7 @@
 #define SCUMM_INSANE_MORTIMER_MORTIMER_H
 
 #include "engines/engine.h"
+#include "backends/keymapper/keymap.h"
 #include "audio/mixer.h"
 #include "graphics/surface.h"
 #include "scumm/insane/mortimer/flight.h"
@@ -36,6 +37,7 @@ public:
 	~MortimerEngine() override;
 	Common::Error run() override;
 	bool hasFeature(EngineFeature feature) const override;
+	static Common::KeymapArray initKeymaps();
 
 protected:
 	void pauseEngineIntern(bool pause) override;
@@ -47,23 +49,39 @@ private:
 	bool nextFlight(bool skip = false);
 	bool restart(bool all = false);
 	void setMouse(const Common::Point &position);
+	void updateSteering();
+	void updateInterface();
+	void fire();
+	Common::Point moviePoint(const Common::Point &point) const;
+	Common::Point screenPoint(const Common::Point &point) const;
 	void draw();
-	void drawSprite(const MortimerSprite &sprite, int x, int y);
+	void drawSprite(const MortimerSprite &sprite, int x, int y, bool centered = false, const Common::Rect *clip = nullptr);
+	void drawProjectile();
+	void drawTargets();
+	void drawDashboard();
+	bool speechPlaying() const;
 	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type);
 	void playFlightSounds();
 	void stopSounds();
 
 	MortimerFlight _flight;
 	Graphics::Surface _screen;
-	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons;
-	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound;
+	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons, _head;
+	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
+	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound, _glowSound, _balloonSound;
 	Common::Array<Audio::SoundHandle> _flightHandles;
-	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle;
+	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle, _glowHandle;
 	Common::String _loadError;
-	Common::Point _mouse, _view;
+	Common::Rect _viewport;
+	Common::Point _mouse, _view, _aim, _shot;
+	Common::Point _steeringHistory[10];
+	int _steering[4];
+	MortimerFlight::Weapon _shotWeapon;
 	uint32 _nextFrameTime, _pauseTime;
 	uint _segment, _completedRescues, _completedSpecies, _shellShocks;
-	bool _finished;
+	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
+	uint _iconFlash[7];
+	bool _finished, _controllerMode;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index 5f474054257..fb7f1e2b835 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -1134,6 +1134,14 @@ Common::KeymapArray ScummMetaEngine::initKeymaps(const char *target) const {
 	Common::String gameId = ConfMan.get("gameid", target);
 	Action *act;
 
+#ifdef ENABLE_SCUMM_7_8
+	if (gameId == "mortimer") {
+		for (uint i = 0; i < keymaps.size(); ++i)
+			delete keymaps[i];
+		return MortimerEngine::initKeymaps();
+	}
+#endif
+
 	if (gameId == "rebel1" || gameId == "rebel2") {
 		for (uint i = 0; i < keymaps.size(); ++i) {
 			if (keymaps[i]->getId() == "engine-default") {


Commit: af9e3bf7ee9afb611446f6102cfcdc70f67de93d
    https://github.com/scummvm/scummvm/commit/af9e3bf7ee9afb611446f6102cfcdc70f67de93d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: implemented intro video decoding and playback

Changed paths:
  A engines/scumm/insane/mortimer/movie.cpp
  A engines/scumm/insane/mortimer/movie.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index bf4b728ad80..63971983352 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -19,369 +19,45 @@
  */
 
 #include "scumm/insane/mortimer/flight.h"
-#include "scumm/smush/codec37.h"
-#include "scumm/smush/rebel/codec_ra2.h"
 
 #include "common/endian.h"
-#include "common/stream.h"
 #include "common/util.h"
 
 namespace Scumm {
 
-namespace {
-
-struct Chunk {
-	uint32 tag, offset, size;
-};
-
-bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk, bool padded = true) {
-	if (pos > end || end - pos < 8)
-		return false;
-	chunk.tag = READ_BE_UINT32(data + pos);
-	chunk.size = READ_BE_UINT32(data + pos + 4);
-	chunk.offset = pos + 8;
-	if (chunk.size > end - chunk.offset)
-		return false;
-	pos = chunk.offset + chunk.size;
-	if (padded && (chunk.size & 1)) {
-		if (pos == end)
-			return false;
-		++pos;
-	}
-	return true;
-}
-
-bool readData(Common::SeekableReadStream &stream, Common::Array<byte> &data) {
-	const int64 size = stream.size();
-	if (size < 8 || size > 64 * 1024 * 1024 || !stream.seek(0))
-		return false;
-	data.resize(size);
-	return stream.read(data.begin(), data.size()) == data.size();
-}
-
-bool isContainer(const Common::Array<byte> &data, uint32 tag) {
-	return data.size() >= 8 && READ_BE_UINT32(data.begin()) == tag &&
-		READ_BE_UINT32(data.begin() + 4) == data.size() - 8;
-}
-
-bool validRows(const byte *src, uint32 size, int height, bool remap) {
-	uint32 pos = 0;
-	// Remap masks can include additional rows below the FOBJ clipping height.
-	for (int y = 0; y < height || (remap && pos + 1 < size); ++y) {
-		if (size - pos < 2)
-			return false;
-		const uint len = READ_LE_UINT16(src + pos);
-		pos += 2;
-		if (len > size - pos)
-			return false;
-		const uint32 end = pos + len;
-		if (remap) {
-			// Skip/run pairs; a final skip without a run is also permitted.
-			pos = end;
-		} else {
-			while (pos < end) {
-				const byte code = src[pos++];
-				const uint count = (code & 1) ? 1 : (code >> 1) + 1;
-				if (count > end - pos)
-					return false;
-				pos += count;
-			}
-		}
-	}
-	// FOBJ includes the encoder's final byte of alignment in its payload size.
-	return pos == size || (pos + 1 == size && src[pos] == 0);
-}
-
-// Codec37's existing decoder has no input-length argument. Validate the two
-// encodings present in the demo flights before passing any data to it.
-bool validBackground(const byte *src, uint32 size) {
-	if (size < 16 || src[1] != 1 || src[12] != 4)
-		return false;
-	uint32 pos = 16;
-	if (src[0] == 2) {
-		if (READ_LE_UINT32(src + 4) != MortimerFlight::kWidth * MortimerFlight::kHeight)
-			return false;
-		uint remaining = MortimerFlight::kWidth * MortimerFlight::kHeight;
-		while (remaining) {
-			if (pos == size)
-				return false;
-			const byte code = src[pos++];
-			const uint run = (code >> 1) + 1;
-			const uint count = (code & 1) ? 1 : run;
-			if (count > size - pos || run > remaining)
-				return false;
-			pos += count;
-			remaining -= run;
-		}
-	} else if (src[0] == 3) {
-		for (uint block = 0; block < MortimerFlight::kWidth * MortimerFlight::kHeight / 16; ++block) {
-			if (pos == size)
-				return false;
-			const byte code = src[pos++];
-			const uint count = code == 0xff ? 16 : code == 0xfe ? 4 : code == 0xfd ? 1 : 0;
-			if (count > size - pos)
-				return false;
-			pos += count;
-		}
-	} else {
-		return false;
-	}
-	return pos <= size;
-}
-
-bool validObject(const byte *data, uint32 size, bool sprite = false) {
-	if (size < 14)
-		return false;
-	const int width = READ_LE_INT16(data + 6), height = READ_LE_INT16(data + 8);
-	if (width <= 0 || height <= 0 || width > 640 || height > 480)
-		return false;
-	if (READ_LE_INT16(data + 2) + width > 32767 || READ_LE_INT16(data + 4) + height > 32767)
-		return false;
-	const byte codec = data[0];
-	if (sprite)
-		return codec == 1 && validRows(data + 14, size - 14, height, false);
-	if (codec == 37)
-		return width == MortimerFlight::kWidth && height == MortimerFlight::kHeight &&
-			READ_LE_UINT32(data + 2) == 0 && validBackground(data + 14, size - 14);
-	if (codec != 1 && codec != 3 && codec != 23)
-		return false;
-	uint32 offset = 14;
-	if (codec == 23) {
-		const uint16 mode = READ_LE_UINT16(data + 12);
-		if (mode != 0x100 && mode != 0x101)
-			return false;
-		if (mode == 0x100)
-			offset += 256;
-	}
-	return offset <= size && validRows(data + offset, size - offset, height, codec == 23);
-}
-
-bool validPalette(const byte *data, uint32 size) {
-	return (size == 2308 && READ_BE_UINT32(data) == 2) ||
-		(size == 6 && READ_BE_UINT32(data) == 1);
-}
-
-bool validTransition(const byte *data, uint32 size) {
-	// IACT 8/46 contains a length-prefixed filename followed by CR/LF/EOF.
-	if (size < 22 || READ_LE_UINT16(data + 14) != size - 18 ||
-		data[size - 3] != '\r' || data[size - 2] != '\n' || data[size - 1] != 0x1a)
-		return false;
-	for (uint i = 18; i < size - 3; ++i)
-		if (data[i] < 32 || data[i] > 126)
-			return false;
-	return true;
-}
-
-bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
-	if (!isContainer(data, MKTAG('S', 'A', 'U', 'D')))
-		return false;
-	sound.samples.clear();
-	uint32 pos = 8;
-	while (pos < data.size()) {
-		Chunk chunk;
-		// SAUD chunks are not word-aligned, unlike the surrounding SMUSH file.
-		if (!nextChunk(data.begin(), data.size(), pos, chunk, false))
-			return false;
-		const byte *src = data.begin() + chunk.offset;
-		if (chunk.tag == MKTAG('S', 'D', 'A', 'T')) {
-			if (!sound.samples.empty())
-				return false;
-			sound.samples.assign(src, src + chunk.size);
-		} else if (chunk.tag == MKTAG('S', 'H', 'D', 'R') && chunk.size == 4) {
-			sound.rate = READ_LE_UINT32(src);
-		} else if (chunk.tag == MKTAG('S', 'T', 'R', 'K') && chunk.size >= 14 && src[0] == 6 && src[1] == 12) {
-			sound.rate = READ_BE_UINT32(src + 10);
-		}
-	}
-	return !sound.samples.empty() && sound.rate >= 4000 && sound.rate <= 48000;
-}
-
-} // End of anonymous namespace
-
-bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites) {
-	Common::Array<byte> data;
-	sprites.clear();
-	if (!readData(stream, data) || !isContainer(data, MKTAG('A', 'N', 'I', 'M')))
-		return false;
-	uint32 pos = 8;
-	while (pos < data.size()) {
-		Chunk frame;
-		if (!nextChunk(data.begin(), data.size(), pos, frame))
-			return false;
-		if (frame.tag != MKTAG('F', 'R', 'M', 'E'))
-			continue;
-		uint32 subpos = frame.offset;
-		const uint before = sprites.size();
-		while (subpos < frame.offset + frame.size) {
-			Chunk chunk;
-			if (!nextChunk(data.begin(), frame.offset + frame.size, subpos, chunk))
-				return false;
-			if (chunk.tag != MKTAG('F', 'O', 'B', 'J'))
-				continue;
-			const byte *src = data.begin() + chunk.offset;
-			if (!validObject(src, chunk.size, true))
-				return false;
-			MortimerSprite sprite;
-			sprite.x = READ_LE_INT16(src + 2);
-			sprite.y = READ_LE_INT16(src + 4);
-			sprite.width = READ_LE_INT16(src + 6);
-			sprite.height = READ_LE_INT16(src + 8);
-			sprite.pixels.resize(sprite.width * sprite.height);
-			memset(sprite.pixels.begin(), 0, sprite.pixels.size());
-			smushDecodeRLEOpaque(sprite.pixels.begin(), src + 14, 0, 0, sprite.width, sprite.height, sprite.width, chunk.size - 14);
-			sprites.push_back(sprite);
-		}
-		if (sprites.size() != before + 1)
-			return false;
-	}
-	return !sprites.empty();
-}
-
-bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound) {
-	Common::Array<byte> data;
-	return readData(stream, data) && decodeSound(data, sound);
-}
-
-MortimerFlight::MortimerFlight() : _decoder(nullptr), _nextFrame(0), _frameRate(10), _rescuedCount(0), _rescuedSpecies(0) {
-	_background.resize(kWidth * kHeight);
-	_pixels.resize(kWidth * kHeight);
-	memset(_initialPalette, 0, sizeof(_initialPalette));
+MortimerFlight::MortimerFlight() : _initialInteractive(true), _interactive(true) {
 	reset();
 }
 
-MortimerFlight::~MortimerFlight() {
-	delete _decoder;
-}
-
-bool MortimerFlight::fail(const char *message) {
-	_error = message;
-	_frames.clear();
-	return false;
-}
-
-bool MortimerFlight::load(Common::SeekableReadStream &stream) {
-	_frames.clear();
-	_sounds.clear();
-	_error.clear();
-	reset();
-	if (!readData(stream, _data) || !isContainer(_data, MKTAG('A', 'N', 'I', 'M')))
-		return fail("Invalid Mortimer flight ANIM container");
-
-	struct Track {
-		uint16 id, nextBlock;
-		MortimerSound sound;
-		Common::Array<byte> bytes;
-	};
-	Common::Array<Track> tracks;
-	uint32 pos = 8;
-	uint expectedFrames = 0;
-	while (pos < _data.size()) {
-		Chunk chunk;
-		if (!nextChunk(_data.begin(), _data.size(), pos, chunk))
-			return fail("Truncated Mortimer flight chunk");
-		const byte *src = _data.begin() + chunk.offset;
-		if (chunk.tag == MKTAG('A', 'H', 'D', 'R')) {
-			if (expectedFrames || chunk.size < 778 || READ_LE_UINT16(src) != 2)
-				return fail("Unsupported Mortimer flight header");
-			expectedFrames = READ_LE_UINT16(src + 2);
-			_frameRate = READ_LE_UINT32(src + 774);
-			if (!expectedFrames || _frameRate != 10)
-				return fail("Unsupported Mortimer flight timing");
-			memcpy(_initialPalette, src + 6, sizeof(_initialPalette));
-			memcpy(_palette, _initialPalette, sizeof(_palette));
-		} else if (chunk.tag == MKTAG('F', 'R', 'M', 'E')) {
-			if (!expectedFrames)
-				return fail("Mortimer flight has no header");
-			uint32 subpos = chunk.offset;
-			uint backgrounds = 0;
-			while (subpos < chunk.offset + chunk.size) {
-				Chunk sub;
-				if (!nextChunk(_data.begin(), chunk.offset + chunk.size, subpos, sub))
-					return fail("Truncated Mortimer flight frame");
-				const byte *body = _data.begin() + sub.offset;
-				if (sub.tag == MKTAG('F', 'O', 'B', 'J')) {
-					if (!validObject(body, sub.size))
-						return fail("Unsupported or truncated Mortimer flight image");
-					if (body[0] == 37) {
-						++backgrounds;
-						if (_frames.empty() && body[14] != 2)
-							return fail("Mortimer flight does not start with a keyframe");
-					}
-				} else if (sub.tag == MKTAG('S', 'K', 'I', 'P')) {
-					if (sub.size != 4 || READ_LE_UINT16(body) >= 256 || READ_LE_UINT16(body + 2) >= 256)
-						return fail("Invalid Mortimer object toggle");
-				} else if (sub.tag == MKTAG('I', 'A', 'C', 'T')) {
-					if (sub.size < 8)
-						return fail("Truncated Mortimer flight action");
-					const uint16 code = READ_LE_UINT16(body);
-					if ((code >= 2 && code <= 4 && (sub.size < (code == 4 ? 18U : 12U) || READ_LE_UINT16(body + 8) >= 256)) ||
-						(code == 5 && sub.size < 28))
-						return fail("Invalid Mortimer flight action");
-					if (code == 2 && READ_LE_UINT16(body + 4) == 3 && READ_LE_UINT16(body + 10) >= 256)
-						return fail("Invalid Mortimer conditional object");
-					if (code == 8 && READ_LE_UINT16(body + 2) == 46 && !validTransition(body, sub.size))
-						return fail("Invalid Mortimer flight transition");
-				} else if (sub.tag == MKTAG('X', 'P', 'A', 'L')) {
-					if (!validPalette(body, sub.size))
-						return fail("Invalid Mortimer palette change");
-				} else if (sub.tag == MKTAG('P', 'S', 'A', 'D')) {
-					if (sub.size < 10)
-						return fail("Truncated Mortimer speech block");
-					const uint16 id = READ_LE_UINT16(body), block = READ_LE_UINT16(body + 2);
-					uint index = 0;
-					while (index < tracks.size() && tracks[index].id != id)
-						++index;
-					if (index == tracks.size()) {
-						Track track;
-						track.id = id;
-						track.nextBlock = 0;
-						track.sound.frame = _frames.size();
-						track.sound.music = (READ_LE_UINT16(body + 6) & 64) != 0;
-						track.sound.volume = MIN(255, body[8] * 2);
-						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
-						tracks.push_back(track);
-					}
-					if (tracks[index].nextBlock++ != block)
-						return fail("Missing Mortimer speech block");
-					Common::Array<byte> &bytes = tracks[index].bytes;
-					const uint oldSize = bytes.size();
-					bytes.resize(oldSize + sub.size - 10);
-					memcpy(bytes.begin() + oldSize, body + 10, sub.size - 10);
-				}
-			}
-			// The final Big Tree frame contains an opaque RLE image only.
-			if (backgrounds > 1 || (_frames.empty() && backgrounds != 1))
-				return fail("Invalid Mortimer flight background");
-			_frames.push_back({chunk.offset, chunk.size});
-		}
-	}
-	if (_frames.size() != expectedFrames || !expectedFrames)
-		return fail("Incomplete Mortimer flight");
-	for (uint i = 0; i < tracks.size(); ++i) {
-		if (!decodeSound(tracks[i].bytes, tracks[i].sound))
-			return fail("Unsupported or incomplete Mortimer speech");
-		_sounds.push_back(tracks[i].sound);
-	}
-	return true;
+bool MortimerFlight::load(Common::SeekableReadStream &stream, bool interactive) {
+	_initialInteractive = interactive;
+	return MortimerMovie::load(stream);
 }
 
 void MortimerFlight::reset() {
-	delete _decoder;
-	_decoder = new SmushDeltaBlocksDecoder(kWidth, kHeight);
-	_nextFrame = _rescuedCount = _rescuedSpecies = 0;
+	MortimerMovie::reset();
+	_rescuedCount = _rescuedSpecies = 0;
 	_nextFlight.clear();
 	_weapon = kRevitascope;
-	memcpy(_palette, _initialPalette, sizeof(_palette));
-	memset(_paletteDelta, 0, sizeof(_paletteDelta));
-	memset(_shiftedPalette, 0, sizeof(_shiftedPalette));
+	_interactive = _initialInteractive;
 	_targets.clear();
-	_objects.clear();
 	_obstacles.clear();
 	memset(_toggles, 0, sizeof(_toggles));
-	memset(_background.begin(), 0, _background.size());
-	memset(_pixels.begin(), 0, _pixels.size());
-	for (uint i = 0; i < 256; ++i)
-		_remap[i] = i;
+}
+
+bool MortimerFlight::advance() {
+	_obstacles.clear();
+	for (uint i = 0; i < _targets.size(); ++i)
+		_targets[i].active = false;
+	return MortimerMovie::advance();
+}
+
+int MortimerFlight::objectScale(byte codec) const {
+	return _interactive ? 1 : MortimerMovie::objectScale(codec);
+}
+
+bool MortimerFlight::skipObject(uint16 a, uint16 b) const {
+	return a != 0xffff && (b ? _toggles[a] != _toggles[b] : _toggles[a]);
 }
 
 MortimerFlight::Target *MortimerFlight::findTarget(uint16 id) {
@@ -392,6 +68,7 @@ MortimerFlight::Target *MortimerFlight::findTarget(uint16 id) {
 }
 
 void MortimerFlight::handleAction(const byte *data, uint32 size) {
+	MortimerMovie::handleAction(data, size);
 	const uint16 code = READ_LE_UINT16(data), flags = READ_LE_UINT16(data + 2);
 	const uint16 mode = READ_LE_UINT16(data + 4), id = READ_LE_UINT16(data + 6);
 	if (code >= 2 && code <= 4 && mode == 1) {
@@ -452,6 +129,8 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 	} else if (code == 6 && (flags == 9 || flags == 10)) {
 		// SHELL 407c5f calls 40b844/40b7ce at the scripted weapon changes.
 		_weapon = flags == 9 ? kSlimeGun : kRevitascope;
+	} else if (code == 6 && (flags == 11 || flags == 25)) {
+		_interactive = flags == 11;
 	} else if (code == 8 && flags == 46) {
 		_nextFlight = Common::String((const char *)data + 18, size - 21);
 		for (uint i = 0; i < _nextFlight.size(); ++i)
@@ -460,99 +139,6 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 	}
 }
 
-void MortimerFlight::handlePalette(const byte *data, uint32 size) {
-	if (size == 2308) {
-		for (uint i = 0; i < 768; ++i) {
-			_shiftedPalette[i] = _palette[i] << 7;
-			_paletteDelta[i] = READ_LE_INT16(data + 4 + i * 2);
-		}
-		memcpy(_palette, data + 1540, sizeof(_palette));
-	} else {
-		for (uint i = 0; i < 768; ++i) {
-			_shiftedPalette[i] += _paletteDelta[i];
-			_palette[i] = CLIP<int32>(_shiftedPalette[i] >> 7, 0, 255);
-		}
-	}
-}
-
-bool MortimerFlight::advance() {
-	if (_nextFrame >= _frames.size())
-		return false;
-	_objects.clear();
-	_obstacles.clear();
-	for (uint i = 0; i < _targets.size(); ++i)
-		_targets[i].active = false;
-	const Frame &frame = _frames[_nextFrame++];
-	uint32 pos = frame.offset;
-	uint16 skipA = 0xffff, skipB = 0;
-	while (pos < frame.offset + frame.size) {
-		Chunk chunk;
-		if (!nextChunk(_data.begin(), frame.offset + frame.size, pos, chunk))
-			return fail("Truncated Mortimer flight frame");
-		const byte *src = _data.begin() + chunk.offset;
-		if (chunk.tag == MKTAG('S', 'K', 'I', 'P')) {
-			skipA = READ_LE_UINT16(src);
-			skipB = READ_LE_UINT16(src + 2);
-			continue;
-		}
-		if (chunk.tag == MKTAG('I', 'A', 'C', 'T')) {
-			handleAction(src, chunk.size);
-		} else if (chunk.tag == MKTAG('X', 'P', 'A', 'L')) {
-			handlePalette(src, chunk.size);
-		} else if (chunk.tag == MKTAG('F', 'O', 'B', 'J')) {
-			if (src[0] == 37) {
-				_decoder->decode(_background.begin(), src + 14);
-			} else {
-				if (src[0] == 23 && READ_LE_UINT16(src + 12) == 0x100)
-					memcpy(_remap, src + 14, sizeof(_remap));
-				_objects.push_back({chunk.offset, chunk.size, skipA, skipB});
-			}
-		}
-		skipA = 0xffff;
-		skipB = 0;
-	}
-	render();
-	return true;
-}
-
-void MortimerFlight::render() {
-	memcpy(_pixels.begin(), _background.begin(), _pixels.size());
-	for (uint i = 0; i < _objects.size(); ++i) {
-		const Object &object = _objects[i];
-		if (object.skipA != 0xffff && (object.skipB ? _toggles[object.skipA] != _toggles[object.skipB] : _toggles[object.skipA]))
-			continue;
-		const byte *src = _data.begin() + object.offset;
-		const int x = READ_LE_INT16(src + 2), y = READ_LE_INT16(src + 4);
-		const int width = READ_LE_INT16(src + 6), height = READ_LE_INT16(src + 8);
-		Common::Rect clip(x, y, x + width, y + height);
-		clip.clip(Common::Rect(kWidth, kHeight));
-		if (clip.isEmpty())
-			continue;
-		// Decode into an object-sized buffer so offscreen sprites cannot write
-		// beyond the flight surface. Remap objects must start with its pixels.
-		_scratch.resize(width * height);
-		memset(_scratch.begin(), 0, _scratch.size());
-		if (src[0] == 23) {
-			for (int row = clip.top; row < clip.bottom; ++row)
-				memcpy(_scratch.begin() + (row - y) * width + clip.left - x,
-					_pixels.begin() + row * kWidth + clip.left, clip.width());
-			const uint offset = READ_LE_UINT16(src + 12) == 0x100 ? 270 : 14;
-			smushDecodeRA2SkipRemap(_scratch.begin(), src + offset, 0, 0, width, height, width, object.size - offset, _remap, 0);
-		} else {
-			smushDecodeRLEOpaque(_scratch.begin(), src + 14, 0, 0, width, height, width, object.size - 14);
-		}
-		for (int row = clip.top; row < clip.bottom; ++row) {
-			const byte *line = _scratch.begin() + (row - y) * width + clip.left - x;
-			byte *dest = _pixels.begin() + row * kWidth + clip.left;
-			for (int col = 0; col < clip.width(); ++col) {
-				// Mortimer treats zero as transparent in literal runs as well.
-				if (src[0] == 1 || src[0] == 23 || line[col])
-					dest[col] = line[col];
-			}
-		}
-	}
-}
-
 MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 	// Later objects are drawn on top of earlier ones.
 	for (int i = int(_targets.size()) - 1; i >= 0; --i) {
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index fa6f9cd1770..b7320c21427 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -21,39 +21,11 @@
 #ifndef SCUMM_INSANE_MORTIMER_FLIGHT_H
 #define SCUMM_INSANE_MORTIMER_FLIGHT_H
 
-#include "common/array.h"
-#include "common/rect.h"
-#include "common/str.h"
-
-namespace Common {
-class SeekableReadStream;
-}
+#include "scumm/insane/mortimer/movie.h"
 
 namespace Scumm {
 
-class SmushDeltaBlocksDecoder;
-
-struct MortimerSprite {
-	int x, y, width, height;
-	Common::Array<byte> pixels;
-};
-
-struct MortimerSound {
-	uint frame = 0;
-	uint rate = 11025;
-	byte volume = 255;
-	int8 balance = 0;
-	bool music = false;
-	Common::Array<byte> samples;
-};
-
-bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites);
-bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
-
-// The Windows demo flights. No ScummEngine, InsaneRebel or SmushPlayer
-// state is used here; only the existing, stateless RLE helpers and a private
-// codec37 decoder are shared with the other SMUSH games.
-class MortimerFlight {
+class MortimerFlight : public MortimerMovie {
 public:
 	enum { kWidth = 384, kHeight = 288 };
 	enum Weapon { kRevitascope, kSlimeGun };
@@ -67,58 +39,36 @@ public:
 	};
 
 	MortimerFlight();
-	~MortimerFlight();
-	bool load(Common::SeekableReadStream &stream);
-	void reset();
-	bool advance();
+	bool load(Common::SeekableReadStream &stream, bool interactive = true);
+	void reset() override;
+	bool advance() override;
 	ShotResult shoot(const Common::Point &point);
 	bool hitsObstacle(const Common::Point &point) const;
 
-	const byte *pixels() const { return _pixels.begin(); }
-	const byte *palette() const { return _palette; }
-	uint frame() const { return _nextFrame ? _nextFrame - 1 : 0; }
-	uint frameCount() const { return _frames.size(); }
-	uint frameRate() const { return _frameRate; }
 	uint rescuedCount() const { return _rescuedCount; }
 	uint rescuedSpecies() const { return _rescuedSpecies; }
 	Weapon weapon() const { return _weapon; }
 	const Common::String &nextFlight() const { return _nextFlight; }
-	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
 	const Common::Array<Target> &targets() const { return _targets; }
-	const Common::String &errorMessage() const { return _error; }
+	bool interactive() const { return _interactive; }
 
 private:
-	struct Frame {
-		uint32 offset, size;
-	};
-	struct Object {
-		uint32 offset, size;
-		uint16 skipA, skipB;
-	};
 	struct Obstacle {
 		Common::Point corners[4];
 	};
 
-	bool fail(const char *message);
-	void handleAction(const byte *data, uint32 size);
-	void handlePalette(const byte *data, uint32 size);
-	void render();
+	void handleAction(const byte *data, uint32 size) override;
+	bool skipObject(uint16 a, uint16 b) const override;
+	int objectScale(byte codec) const override;
 	Target *findTarget(uint16 id);
 
-	Common::Array<byte> _data, _background, _pixels, _scratch;
-	Common::Array<Frame> _frames;
-	Common::Array<Object> _objects;
 	Common::Array<Target> _targets;
 	Common::Array<Obstacle> _obstacles;
-	Common::Array<MortimerSound> _sounds;
-	SmushDeltaBlocksDecoder *_decoder;
-	Common::String _error, _nextFlight;
-	byte _palette[768], _initialPalette[768], _remap[256];
-	int16 _paletteDelta[768];
-	int32 _shiftedPalette[768];
+	Common::String _nextFlight;
 	bool _toggles[256];
+	bool _initialInteractive, _interactive;
 	Weapon _weapon;
-	uint _nextFrame, _frameRate, _rescuedCount, _rescuedSpecies;
+	uint _rescuedCount, _rescuedSpecies;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index dbfe3d2d1e6..37987aa99a2 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -37,8 +37,18 @@
 
 namespace Scumm {
 
+// SHELL's demo scene list at 444220 plays all five introductions before
+// TITLE.SAN. The title continues directly into the first playable scene.
+static const char *const kDemoIntros[] = {
+	"RESOURCE/BEGIN/INTRO1_H.SAN",
+	"RESOURCE/BEGIN/INTRO2_H.SAN",
+	"RESOURCE/BEGIN/INTRO3_H.SAN",
+	"RESOURCE/BEGIN/INTRO4_H.SAN",
+	"RESOURCE/BEGIN/INTRO5_H.SAN"
+};
+
 static const char *const kDemoFlights[] = {
-	"RESOURCE/DEMO/DEMO_S1.SAN",
+	"RESOURCE/DEMO/TITLE.SAN",
 	"RESOURCE/DEMO/DEMO_PL.SAN",
 	"RESOURCE/DEMO/DEMO_BT.SAN"
 };
@@ -100,7 +110,7 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 
 MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
-	_nextFrameTime(0), _pauseTime(0), _segment(0), _completedRescues(0), _completedSpecies(0),
+	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _segment(0), _completedRescues(0), _completedSpecies(0),
 	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
 	_pendingGlowStones(0), _finished(false), _controllerMode(false) {
 	memset(_steering, 0, sizeof(_steering));
@@ -164,59 +174,100 @@ bool MortimerEngine::loadResources() {
 		}
 		file.close();
 	}
-	return loadFlight(0);
+	return true;
+}
+
+MortimerMovie &MortimerEngine::currentMovie() {
+	return _introIndex >= 0 ? _intro : _flight;
+}
+
+const MortimerMovie &MortimerEngine::currentMovie() const {
+	return _introIndex >= 0 ? _intro : _flight;
+}
+
+bool MortimerEngine::loadIntro(uint index) {
+	stopSounds();
+	_movieHandles.clear();
+	Common::File file;
+	if (!file.open(Common::Path(kDemoIntros[index]))) {
+		_loadError = Common::String::format("Missing %s", kDemoIntros[index]);
+		return false;
+	}
+	// The fifth introduction starts with native 640 x 480 animation.
+	if (!_intro.load(file, index != 4)) {
+		_loadError = Common::String::format("%s: %s", kDemoIntros[index], _intro.errorMessage().c_str());
+		return false;
+	}
+	_introIndex = index;
+	_movieHandles.resize(_intro.sounds().size());
+	return true;
 }
 
 bool MortimerEngine::loadFlight(uint segment) {
-	// The mixer borrows the samples owned by the flight being replaced.
+	// The mixer borrows the samples owned by the movie being replaced.
 	stopSounds();
-	_flightHandles.clear();
+	_movieHandles.clear();
 	Common::File file;
 	if (!file.open(Common::Path(kDemoFlights[segment]))) {
 		_loadError = Common::String::format("Missing %s", kDemoFlights[segment]);
 		return false;
 	}
-	if (!_flight.load(file)) {
+	if (!_flight.load(file, segment != 0)) {
 		_loadError = Common::String::format("%s: %s", kDemoFlights[segment], _flight.errorMessage().c_str());
 		return false;
 	}
 	_segment = segment;
-	_flightHandles.resize(_flight.sounds().size());
+	_introIndex = -1;
+	_movieHandles.resize(_flight.sounds().size());
 	return true;
 }
 
-void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type) {
+void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset) {
 	_mixer->stopHandle(handle);
 	// The engine owns the samples until all its handles have been stopped.
-	Audio::AudioStream *stream = Audio::makeRawStream(sound.samples.begin(), sound.samples.size(), sound.rate,
+	Audio::AudioStream *stream = Audio::makeRawStream(sound.samples.begin() + offset, sound.samples.size() - offset, sound.rate,
 		Audio::FLAG_UNSIGNED, DisposeAfterUse::NO);
 	_mixer->playStream(type, &handle, stream, -1, sound.volume, sound.balance);
 }
 
-void MortimerEngine::playFlightSounds() {
-	for (uint i = 0; i < _flight.sounds().size(); ++i)
-		if (_flight.sounds()[i].frame == _flight.frame())
-			playSound(_flight.sounds()[i], _flightHandles[i],
-				_flight.sounds()[i].music ? Audio::Mixer::kMusicSoundType : Audio::Mixer::kSpeechSoundType);
+void MortimerEngine::playMovieSounds(bool seek) {
+	const MortimerMovie &movie = currentMovie();
+	for (uint i = 0; i < movie.sounds().size(); ++i) {
+		const MortimerSound &sound = movie.sounds()[i];
+		if (sound.frame > movie.frame() || (!seek && sound.frame != movie.frame()))
+			continue;
+		const uint64 offset = uint64(movie.frame() - sound.frame) * sound.rate / movie.frameRate();
+		if (offset < sound.samples.size())
+			playSound(sound, _movieHandles[i], sound.music ? Audio::Mixer::kMusicSoundType :
+				(sound.speech ? Audio::Mixer::kSpeechSoundType : Audio::Mixer::kSFXSoundType), offset);
+	}
 }
 
 void MortimerEngine::stopSounds() {
-	for (uint i = 0; i < _flightHandles.size(); ++i)
-		_mixer->stopHandle(_flightHandles[i]);
+	for (uint i = 0; i < _movieHandles.size(); ++i)
+		_mixer->stopHandle(_movieHandles[i]);
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
 	_mixer->stopHandle(_collisionHandle);
 	_mixer->stopHandle(_glowHandle);
 }
 
-bool MortimerEngine::startFlight() {
+bool MortimerEngine::startMovie() {
 	stopSounds();
-	_flight.reset();
-	if (!_flight.advance()) {
-		_loadError = _flight.errorMessage();
+	MortimerMovie &movie = currentMovie();
+	movie.reset();
+	if (!movie.advance()) {
+		_loadError = movie.errorMessage();
 		return false;
 	}
 	_finished = false;
+	_system->getPaletteManager()->setPalette(movie.palette(), 0, 256);
+	_nextFrameTime = _system->getMillis() + 1000 / movie.frameRate();
+	playMovieSounds();
+	return true;
+}
+
+bool MortimerEngine::startFlight() {
 	_uiFrame = 0;
 	_shotFrame = 3;
 	_glowFrame = 10;
@@ -228,13 +279,12 @@ bool MortimerEngine::startFlight() {
 	setMouse(Common::Point(320, 240));
 	updateSteering();
 	_system->warpMouse(_mouse.x, _mouse.y);
-	_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
-	_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
-	playFlightSounds();
-	return true;
+	return startMovie();
 }
 
 bool MortimerEngine::restart(bool all) {
+	if (_introIndex >= 0 && !all)
+		return startMovie();
 	_shellShocks = 0;
 	if (all) {
 		_completedRescues = _completedSpecies = 0;
@@ -244,8 +294,27 @@ bool MortimerEngine::restart(bool all) {
 	return startFlight();
 }
 
-bool MortimerEngine::nextFlight(bool skip) {
+bool MortimerEngine::nextScene(bool skip) {
 	stopSounds();
+	if (_introIndex >= 0) {
+		if (uint(_introIndex + 1) < ARRAYSIZE(kDemoIntros))
+			return loadIntro(_introIndex + 1) && startMovie();
+		return loadFlight(0) && startFlight();
+	}
+	if (skip && !_flight.interactive()) {
+		// Keep TITLE's music at the matching position when skipping its
+		// opening; the same track continues after the cockpit appears.
+		while (!_flight.interactive()) {
+			if (!_flight.advance()) {
+				_loadError = _flight.errorMessage().empty() ? "Mortimer title has no gameplay transition" : _flight.errorMessage();
+				return false;
+			}
+		}
+		_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
+		_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
+		playMovieSounds(true);
+		return true;
+	}
 	if (_segment + 1 == ARRAYSIZE(kDemoFlights)) {
 		_finished = true;
 		return true;
@@ -344,8 +413,8 @@ void MortimerEngine::updateInterface() {
 }
 
 bool MortimerEngine::speechPlaying() const {
-	for (uint i = 0; i < _flightHandles.size(); ++i)
-		if (!_flight.sounds()[i].music && _mixer->isSoundHandleActive(_flightHandles[i]))
+	for (uint i = 0; i < _movieHandles.size(); ++i)
+		if (currentMovie().sounds()[i].speech && _mixer->isSoundHandleActive(_movieHandles[i]))
 			return true;
 	return false;
 }
@@ -366,7 +435,7 @@ Common::Error MortimerEngine::run() {
 	syncSoundSettings();
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
-	bool exit = !restart();
+	bool exit = !(loadIntro(0) && startMovie());
 	while (!shouldQuit() && !exit) {
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
@@ -382,14 +451,14 @@ Common::Error MortimerEngine::run() {
 			} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
 				switch (event.customType) {
 				case kActionFire:
-					if (!isPaused() && !_finished)
+					if (!isPaused() && !_finished && interactive())
 						fire();
 					break;
 				case kActionSkip:
 					if (pause.isActive())
 						pause.clear();
 					else
-						exit = !nextFlight(true);
+						exit = !nextScene(true);
 					break;
 				case kActionQuit:
 					exit = true;
@@ -423,20 +492,23 @@ Common::Error MortimerEngine::run() {
 		if (exit)
 			break;
 		if (!isPaused() && !_finished && int32(_system->getMillis() - _nextFrameTime) >= 0) {
-			_nextFrameTime += 1000 / _flight.frameRate();
-			updateSteering();
-			updateInterface();
-			if (!_flight.advance()) {
-				if (!_flight.errorMessage().empty()) {
-					_loadError = _flight.errorMessage();
+			MortimerMovie &movie = currentMovie();
+			_nextFrameTime += 1000 / movie.frameRate();
+			if (interactive()) {
+				updateSteering();
+				updateInterface();
+			}
+			if (!movie.advance()) {
+				if (!movie.errorMessage().empty()) {
+					_loadError = movie.errorMessage();
 					break;
 				}
-				if (!nextFlight())
+				if (!nextScene())
 					break;
 			} else {
-				playFlightSounds();
-				_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
-				if (_flight.hitsObstacle(moviePoint(_aim))) {
+				playMovieSounds();
+				_system->getPaletteManager()->setPalette(movie.palette(), 0, 256);
+				if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
 					++_shellShocks;
 					playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
 					// SHELL 40be36 resets the flight after eighteen shell shocks.
@@ -446,7 +518,7 @@ Common::Error MortimerEngine::run() {
 			}
 			// Avoid racing through queued frames following a slow backend call.
 			if (int32(_system->getMillis() - _nextFrameTime) > 250)
-				_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
+				_nextFrameTime = _system->getMillis() + 1000 / currentMovie().frameRate();
 		}
 		draw();
 		if (_finished) {
@@ -569,21 +641,25 @@ void MortimerEngine::drawDashboard() {
 }
 
 void MortimerEngine::draw() {
-	_screen.fillRect(Common::Rect(640, 480), 10);
-	for (int y = _viewport.top; y < _viewport.bottom; ++y) {
-		const byte *src = _flight.pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerFlight::kWidth + _view.x;
-		byte *dst = (byte *)_screen.getBasePtr(_viewport.left, y);
-		for (int x = 0; x < _viewport.width(); ++x)
-			dst[x] = src[x / 2];
+	if (interactive()) {
+		_screen.fillRect(Common::Rect(640, 480), 10);
+		for (int y = _viewport.top; y < _viewport.bottom; ++y) {
+			const byte *src = _flight.pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerMovie::kScreenWidth + _view.x;
+			byte *dst = (byte *)_screen.getBasePtr(_viewport.left, y);
+			for (int x = 0; x < _viewport.width(); ++x)
+				dst[x] = src[x / 2];
+		}
+		drawTargets();
+		drawSprite(_head[_uiFrame % 4], 270, 200);
+		drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
+		drawSprite(_cockpit[0], 0, 0);
+		drawSprite(_cockpit[1], 567, 0);
+		drawSprite(_cockpit[2], 0, 0);
+		drawDashboard();
+		drawProjectile();
+	} else {
+		_screen.copyRectToSurface(currentMovie().pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
 	}
-	drawTargets();
-	drawSprite(_head[_uiFrame % 4], 270, 200);
-	drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
-	drawSprite(_cockpit[0], 0, 0);
-	drawSprite(_cockpit[1], 567, 0);
-	drawSprite(_cockpit[2], 0, 0);
-	drawDashboard();
-	drawProjectile();
 	if (isPaused()) {
 		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 336b002243e..dbdcfacaa0c 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -44,10 +44,15 @@ protected:
 
 private:
 	bool loadResources();
+	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
+	bool startMovie();
 	bool startFlight();
-	bool nextFlight(bool skip = false);
+	bool nextScene(bool skip = false);
 	bool restart(bool all = false);
+	MortimerMovie &currentMovie();
+	const MortimerMovie &currentMovie() const;
+	bool interactive() const { return _introIndex < 0 && _flight.interactive(); }
 	void setMouse(const Common::Point &position);
 	void updateSteering();
 	void updateInterface();
@@ -60,16 +65,17 @@ private:
 	void drawTargets();
 	void drawDashboard();
 	bool speechPlaying() const;
-	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type);
-	void playFlightSounds();
+	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset = 0);
+	void playMovieSounds(bool seek = false);
 	void stopSounds();
 
+	MortimerMovie _intro;
 	MortimerFlight _flight;
 	Graphics::Surface _screen;
 	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons, _head;
 	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
 	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound, _glowSound, _balloonSound;
-	Common::Array<Audio::SoundHandle> _flightHandles;
+	Common::Array<Audio::SoundHandle> _movieHandles;
 	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle, _glowHandle;
 	Common::String _loadError;
 	Common::Rect _viewport;
@@ -78,6 +84,7 @@ private:
 	int _steering[4];
 	MortimerFlight::Weapon _shotWeapon;
 	uint32 _nextFrameTime, _pauseTime;
+	int _introIndex;
 	uint _segment, _completedRescues, _completedSpecies, _shellShocks;
 	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
 	uint _iconFlash[7];
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
new file mode 100644
index 00000000000..d7f6777a151
--- /dev/null
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -0,0 +1,557 @@
+/* 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 "scumm/insane/mortimer/movie.h"
+#include "scumm/smush/codec37.h"
+#include "scumm/smush/rebel/codec_ra2.h"
+
+#include "common/endian.h"
+#include "common/stream.h"
+#include "common/util.h"
+
+namespace Scumm {
+
+namespace {
+
+struct Chunk {
+	uint32 tag, offset, size;
+};
+
+bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk, bool padded = true) {
+	if (pos > end || end - pos < 8)
+		return false;
+	chunk.tag = READ_BE_UINT32(data + pos);
+	chunk.size = READ_BE_UINT32(data + pos + 4);
+	chunk.offset = pos + 8;
+	if (chunk.size > end - chunk.offset)
+		return false;
+	pos = chunk.offset + chunk.size;
+	if (padded && (chunk.size & 1)) {
+		if (pos == end)
+			return false;
+		++pos;
+	}
+	return true;
+}
+
+bool readData(Common::SeekableReadStream &stream, Common::Array<byte> &data) {
+	const int64 size = stream.size();
+	if (size < 8 || size > 64 * 1024 * 1024 || !stream.seek(0))
+		return false;
+	data.resize(size);
+	return stream.read(data.begin(), data.size()) == data.size();
+}
+
+bool isContainer(const Common::Array<byte> &data, uint32 tag) {
+	return data.size() >= 8 && READ_BE_UINT32(data.begin()) == tag &&
+		READ_BE_UINT32(data.begin() + 4) == data.size() - 8;
+}
+
+bool validRows(const byte *src, uint32 size, int height, bool remap) {
+	uint32 pos = 0;
+	// Remap masks can include additional rows below the FOBJ clipping height.
+	for (int y = 0; y < height || (remap && pos + 1 < size); ++y) {
+		if (size - pos < 2)
+			return false;
+		const uint len = READ_LE_UINT16(src + pos);
+		pos += 2;
+		if (len > size - pos)
+			return false;
+		const uint32 end = pos + len;
+		if (remap) {
+			// Skip/run pairs; a final skip without a run is also permitted.
+			pos = end;
+		} else {
+			while (pos < end) {
+				const byte code = src[pos++];
+				const uint count = (code & 1) ? 1 : (code >> 1) + 1;
+				if (count > end - pos)
+					return false;
+				pos += count;
+			}
+		}
+	}
+	// FOBJ includes the encoder's final byte of alignment in its payload size.
+	return pos == size || (pos + 1 == size && src[pos] == 0);
+}
+
+// Codec37's existing decoder has no input-length argument. Validate the two
+// encodings present in the demo movies before passing any data to it.
+bool validBackground(const byte *src, uint32 size, int width, int height) {
+	if (size < 16 || src[1] != 1 || (src[12] != 0 && src[12] != 4))
+		return false;
+	uint32 pos = 16;
+	if (src[0] == 2) {
+		if (READ_LE_UINT32(src + 4) != uint(width * height))
+			return false;
+		uint remaining = uint(width * height);
+		while (remaining) {
+			if (pos == size)
+				return false;
+			const byte code = src[pos++];
+			const uint run = (code >> 1) + 1;
+			const uint count = (code & 1) ? 1 : run;
+			if (count > size - pos || run > remaining)
+				return false;
+			pos += count;
+			remaining -= run;
+		}
+	} else if (src[0] == 3) {
+		for (uint block = 0; block < uint(width * height) / 16; ++block) {
+			if (pos == size)
+				return false;
+			const byte code = src[pos++];
+			const uint count = code == 0xff ? 16 : (src[12] & 4) && code == 0xfe ? 4 : (src[12] & 4) && code == 0xfd ? 1 : 0;
+			if (count > size - pos)
+				return false;
+			pos += count;
+		}
+	} else {
+		return false;
+	}
+	return pos <= size;
+}
+
+bool validObject(const byte *data, uint32 size, bool sprite = false) {
+	if (size < 14)
+		return false;
+	const int width = READ_LE_INT16(data + 6), height = READ_LE_INT16(data + 8);
+	if (width <= 0 || height <= 0 || width > 640 || height > 480)
+		return false;
+	if (READ_LE_INT16(data + 2) + width > 32767 || READ_LE_INT16(data + 4) + height > 32767)
+		return false;
+	const byte codec = data[0];
+	if (sprite)
+		return codec == 1 && validRows(data + 14, size - 14, height, false);
+	if (codec == 37)
+		return !(width & 3) && !(height & 3) && validBackground(data + 14, size - 14, width, height);
+	if (codec != 1 && codec != 3 && codec != 23)
+		return false;
+	uint32 offset = 14;
+	if (codec == 23) {
+		const uint16 mode = READ_LE_UINT16(data + 12);
+		if (mode != 0x100 && mode != 0x101)
+			return false;
+		if (mode == 0x100)
+			offset += 256;
+	}
+	return offset <= size && validRows(data + offset, size - offset, height, codec == 23);
+}
+
+bool validPalette(const byte *data, uint32 size) {
+	return (size == 2308 && READ_BE_UINT32(data) == 2) ||
+		(size == 6 && READ_BE_UINT32(data) == 1);
+}
+
+bool validTransition(const byte *data, uint32 size) {
+	// IACT 8/46 contains a length-prefixed filename followed by CR/LF/EOF.
+	if (size < 22 || READ_LE_UINT16(data + 14) != size - 18 ||
+		data[size - 3] != '\r' || data[size - 2] != '\n' || data[size - 1] != 0x1a)
+		return false;
+	for (uint i = 18; i < size - 3; ++i)
+		if (data[i] < 32 || data[i] > 126)
+			return false;
+	return true;
+}
+
+bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
+	if (!isContainer(data, MKTAG('S', 'A', 'U', 'D')))
+		return false;
+	sound.samples.clear();
+	uint32 pos = 8;
+	while (pos < data.size()) {
+		Chunk chunk;
+		// SAUD chunks are not word-aligned, unlike the surrounding SMUSH file.
+		if (!nextChunk(data.begin(), data.size(), pos, chunk, false))
+			return false;
+		const byte *src = data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('S', 'D', 'A', 'T')) {
+			if (!sound.samples.empty())
+				return false;
+			sound.samples.assign(src, src + chunk.size);
+		} else if (chunk.tag == MKTAG('S', 'H', 'D', 'R') && chunk.size == 4) {
+			sound.rate = READ_LE_UINT32(src);
+		} else if (chunk.tag == MKTAG('S', 'T', 'R', 'K') && chunk.size >= 14 && src[0] == 6 && src[1] == 12) {
+			sound.rate = READ_BE_UINT32(src + 10);
+		}
+	}
+	return !sound.samples.empty() && sound.rate >= 4000 && sound.rate <= 48000;
+}
+
+} // End of anonymous namespace
+
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites) {
+	Common::Array<byte> data;
+	sprites.clear();
+	if (!readData(stream, data) || !isContainer(data, MKTAG('A', 'N', 'I', 'M')))
+		return false;
+	uint32 pos = 8;
+	while (pos < data.size()) {
+		Chunk frame;
+		if (!nextChunk(data.begin(), data.size(), pos, frame))
+			return false;
+		if (frame.tag != MKTAG('F', 'R', 'M', 'E'))
+			continue;
+		uint32 subpos = frame.offset;
+		const uint before = sprites.size();
+		while (subpos < frame.offset + frame.size) {
+			Chunk chunk;
+			if (!nextChunk(data.begin(), frame.offset + frame.size, subpos, chunk))
+				return false;
+			if (chunk.tag != MKTAG('F', 'O', 'B', 'J'))
+				continue;
+			const byte *src = data.begin() + chunk.offset;
+			if (!validObject(src, chunk.size, true))
+				return false;
+			MortimerSprite sprite;
+			sprite.x = READ_LE_INT16(src + 2);
+			sprite.y = READ_LE_INT16(src + 4);
+			sprite.width = READ_LE_INT16(src + 6);
+			sprite.height = READ_LE_INT16(src + 8);
+			sprite.pixels.resize(sprite.width * sprite.height);
+			memset(sprite.pixels.begin(), 0, sprite.pixels.size());
+			smushDecodeRLEOpaque(sprite.pixels.begin(), src + 14, 0, 0, sprite.width, sprite.height, sprite.width, chunk.size - 14);
+			sprites.push_back(sprite);
+		}
+		if (sprites.size() != before + 1)
+			return false;
+	}
+	return !sprites.empty();
+}
+
+bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound) {
+	Common::Array<byte> data;
+	return readData(stream, data) && decodeSound(data, sound);
+}
+
+MortimerMovie::MortimerMovie() : _decoder(nullptr), _decoderWidth(0), _decoderHeight(0),
+	_doubled(true), _initialDoubled(true), _storeNext(false), _nextFrame(0), _frameRate(10) {
+	_pixels.resize(kScreenWidth * kScreenHeight);
+	_previous.resize(_pixels.size());
+	memset(_initialPalette, 0, sizeof(_initialPalette));
+	reset();
+}
+
+MortimerMovie::~MortimerMovie() {
+	delete _decoder;
+}
+
+bool MortimerMovie::fail(const char *message) {
+	_error = message;
+	_frames.clear();
+	return false;
+}
+
+bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
+	_initialDoubled = doubled;
+	_frames.clear();
+	_sounds.clear();
+	_error.clear();
+	reset();
+	if (!readData(stream, _data) || !isContainer(_data, MKTAG('A', 'N', 'I', 'M')))
+		return fail("Invalid Mortimer movie ANIM container");
+
+	struct Track {
+		uint16 id, flags, nextBlock;
+		MortimerSound sound;
+		Common::Array<byte> bytes;
+	};
+	Common::Array<Track> tracks;
+	uint32 pos = 8;
+	uint expectedFrames = 0;
+	int decoderWidth = 0, decoderHeight = 0;
+	while (pos < _data.size()) {
+		Chunk chunk;
+		if (!nextChunk(_data.begin(), _data.size(), pos, chunk))
+			return fail("Truncated Mortimer movie chunk");
+		const byte *src = _data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('A', 'H', 'D', 'R')) {
+			if (expectedFrames || chunk.size < 778 || READ_LE_UINT16(src) != 2)
+				return fail("Unsupported Mortimer movie header");
+			expectedFrames = READ_LE_UINT16(src + 2);
+			_frameRate = READ_LE_UINT32(src + 774);
+			if (!expectedFrames || _frameRate != 10)
+				return fail("Unsupported Mortimer movie timing");
+			memcpy(_initialPalette, src + 6, sizeof(_initialPalette));
+			memcpy(_palette, _initialPalette, sizeof(_palette));
+		} else if (chunk.tag == MKTAG('F', 'R', 'M', 'E')) {
+			if (!expectedFrames)
+				return fail("Mortimer movie has no header");
+			uint32 subpos = chunk.offset;
+			uint backgrounds = 0;
+			while (subpos < chunk.offset + chunk.size) {
+				Chunk sub;
+				if (!nextChunk(_data.begin(), chunk.offset + chunk.size, subpos, sub))
+					return fail("Truncated Mortimer movie frame");
+				const byte *body = _data.begin() + sub.offset;
+				if (sub.tag == MKTAG('F', 'O', 'B', 'J')) {
+					if (!validObject(body, sub.size))
+						return fail("Unsupported or truncated Mortimer movie image");
+					if (body[0] == 37) {
+						++backgrounds;
+						const int width = READ_LE_UINT16(body + 6), height = READ_LE_UINT16(body + 8);
+						if ((width != decoderWidth || height != decoderHeight) && body[14] != 2)
+							return fail("Mortimer movie changes dimensions without a keyframe");
+						decoderWidth = width;
+						decoderHeight = height;
+					}
+				} else if (sub.tag == MKTAG('S', 'K', 'I', 'P')) {
+					if (sub.size != 4 || READ_LE_UINT16(body) >= 256 || READ_LE_UINT16(body + 2) >= 256)
+						return fail("Invalid Mortimer object toggle");
+				} else if (sub.tag == MKTAG('I', 'A', 'C', 'T')) {
+					if (sub.size < 8)
+						return fail("Truncated Mortimer movie action");
+					const uint16 code = READ_LE_UINT16(body);
+					if ((code >= 2 && code <= 4 && (sub.size < (code == 4 ? 18U : 12U) || READ_LE_UINT16(body + 8) >= 256)) ||
+						(code == 5 && sub.size < 28))
+						return fail("Invalid Mortimer movie action");
+					if (code == 2 && READ_LE_UINT16(body + 4) == 3 && READ_LE_UINT16(body + 10) >= 256)
+						return fail("Invalid Mortimer conditional object");
+					if (code == 8 && READ_LE_UINT16(body + 2) == 46 && !validTransition(body, sub.size))
+						return fail("Invalid Mortimer movie transition");
+				} else if (sub.tag == MKTAG('X', 'P', 'A', 'L')) {
+					if (!validPalette(body, sub.size))
+						return fail("Invalid Mortimer palette change");
+				} else if (sub.tag == MKTAG('N', 'P', 'A', 'L')) {
+					if (sub.size != 768)
+						return fail("Invalid Mortimer palette");
+				} else if (sub.tag == MKTAG('S', 'T', 'O', 'R') || sub.tag == MKTAG('F', 'T', 'C', 'H')) {
+					if (sub.size != (sub.tag == MKTAG('S', 'T', 'O', 'R') ? 4U : 6U))
+						return fail("Invalid Mortimer background command");
+				} else if (sub.tag == MKTAG('P', 'S', 'A', 'D')) {
+					if (sub.size < 10)
+						return fail("Truncated Mortimer audio block");
+					const uint16 id = READ_LE_UINT16(body), block = READ_LE_UINT16(body + 2);
+					const uint16 flags = READ_LE_UINT16(body + 6) & 0xc0;
+					uint index = 0;
+					while (index < tracks.size() && (tracks[index].id != id || tracks[index].flags != flags))
+						++index;
+					if (index == tracks.size()) {
+						Track track;
+						track.id = id;
+						track.flags = flags;
+						track.nextBlock = 0;
+						track.sound.frame = _frames.size();
+						track.sound.music = (flags & 64) != 0;
+						track.sound.speech = (flags & 128) != 0;
+						track.sound.volume = MIN(255, body[8] * 2);
+						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
+						tracks.push_back(track);
+					}
+					if (tracks[index].nextBlock++ != block)
+						return fail("Missing Mortimer audio block");
+					Common::Array<byte> &bytes = tracks[index].bytes;
+					const uint oldSize = bytes.size();
+					bytes.resize(oldSize + sub.size - 10);
+					memcpy(bytes.begin() + oldSize, body + 10, sub.size - 10);
+				}
+			}
+			if (backgrounds > 1)
+				return fail("Invalid Mortimer movie background");
+			_frames.push_back({chunk.offset, chunk.size});
+		}
+	}
+	if (_frames.size() != expectedFrames || !expectedFrames)
+		return fail("Incomplete Mortimer movie");
+	for (uint i = 0; i < tracks.size(); ++i) {
+		if (!decodeSound(tracks[i].bytes, tracks[i].sound))
+			return fail("Unsupported or incomplete Mortimer audio");
+		_sounds.push_back(tracks[i].sound);
+	}
+	return true;
+}
+
+void MortimerMovie::reset() {
+	delete _decoder;
+	_decoder = nullptr;
+	_decoderWidth = _decoderHeight = 0;
+	_nextFrame = 0;
+	_doubled = _initialDoubled;
+	_storeNext = false;
+	_storedObject = Object();
+	_objects.clear();
+	memcpy(_palette, _initialPalette, sizeof(_palette));
+	memset(_paletteDelta, 0, sizeof(_paletteDelta));
+	memset(_shiftedPalette, 0, sizeof(_shiftedPalette));
+	memset(_pixels.begin(), 0, _pixels.size());
+	memset(_previous.begin(), 0, _previous.size());
+	for (uint i = 0; i < 256; ++i)
+		_remap[i] = i;
+}
+
+void MortimerMovie::handleAction(const byte *data, uint32 size) {
+	if (READ_LE_UINT16(data) == 6) {
+		const uint16 flags = READ_LE_UINT16(data + 2);
+		if (flags == 7 || flags == 38)
+			_doubled = flags == 38;
+	}
+}
+
+int MortimerMovie::objectScale(byte codec) const {
+	// SHELL 401392/402430 double codec37 and codec3 images.
+	// Codec1 artwork uses native screen coordinates.
+	return _doubled && codec != 1 ? 2 : 1;
+}
+
+void MortimerMovie::handlePalette(const byte *data, uint32 size) {
+	if (size == 2308) {
+		for (uint i = 0; i < 768; ++i) {
+			_shiftedPalette[i] = _palette[i] << 7;
+			_paletteDelta[i] = READ_LE_INT16(data + 4 + i * 2);
+		}
+		memcpy(_palette, data + 1540, sizeof(_palette));
+	} else {
+		for (uint i = 0; i < 768; ++i) {
+			_shiftedPalette[i] += _paletteDelta[i];
+			_palette[i] = CLIP<int32>(_shiftedPalette[i] >> 7, 0, 255);
+		}
+	}
+}
+
+bool MortimerMovie::decodeObject(Object &object) {
+	const byte *src = _data.begin() + object.offset;
+	object.codec = src[0];
+	object.x = READ_LE_INT16(src + 2);
+	object.y = READ_LE_INT16(src + 4);
+	object.width = READ_LE_UINT16(src + 6);
+	object.height = READ_LE_UINT16(src + 8);
+	object.scale = objectScale(object.codec);
+	if (object.codec == 23) {
+		if (READ_LE_UINT16(src + 12) == 0x100)
+			memcpy(_remap, src + 14, sizeof(_remap));
+		memcpy(object.remap, _remap, sizeof(_remap));
+		return true;
+	}
+	object.pixels.resize(object.width * object.height);
+	if (object.codec == 37) {
+		if (_decoderWidth != object.width || _decoderHeight != object.height) {
+			if (src[14] != 2)
+				return fail("Mortimer movie changes dimensions without a keyframe");
+			delete _decoder;
+			_decoderWidth = object.width;
+			_decoderHeight = object.height;
+			_decoder = new SmushDeltaBlocksDecoder(_decoderWidth, _decoderHeight);
+		}
+		_decoder->decode(object.pixels.begin(), src + 14);
+	} else {
+		memset(object.pixels.begin(), 0, object.pixels.size());
+		smushDecodeRLEOpaque(object.pixels.begin(), src + 14, 0, 0,
+			object.width, object.height, object.width, object.size - 14);
+	}
+	return true;
+}
+
+bool MortimerMovie::advance() {
+	if (_nextFrame >= _frames.size())
+		return false;
+	memcpy(_previous.begin(), _pixels.begin(), _pixels.size());
+	_objects.clear();
+	const Frame &current = _frames[_nextFrame++];
+	uint32 pos = current.offset;
+	uint16 skipA = 0xffff, skipB = 0;
+	while (pos < current.offset + current.size) {
+		Chunk chunk;
+		if (!nextChunk(_data.begin(), current.offset + current.size, pos, chunk))
+			return fail("Truncated Mortimer movie frame");
+		const byte *src = _data.begin() + chunk.offset;
+		if (chunk.tag == MKTAG('S', 'K', 'I', 'P')) {
+			skipA = READ_LE_UINT16(src);
+			skipB = READ_LE_UINT16(src + 2);
+			continue;
+		}
+		if (chunk.tag == MKTAG('I', 'A', 'C', 'T')) {
+			handleAction(src, chunk.size);
+		} else if (chunk.tag == MKTAG('X', 'P', 'A', 'L')) {
+			handlePalette(src, chunk.size);
+		} else if (chunk.tag == MKTAG('N', 'P', 'A', 'L')) {
+			memcpy(_palette, src, sizeof(_palette));
+		} else if (chunk.tag == MKTAG('S', 'T', 'O', 'R')) {
+			_storeNext = true;
+		} else if (chunk.tag == MKTAG('F', 'T', 'C', 'H')) {
+			if (!_storedObject.size)
+				return fail("Mortimer movie fetches an unstored background");
+			Object object = _storedObject;
+			object.x += READ_LE_INT16(src + 2);
+			object.y += READ_LE_INT16(src + 4);
+			object.scale = objectScale(object.codec);
+			object.skipA = skipA;
+			object.skipB = skipB;
+			_objects.push_back(object);
+		} else if (chunk.tag == MKTAG('F', 'O', 'B', 'J')) {
+			Object object;
+			object.offset = chunk.offset;
+			object.size = chunk.size;
+			object.skipA = skipA;
+			object.skipB = skipB;
+			if (!decodeObject(object))
+				return false;
+			if (_storeNext) {
+				_storedObject = object;
+				_storeNext = false;
+			}
+			_objects.push_back(object);
+		}
+		skipA = 0xffff;
+		skipB = 0;
+	}
+	render();
+	return true;
+}
+
+void MortimerMovie::render() {
+	memcpy(_pixels.begin(), _previous.begin(), _pixels.size());
+	for (uint i = 0; i < _objects.size(); ++i) {
+		const Object &object = _objects[i];
+		if (skipObject(object.skipA, object.skipB))
+			continue;
+		const int x = object.x * object.scale, y = object.y * object.scale;
+		Common::Rect clip(CLIP<int>(x, 0, kScreenWidth), CLIP<int>(y, 0, kScreenHeight),
+			CLIP<int>(x + object.width * object.scale, 0, kScreenWidth),
+			CLIP<int>(y + object.height * object.scale, 0, kScreenHeight));
+		if (clip.isEmpty())
+			continue;
+		const byte *pixels = object.pixels.begin();
+		if (object.codec == 23) {
+			_scratch.resize(object.width * object.height);
+			memset(_scratch.begin(), 0, _scratch.size());
+			for (int row = clip.top; row < clip.bottom; ++row)
+				for (int col = clip.left; col < clip.right; ++col)
+					_scratch[(row - y) / object.scale * object.width + (col - x) / object.scale] = _pixels[row * kScreenWidth + col];
+			const byte *src = _data.begin() + object.offset;
+			const uint offset = READ_LE_UINT16(src + 12) == 0x100 ? 270 : 14;
+			smushDecodeRA2SkipRemap(_scratch.begin(), src + offset, 0, 0, object.width,
+				object.height, object.width, object.size - offset, object.remap, 0);
+			pixels = _scratch.begin();
+		}
+		for (int row = clip.top; row < clip.bottom; ++row) {
+			const byte *line = pixels + (row - y) / object.scale * object.width;
+			byte *dest = _pixels.begin() + row * kScreenWidth;
+			for (int col = clip.left; col < clip.right; ++col) {
+				const byte color = line[(col - x) / object.scale];
+				// SHELL 410160/401b20 preserve the destination for every
+				// zero pixel in both RLE codecs, including literal runs.
+				if ((object.codec != 1 && object.codec != 3) || color)
+					dest[col] = color;
+			}
+		}
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
new file mode 100644
index 00000000000..080b3d13f22
--- /dev/null
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -0,0 +1,113 @@
+/* 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 SCUMM_INSANE_MORTIMER_MOVIE_H
+#define SCUMM_INSANE_MORTIMER_MOVIE_H
+
+#include "common/array.h"
+#include "common/rect.h"
+#include "common/str.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Scumm {
+
+class SmushDeltaBlocksDecoder;
+
+struct MortimerSprite {
+	int x, y, width, height;
+	Common::Array<byte> pixels;
+};
+
+struct MortimerSound {
+	uint frame = 0;
+	uint rate = 11025;
+	byte volume = 255;
+	int8 balance = 0;
+	bool music = false;
+	bool speech = true;
+	Common::Array<byte> samples;
+};
+
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites);
+bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
+
+// Mortimer's SMUSH movie reader. Gameplay supplies the IACT and SKIP rules.
+class MortimerMovie {
+public:
+	enum { kScreenWidth = 640, kScreenHeight = 480 };
+
+	MortimerMovie();
+	virtual ~MortimerMovie();
+	bool load(Common::SeekableReadStream &stream, bool doubled = true);
+	virtual void reset();
+	virtual bool advance();
+
+	const byte *pixels() const { return _pixels.begin(); }
+	const byte *palette() const { return _palette; }
+	uint frame() const { return _nextFrame ? _nextFrame - 1 : 0; }
+	uint frameCount() const { return _frames.size(); }
+	uint frameRate() const { return _frameRate; }
+	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
+	const Common::String &errorMessage() const { return _error; }
+
+protected:
+	virtual void handleAction(const byte *data, uint32 size);
+	virtual bool skipObject(uint16 a, uint16 b) const { return false; }
+	virtual int objectScale(byte codec) const;
+	void render();
+
+private:
+	struct Frame {
+		uint32 offset, size;
+	};
+	struct Object {
+		uint32 offset = 0, size = 0;
+		uint16 skipA = 0xffff, skipB = 0;
+		int x = 0, y = 0, width = 0, height = 0, scale = 1;
+		byte codec = 0;
+		byte remap[256] = {};
+		Common::Array<byte> pixels;
+	};
+
+	bool fail(const char *message);
+	void handlePalette(const byte *data, uint32 size);
+	bool decodeObject(Object &object);
+
+	Common::Array<byte> _data, _pixels, _previous, _scratch;
+	Common::Array<Frame> _frames;
+	Common::Array<Object> _objects;
+	Common::Array<MortimerSound> _sounds;
+	Object _storedObject;
+	SmushDeltaBlocksDecoder *_decoder;
+	Common::String _error;
+	byte _palette[768], _initialPalette[768], _remap[256];
+	int16 _paletteDelta[768];
+	int32 _shiftedPalette[768];
+	int _decoderWidth, _decoderHeight;
+	bool _doubled, _initialDoubled, _storeNext;
+	uint _nextFrame, _frameRate;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 6c37ae4de2f..cd60315f8ab 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -148,6 +148,7 @@ MODULE_OBJS += \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
 	insane/mortimer/flight.o \
+	insane/mortimer/movie.o \
 	insane/mortimer/mortimer.o \
 	insane/rebel/rebel_audio.o \
 	insane/rebel/rebel_gamepad.o \


Commit: bbb9d83c865c12d27b87b4cb1c9910704f0868ce
    https://github.com/scummvm/scummvm/commit/bbb9d83c865c12d27b87b4cb1c9910704f0868ce
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: implemented basic interview handling

Changed paths:
  A engines/scumm/insane/mortimer/interview.cpp
  A engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
new file mode 100644
index 00000000000..4b107a6e0d7
--- /dev/null
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -0,0 +1,492 @@
+/* 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 "scumm/insane/mortimer/interview.h"
+
+#include "common/file.h"
+#include "common/tokenizer.h"
+#include "common/util.h"
+#include "graphics/surface.h"
+
+namespace Scumm {
+
+namespace {
+
+// SHELL 444030: the order also matches the rescued-animal flags and icons.
+const char *const kAnimals[] = {"Rhinoceros", "Baboon", "Cheetah", "Elephant", "Giraffe", "Lion", "Oxpecker"};
+const char *const kMovieNames[] = {"RHIN", "BABO", "CHEE", "ELEP", "GIRA", "LION", "OXPE"};
+const char *const kRiddleNames[] = {"RHI", "BAB", "CHE", "ELE", "GIR", "LIO", "OXP"};
+
+struct Control {
+	int id, left, top, right, bottom;
+	const char *help;
+};
+
+// SHELL 441bb0. The large text and picture regions repeat the last question.
+const Control kControls[] = {
+	{62, 9, 0, 376, 172, "HTEXTSCR"},
+	{46, 126, 187, 366, 238, "HINTVIEW"},
+	{47, 126, 239, 366, 284, "HINTVIEW"},
+	{48, 126, 285, 360, 330, "HINTVIEW"},
+	{63, 375, 65, 624, 326, "HIMAGWIN"},
+	{64, 14, 330, 140, 476, "HSPEAKER"},
+	{65, 486, 355, 587, 465, "HGLOWSTN"},
+	{67, 182, 441, 442, 459, "HSHELSHK"},
+	{66, 263, 465, 350, 479, "HSLMSCOP"},
+	{50, 396, 6, 463, 64, "HBUTTON1"},
+	{51, 493, 6, 616, 60, "HDONEBUT"},
+	{52, 10, 242, 124, 329, "HPLAYRID"},
+	{53, 8, 174, 125, 241, "HSOLVRID"},
+	{55, 147, 343, 197, 407, "HPICTURE"},
+	{56, 198, 336, 242, 407, "HPICTURE"},
+	{57, 243, 331, 286, 407, "HPICTURE"},
+	{58, 287, 330, 332, 407, "HPICTURE"},
+	{59, 333, 331, 377, 407, "HPICTURE"},
+	{60, 378, 335, 423, 407, "HPICTURE"},
+	{61, 428, 341, 469, 407, "HPICTURE"}
+};
+
+const Common::Point kQuestionOrigins[] = {{122, 191}, {120, 234}, {124, 278}};
+
+void blit(Graphics::Surface &screen, const MortimerSprite &sprite, int x, int y) {
+	x += sprite.x;
+	y += sprite.y;
+	Common::Rect clip(x, y, x + sprite.width, y + sprite.height);
+	clip.clip(Common::Rect(screen.w, screen.h));
+	if (clip.isEmpty())
+		return;
+	for (int row = clip.top; row < clip.bottom; ++row) {
+		const byte *src = sprite.pixels.begin() + (row - y) * sprite.width;
+		byte *dst = (byte *)screen.getBasePtr(0, row);
+		for (int col = clip.left; col < clip.right; ++col)
+			if (src[col - x])
+				dst[col] = src[col - x];
+	}
+}
+
+} // End of anonymous namespace
+
+bool MortimerFont::load(const char *path) {
+	Common::File file;
+	if (!file.open(Common::Path(path)) || !loadMortimerSprites(file, _glyphs) || _glyphs.size() <= 126)
+		return false;
+	_width = _height = 0;
+	for (uint i = 0; i < _glyphs.size(); ++i) {
+		_width = MAX(_width, _glyphs[i].width);
+		_height = MAX(_height, _glyphs[i].height);
+	}
+	return true;
+}
+
+int MortimerFont::getCharWidth(uint32 chr) const {
+	return chr < _glyphs.size() ? _glyphs[chr].width : 0;
+}
+
+void MortimerFont::drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const {
+	if (chr < _glyphs.size())
+		blit(*surface, _glyphs[chr], x, y);
+}
+
+MortimerInterview::MortimerInterview() : _random("mortimer"), _stage(kIdle), _effect(kNoEffect),
+	_answer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0),
+	_species(0), _stones(0), _guesses(3), _frame(0), _switchFrame(6), _payment(0),
+	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false) {
+	memset(_palette, 0, sizeof(_palette));
+	memset(_gatePalette, 0, sizeof(_gatePalette));
+}
+
+bool MortimerInterview::loadText(const char *path, bool riddles) {
+	Common::File file;
+	if (!file.open(Common::Path(path)))
+		return false;
+	int animal = -1, answer = -1;
+	while (!file.eos() && !file.err()) {
+		Common::String line = file.readLine();
+		line.trim();
+		if (line.empty())
+			continue;
+		if (line[0] == 0x1a)
+			break;
+		if (line[0] == '#') {
+			const Common::String heading = line.substr(1);
+			if (heading.hasPrefix("Answer") && heading.size() == 7) {
+				answer = heading[6] - '1';
+			} else if (heading == "Easy" || heading == "Medium" || heading == "Hard") {
+				answer = heading == "Easy" ? 0 : -1;
+			} else {
+				animal = answer = -1;
+				for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i)
+					if (heading.equalsIgnoreCase(kAnimals[i]))
+						animal = i;
+			}
+		} else if (animal >= 0 && answer >= 0 && answer < 3) {
+			Common::String &text = riddles ? _riddles[animal] : _dialogue[animal][answer];
+			if (!text.empty())
+				text += '\n';
+			text += line;
+		}
+	}
+	for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i) {
+		if (riddles && _riddles[i].empty())
+			return false;
+		for (uint j = 0; !riddles && j < 3; ++j)
+			if (_dialogue[i][j].empty())
+				return false;
+	}
+	return !file.err();
+}
+
+bool MortimerInterview::loadResources() {
+	struct Bank {
+		const char *name;
+		Common::Array<MortimerSprite> *sprites;
+		uint count;
+		byte *palette;
+	};
+	const Bank banks[] = {
+		{"RESOURCE/GENERAL/INTERV_H.NUT", &_panel, 21, nullptr},
+		{"RESOURCE/SAVANNA/S_ANIM_H.NUT", &_portraits, 8, _palette},
+		{"RESOURCE/SAVANNA/S_GATE_H.NUT", &_gate, 1, _gatePalette},
+		{"RESOURCE/GENERAL/CURS_I_H.NUT", &_cursor, 4, nullptr}
+	};
+	Common::File file;
+	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
+		if (!file.open(Common::Path(banks[i].name)) ||
+			!loadMortimerSprites(file, *banks[i].sprites, banks[i].palette) || banks[i].sprites->size() < banks[i].count) {
+			_error = Common::String::format("Missing or invalid %s", banks[i].name);
+			return false;
+		}
+		file.close();
+	}
+	if (!_font.load("RESOURCE/GENERAL/MORTFNTH.NUT") || !_captionFont.load("RESOURCE/GENERAL/SUBTFONT.NUT")) {
+		_error = "Missing or invalid Mortimer font";
+		return false;
+	}
+	if (!loadText("RESOURCE/DATA/DIALOG_F.DAT", false) || !loadText("RESOURCE/DATA/RIDDLE_F.DAT", true)) {
+		_error = "Missing or invalid Mortimer dialogue or riddle text";
+		return false;
+	}
+	if (!file.open("RESOURCE/DATA/SAV_SONG.TRS")) {
+		_error = "Missing Mortimer song captions";
+		return false;
+	}
+	uint id = 0;
+	while (!file.eos() && !file.err()) {
+		Common::String line = file.readLine();
+		line.trim();
+		if (line.hasPrefix("#define")) {
+			Common::StringTokenizer tokens(line);
+			tokens.nextToken();
+			tokens.nextToken();
+			id = atoi(tokens.nextToken().c_str());
+		} else if (line.hasPrefix("//") && id < ARRAYSIZE(_captions)) {
+			_captions[id] = line.substr(2);
+		}
+	}
+	return !file.err();
+}
+
+void MortimerInterview::resetRiddle() {
+	_answer = -1;
+}
+
+void MortimerInterview::start(uint species, uint stones, int scene) {
+	_species = species;
+	_stones = stones;
+	_guesses = 3;
+	_frame = _payment = 0;
+	_switchFrame = 6;
+	_selected = -1;
+	_question = _textScroll = 0;
+	_lastAction = 46;
+	_riddleView = _solving = _help = false;
+	_effect = kNoEffect;
+	if (_answer < 0) {
+		// SHELL 40cd65 restricts the demo to baboon, elephant and oxpecker.
+		const int answers[] = {1, 3, 6};
+		_answer = answers[_random.getRandomNumber(2)];
+	}
+	for (int i = 0; i < 7 && _selected < 0; ++i)
+		if (_species & (1 << i))
+			_selected = i;
+	switch (scene) {
+	case 4: enter(kIdle); break;
+	case 8: enter(kPopup); break;
+	case 18: enter(kSong); break;
+	case 25: enter(kEnding); break;
+	default: enter(kGate); break;
+	}
+}
+
+Common::String MortimerInterview::animalMovie(const char *pattern, int animal) const {
+	return Common::String::format(pattern, kRiddleNames[animal]);
+}
+
+void MortimerInterview::enter(Stage stage) {
+	_stage = stage;
+	_movieName.clear();
+	switch (stage) {
+	case kGate: _movieName = "RESOURCE/SAVANNA/SGATE.SAN"; break;
+	case kSong: _movieName = animalMovie("RESOURCE/SAVANNA/S%sSNG0.SAN", _answer); break;
+	case kPopup: _movieName = "RESOURCE/SAVANNA/S_POPUPH.SAN"; break;
+	case kAnswer:
+		_movieName = Common::String::format("RESOURCE/SAVANNA/S%s%d_H.SAN", kMovieNames[_selected], _question + 1);
+		break;
+	case kRiddle: _movieName = animalMovie("RESOURCE/SAVANNA/S%sRIH0.SAN", _answer); break;
+	case kSolve: _movieName = "RESOURCE/SAVANNA/S_SOLVEH.SAN"; break;
+	case kNoStones: _movieName = "RESOURCE/GENERAL/SGMOR13.SAN"; break;
+	case kGuess: _movieName = animalMovie("RESOURCE/SAVANNA/ANS_%sH.SAN", _guess); break;
+	case kCorrect: _movieName = animalMovie("RESOURCE/SAVANNA/ANS%sH0.SAN", _answer); break;
+	case kWrong: _movieName = "RESOURCE/SAVANNA/S_WRONGH.SAN"; break;
+	case kOpenGate: _movieName = "RESOURCE/SAVANNA/SGYESH.SAN"; break;
+	case kEnding: _movieName = "RESOURCE/ENDING/DEMOENDH.SAN"; break;
+	default: break;
+	}
+	_moviePending = !_movieName.empty();
+}
+
+bool MortimerInterview::fullScreen() const {
+	return _stage == kGate || _stage == kSong || _stage == kPopup || _stage == kOpenGate || _stage == kEnding || _stage == kFinished;
+}
+
+bool MortimerInterview::movieDoubled() const {
+	return _stage == kGate || _stage == kSong || _stage == kOpenGate;
+}
+
+const byte *MortimerInterview::palette(const MortimerMovie &movie) const {
+	if (playingMovie() && _stage != kHelp && _stage != kNoStones)
+		return movie.palette();
+	return _riddleView ? _gatePalette : _palette;
+}
+
+int MortimerInterview::hitControl(const Common::Point &point) const {
+	for (uint i = 0; i < ARRAYSIZE(kControls); ++i) {
+		const Control &c = kControls[i];
+		if (point.x >= c.left && point.x <= c.right && point.y >= c.top && point.y <= c.bottom) {
+			if (!_help && c.id >= 55 && c.id <= 61 && !(_species & (1 << (c.id - 55))))
+				return -1;
+			if (!_help && c.id >= 46 && c.id <= 48 && (_riddleView || _selected < 0))
+				return -1;
+			return c.id;
+		}
+	}
+	return -1;
+}
+
+void MortimerInterview::selectAnimal(int animal) {
+	if (_selected != animal || _riddleView) {
+		_switchFrame = 0;
+		_effect = kSwitchEffect;
+	}
+	_selected = animal;
+	_riddleView = false;
+	_question = _textScroll = 0;
+	_lastAction = 46;
+	enter(kIdle);
+}
+
+void MortimerInterview::click(const Common::Point &point) {
+	if (fullScreen() || _stage == kPay || _stage == kGuess || _stage == kCorrect || _stage == kWrong || _stage == kSolve || _stage == kNoStones)
+		return;
+	int action = hitControl(point);
+	if (action < 0)
+		return;
+	_effect = kButtonEffect;
+	if (action < 55 || action > 61)
+		_solving = false;
+	if (action == 50) {
+		_help = !_help;
+		enter(kIdle);
+		if (_help && !_helpExplained) {
+			_helpExplained = true;
+			enter(kHelp);
+			_movieName = "RESOURCE/GENERAL/HELP/HBUTTON1.SAN";
+			_moviePending = true;
+		}
+		return;
+	}
+	if (_help) {
+		for (uint i = 0; i < ARRAYSIZE(kControls); ++i)
+			if (kControls[i].id == action) {
+				enter(kHelp);
+				_movieName = Common::String::format("RESOURCE/GENERAL/HELP/%s.SAN", kControls[i].help);
+				_moviePending = true;
+				_help = false;
+				return;
+			}
+	}
+	if (action == 51) {
+		enter(kFlyAway);
+		return;
+	}
+	if (_selected < 0)
+		return;
+	if (action == 62 || action == 63)
+		action = _lastAction;
+	if (action >= 55 && action <= 61) {
+		if (_solving) {
+			_guess = action - 55;
+			_solving = false;
+			_payment = 5;
+			--_guesses;
+			enter(kPay);
+		} else {
+			selectAnimal(action - 55);
+		}
+	} else if (action >= 46 && action <= 48 && !_riddleView) {
+		_solving = false;
+		_question = action - 46;
+		_textScroll = 0;
+		_lastAction = action;
+		enter(kAnswer);
+	} else if (action == 52 || action == 53) {
+		_solving = false;
+		_riddleView = true;
+		_textScroll = 0;
+		_lastAction = 52;
+		enter(action == 52 ? kRiddle : kSolve);
+	}
+}
+
+void MortimerInterview::scroll(int direction) {
+	_textScroll = MAX(0, _textScroll + direction);
+}
+
+MortimerInterview::Effect MortimerInterview::takeEffect() {
+	Effect result = _effect;
+	_effect = kNoEffect;
+	return result;
+}
+
+void MortimerInterview::update() {
+	++_frame;
+	if (_switchFrame < 6)
+		++_switchFrame;
+	if (_stage == kPay && (_frame % 3) == 0) {
+		--_stones;
+		--_payment;
+		_effect = kPayoutEffect;
+		if (!_payment)
+			enter(kGuess);
+	}
+}
+
+void MortimerInterview::movieFinished() {
+	switch (_stage) {
+	case kGate: enter(kSong); break;
+	case kSong: enter(kPopup); break;
+	case kSolve:
+		if (_stones < 5) {
+			enter(kNoStones);
+		} else {
+			_solving = true;
+			enter(kIdle);
+		}
+		break;
+	case kGuess: enter(_guess == _answer ? kCorrect : kWrong); break;
+	case kCorrect: enter(kOpenGate); break;
+	case kWrong:
+		if (!_guesses)
+			enter(kFlyAway);
+		else
+			enter(kIdle);
+		break;
+	case kOpenGate: enter(kEnding); break;
+	case kEnding: enter(kFinished); break;
+	default: enter(kIdle); break;
+	}
+}
+
+void MortimerInterview::skip() {
+	if (playingMovie())
+		movieFinished();
+	else if (_stage != kPay)
+		enter(kFlyAway);
+}
+
+void MortimerInterview::drawText(Graphics::Surface &screen, const Common::String &text) const {
+	Common::Array<Common::String> lines;
+	_font.wordWrapText(text, 330, lines, 0, Graphics::kWordWrapOnExplicitNewLines);
+	const int height = _font.getFontHeight();
+	const int visible = MIN<int>(lines.size(), 140 / height);
+	const int first = MIN<int>(_textScroll, lines.size() - visible);
+	const int top = 96 - visible * height / 2;
+	for (int i = 0; i < visible; ++i)
+		_font.drawString(&screen, lines[first + i], 29, top + i * height, 330, 0, Graphics::kTextAlignCenter);
+}
+
+void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &movie) const {
+	if (fullScreen()) {
+		screen.copyRectToSurface(movie.pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
+		for (uint i = 0; i < movie.captions().size(); ++i) {
+			const MortimerMovie::Caption &caption = movie.captions()[i];
+			if (caption.id < ARRAYSIZE(_captions))
+				_captionFont.drawString(&screen, _captions[caption.id], 0, caption.y, MIN<int>(640, caption.x * 2), 0, Graphics::kTextAlignCenter);
+		}
+		return;
+	}
+	screen.fillRect(Common::Rect(640, 480), 10);
+	blit(screen, _riddleView ? _gate[0] : _portraits[_selected < 0 ? 7 : _selected], 354, 69);
+	if (playingMovie() && _stage != kHelp && _stage != kNoStones) {
+		for (int y = 0; y < 320 && y + 69 < screen.h; ++y) {
+			const byte *src = movie.pixels() + y * MortimerMovie::kScreenWidth;
+			byte *dst = (byte *)screen.getBasePtr(354, y + 69);
+			for (int x = 0; x < 640 - 354; ++x)
+				if (src[x])
+					dst[x] = src[x];
+		}
+	}
+	if (_switchFrame < 6)
+		blit(screen, _panel[10 + _switchFrame], 354, 69);
+	blit(screen, _panel[0], 0, 0);
+	if (_riddleView || _selected < 0) {
+		blit(screen, _panel[7], 123, 175);
+		blit(screen, _panel[1], (_frame / 4) % 2 ? 102 : 30, (_frame / 4) % 2 ? 282 : 286);
+	} else if (_selected >= 0) {
+		blit(screen, _panel[4 + _question], kQuestionOrigins[_question].x, kQuestionOrigins[_question].y);
+	}
+	if (_help)
+		blit(screen, _panel[9], 400, 6);
+	if (_solving)
+		blit(screen, _panel[2], 11, 174);
+	if (_selected >= 0)
+		drawText(screen, _riddleView ? _riddles[_answer] : _dialogue[_selected][_question]);
+}
+
+void MortimerInterview::drawCursor(Graphics::Surface &screen, const Common::Point &point) const {
+	if (!fullScreen()) {
+		if (_stage == kPay)
+			blit(screen, _panel[19 + _frame % 2], 508, 451);
+		const int cursor = (_help ? 2 : 0) + (hitControl(point) < 0 ? 1 : 0);
+		blit(screen, _cursor[cursor], point.x, point.y);
+	}
+}
+
+int MortimerInterview::highlightedAnimal() const {
+	if (_solving) {
+		int animal = (_frame / 2) % 7;
+		for (int i = 0; i < 7; ++i, animal = (animal + 1) % 7)
+			if (_species & (1 << animal))
+				return animal;
+	}
+	return _riddleView ? -1 : _selected;
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
new file mode 100644
index 00000000000..404e8c57fc7
--- /dev/null
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -0,0 +1,110 @@
+/* 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 SCUMM_INSANE_MORTIMER_INTERVIEW_H
+#define SCUMM_INSANE_MORTIMER_INTERVIEW_H
+
+#include "common/random.h"
+#include "graphics/font.h"
+#include "scumm/insane/mortimer/movie.h"
+
+namespace Graphics {
+struct Surface;
+}
+
+namespace Scumm {
+
+class MortimerFont : public Graphics::Font {
+public:
+	bool load(const char *path);
+	int getFontHeight() const override { return _height; }
+	int getMaxCharWidth() const override { return _width; }
+	int getCharWidth(uint32 chr) const override;
+	void drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const override;
+
+private:
+	Common::Array<MortimerSprite> _glyphs;
+	int _height = 0, _width = 0;
+};
+
+// The native interview controller in SHELL 40a884, including its riddle
+// callbacks. Movie requests are consumed by the engine after stopping audio.
+class MortimerInterview {
+public:
+	enum Effect { kNoEffect, kButtonEffect, kPayoutEffect, kSwitchEffect };
+
+	MortimerInterview();
+	bool loadResources();
+	void resetRiddle();
+	void start(uint species, uint stones, int scene = 10);
+	void click(const Common::Point &point);
+	void scroll(int direction);
+	void update();
+	void movieFinished();
+	void movieLoaded() { _moviePending = false; }
+	void skip();
+	void draw(Graphics::Surface &screen, const MortimerMovie &movie) const;
+	void drawCursor(Graphics::Surface &screen, const Common::Point &point) const;
+
+	bool needsMovie() const { return _moviePending; }
+	bool playingMovie() const { return !_movieName.empty(); }
+	bool fullScreen() const;
+	bool showDashboard() const { return !fullScreen() || _stage == kPopup; }
+	bool movieDoubled() const;
+	bool wantsFlight() const { return _stage == kFlyAway; }
+	bool finished() const { return _stage == kFinished; }
+	const Common::String &movieName() const { return _movieName; }
+	const Common::String &errorMessage() const { return _error; }
+	const byte *palette(const MortimerMovie &movie) const;
+	uint stones() const { return _stones; }
+	uint species() const { return _species; }
+	int highlightedAnimal() const;
+	Effect takeEffect();
+
+private:
+	enum Stage {
+		kGate, kSong, kPopup, kIdle, kAnswer, kRiddle, kSolve,
+		kNoStones, kPay, kGuess, kCorrect, kWrong, kOpenGate,
+		kEnding, kHelp, kFlyAway, kFinished
+	};
+
+	void enter(Stage stage);
+	void selectAnimal(int animal);
+	int hitControl(const Common::Point &point) const;
+	bool loadText(const char *path, bool riddles);
+	void drawText(Graphics::Surface &screen, const Common::String &text) const;
+	Common::String animalMovie(const char *pattern, int animal) const;
+
+	Common::RandomSource _random;
+	Common::Array<MortimerSprite> _panel, _portraits, _gate, _cursor;
+	MortimerFont _font, _captionFont;
+	Common::String _dialogue[7][3], _riddles[7], _captions[16];
+	Common::String _movieName, _error;
+	byte _palette[768], _gatePalette[768];
+	Stage _stage;
+	Effect _effect;
+	int _answer, _selected, _question, _guess, _lastAction, _textScroll;
+	uint _species, _stones, _guesses, _frame, _switchFrame, _payment;
+	bool _moviePending, _riddleView, _solving, _help, _helpExplained;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 37987aa99a2..5d98d35f8f5 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -23,6 +23,7 @@
 #include "audio/audiostream.h"
 #include "audio/decoders/raw.h"
 #include "backends/keymapper/action.h"
+#include "common/config-manager.h"
 #include "common/events.h"
 #include "common/file.h"
 #include "common/system.h"
@@ -110,9 +111,9 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 
 MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
-	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _segment(0), _completedRescues(0), _completedSpecies(0),
+	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _segment(0), _carriedStones(0), _completedSpecies(0),
 	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
-	_pendingGlowStones(0), _finished(false), _controllerMode(false) {
+	_pendingGlowStones(0), _finished(false), _controllerMode(false), _interviewActive(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 }
@@ -165,7 +166,9 @@ bool MortimerEngine::loadResources() {
 		{"RESOURCE/GENERAL/SFX/SLMGUN2.SAD", &_slimeSound},
 		{"RESOURCE/GENERAL/SFX/MUCKHIT1.SAD", &_slimeHitSound},
 		{"RESOURCE/GENERAL/SFX/GLOWIN1.SAD", &_glowSound},
-		{"RESOURCE/GENERAL/SFX/BURST1.SAD", &_balloonSound}
+		{"RESOURCE/GENERAL/SFX/BURST1.SAD", &_balloonSound},
+		{"RESOURCE/GENERAL/SFX/BUTTON7.SAD", &_buttonSound},
+		{"RESOURCE/GENERAL/SFX/PAYOUT.SAD", &_payoutSound}
 	};
 	for (uint i = 0; i < ARRAYSIZE(effects); ++i) {
 		if (!file.open(Common::Path(effects[i].name)) || !loadMortimerSound(file, *effects[i].sound)) {
@@ -174,15 +177,19 @@ bool MortimerEngine::loadResources() {
 		}
 		file.close();
 	}
+	if (!_interview.loadResources()) {
+		_loadError = _interview.errorMessage();
+		return false;
+	}
 	return true;
 }
 
 MortimerMovie &MortimerEngine::currentMovie() {
-	return _introIndex >= 0 ? _intro : _flight;
+	return _interviewActive || _introIndex >= 0 ? _intro : _flight;
 }
 
 const MortimerMovie &MortimerEngine::currentMovie() const {
-	return _introIndex >= 0 ? _intro : _flight;
+	return _interviewActive || _introIndex >= 0 ? _intro : _flight;
 }
 
 bool MortimerEngine::loadIntro(uint index) {
@@ -252,10 +259,12 @@ void MortimerEngine::stopSounds() {
 	_mixer->stopHandle(_glowHandle);
 }
 
-bool MortimerEngine::startMovie() {
+bool MortimerEngine::startMovie(bool preserveScreen) {
 	stopSounds();
 	MortimerMovie &movie = currentMovie();
 	movie.reset();
+	if (preserveScreen)
+		movie.setBackground((const byte *)_screen.getPixels());
 	if (!movie.advance()) {
 		_loadError = movie.errorMessage();
 		return false;
@@ -271,7 +280,7 @@ bool MortimerEngine::startFlight() {
 	_uiFrame = 0;
 	_shotFrame = 3;
 	_glowFrame = 10;
-	_glowStones = MIN(_completedRescues, 999U);
+	_glowStones = MIN(_carriedStones, 999U);
 	_pendingGlowStones = 0;
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 	for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
@@ -283,11 +292,15 @@ bool MortimerEngine::startFlight() {
 }
 
 bool MortimerEngine::restart(bool all) {
+	if (_interviewActive)
+		all = true;
 	if (_introIndex >= 0 && !all)
 		return startMovie();
 	_shellShocks = 0;
 	if (all) {
-		_completedRescues = _completedSpecies = 0;
+		_interviewActive = false;
+		_interview.resetRiddle();
+		_carriedStones = _completedSpecies = 0;
 		if (!loadFlight(0))
 			return false;
 	}
@@ -296,6 +309,10 @@ bool MortimerEngine::restart(bool all) {
 
 bool MortimerEngine::nextScene(bool skip) {
 	stopSounds();
+	if (_interviewActive) {
+		_interview.skip();
+		return processInterview();
+	}
 	if (_introIndex >= 0) {
 		if (uint(_introIndex + 1) < ARRAYSIZE(kDemoIntros))
 			return loadIntro(_introIndex + 1) && startMovie();
@@ -316,20 +333,106 @@ bool MortimerEngine::nextScene(bool skip) {
 		return true;
 	}
 	if (_segment + 1 == ARRAYSIZE(kDemoFlights)) {
-		_finished = true;
-		return true;
+		_carriedStones += _flight.rescuedCount();
+		_completedSpecies |= _flight.rescuedSpecies();
+		_glowStones = MIN(_carriedStones, 999U);
+		_pendingGlowStones = 0;
+		_glowFrame = 10;
+		_interviewActive = true;
+		_interview.start(_completedSpecies, _glowStones);
+		return processInterview();
 	}
-	// Only follow the demo's two flight branches, never an interview or a
-	// full-game resource named by another SAN file.
+	// The demo's flight movies must follow their expected SAN branches.
 	if (!skip && !_flight.nextFlight().equalsIgnoreCase(kDemoFlights[_segment + 1])) {
 		_loadError = Common::String::format("Unexpected flight transition in %s", kDemoFlights[_segment]);
 		return false;
 	}
-	_completedRescues += _flight.rescuedCount();
+	_carriedStones += _flight.rescuedCount();
 	_completedSpecies |= _flight.rescuedSpecies();
 	return loadFlight(_segment + 1) && startFlight();
 }
 
+bool MortimerEngine::processInterview() {
+	if (_interview.wantsFlight()) {
+		_carriedStones = _interview.stones();
+		_completedSpecies = _interview.species();
+		_interviewActive = false;
+		_shellShocks = 0;
+		return loadFlight(0) && startFlight();
+	}
+	if (_interview.finished()) {
+		stopSounds();
+		_finished = true;
+		return true;
+	}
+	if (_interview.needsMovie()) {
+		stopSounds();
+		_movieHandles.clear();
+		Common::File file;
+		if (!file.open(Common::Path(_interview.movieName()))) {
+			_loadError = Common::String::format("Missing %s", _interview.movieName().c_str());
+			return false;
+		}
+		if (!_intro.load(file, _interview.movieDoubled())) {
+			_loadError = Common::String::format("%s: %s", _interview.movieName().c_str(), _intro.errorMessage().c_str());
+			return false;
+		}
+		_movieHandles.resize(_intro.sounds().size());
+		_interview.movieLoaded();
+		if (!startMovie(_interview.fullScreen()))
+			return false;
+	}
+	const MortimerInterview::Effect effect = _interview.takeEffect();
+	if (effect != MortimerInterview::kNoEffect)
+		playSound(effect == MortimerInterview::kPayoutEffect ? _payoutSound :
+			(effect == MortimerInterview::kSwitchEffect ? _balloonSound : _buttonSound), _scopeHandle, Audio::Mixer::kSFXSoundType);
+	_glowStones = _interview.stones();
+	_system->getPaletteManager()->setPalette(_interview.palette(_intro), 0, 256);
+	return true;
+}
+
+bool MortimerEngine::updateScene() {
+	if (_interviewActive) {
+		if (_controllerMode) {
+			setMouse(Common::Point(_mouse.x + (_steering[1] - _steering[0]) * 18 / Common::JOYAXIS_MAX,
+				_mouse.y + (_steering[3] - _steering[2]) * 18 / Common::JOYAXIS_MAX));
+		}
+		updateInterface();
+		_interview.update();
+		const bool changed = _interview.needsMovie();
+		if (!processInterview())
+			return false;
+		if (changed || !_interview.playingMovie())
+			return true;
+	} else if (interactive()) {
+		updateSteering();
+		updateInterface();
+	}
+	MortimerMovie &movie = currentMovie();
+	if (!movie.advance()) {
+		if (!movie.errorMessage().empty()) {
+			_loadError = movie.errorMessage();
+			return false;
+		}
+		if (_interviewActive) {
+			stopSounds();
+			_interview.movieFinished();
+			return processInterview();
+		}
+		return nextScene();
+	}
+	playMovieSounds();
+	_system->getPaletteManager()->setPalette(_interviewActive ? _interview.palette(movie) : movie.palette(), 0, 256);
+	if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
+		++_shellShocks;
+		playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
+		// SHELL 40be36 resets the flight after eighteen shell shocks.
+		if (_shellShocks >= 18)
+			return restart();
+	}
+	return true;
+}
+
 void MortimerEngine::setMouse(const Common::Point &position) {
 	_mouse.x = CLIP<int>(position.x, 0, 639);
 	_mouse.y = CLIP<int>(position.y, 0, 479);
@@ -432,10 +535,24 @@ Common::Error MortimerEngine::run() {
 		return Common::Error(Common::kReadingFailed, _loadError);
 	initGraphics(640, 480);
 	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
+	_screen.fillRect(Common::Rect(640, 480), 10);
 	syncSoundSettings();
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
-	bool exit = !(loadIntro(0) && startMovie());
+	bool exit;
+	// Use the original scene IDs for ScummVM's developer startup shortcut,
+	// with all animals rescued and enough glowstones to review the riddles.
+	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : 0;
+	if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
+		stopSounds();
+		_introIndex = -1;
+		_interviewActive = true;
+		_interview.start(0x7f, 30, boot);
+		exit = !processInterview();
+		_nextFrameTime = _system->getMillis() + 1000 / currentMovie().frameRate();
+	} else {
+		exit = !(loadIntro(0) && startMovie());
+	}
 	while (!shouldQuit() && !exit) {
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
@@ -448,11 +565,23 @@ Common::Error MortimerEngine::run() {
 				_steering[event.joystick.axis] = CLIP<int>(event.joystick.position, 0, Common::JOYAXIS_MAX);
 				if (event.joystick.position && !isPaused())
 					_controllerMode = true;
+			} else if (_interviewActive && !isPaused() &&
+				(event.type == Common::EVENT_WHEELUP || event.type == Common::EVENT_WHEELDOWN)) {
+				_interview.scroll(event.type == Common::EVENT_WHEELUP ? -1 : 1);
 			} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
 				switch (event.customType) {
 				case kActionFire:
-					if (!isPaused() && !_finished && interactive())
-						fire();
+					if (!isPaused() && !_finished) {
+						if (_interviewActive) {
+							const bool playing = _interview.playingMovie();
+							_interview.click(_mouse);
+							if (playing && !_interview.playingMovie())
+								stopSounds();
+							exit = !processInterview();
+						} else if (interactive()) {
+							fire();
+						}
+					}
 					break;
 				case kActionSkip:
 					if (pause.isActive())
@@ -492,30 +621,9 @@ Common::Error MortimerEngine::run() {
 		if (exit)
 			break;
 		if (!isPaused() && !_finished && int32(_system->getMillis() - _nextFrameTime) >= 0) {
-			MortimerMovie &movie = currentMovie();
-			_nextFrameTime += 1000 / movie.frameRate();
-			if (interactive()) {
-				updateSteering();
-				updateInterface();
-			}
-			if (!movie.advance()) {
-				if (!movie.errorMessage().empty()) {
-					_loadError = movie.errorMessage();
-					break;
-				}
-				if (!nextScene())
-					break;
-			} else {
-				playMovieSounds();
-				_system->getPaletteManager()->setPalette(movie.palette(), 0, 256);
-				if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
-					++_shellShocks;
-					playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
-					// SHELL 40be36 resets the flight after eighteen shell shocks.
-					if (_shellShocks >= 18 && !restart())
-						break;
-				}
-			}
+			_nextFrameTime += 1000 / currentMovie().frameRate();
+			if (!updateScene())
+				break;
 			// Avoid racing through queued frames following a slow backend call.
 			if (int32(_system->getMillis() - _nextFrameTime) > 250)
 				_nextFrameTime = _system->getMillis() + 1000 / currentMovie().frameRate();
@@ -619,9 +727,10 @@ void MortimerEngine::drawDashboard() {
 	const Common::Point iconOrigins[] = {{158, 364}, {203, 358}, {248, 358}, {293, 354}, {339, 355}, {383, 360}, {428, 361}};
 	const Common::Point flashOrigins[] = {{154, 345}, {198, 338}, {243, 334}, {288, 332}, {333, 334}, {378, 338}, {422, 345}};
 	for (uint species = 0; species < 7; ++species) {
-		if (_iconFlash[species] & 1)
+		if ((_iconFlash[species] & 1) || (_interviewActive && int(species) == _interview.highlightedAnimal()))
 			drawSprite(_dashboard[species], flashOrigins[species].x, flashOrigins[species].y);
-		const bool rescued = (_completedSpecies | _flight.rescuedSpecies()) & (1 << species);
+		const uint available = _interviewActive ? _interview.species() : (_completedSpecies | _flight.rescuedSpecies());
+		const bool rescued = available & (1 << species);
 		drawSprite(_icons[species * 2 + (rescued ? 0 : 1)], iconOrigins[species].x, iconOrigins[species].y);
 	}
 	const uint thresholds[] = {1, 2, 3, 4, 7, 20, 30, 40, 50};
@@ -641,7 +750,12 @@ void MortimerEngine::drawDashboard() {
 }
 
 void MortimerEngine::draw() {
-	if (interactive()) {
+	if (_interviewActive) {
+		_interview.draw(_screen, _intro);
+		if (_interview.showDashboard())
+			drawDashboard();
+		_interview.drawCursor(_screen, _mouse);
+	} else if (interactive()) {
 		_screen.fillRect(Common::Rect(640, 480), 10);
 		for (int y = _viewport.top; y < _viewport.bottom; ++y) {
 			const byte *src = _flight.pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerMovie::kScreenWidth + _view.x;
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index dbdcfacaa0c..c6b5ac10433 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -26,6 +26,7 @@
 #include "audio/mixer.h"
 #include "graphics/surface.h"
 #include "scumm/insane/mortimer/flight.h"
+#include "scumm/insane/mortimer/interview.h"
 
 namespace Scumm {
 
@@ -46,13 +47,15 @@ private:
 	bool loadResources();
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
-	bool startMovie();
+	bool startMovie(bool preserveScreen = false);
 	bool startFlight();
+	bool processInterview();
+	bool updateScene();
 	bool nextScene(bool skip = false);
 	bool restart(bool all = false);
 	MortimerMovie &currentMovie();
 	const MortimerMovie &currentMovie() const;
-	bool interactive() const { return _introIndex < 0 && _flight.interactive(); }
+	bool interactive() const { return !_interviewActive && _introIndex < 0 && _flight.interactive(); }
 	void setMouse(const Common::Point &position);
 	void updateSteering();
 	void updateInterface();
@@ -71,10 +74,12 @@ private:
 
 	MortimerMovie _intro;
 	MortimerFlight _flight;
+	MortimerInterview _interview;
 	Graphics::Surface _screen;
 	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons, _head;
 	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
 	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound, _glowSound, _balloonSound;
+	MortimerSound _buttonSound, _payoutSound;
 	Common::Array<Audio::SoundHandle> _movieHandles;
 	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle, _glowHandle;
 	Common::String _loadError;
@@ -85,10 +90,10 @@ private:
 	MortimerFlight::Weapon _shotWeapon;
 	uint32 _nextFrameTime, _pauseTime;
 	int _introIndex;
-	uint _segment, _completedRescues, _completedSpecies, _shellShocks;
+	uint _segment, _carriedStones, _completedSpecies, _shellShocks;
 	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
 	uint _iconFlash[7];
-	bool _finished, _controllerMode;
+	bool _finished, _controllerMode, _interviewActive;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index d7f6777a151..6d7dc2da1c6 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -92,10 +92,10 @@ bool validRows(const byte *src, uint32 size, int height, bool remap) {
 	return pos == size || (pos + 1 == size && src[pos] == 0);
 }
 
-// Codec37's existing decoder has no input-length argument. Validate the two
+// Codec37's existing decoder has no input-length argument. Validate the
 // encodings present in the demo movies before passing any data to it.
 bool validBackground(const byte *src, uint32 size, int width, int height) {
-	if (size < 16 || src[1] != 1 || (src[12] != 0 && src[12] != 4))
+	if (size < 16 || src[1] != 1 || (src[12] & ~6))
 		return false;
 	uint32 pos = 16;
 	if (src[0] == 2) {
@@ -113,11 +113,21 @@ bool validBackground(const byte *src, uint32 size, int width, int height) {
 			pos += count;
 			remaining -= run;
 		}
-	} else if (src[0] == 3) {
-		for (uint block = 0; block < uint(width * height) / 16; ++block) {
+	} else if (src[0] == 3 || src[0] == 4) {
+		const uint blocks = uint(width * height) / 16;
+		for (uint block = 0; block < blocks; ++block) {
 			if (pos == size)
 				return false;
 			const byte code = src[pos++];
+			if (src[0] == 4 && code == 0) {
+				if (pos == size)
+					return false;
+				const uint run = src[pos++] + 1;
+				if (run > blocks - block)
+					return false;
+				block += run - 1;
+				continue;
+			}
 			const uint count = code == 0xff ? 16 : (src[12] & 4) && code == 0xfe ? 4 : (src[12] & 4) && code == 0xfd ? 1 : 0;
 			if (count > size - pos)
 				return false;
@@ -197,7 +207,7 @@ bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
 
 } // End of anonymous namespace
 
-bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites) {
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites, byte *palette) {
 	Common::Array<byte> data;
 	sprites.clear();
 	if (!readData(stream, data) || !isContainer(data, MKTAG('A', 'N', 'I', 'M')))
@@ -207,6 +217,11 @@ bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<Morti
 		Chunk frame;
 		if (!nextChunk(data.begin(), data.size(), pos, frame))
 			return false;
+		if (palette && frame.tag == MKTAG('A', 'H', 'D', 'R')) {
+			if (frame.size < 774)
+				return false;
+			memcpy(palette, data.begin() + frame.offset + 6, 768);
+		}
 		if (frame.tag != MKTAG('F', 'R', 'M', 'E'))
 			continue;
 		uint32 subpos = frame.offset;
@@ -332,6 +347,9 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 				} else if (sub.tag == MKTAG('N', 'P', 'A', 'L')) {
 					if (sub.size != 768)
 						return fail("Invalid Mortimer palette");
+				} else if (sub.tag == MKTAG('T', 'R', 'E', 'S')) {
+					if (sub.size != 18)
+						return fail("Invalid Mortimer caption");
 				} else if (sub.tag == MKTAG('S', 'T', 'O', 'R') || sub.tag == MKTAG('F', 'T', 'C', 'H')) {
 					if (sub.size != (sub.tag == MKTAG('S', 'T', 'O', 'R') ? 4U : 6U))
 						return fail("Invalid Mortimer background command");
@@ -387,6 +405,8 @@ void MortimerMovie::reset() {
 	_storeNext = false;
 	_storedObject = Object();
 	_objects.clear();
+	_captions.clear();
+	_background.clear();
 	memcpy(_palette, _initialPalette, sizeof(_palette));
 	memset(_paletteDelta, 0, sizeof(_paletteDelta));
 	memset(_shiftedPalette, 0, sizeof(_shiftedPalette));
@@ -396,6 +416,11 @@ void MortimerMovie::reset() {
 		_remap[i] = i;
 }
 
+void MortimerMovie::setBackground(const byte *pixels) {
+	_background.resize(_pixels.size());
+	memcpy(_background.begin(), pixels, _background.size());
+}
+
 void MortimerMovie::handleAction(const byte *data, uint32 size) {
 	if (READ_LE_UINT16(data) == 6) {
 		const uint16 flags = READ_LE_UINT16(data + 2);
@@ -433,6 +458,8 @@ bool MortimerMovie::decodeObject(Object &object) {
 	object.width = READ_LE_UINT16(src + 6);
 	object.height = READ_LE_UINT16(src + 8);
 	object.scale = objectScale(object.codec);
+	object.transparent = object.codec == 1 || object.codec == 3 ||
+		(object.codec == 37 && object.scale == 1 && (src[26] & 2));
 	if (object.codec == 23) {
 		if (READ_LE_UINT16(src + 12) == 0x100)
 			memcpy(_remap, src + 14, sizeof(_remap));
@@ -463,6 +490,7 @@ bool MortimerMovie::advance() {
 		return false;
 	memcpy(_previous.begin(), _pixels.begin(), _pixels.size());
 	_objects.clear();
+	_captions.clear();
 	const Frame &current = _frames[_nextFrame++];
 	uint32 pos = current.offset;
 	uint16 skipA = 0xffff, skipB = 0;
@@ -482,6 +510,8 @@ bool MortimerMovie::advance() {
 			handlePalette(src, chunk.size);
 		} else if (chunk.tag == MKTAG('N', 'P', 'A', 'L')) {
 			memcpy(_palette, src, sizeof(_palette));
+		} else if (chunk.tag == MKTAG('T', 'R', 'E', 'S')) {
+			_captions.push_back({READ_LE_INT16(src), READ_LE_INT16(src + 2), READ_LE_UINT16(src + 16)});
 		} else if (chunk.tag == MKTAG('S', 'T', 'O', 'R')) {
 			_storeNext = true;
 		} else if (chunk.tag == MKTAG('F', 'T', 'C', 'H')) {
@@ -491,6 +521,8 @@ bool MortimerMovie::advance() {
 			object.x += READ_LE_INT16(src + 2);
 			object.y += READ_LE_INT16(src + 4);
 			object.scale = objectScale(object.codec);
+			if (object.codec == 37)
+				object.transparent = object.scale == 1 && (_data[object.offset + 26] & 2);
 			object.skipA = skipA;
 			object.skipB = skipB;
 			_objects.push_back(object);
@@ -516,7 +548,11 @@ bool MortimerMovie::advance() {
 }
 
 void MortimerMovie::render() {
-	memcpy(_pixels.begin(), _previous.begin(), _pixels.size());
+	// Transparent cutscenes compose over the scene beneath the movie, not
+	// over their own previous overlays (notably the rising interview panel).
+	// Frames containing only palette or audio commands retain the last image.
+	const byte *base = !_background.empty() && !_objects.empty() ? _background.begin() : _previous.begin();
+	memcpy(_pixels.begin(), base, _pixels.size());
 	for (uint i = 0; i < _objects.size(); ++i) {
 		const Object &object = _objects[i];
 		if (skipObject(object.skipA, object.skipB))
@@ -547,7 +583,7 @@ void MortimerMovie::render() {
 				const byte color = line[(col - x) / object.scale];
 				// SHELL 410160/401b20 preserve the destination for every
 				// zero pixel in both RLE codecs, including literal runs.
-				if ((object.codec != 1 && object.codec != 3) || color)
+				if (!object.transparent || color)
 					dest[col] = color;
 			}
 		}
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index 080b3d13f22..49e4197c746 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -48,19 +48,24 @@ struct MortimerSound {
 	Common::Array<byte> samples;
 };
 
-bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites);
+bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites, byte *palette = nullptr);
 bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
 
 // Mortimer's SMUSH movie reader. Gameplay supplies the IACT and SKIP rules.
 class MortimerMovie {
 public:
 	enum { kScreenWidth = 640, kScreenHeight = 480 };
+	struct Caption {
+		int16 x, y;
+		uint16 id;
+	};
 
 	MortimerMovie();
 	virtual ~MortimerMovie();
 	bool load(Common::SeekableReadStream &stream, bool doubled = true);
 	virtual void reset();
 	virtual bool advance();
+	void setBackground(const byte *pixels);
 
 	const byte *pixels() const { return _pixels.begin(); }
 	const byte *palette() const { return _palette; }
@@ -68,6 +73,7 @@ public:
 	uint frameCount() const { return _frames.size(); }
 	uint frameRate() const { return _frameRate; }
 	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
+	const Common::Array<Caption> &captions() const { return _captions; }
 	const Common::String &errorMessage() const { return _error; }
 
 protected:
@@ -85,6 +91,7 @@ private:
 		uint16 skipA = 0xffff, skipB = 0;
 		int x = 0, y = 0, width = 0, height = 0, scale = 1;
 		byte codec = 0;
+		bool transparent = false;
 		byte remap[256] = {};
 		Common::Array<byte> pixels;
 	};
@@ -93,10 +100,11 @@ private:
 	void handlePalette(const byte *data, uint32 size);
 	bool decodeObject(Object &object);
 
-	Common::Array<byte> _data, _pixels, _previous, _scratch;
+	Common::Array<byte> _data, _pixels, _previous, _scratch, _background;
 	Common::Array<Frame> _frames;
 	Common::Array<Object> _objects;
 	Common::Array<MortimerSound> _sounds;
+	Common::Array<Caption> _captions;
 	Object _storedObject;
 	SmushDeltaBlocksDecoder *_decoder;
 	Common::String _error;
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index cd60315f8ab..a480e3aa651 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -148,6 +148,7 @@ MODULE_OBJS += \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
 	insane/mortimer/flight.o \
+	insane/mortimer/interview.o \
 	insane/mortimer/movie.o \
 	insane/mortimer/mortimer.o \
 	insane/rebel/rebel_audio.o \


Commit: 1c2a61c56e18fc351c5d6a2a3899a5e69847ee0b
    https://github.com/scummvm/scummvm/commit/1c2a61c56e18fc351c5d6a2a3899a5e69847ee0b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: improved video playback to avoid slowdowns

Changed paths:
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h


diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index 63971983352..b6d7c9c9582 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -45,11 +45,11 @@ void MortimerFlight::reset() {
 	memset(_toggles, 0, sizeof(_toggles));
 }
 
-bool MortimerFlight::advance() {
+bool MortimerFlight::advance(bool renderFrame) {
 	_obstacles.clear();
 	for (uint i = 0; i < _targets.size(); ++i)
 		_targets[i].active = false;
-	return MortimerMovie::advance();
+	return MortimerMovie::advance(renderFrame);
 }
 
 int MortimerFlight::objectScale(byte codec) const {
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index b7320c21427..39724a98d8c 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -41,7 +41,7 @@ public:
 	MortimerFlight();
 	bool load(Common::SeekableReadStream &stream, bool interactive = true);
 	void reset() override;
-	bool advance() override;
+	bool advance(bool renderFrame = true) override;
 	ShotResult shoot(const Common::Point &point);
 	bool hitsObstacle(const Common::Point &point) const;
 
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 5d98d35f8f5..14bc609650e 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -320,13 +320,19 @@ bool MortimerEngine::nextScene(bool skip) {
 	}
 	if (skip && !_flight.interactive()) {
 		// Keep TITLE's music at the matching position when skipping its
-		// opening; the same track continues after the cockpit appears.
+		// opening, without compositing images that will never be displayed.
 		while (!_flight.interactive()) {
-			if (!_flight.advance()) {
+			if (!_flight.advance(false)) {
 				_loadError = _flight.errorMessage().empty() ? "Mortimer title has no gameplay transition" : _flight.errorMessage();
 				return false;
 			}
 		}
+		// The frame following the mode switch is a complete keyframe of
+		// the playable scene, so it does not need the skipped title image.
+		if (!_flight.advance()) {
+			_loadError = "Mortimer title has no gameplay frame";
+			return false;
+		}
 		_system->getPaletteManager()->setPalette(_flight.palette(), 0, 256);
 		_nextFrameTime = _system->getMillis() + 1000 / _flight.frameRate();
 		playMovieSounds(true);
@@ -540,9 +546,9 @@ Common::Error MortimerEngine::run() {
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
 	bool exit;
-	// Use the original scene IDs for ScummVM's developer startup shortcut,
-	// with all animals rescued and enough glowstones to review the riddles.
-	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : 0;
+	// Use the original scene IDs for ScummVM's developer startup shortcut.
+	// Interview entry points supply rescued animals and glowstones.
+	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : -1;
 	if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
 		stopSounds();
 		_introIndex = -1;
@@ -550,8 +556,10 @@ Common::Error MortimerEngine::run() {
 		_interview.start(0x7f, 30, boot);
 		exit = !processInterview();
 		_nextFrameTime = _system->getMillis() + 1000 / currentMovie().frameRate();
+	} else if (boot == 0) {
+		exit = !(loadFlight(0) && startFlight());
 	} else {
-		exit = !(loadIntro(0) && startMovie());
+		exit = !(loadIntro(boot >= 19 && boot <= 23 ? boot - 19 : 0) && startMovie());
 	}
 	while (!shouldQuit() && !exit) {
 		Common::Event event;
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 6d7dc2da1c6..d0f418ec0bc 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -24,6 +24,7 @@
 
 #include "common/endian.h"
 #include "common/stream.h"
+#include "common/system.h"
 #include "common/util.h"
 
 namespace Scumm {
@@ -275,6 +276,7 @@ bool MortimerMovie::fail(const char *message) {
 }
 
 bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
+	const uint32 started = g_system->getMillis();
 	_initialDoubled = doubled;
 	_frames.clear();
 	_sounds.clear();
@@ -285,8 +287,9 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 
 	struct Track {
 		uint16 id, flags, nextBlock;
+		uint32 size = 0;
 		MortimerSound sound;
-		Common::Array<byte> bytes;
+		Common::Array<Chunk> blocks;
 	};
 	Common::Array<Track> tracks;
 	uint32 pos = 8;
@@ -375,10 +378,8 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 					}
 					if (tracks[index].nextBlock++ != block)
 						return fail("Missing Mortimer audio block");
-					Common::Array<byte> &bytes = tracks[index].bytes;
-					const uint oldSize = bytes.size();
-					bytes.resize(oldSize + sub.size - 10);
-					memcpy(bytes.begin() + oldSize, body + 10, sub.size - 10);
+					tracks[index].blocks.push_back(sub);
+					tracks[index].size += sub.size - 10;
 				}
 			}
 			if (backgrounds > 1)
@@ -389,10 +390,21 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 	if (_frames.size() != expectedFrames || !expectedFrames)
 		return fail("Incomplete Mortimer movie");
 	for (uint i = 0; i < tracks.size(); ++i) {
-		if (!decodeSound(tracks[i].bytes, tracks[i].sound))
+		// Allocate each track once. Growing an exact-sized byte array for
+		// every PSAD block repeatedly copied the entire accumulated audio.
+		Track &track = tracks[i];
+		Common::Array<byte> bytes(track.size);
+		uint32 offset = 0;
+		for (uint j = 0; j < track.blocks.size(); ++j) {
+			const Chunk &block = track.blocks[j];
+			memcpy(bytes.begin() + offset, _data.begin() + block.offset + 10, block.size - 10);
+			offset += block.size - 10;
+		}
+		if (!decodeSound(bytes, track.sound))
 			return fail("Unsupported or incomplete Mortimer audio");
-		_sounds.push_back(tracks[i].sound);
+		_sounds.push_back(Common::move(track.sound));
 	}
+	debug(1, "Mortimer: loaded %u movie frames in %u ms", _frames.size(), g_system->getMillis() - started);
 	return true;
 }
 
@@ -437,11 +449,14 @@ int MortimerMovie::objectScale(byte codec) const {
 
 void MortimerMovie::handlePalette(const byte *data, uint32 size) {
 	if (size == 2308) {
+		// SHELL 414a14 installs XPAL's base palette before initializing the
+		// fade accumulators. Using the preceding scene's colors corrupts
+		// subsequent delta frames, even though this first frame looks right.
+		memcpy(_palette, data + 1540, sizeof(_palette));
 		for (uint i = 0; i < 768; ++i) {
 			_shiftedPalette[i] = _palette[i] << 7;
 			_paletteDelta[i] = READ_LE_INT16(data + 4 + i * 2);
 		}
-		memcpy(_palette, data + 1540, sizeof(_palette));
 	} else {
 		for (uint i = 0; i < 768; ++i) {
 			_shiftedPalette[i] += _paletteDelta[i];
@@ -485,10 +500,11 @@ bool MortimerMovie::decodeObject(Object &object) {
 	return true;
 }
 
-bool MortimerMovie::advance() {
+bool MortimerMovie::advance(bool renderFrame) {
 	if (_nextFrame >= _frames.size())
 		return false;
-	memcpy(_previous.begin(), _pixels.begin(), _pixels.size());
+	if (renderFrame)
+		memcpy(_previous.begin(), _pixels.begin(), _pixels.size());
 	_objects.clear();
 	_captions.clear();
 	const Frame &current = _frames[_nextFrame++];
@@ -525,7 +541,7 @@ bool MortimerMovie::advance() {
 				object.transparent = object.scale == 1 && (_data[object.offset + 26] & 2);
 			object.skipA = skipA;
 			object.skipB = skipB;
-			_objects.push_back(object);
+			_objects.push_back(Common::move(object));
 		} else if (chunk.tag == MKTAG('F', 'O', 'B', 'J')) {
 			Object object;
 			object.offset = chunk.offset;
@@ -538,12 +554,13 @@ bool MortimerMovie::advance() {
 				_storedObject = object;
 				_storeNext = false;
 			}
-			_objects.push_back(object);
+			_objects.push_back(Common::move(object));
 		}
 		skipA = 0xffff;
 		skipB = 0;
 	}
-	render();
+	if (renderFrame)
+		render();
 	return true;
 }
 
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index 49e4197c746..bdab3b107a7 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -64,7 +64,8 @@ public:
 	virtual ~MortimerMovie();
 	bool load(Common::SeekableReadStream &stream, bool doubled = true);
 	virtual void reset();
-	virtual bool advance();
+	// Suppress compositing while seeking to a frame that replaces the image.
+	virtual bool advance(bool renderFrame = true);
 	void setBackground(const byte *pixels);
 
 	const byte *pixels() const { return _pixels.begin(); }


Commit: c866c4ade3524ff9e11985425a6f21f324cd9d13
    https://github.com/scummvm/scummvm/commit/c866c4ade3524ff9e11985425a6f21f324cd9d13
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:42+02:00

Commit Message:
SCUMM: MORTIMER: added full game detection

Changed paths:
    devtools/scumm-md5.txt
    engines/scumm/detection_tables.h
    engines/scumm/scumm-md5.h


diff --git a/devtools/scumm-md5.txt b/devtools/scumm-md5.txt
index 98fbe672899..ec92e15f7a9 100644
--- a/devtools/scumm-md5.txt
+++ b/devtools/scumm-md5.txt
@@ -450,7 +450,8 @@ ft	Full Throttle
 	8ead2c01c48c2e3d15d6a89b86cd84ab	19697	he	DOS	-	-	-	Hebrew fan translation
 
 mortimer	Mortimer and the Riddles of the Medallion
-	3450a722d29c150a4bdac5ba9b5f3b46	290304	en	Windows	-	Demo	Windows demo launcher	-
+	b6d609c97e1d089e27ef5eadd6bb3fc1	450048	en	Windows	-	-	Windows retail launcher	-
+	3450a722d29c150a4bdac5ba9b5f3b46	290304	en	Windows	Demo	Demo	Windows demo launcher	-
 
 rebel1	Star Wars: Rebel Assault
 	0ca9c07185b4969764eaf3ecd0de06e9	111530	en	DOS	Demo v0.67	Demo v0.67	Non-interactive demo	-
diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index d94ce23f1bb..e1f2a720abc 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -251,7 +251,8 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
-	{"mortimer", 0, 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Demo", 0, GID_DIG, 7, 0, MDT_NONE, GF_DEMO, UNK, GUIO4(GUIO_NOMIDI, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/scumm-md5.h b/engines/scumm/scumm-md5.h
index f5365db9848..6d8900a1659 100644
--- a/engines/scumm/scumm-md5.h
+++ b/engines/scumm/scumm-md5.h
@@ -1,5 +1,5 @@
 /*
-  This file was generated by the md5table tool on Wed Sep 30 06:55:18 2026
+  This file was generated by the md5table tool on Wed Sep 30 14:18:16 2026
   DO NOT EDIT MANUALLY!
  */
 
@@ -196,7 +196,7 @@ static const MD5Table md5table[] = {
 	{ "330f631502e381a4e199a3f7cb483c20", "indy3", "EGA", "EGA", -1, Common::DE_DEU, Common::kPlatformAmiga },
 	{ "33e989f85da700e2014d00f345cab3d7", "puttrace", "HE 98.5", "", 32754, Common::FR_FRA, Common::kPlatformWindows },
 	{ "3433be9866ca4261b2d5d25374e3f243", "monkey", "VGA", "", 8347, Common::FR_FRA, Common::kPlatformAmiga },
-	{ "3450a722d29c150a4bdac5ba9b5f3b46", "mortimer", "", "Demo", 290304, Common::EN_ANY, Common::kPlatformWindows },
+	{ "3450a722d29c150a4bdac5ba9b5f3b46", "mortimer", "Demo", "Demo", 290304, Common::EN_ANY, Common::kPlatformWindows },
 	{ "3486ede0f904789267d4bcc5537a46d4", "puttzoo", "", "Demo", 14337, Common::EN_ANY, Common::kPlatformMacintosh },
 	{ "35183fc18b1e04cdc9070078260b2c03", "zak", "V1", "", 1914, Common::EN_ANY, Common::kPlatformC64 },
 	{ "356fb5f680b68251333016175935d126", "BluesABCTime", "HE CUP", "Preview", 4133436, Common::UNK_LANG, Common::kPlatformUnknown },
@@ -615,6 +615,7 @@ static const MD5Table md5table[] = {
 	{ "b628506f7def772e40de0aa5440fb8e1", "activity", "HE 70", "", 9410, Common::EN_ANY, Common::kPlatformWindows },
 	{ "b6a7f83e77e33879f669d4cc5f28403c", "freddi2", "HE 80", "", 65305, Common::JA_JPN, Common::kPlatformWindows },
 	{ "b6b286246b05c147e3f8b15505b1648c", "freddi", "HE 73", "Demo", 9800, Common::JA_JPN, Common::kPlatformWindows },
+	{ "b6d609c97e1d089e27ef5eadd6bb3fc1", "mortimer", "", "", 450048, Common::EN_ANY, Common::kPlatformWindows },
 	{ "b7479cd302cd82f1cd2b5a12186d1401", "atlantis", "", "Floppy", 12030, Common::RU_RUS, Common::kPlatformDOS },
 	{ "b7d37d6b786b5a22deea3b038eca96ca", "maniac", "NES", "", 2082, Common::ES_ESP, Common::kPlatformNES },
 	{ "b83cfea649a94907062e176e41279a58", "football", "", "Steam", -1, Common::EN_ANY, Common::kPlatformWindows },


Commit: 8b1b2cc97c65ce457c643e219c19a7754076db53
    https://github.com/scummvm/scummvm/commit/8b1b2cc97c65ce457c643e219c19a7754076db53
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: full game intro implemented

Changed paths:
  A engines/scumm/insane/mortimer/text.cpp
  A engines/scumm/insane/mortimer/text.h
    engines/scumm/detection_tables.h
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h
    engines/scumm/metaengine.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index e1f2a720abc..d3ebd48c32b 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -251,7 +251,7 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
-	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO3(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOASPECT)},
 	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 4b107a6e0d7..8baa56f2073 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -83,27 +83,6 @@ void blit(Graphics::Surface &screen, const MortimerSprite &sprite, int x, int y)
 
 } // End of anonymous namespace
 
-bool MortimerFont::load(const char *path) {
-	Common::File file;
-	if (!file.open(Common::Path(path)) || !loadMortimerSprites(file, _glyphs) || _glyphs.size() <= 126)
-		return false;
-	_width = _height = 0;
-	for (uint i = 0; i < _glyphs.size(); ++i) {
-		_width = MAX(_width, _glyphs[i].width);
-		_height = MAX(_height, _glyphs[i].height);
-	}
-	return true;
-}
-
-int MortimerFont::getCharWidth(uint32 chr) const {
-	return chr < _glyphs.size() ? _glyphs[chr].width : 0;
-}
-
-void MortimerFont::drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const {
-	if (chr < _glyphs.size())
-		blit(*surface, _glyphs[chr], x, y);
-}
-
 MortimerInterview::MortimerInterview() : _random("mortimer"), _stage(kIdle), _effect(kNoEffect),
 	_answer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0),
 	_species(0), _stones(0), _guesses(3), _frame(0), _switchFrame(6), _payment(0),
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index 404e8c57fc7..4487c948ba9 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -22,8 +22,7 @@
 #define SCUMM_INSANE_MORTIMER_INTERVIEW_H
 
 #include "common/random.h"
-#include "graphics/font.h"
-#include "scumm/insane/mortimer/movie.h"
+#include "scumm/insane/mortimer/text.h"
 
 namespace Graphics {
 struct Surface;
@@ -31,19 +30,6 @@ struct Surface;
 
 namespace Scumm {
 
-class MortimerFont : public Graphics::Font {
-public:
-	bool load(const char *path);
-	int getFontHeight() const override { return _height; }
-	int getMaxCharWidth() const override { return _width; }
-	int getCharWidth(uint32 chr) const override;
-	void drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const override;
-
-private:
-	Common::Array<MortimerSprite> _glyphs;
-	int _height = 0, _width = 0;
-};
-
 // The native interview controller in SHELL 40a884, including its riddle
 // callbacks. Movie requests are consumed by the engine after stopping audio.
 class MortimerInterview {
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 14bc609650e..f620dbdd974 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -48,6 +48,16 @@ static const char *const kDemoIntros[] = {
 	"RESOURCE/BEGIN/INTRO5_H.SAN"
 };
 
+const char *const kRetailIntros[] = {
+	"RESOURCE/BEGIN/GOLDGUY.SAN",
+	"RESOURCE/BEGIN/INTRO1_H.SAN",
+	"RESOURCE/BEGIN/INTRO2_H.SAN",
+	"RESOURCE/BEGIN/INTRO3_H.SAN",
+	"RESOURCE/BEGIN/INTRO4_H.SAN",
+	"RESOURCE/BEGIN/INTRO5HB.SAN",
+	"RESOURCE/BEGIN/TITLE.SAN"
+};
+
 static const char *const kDemoFlights[] = {
 	"RESOURCE/DEMO/TITLE.SAN",
 	"RESOURCE/DEMO/DEMO_PL.SAN",
@@ -109,7 +119,7 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 	return keymaps;
 }
 
-MortimerEngine::MortimerEngine(OSystem *system) : Engine(system),
+MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
 	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _segment(0), _carriedStones(0), _completedSpecies(0),
 	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
@@ -185,24 +195,33 @@ bool MortimerEngine::loadResources() {
 }
 
 MortimerMovie &MortimerEngine::currentMovie() {
-	return _interviewActive || _introIndex >= 0 ? _intro : _flight;
+	if (_interviewActive || _introIndex >= 0)
+		return _intro;
+	return _flight;
 }
 
 const MortimerMovie &MortimerEngine::currentMovie() const {
-	return _interviewActive || _introIndex >= 0 ? _intro : _flight;
+	if (_interviewActive || _introIndex >= 0)
+		return _intro;
+	return _flight;
+}
+
+uint MortimerEngine::introCount() const {
+	return _isDemo ? ARRAYSIZE(kDemoIntros) : ARRAYSIZE(kRetailIntros);
 }
 
 bool MortimerEngine::loadIntro(uint index) {
 	stopSounds();
 	_movieHandles.clear();
+	const char *name = _isDemo ? kDemoIntros[index] : kRetailIntros[index];
 	Common::File file;
-	if (!file.open(Common::Path(kDemoIntros[index]))) {
-		_loadError = Common::String::format("Missing %s", kDemoIntros[index]);
+	if (!file.open(Common::Path(name))) {
+		_loadError = Common::String::format("Missing %s", name);
 		return false;
 	}
 	// The fifth introduction starts with native 640 x 480 animation.
-	if (!_intro.load(file, index != 4)) {
-		_loadError = Common::String::format("%s: %s", kDemoIntros[index], _intro.errorMessage().c_str());
+	if (!_intro.load(file, index != (_isDemo ? 4U : 5U))) {
+		_loadError = Common::String::format("%s: %s", name, _intro.errorMessage().c_str());
 		return false;
 	}
 	_introIndex = index;
@@ -292,6 +311,8 @@ bool MortimerEngine::startFlight() {
 }
 
 bool MortimerEngine::restart(bool all) {
+	if (!_isDemo)
+		return all ? loadIntro(0) && startMovie() : startMovie();
 	if (_interviewActive)
 		all = true;
 	if (_introIndex >= 0 && !all)
@@ -314,8 +335,11 @@ bool MortimerEngine::nextScene(bool skip) {
 		return processInterview();
 	}
 	if (_introIndex >= 0) {
-		if (uint(_introIndex + 1) < ARRAYSIZE(kDemoIntros))
+		if (uint(_introIndex + 1) < introCount())
 			return loadIntro(_introIndex + 1) && startMovie();
+		// Retail gameplay follows the title once its campaign is implemented.
+		if (!_isDemo)
+			return false;
 		return loadFlight(0) && startFlight();
 	}
 	if (skip && !_flight.interactive()) {
@@ -537,8 +561,10 @@ void MortimerEngine::pauseEngineIntern(bool pause) {
 }
 
 Common::Error MortimerEngine::run() {
-	if (!loadResources())
+	if (_isDemo && !loadResources())
 		return Common::Error(Common::kReadingFailed, _loadError);
+	if (!_isDemo && !_subtitles.load("RESOURCE/DATA/BEGIN.TRS", "RESOURCE/GENERAL/ESTRO20.NUT", "RESOURCE/GENERAL/MORTFNTH.NUT"))
+		return Common::Error(Common::kReadingFailed, "Missing or invalid Mortimer subtitle resources");
 	initGraphics(640, 480);
 	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
 	_screen.fillRect(Common::Rect(640, 480), 10);
@@ -549,7 +575,9 @@ Common::Error MortimerEngine::run() {
 	// Use the original scene IDs for ScummVM's developer startup shortcut.
 	// Interview entry points supply rescued animals and glowstones.
 	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : -1;
-	if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
+	if (!_isDemo) {
+		exit = !(loadIntro(boot >= 24 && boot <= 30 ? boot - 24 : 0) && startMovie());
+	} else if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
 		stopSounds();
 		_introIndex = -1;
 		_interviewActive = true;
@@ -781,6 +809,8 @@ void MortimerEngine::draw() {
 		drawProjectile();
 	} else {
 		_screen.copyRectToSurface(currentMovie().pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
+		if (!_isDemo)
+			_subtitles.draw(_screen, currentMovie().captions(), ConfMan.getBool("subtitles"));
 	}
 	if (isPaused()) {
 		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index c6b5ac10433..3fbaa77f00c 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -34,7 +34,7 @@ namespace Scumm {
 // and both Rebel Assault engines.
 class MortimerEngine : public Engine {
 public:
-	explicit MortimerEngine(OSystem *system);
+	MortimerEngine(OSystem *system, bool isDemo);
 	~MortimerEngine() override;
 	Common::Error run() override;
 	bool hasFeature(EngineFeature feature) const override;
@@ -45,6 +45,7 @@ protected:
 
 private:
 	bool loadResources();
+	uint introCount() const;
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
 	bool startMovie(bool preserveScreen = false);
@@ -72,9 +73,11 @@ private:
 	void playMovieSounds(bool seek = false);
 	void stopSounds();
 
-	MortimerMovie _intro;
+	const bool _isDemo;
+	MortimerCutscene _intro;
 	MortimerFlight _flight;
 	MortimerInterview _interview;
+	MortimerSubtitles _subtitles;
 	Graphics::Surface _screen;
 	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons, _head;
 	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index d0f418ec0bc..850686f1bbd 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -150,7 +150,7 @@ bool validObject(const byte *data, uint32 size, bool sprite = false) {
 		return false;
 	const byte codec = data[0];
 	if (sprite)
-		return codec == 1 && validRows(data + 14, size - 14, height, false);
+		return codec == 44 || (codec == 1 && validRows(data + 14, size - 14, height, false));
 	if (codec == 37)
 		return !(width & 3) && !(height & 3) && validBackground(data + 14, size - 14, width, height);
 	if (codec != 1 && codec != 3 && codec != 23)
@@ -208,6 +208,43 @@ bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
 
 } // End of anonymous namespace
 
+bool decodeMortimerGlyph(const byte *src, uint32 size, MortimerSprite &sprite) {
+	sprite.mask.resize(sprite.pixels.size());
+	memset(sprite.mask.begin(), 0, sprite.mask.size());
+	uint32 pos = 0;
+	for (int y = 0; y < sprite.height; ++y) {
+		if (size - pos < 2)
+			return false;
+		const uint length = READ_LE_UINT16(src + pos);
+		pos += 2;
+		if (length > size - pos)
+			return false;
+		const uint32 end = pos + length;
+		uint x = 0;
+		while (x < uint(sprite.width)) {
+			if (end - pos < 2)
+				return false;
+			x += READ_LE_UINT16(src + pos);
+			pos += 2;
+			if (x >= uint(sprite.width))
+				break;
+			if (end - pos < 2)
+				return false;
+			const uint count = MIN<uint>(READ_LE_UINT16(src + pos) + 1, sprite.width - x);
+			pos += 2;
+			if (count > end - pos)
+				return false;
+			const uint offset = y * sprite.width + x;
+			memcpy(sprite.pixels.begin() + offset, src + pos, count);
+			memset(sprite.mask.begin() + offset, 1, count);
+			pos += count;
+			x += count;
+		}
+		pos = end;
+	}
+	return true;
+}
+
 bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites, byte *palette) {
 	Common::Array<byte> data;
 	sprites.clear();
@@ -243,7 +280,12 @@ bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<Morti
 			sprite.height = READ_LE_INT16(src + 8);
 			sprite.pixels.resize(sprite.width * sprite.height);
 			memset(sprite.pixels.begin(), 0, sprite.pixels.size());
-			smushDecodeRLEOpaque(sprite.pixels.begin(), src + 14, 0, 0, sprite.width, sprite.height, sprite.width, chunk.size - 14);
+			if (src[0] == 44) {
+				if (!decodeMortimerGlyph(src + 14, chunk.size - 14, sprite))
+					return false;
+			} else {
+				smushDecodeRLEOpaque(sprite.pixels.begin(), src + 14, 0, 0, sprite.width, sprite.height, sprite.width, chunk.size - 14);
+			}
 			sprites.push_back(sprite);
 		}
 		if (sprites.size() != before + 1)
@@ -361,10 +403,19 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 						return fail("Truncated Mortimer audio block");
 					const uint16 id = READ_LE_UINT16(body), block = READ_LE_UINT16(body + 2);
 					const uint16 flags = READ_LE_UINT16(body + 6) & 0xc0;
-					uint index = 0;
-					while (index < tracks.size() && (tracks[index].id != id || tracks[index].flags != flags))
-						++index;
-					if (index == tracks.size()) {
+					int index = -1;
+					// Block zero starts a new sound, even when its ID is reused.
+					if (block) {
+						for (int i = int(tracks.size()) - 1; i >= 0; --i) {
+							if (tracks[i].id == id && tracks[i].flags == flags) {
+								index = i;
+								break;
+							}
+						}
+						if (index < 0)
+							return fail("Missing Mortimer audio block");
+					}
+					if (index < 0) {
 						Track track;
 						track.id = id;
 						track.flags = flags;
@@ -374,6 +425,7 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 						track.sound.speech = (flags & 128) != 0;
 						track.sound.volume = MIN(255, body[8] * 2);
 						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
+						index = tracks.size();
 						tracks.push_back(track);
 					}
 					if (tracks[index].nextBlock++ != block)
@@ -607,4 +659,27 @@ void MortimerMovie::render() {
 	}
 }
 
+MortimerCutscene::MortimerCutscene(bool girl) : _girl(girl) {
+	reset();
+}
+
+void MortimerCutscene::reset() {
+	MortimerMovie::reset();
+	memset(_toggles, 0, sizeof(_toggles));
+}
+
+void MortimerCutscene::handleAction(const byte *data, uint32 size) {
+	MortimerMovie::handleAction(data, size);
+	if (READ_LE_UINT16(data) != 2 || READ_LE_UINT16(data + 4) != 1)
+		return;
+	const uint16 id = READ_LE_UINT16(data + 6), toggle = READ_LE_UINT16(data + 8);
+	// INTRO4_H contains both player characters under separate object toggles.
+	if (id == 3000 || id == 4000)
+		_toggles[toggle] = id == 3000 ? _girl : !_girl;
+}
+
+bool MortimerCutscene::skipObject(uint16 a, uint16 b) const {
+	return a != 0xffff && (b ? _toggles[a] != _toggles[b] : _toggles[a]);
+}
+
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index bdab3b107a7..cb75407320b 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -36,6 +36,8 @@ class SmushDeltaBlocksDecoder;
 struct MortimerSprite {
 	int x, y, width, height;
 	Common::Array<byte> pixels;
+	// Codec 44 uses skipped pixels for transparency and relative palette colors.
+	Common::Array<byte> mask;
 };
 
 struct MortimerSound {
@@ -117,6 +119,20 @@ private:
 	uint _nextFrame, _frameRate;
 };
 
+class MortimerCutscene : public MortimerMovie {
+public:
+	explicit MortimerCutscene(bool girl = false);
+	void reset() override;
+
+protected:
+	void handleAction(const byte *data, uint32 size) override;
+	bool skipObject(uint16 a, uint16 b) const override;
+
+private:
+	const bool _girl;
+	bool _toggles[256];
+};
+
 } // End of namespace Scumm
 
 #endif
diff --git a/engines/scumm/insane/mortimer/text.cpp b/engines/scumm/insane/mortimer/text.cpp
new file mode 100644
index 00000000000..bda3d4fe25a
--- /dev/null
+++ b/engines/scumm/insane/mortimer/text.cpp
@@ -0,0 +1,147 @@
+/* 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 "scumm/insane/mortimer/text.h"
+
+#include "common/file.h"
+#include "common/tokenizer.h"
+#include "common/util.h"
+#include "graphics/surface.h"
+
+namespace Scumm {
+
+bool MortimerFont::load(const char *path) {
+	Common::File file;
+	return file.open(Common::Path(path)) && load(file);
+}
+
+bool MortimerFont::load(Common::SeekableReadStream &stream) {
+	_width = _height = 0;
+	if (!loadMortimerSprites(stream, _glyphs) || _glyphs.size() < 126)
+		return false;
+	for (uint i = 0; i < _glyphs.size(); ++i) {
+		_width = MAX(_width, _glyphs[i].width);
+		_height = MAX(_height, _glyphs[i].height);
+	}
+	return true;
+}
+
+int MortimerFont::getCharWidth(uint32 chr) const {
+	return chr < _glyphs.size() ? _glyphs[chr].width : 0;
+}
+
+void MortimerFont::drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const {
+	if (chr >= _glyphs.size())
+		return;
+	const MortimerSprite &glyph = _glyphs[chr];
+	x += glyph.x;
+	y += glyph.y;
+	Common::Rect clip(x, y, x + glyph.width, y + glyph.height);
+	clip.clip(Common::Rect(surface->w, surface->h));
+	if (clip.isEmpty())
+		return;
+	for (int row = clip.top; row < clip.bottom; ++row) {
+		byte *dst = (byte *)surface->getBasePtr(0, row);
+		for (int col = clip.left; col < clip.right; ++col) {
+			const uint offset = (row - y) * glyph.width + col - x;
+			const byte pixel = glyph.pixels[offset];
+			if (!glyph.mask.empty()) {
+				// Mortimer's codec 44 shifts the whole glyph palette, including shading.
+				if (glyph.mask[offset])
+					dst[col] = byte(pixel + color - 1);
+			} else if (pixel) {
+				dst[col] = pixel;
+			}
+		}
+	}
+}
+
+bool MortimerSubtitles::load(const char *textPath, const char *fontPath, const char *infoFontPath) {
+	Common::File file;
+	return file.open(Common::Path(textPath)) && loadText(file) && _font.load(fontPath) && _infoFont.load(infoFontPath);
+}
+
+bool MortimerSubtitles::loadText(Common::SeekableReadStream &stream) {
+	_entries.clear();
+	int id = -1;
+	while (!stream.eos() && !stream.err()) {
+		Common::String line = stream.readLine();
+		line.trim();
+		if (line.empty())
+			continue;
+		if (line[0] == 0x1a)
+			break;
+		if (line[0] == '#') {
+			// Labels may contain spaces; the numeric ID is the final token.
+			Common::StringTokenizer tokens(line);
+			Common::String token;
+			while (!tokens.empty())
+				token = tokens.nextToken();
+			char *end;
+			const long value = strtol(token.c_str(), &end, 10);
+			if (end == token.c_str() || *end || value < 0 || value > 65535)
+				return false;
+			id = value;
+		} else if (line.hasPrefix("//") && id >= 0) {
+			line = line.substr(2);
+			Entry &entry = _entries[id];
+			if (entry.text.empty() && line.hasPrefix("$")) {
+				entry.information = true;
+				line = line.substr(1);
+			}
+			if (line.size() >= 5 && line.hasPrefix("^c") &&
+				Common::isDigit(line[2]) && Common::isDigit(line[3]) && Common::isDigit(line[4])) {
+				entry.color = atoi(line.substr(2, 3).c_str());
+				line = line.substr(5);
+			}
+			if (!entry.text.empty())
+				entry.text += '\n';
+			entry.text += line;
+		}
+	}
+	return !stream.err() && !_entries.empty();
+}
+
+void MortimerSubtitles::draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled) const {
+	for (uint i = 0; i < captions.size(); ++i) {
+		const MortimerMovie::Caption &caption = captions[i];
+		const auto found = _entries.find(caption.id);
+		if (found == _entries.end())
+			continue;
+		const Entry &entry = found->_value;
+		if ((!enabled && !entry.information) || entry.text.empty())
+			continue;
+		const MortimerFont &font = entry.information ? _infoFont : _font;
+		Common::Array<Common::String> lines;
+		const int width = font.wordWrapText(entry.text, screen.w, lines);
+		const int lineHeight = font.getFontHeight() + 1;
+		const int height = lines.size() * lineHeight - 1;
+		const int x = CLIP<int>(caption.x, width / 2, screen.w - (width + 1) / 2);
+		int y = caption.y - (entry.information ? height / 2 : 0);
+		y = MAX(0, MIN<int>(y, screen.h - height));
+		for (uint j = 0; j < lines.size(); ++j) {
+			const int lineWidth = font.getStringWidth(lines[j]);
+			font.drawString(&screen, lines[j], x - lineWidth / 2, y + j * lineHeight, lineWidth, entry.color,
+				Graphics::kTextAlignLeft, 0, false);
+		}
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/text.h b/engines/scumm/insane/mortimer/text.h
new file mode 100644
index 00000000000..53cbf1dbe05
--- /dev/null
+++ b/engines/scumm/insane/mortimer/text.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 SCUMM_INSANE_MORTIMER_TEXT_H
+#define SCUMM_INSANE_MORTIMER_TEXT_H
+
+#include "common/hashmap.h"
+#include "graphics/font.h"
+#include "scumm/insane/mortimer/movie.h"
+
+namespace Scumm {
+
+class MortimerFont : public Graphics::Font {
+public:
+	bool load(const char *path);
+	bool load(Common::SeekableReadStream &stream);
+	int getFontHeight() const override { return _height; }
+	int getMaxCharWidth() const override { return _width; }
+	int getCharWidth(uint32 chr) const override;
+	void drawChar(Graphics::Surface *surface, uint32 chr, int x, int y, uint32 color) const override;
+
+private:
+	Common::Array<MortimerSprite> _glyphs;
+	int _height = 0, _width = 0;
+};
+
+class MortimerSubtitles {
+public:
+	bool load(const char *textPath, const char *fontPath, const char *infoFontPath);
+	bool loadText(Common::SeekableReadStream &stream);
+	void draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled) const;
+
+private:
+	struct Entry {
+		Common::String text;
+		byte color = 1;
+		bool information = false;
+	};
+
+	MortimerFont _font, _infoFont;
+	Common::HashMap<uint16, Entry> _entries;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index fb7f1e2b835..74534a55666 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -473,7 +473,7 @@ Common::Error ScummMetaEngine::createInstance(OSystem *syst, Engine **engine,
 	// instantiate the appropriate game engine. Hooray!
 #ifdef ENABLE_SCUMM_7_8
 	if (res.game.id == GID_MORTIMER) {
-		*engine = new MortimerEngine(syst);
+		*engine = new MortimerEngine(syst, (res.game.features & GF_DEMO) != 0);
 		return Common::kNoError;
 	}
 #endif
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index a480e3aa651..68d96c91920 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -151,6 +151,7 @@ MODULE_OBJS += \
 	insane/mortimer/interview.o \
 	insane/mortimer/movie.o \
 	insane/mortimer/mortimer.o \
+	insane/mortimer/text.o \
 	insane/rebel/rebel_audio.o \
 	insane/rebel/rebel_gamepad.o \
 	insane/rebel/rebel_touch.o \


Commit: cce591bdbf687a67bb8eaf39acaf4a6b92928a66
    https://github.com/scummvm/scummvm/commit/cce591bdbf687a67bb8eaf39acaf4a6b92928a66
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: implement campaign progression

Changed paths:
  A engines/scumm/insane/mortimer/campaign.cpp
  A engines/scumm/insane/mortimer/campaign.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h
    engines/scumm/insane/mortimer/text.cpp
    engines/scumm/insane/mortimer/text.h
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/campaign.cpp b/engines/scumm/insane/mortimer/campaign.cpp
new file mode 100644
index 00000000000..64978b0501e
--- /dev/null
+++ b/engines/scumm/insane/mortimer/campaign.cpp
@@ -0,0 +1,144 @@
+/* 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 "scumm/insane/mortimer/campaign.h"
+
+#include "common/util.h"
+
+namespace Scumm {
+
+const MortimerRegion kMortimerRegions[] = {
+	{"RESOURCE/SAVANNA", 'S',
+		{"RHI", "BAB", "CHE", "ELE", "GIR", "LIO", "OXP"},
+		{"Rhinoceros", "Baboon", "Cheetah", "Elephant", "Giraffe", "Lion", "Oxpecker"},
+		"RESOURCE/SAVANNA/SAVANNA1.SAN", "RESOURCE/DATA/SAV_SONG.TRS"},
+	{"RESOURCE/FOREST", 'F',
+		{"GRI", "BEA", "BAL", "FLY", "GOA", "MOO", "OPO"},
+		{"Grizzly Bear", "Beaver", "Bald Eagle", "Flying Squirrel", "Rocky Mountain Goat", "Moose", "Opossum"},
+		"RESOURCE/FOREST/FOREST1.SAN", "RESOURCE/DATA/FOR_SONG.TRS"},
+	{"RESOURCE/ICE", 'I',
+		{"WOL", "HUM", "KIL", "NAR", "POL", "TER", "WAL"},
+		{"Gray Wolf", "Humpback Whale", "Killer Whale", "Narwhal", "Polar Bear", "Arctic Tern", "Walrus"},
+		"RESOURCE/ICE/ICE1.SAN", "RESOURCE/DATA/ICE_SONG.TRS"},
+	{"RESOURCE/DESERT", 'D',
+		{"COY", "ELF", "GIL", "HOR", "LBA", "RAT", "ROA"},
+		{"Coyote", "Elf Owl", "Gila Monster", "Horned Lizard", "Long-Tongued Bat", "Rattlesnake", "Roadrunner"},
+		"RESOURCE/DESERT/DES_01.SAN", "RESOURCE/DATA/DES_SONG.TRS"}
+};
+
+const MortimerRegion &mortimerRegion(uint region) {
+	assert(region < ARRAYSIZE(kMortimerRegions));
+	return kMortimerRegions[region];
+}
+
+uint mortimerGuessCost(uint region, uint difficulty) {
+	const uint costs[3][4] = {{7, 9, 12, 15}, {12, 15, 18, 25}, {15, 18, 25, 30}};
+	assert(region < 4 && difficulty < 3);
+	return costs[difficulty][region];
+}
+
+int mortimerGuessAllowance(uint difficulty) {
+	const int guesses[] = {-1, 3, 1};
+	assert(difficulty < ARRAYSIZE(guesses));
+	return guesses[difficulty];
+}
+
+const MortimerCampaign::Scene kMortimerScenes[] = {
+	{24, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/GOLDGUY.SAN", "RESOURCE/DATA/BEGIN.TRS", true},
+	{25, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/INTRO1_H.SAN", "RESOURCE/DATA/BEGIN.TRS", true},
+	{26, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/INTRO2_H.SAN", "RESOURCE/DATA/BEGIN.TRS", true},
+	{27, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/INTRO3_H.SAN", "RESOURCE/DATA/BEGIN.TRS", true},
+	{28, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/INTRO4_H.SAN", nullptr, true},
+	{29, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/INTRO5H%c.SAN", "RESOURCE/DATA/BEGIN.TRS", false},
+	{30, 5, MortimerCampaign::kMovie, "RESOURCE/BEGIN/TITLE.SAN", "RESOURCE/DATA/BEGIN.TRS", true},
+	{0, 0, MortimerCampaign::kFlight, "RESOURCE/SAVANNA/SAVANNA1.SAN", "RESOURCE/DATA/MORTIMER.TRS", true},
+	{12, 0, MortimerCampaign::kInterview, "RESOURCE/SAVANNA/SGATE.SAN", "RESOURCE/DATA/SAV_SONG.TRS", true},
+	{17, 0, MortimerCampaign::kInterview, nullptr, nullptr, true},
+	{4, 0, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/MORTIMER.TRS", true},
+	{8, 0, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/SAV_SONG.TRS", true},
+	{31, 0, MortimerCampaign::kMovie, "RESOURCE/ENDING/MID_1.SAN", "RESOURCE/DATA/MIDTEXT.TRS", true},
+	{1, 1, MortimerCampaign::kFlight, "RESOURCE/FOREST/FOREST1.SAN", "RESOURCE/DATA/MORTIMER.TRS", true},
+	{13, 1, MortimerCampaign::kInterview, "RESOURCE/FOREST/FGATE.SAN", "RESOURCE/DATA/FOR_SONG.TRS", true},
+	{18, 1, MortimerCampaign::kInterview, nullptr, nullptr, true},
+	{5, 1, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/MORTIMER.TRS", true},
+	{9, 1, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/FOR_SONG.TRS", true},
+	{32, 1, MortimerCampaign::kMovie, "RESOURCE/ENDING/MID_2.SAN", "RESOURCE/DATA/MIDTEXT.TRS", true},
+	{2, 2, MortimerCampaign::kFlight, "RESOURCE/ICE/ICE1.SAN", "RESOURCE/DATA/MORTIMER.TRS", true},
+	{14, 2, MortimerCampaign::kInterview, "RESOURCE/ICE/IGATE.SAN", "RESOURCE/DATA/ICE_SONG.TRS", true},
+	{19, 2, MortimerCampaign::kInterview, nullptr, nullptr, true},
+	{6, 2, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/MORTIMER.TRS", true},
+	{10, 2, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/ICE_SONG.TRS", true},
+	{33, 2, MortimerCampaign::kMovie, "RESOURCE/ENDING/MID_3.SAN", "RESOURCE/DATA/MIDTEXT.TRS", true},
+	{3, 3, MortimerCampaign::kFlight, "RESOURCE/DESERT/DES_01.SAN", "RESOURCE/DATA/MORTIMER.TRS", true},
+	{15, 3, MortimerCampaign::kInterview, "RESOURCE/DESERT/DGATE.SAN", "RESOURCE/DATA/DES_SONG.TRS", true},
+	{20, 3, MortimerCampaign::kInterview, nullptr, nullptr, true},
+	{7, 3, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/MORTIMER.TRS", true},
+	{11, 3, MortimerCampaign::kInterview, nullptr, "RESOURCE/DATA/DES_SONG.TRS", true},
+	{34, 2, MortimerCampaign::kMovie, "RESOURCE/ENDING/END_1.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{35, 2, MortimerCampaign::kMovie, "RESOURCE/ENDING/END_2H%c.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{21, 4, MortimerCampaign::kCastle, "RESOURCE/CASTLE/CS_ENTRY.SAN", "RESOURCE/DATA/CAS_SONG.TRS", true},
+	{16, 4, MortimerCampaign::kCastleRiddle, "RESOURCE/CASTLE/CS_GATE.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{22, 4, MortimerCampaign::kCastleRiddle, nullptr, "RESOURCE/DATA/CAS_SONG.TRS", true},
+	{23, 4, MortimerCampaign::kCastleRiddle, "RESOURCE/CASTLE/CASTLE_I.NUT", "RESOURCE/DATA/CAS_SONG.TRS", true},
+	{36, 6, MortimerCampaign::kMovie, "RESOURCE/ENDING/END_7.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{37, 6, MortimerCampaign::kMovie, "RESOURCE/ENDING/END_8H%c.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{38, 6, MortimerCampaign::kMovie, "RESOURCE/ENDING/END_9.SAN", "RESOURCE/DATA/ENDTEXT.TRS", true},
+	{39, 6, MortimerCampaign::kCredits, "RESOURCE/ENDING/CREDITS.SAN", "RESOURCE/DATA/HISCORE.TRS", true},
+};
+
+MortimerCampaign::MortimerCampaign() : _index(0) {
+}
+
+bool MortimerCampaign::start(int sceneId) {
+	for (uint i = 0; i < ARRAYSIZE(kMortimerScenes); ++i) {
+		if (kMortimerScenes[i].id == sceneId) {
+			_index = i;
+			return true;
+		}
+	}
+	return false;
+}
+
+bool MortimerCampaign::advance() {
+	if (!finished())
+		++_index;
+	return !finished();
+}
+
+bool MortimerCampaign::returnToFlight() {
+	return !finished() && scene().region < 4 && start(scene().region);
+}
+
+const MortimerCampaign::Scene &MortimerCampaign::scene() const {
+	assert(!finished());
+	return kMortimerScenes[_index];
+}
+
+Common::String MortimerCampaign::movieName(bool girl) const {
+	if (!scene().movie)
+		return Common::String();
+	return Common::String::format(scene().movie, girl ? 'G' : 'B');
+}
+
+bool MortimerCampaign::finished() const {
+	return _index >= ARRAYSIZE(kMortimerScenes);
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/campaign.h b/engines/scumm/insane/mortimer/campaign.h
new file mode 100644
index 00000000000..ac6008a33c5
--- /dev/null
+++ b/engines/scumm/insane/mortimer/campaign.h
@@ -0,0 +1,68 @@
+/* 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 SCUMM_INSANE_MORTIMER_CAMPAIGN_H
+#define SCUMM_INSANE_MORTIMER_CAMPAIGN_H
+
+#include "common/str.h"
+
+namespace Scumm {
+
+struct MortimerRegion {
+	const char *directory;
+	char prefix;
+	const char *animals[7];
+	const char *names[7];
+	const char *flight;
+	const char *subtitles;
+};
+
+const MortimerRegion &mortimerRegion(uint region);
+uint mortimerGuessCost(uint region, uint difficulty);
+int mortimerGuessAllowance(uint difficulty);
+
+// Retail scene IDs and ordering from SHELL's campaign tables.
+class MortimerCampaign {
+public:
+	enum Kind { kMovie, kFlight, kInterview, kCastle, kCastleRiddle, kCredits };
+	struct Scene {
+		int id;
+		uint region;
+		Kind kind;
+		const char *movie;
+		const char *subtitles;
+		bool doubled;
+	};
+
+	MortimerCampaign();
+	bool start(int scene = 24);
+	bool advance();
+	bool returnToFlight();
+	const Scene &scene() const;
+	Common::String movieName(bool girl) const;
+	bool finished() const;
+
+private:
+	uint _index;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index b6d7c9c9582..c05f5e83d8d 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -22,27 +22,37 @@
 
 #include "common/endian.h"
 #include "common/util.h"
+#include "common/tokenizer.h"
 
 namespace Scumm {
 
-MortimerFlight::MortimerFlight() : _initialInteractive(true), _interactive(true) {
+MortimerFlight::MortimerFlight(bool isDemo) : _isDemo(isDemo), _initialInteractive(true), _interactive(true),
+	_inBranch(false), _steering(kWidth / 2, kHeight / 2), _collisionRadius(25) {
+	clearProgress();
 	reset();
 }
 
-bool MortimerFlight::load(Common::SeekableReadStream &stream, bool interactive) {
+bool MortimerFlight::load(Common::SeekableReadStream &stream, bool interactive, bool continuation) {
 	_initialInteractive = interactive;
-	return MortimerMovie::load(stream);
+	return MortimerMovie::load(stream, true, continuation);
 }
 
-void MortimerFlight::reset() {
-	MortimerMovie::reset();
+void MortimerFlight::clearProgress() {
 	_rescuedCount = _rescuedSpecies = 0;
-	_nextFlight.clear();
 	_weapon = kRevitascope;
+	memset(_hitTargets, 0, sizeof(_hitTargets));
+	context() = MortimerMovieContext();
+}
+
+void MortimerFlight::reset() {
+	MortimerCutscene::reset();
+	if (_isDemo)
+		clearProgress();
+	_nextFlight.clear();
 	_interactive = _initialInteractive;
 	_targets.clear();
 	_obstacles.clear();
-	memset(_toggles, 0, sizeof(_toggles));
+	_inBranch = false;
 }
 
 bool MortimerFlight::advance(bool renderFrame) {
@@ -56,8 +66,33 @@ int MortimerFlight::objectScale(byte codec) const {
 	return _interactive ? 1 : MortimerMovie::objectScale(codec);
 }
 
-bool MortimerFlight::skipObject(uint16 a, uint16 b) const {
-	return a != 0xffff && (b ? _toggles[a] != _toggles[b] : _toggles[a]);
+bool MortimerFlight::targetHit(uint16 id) const {
+	const uint index = id >= 9000 ? id - 8000 : id;
+	return index < ARRAYSIZE(_hitTargets) && _hitTargets[index];
+}
+
+void MortimerFlight::hitTarget(uint16 id) {
+	const uint index = id >= 9000 ? id - 8000 : id;
+	if (index < ARRAYSIZE(_hitTargets))
+		_hitTargets[index] = true;
+}
+
+bool MortimerFlight::targetsRemain(const Common::String &list) const {
+	Common::StringTokenizer lines(list, "\r\n\x1a");
+	uint count = 0;
+	while (!lines.empty() && count < 100) {
+		const Common::String line = lines.nextToken();
+		uint first = 0, last = 0;
+		const int values = sscanf(line.c_str(), "%u - %u", &first, &last);
+		if (values < 1 || first > 9399)
+			continue;
+		if (values == 1)
+			last = first;
+		for (uint id = first; id <= MIN(last, 9399U) && count < 100; ++id, ++count)
+			if (!targetHit(id))
+				return true;
+	}
+	return false;
 }
 
 MortimerFlight::Target *MortimerFlight::findTarget(uint16 id) {
@@ -68,24 +103,19 @@ MortimerFlight::Target *MortimerFlight::findTarget(uint16 id) {
 }
 
 void MortimerFlight::handleAction(const byte *data, uint32 size) {
-	MortimerMovie::handleAction(data, size);
+	MortimerCutscene::handleAction(data, size);
 	const uint16 code = READ_LE_UINT16(data), flags = READ_LE_UINT16(data + 2);
 	const uint16 mode = READ_LE_UINT16(data + 4), id = READ_LE_UINT16(data + 6);
 	if (code >= 2 && code <= 4 && mode == 1) {
 		const uint16 toggle = READ_LE_UINT16(data + 8);
-		if (id == 2000) {
-			// Offscreen control object: the remap SKIPs compare against this
-			// false toggle, rather than against the individual stone objects.
-			_toggles[toggle] = false;
+		if (id >= 2000 && id < 9000)
 			return;
-		}
-		if (id >= 1000) {
+		if (id >= 1000 && id < 2000) {
 			Target *target = findTarget(id - 1000);
 			if (target)
 				target->remapToggle = toggle;
-			// Keep the remapped silhouette for the duration of its NUT effect.
 			const uint duration = target && target->hitWeapon == kSlimeGun ? 7 : 11;
-			_toggles[toggle] = !target || !target->hit || frame() - target->rescuedAt >= duration;
+			setToggle(toggle, !target || !target->hit || frame() - target->rescuedAt >= duration);
 			return;
 		}
 		Target *target = findTarget(id);
@@ -94,14 +124,14 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 			fresh.id = id;
 			fresh.toggle = toggle;
 			fresh.remapToggle = 0xffff;
-			fresh.rescuedAt = 0;
+			fresh.rescuedAt = frame() - 1000;
 			fresh.hitWeapon = kRevitascope;
-			fresh.active = fresh.hit = false;
+			fresh.active = false;
+			fresh.hit = targetHit(id);
 			// Salt belongs to the hornet whose ID is 100 lower. Destroying a
 			// hornet also disables its subsequent salt objects (SHELL 407c5f).
 			if (id >= 800 && id < 900) {
-				const Target *hornet = findTarget(id - 100);
-				fresh.hit = hornet && hornet->hit;
+				fresh.hit = fresh.hit || targetHit(id - 100);
 			}
 			_targets.push_back(fresh);
 			target = &_targets.back();
@@ -109,7 +139,7 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 		if (!target)
 			return;
 		target->toggle = toggle;
-		_toggles[toggle] = target->hit;
+		setToggle(toggle, target->hit);
 		if (code == 4) {
 			const int x = READ_LE_INT16(data + 10), y = READ_LE_INT16(data + 12);
 			const int w = READ_LE_INT16(data + 14), h = READ_LE_INT16(data + 16);
@@ -119,10 +149,13 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 		} else if (code == 3) {
 			target->active = false;
 		}
-	} else if (code == 2 && mode == 3) {
-		_toggles[READ_LE_UINT16(data + 8)] = !_toggles[READ_LE_UINT16(data + 10)];
-	} else if (code == 5 && flags == 14 && size >= 28) {
+	} else if (code == 4 && mode == 2) {
+		const int x = READ_LE_INT16(data + 10), y = READ_LE_INT16(data + 12);
+		const int w = READ_LE_INT16(data + 14), h = READ_LE_INT16(data + 16);
+		_inBranch = _steering.x >= x && _steering.x <= x + w && _steering.y >= y && _steering.y <= y + h;
+	} else if (code == 5 && (flags == 13 || flags == 14)) {
 		Obstacle obstacle;
+		obstacle.outside = flags == 13;
 		for (uint i = 0; i < 4; ++i)
 			obstacle.corners[i] = Common::Point(READ_LE_INT16(data + 12 + i * 4), READ_LE_INT16(data + 14 + i * 4));
 		_obstacles.push_back(obstacle);
@@ -131,11 +164,19 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 		_weapon = flags == 9 ? kSlimeGun : kRevitascope;
 	} else if (code == 6 && (flags == 11 || flags == 25)) {
 		_interactive = flags == 11;
-	} else if (code == 8 && flags == 46) {
-		_nextFlight = Common::String((const char *)data + 18, size - 21);
-		for (uint i = 0; i < _nextFlight.size(); ++i)
-			if (_nextFlight[i] == '\\')
-				_nextFlight.setChar('/', i);
+	} else if (code == 8 && flags == 46 && size >= 21 && id / 1000 != 7) {
+		Common::String text((const char *)data + 18, size - 21);
+		if (id / 1000 == 11) {
+			context().conditions[MortimerMovieContext::kHostilesRemain] = targetsRemain(text);
+			_weapon = context().conditions[MortimerMovieContext::kHostilesRemain] ? kSlimeGun : kRevitascope;
+		} else if (id / 1000 == 12) {
+			context().conditions[MortimerMovieContext::kAnimalsRemain] = targetsRemain(text);
+		} else if (_inBranch && !text.hasPrefix("@riddles")) {
+			_nextFlight = text;
+			for (uint i = 0; i < _nextFlight.size(); ++i)
+				if (_nextFlight[i] == '\\')
+					_nextFlight.setChar('/', i);
+		}
 	}
 }
 
@@ -145,24 +186,27 @@ MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 		Target &target = _targets[i];
 		if (!target.active || target.hit || !target.rect.contains(point))
 			continue;
-		const uint species = target.id / 100;
+		const uint species = target.id >= 9000 && target.id < 9400 ? 7 : target.id / 100;
 		if (!target.id || species > 9 || species == 8 ||
 			(species < 7 && _weapon != kRevitascope) || (species == 7 && _weapon != kSlimeGun))
 			continue;
 		target.hit = true;
+		hitTarget(target.id);
 		target.rescuedAt = frame();
 		target.hitWeapon = _weapon;
-		_toggles[target.toggle] = true;
+		setToggle(target.toggle, true);
 		if (target.remapToggle != 0xffff)
-			_toggles[target.remapToggle] = false;
+			setToggle(target.remapToggle, false);
 		if (species < 7) {
 			++_rescuedCount;
 			_rescuedSpecies |= 1 << species;
 		} else if (species == 7) {
-			Target *salt = findTarget(target.id + 100);
+			const uint16 saltId = target.id % 100 + 800;
+			hitTarget(saltId);
+			Target *salt = findTarget(saltId);
 			if (salt) {
 				salt->hit = true;
-				_toggles[salt->toggle] = true;
+				setToggle(salt->toggle, true);
 			}
 		}
 		render();
@@ -172,8 +216,8 @@ MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 }
 
 bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
-	// SHELL 40e390 uses a 50 x 50 footprint in movie coordinates.
-	const Common::Rect footprint(point.x - 25, point.y - 25, point.x + 25, point.y + 25);
+	const int radius = _collisionRadius;
+	const Common::Rect footprint(point.x - radius, point.y - radius, point.x + radius, point.y + radius);
 	for (uint i = 0; i < _targets.size(); ++i) {
 		const Target &target = _targets[i];
 		if (target.id >= 800 && target.id < 900 && target.active && !target.hit && target.rect.intersects(footprint))
@@ -181,6 +225,22 @@ bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
 	}
 	for (uint n = 0; n < _obstacles.size(); ++n) {
 		const Obstacle &obstacle = _obstacles[n];
+		if (obstacle.outside) {
+			int centerX = 0, centerY = 0;
+			for (uint i = 0; i < 4; ++i) {
+				centerX += obstacle.corners[i].x;
+				centerY += obstacle.corners[i].y;
+			}
+			for (uint i = 0; i < 4; ++i) {
+				const Common::Point &a = obstacle.corners[i], &b = obstacle.corners[(i + 1) % 4];
+				const int dx = a.y - b.y, dy = b.x - a.x;
+				const int side = dx * (centerX - 4 * a.x) + dy * (centerY - 4 * a.y);
+				const int distance = dx * (point.x - a.x) + dy * (point.y - a.y);
+				if ((side < 0 ? -distance : distance) < radius * (ABS(dx) + ABS(dy)))
+					return true;
+			}
+			continue;
+		}
 		// Separating axes also handle vertical edges and degenerate quads
 		// without the divisions used by the original executable.
 		bool separated = false;
@@ -199,8 +259,8 @@ bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
 				maximum = MAX(maximum, projection);
 			}
 			const int64 center = int64(dx) * point.x + int64(dy) * point.y;
-			const int64 radius = 25 * (int64(ABS(dx)) + ABS(dy));
-			separated = center + radius < minimum || center - radius > maximum;
+			const int64 extent = radius * (int64(ABS(dx)) + ABS(dy));
+			separated = center + extent < minimum || center - extent > maximum;
 		}
 		if (!separated)
 			return true;
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 39724a98d8c..901255dc63f 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -25,7 +25,7 @@
 
 namespace Scumm {
 
-class MortimerFlight : public MortimerMovie {
+class MortimerFlight : public MortimerCutscene {
 public:
 	enum { kWidth = 384, kHeight = 288 };
 	enum Weapon { kRevitascope, kSlimeGun };
@@ -38,8 +38,9 @@ public:
 		bool active, hit;
 	};
 
-	MortimerFlight();
-	bool load(Common::SeekableReadStream &stream, bool interactive = true);
+	explicit MortimerFlight(bool isDemo = true);
+	bool load(Common::SeekableReadStream &stream, bool interactive = true, bool continuation = false);
+	void clearProgress();
 	void reset() override;
 	bool advance(bool renderFrame = true) override;
 	ShotResult shoot(const Common::Point &point);
@@ -48,27 +49,35 @@ public:
 	uint rescuedCount() const { return _rescuedCount; }
 	uint rescuedSpecies() const { return _rescuedSpecies; }
 	Weapon weapon() const { return _weapon; }
+	void resetWeapon() { _weapon = kRevitascope; }
 	const Common::String &nextFlight() const { return _nextFlight; }
 	const Common::Array<Target> &targets() const { return _targets; }
 	bool interactive() const { return _interactive; }
+	void setSteering(const Common::Point &point) { _steering = point; }
+	void setCollisionRadius(uint radius) { _collisionRadius = radius; }
 
 private:
 	struct Obstacle {
 		Common::Point corners[4];
+		bool outside;
 	};
 
 	void handleAction(const byte *data, uint32 size) override;
-	bool skipObject(uint16 a, uint16 b) const override;
 	int objectScale(byte codec) const override;
 	Target *findTarget(uint16 id);
+	bool targetHit(uint16 id) const;
+	void hitTarget(uint16 id);
+	bool targetsRemain(const Common::String &list) const;
 
 	Common::Array<Target> _targets;
 	Common::Array<Obstacle> _obstacles;
 	Common::String _nextFlight;
-	bool _toggles[256];
-	bool _initialInteractive, _interactive;
+	const bool _isDemo;
+	bool _hitTargets[1400];
+	bool _initialInteractive, _interactive, _inBranch;
+	Common::Point _steering;
 	Weapon _weapon;
-	uint _rescuedCount, _rescuedSpecies;
+	uint _rescuedCount, _rescuedSpecies, _collisionRadius;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 8baa56f2073..896fab43901 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -19,6 +19,7 @@
  */
 
 #include "scumm/insane/mortimer/interview.h"
+#include "scumm/insane/mortimer/campaign.h"
 
 #include "common/file.h"
 #include "common/tokenizer.h"
@@ -27,8 +28,6 @@
 
 namespace Scumm {
 
-namespace {
-
 // SHELL 444030: the order also matches the rescued-animal flags and icons.
 const char *const kAnimals[] = {"Rhinoceros", "Baboon", "Cheetah", "Elephant", "Giraffe", "Lion", "Oxpecker"};
 const char *const kMovieNames[] = {"RHIN", "BABO", "CHEE", "ELEP", "GIRA", "LION", "OXPE"};
@@ -81,12 +80,11 @@ void blit(Graphics::Surface &screen, const MortimerSprite &sprite, int x, int y)
 	}
 }
 
-} // End of anonymous namespace
-
-MortimerInterview::MortimerInterview() : _random("mortimer"), _stage(kIdle), _effect(kNoEffect),
-	_answer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0),
-	_species(0), _stones(0), _guesses(3), _frame(0), _switchFrame(6), _payment(0),
-	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false) {
+MortimerInterview::MortimerInterview(bool isDemo) : _random("mortimer"), _isDemo(isDemo),
+	_region(0), _difficulty(0), _textDifficulty(0), _tried(0), _stage(kIdle), _effect(kNoEffect),
+	_answer(-1), _previousAnswer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0), _guesses(3),
+	_species(0), _stones(0), _frame(0), _switchFrame(6), _payment(0),
+	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false), _restartRegion(false) {
 	memset(_palette, 0, sizeof(_palette));
 	memset(_gatePalette, 0, sizeof(_gatePalette));
 }
@@ -108,31 +106,38 @@ bool MortimerInterview::loadText(const char *path, bool riddles) {
 			if (heading.hasPrefix("Answer") && heading.size() == 7) {
 				answer = heading[6] - '1';
 			} else if (heading == "Easy" || heading == "Medium" || heading == "Hard") {
-				answer = heading == "Easy" ? 0 : -1;
+				answer = heading == "Easy" ? 0 : heading == "Medium" ? 1 : 2;
 			} else {
 				animal = answer = -1;
 				for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i)
-					if (heading.equalsIgnoreCase(kAnimals[i]))
+					if (heading.equalsIgnoreCase(_isDemo ? kAnimals[i] : mortimerRegion(_region).names[i]))
 						animal = i;
 			}
 		} else if (animal >= 0 && answer >= 0 && answer < 3) {
-			Common::String &text = riddles ? _riddles[animal] : _dialogue[animal][answer];
+			Common::String &text = riddles ? _riddles[animal][answer] : _dialogue[animal][answer];
 			if (!text.empty())
 				text += '\n';
 			text += line;
 		}
 	}
 	for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i) {
-		if (riddles && _riddles[i].empty())
-			return false;
-		for (uint j = 0; !riddles && j < 3; ++j)
-			if (_dialogue[i][j].empty())
+		for (uint j = 0; j < (riddles && _isDemo ? 1U : 3U); ++j)
+			if ((riddles ? _riddles[i][j] : _dialogue[i][j]).empty())
 				return false;
 	}
 	return !file.err();
 }
 
-bool MortimerInterview::loadResources() {
+bool MortimerInterview::loadResources(uint region) {
+	_region = region;
+	for (uint i = 0; i < 7; ++i)
+		for (uint j = 0; j < 3; ++j) {
+			_dialogue[i][j].clear();
+			_riddles[i][j].clear();
+		}
+	const MortimerRegion &resources = mortimerRegion(_region);
+	const Common::String portraits = Common::String::format("%s/%c_ANIM_H.NUT", resources.directory, resources.prefix);
+	const Common::String gate = Common::String::format("%s/%c_GATE_H.NUT", resources.directory, resources.prefix);
 	struct Bank {
 		const char *name;
 		Common::Array<MortimerSprite> *sprites;
@@ -141,9 +146,9 @@ bool MortimerInterview::loadResources() {
 	};
 	const Bank banks[] = {
 		{"RESOURCE/GENERAL/INTERV_H.NUT", &_panel, 21, nullptr},
-		{"RESOURCE/SAVANNA/S_ANIM_H.NUT", &_portraits, 8, _palette},
-		{"RESOURCE/SAVANNA/S_GATE_H.NUT", &_gate, 1, _gatePalette},
-		{"RESOURCE/GENERAL/CURS_I_H.NUT", &_cursor, 4, nullptr}
+		{portraits.c_str(), &_portraits, 8, _palette},
+		{gate.c_str(), &_gate, 1, _gatePalette},
+		{_isDemo ? "RESOURCE/GENERAL/CURS_I_H.NUT" : "RESOURCE/GENERAL/ARROWS.NUT", &_cursor, 4, nullptr}
 	};
 	Common::File file;
 	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
@@ -154,7 +159,7 @@ bool MortimerInterview::loadResources() {
 		}
 		file.close();
 	}
-	if (!_font.load("RESOURCE/GENERAL/MORTFNTH.NUT") || !_captionFont.load("RESOURCE/GENERAL/SUBTFONT.NUT")) {
+	if (!_font.load("RESOURCE/GENERAL/MORTFNTH.NUT") || !_captionFont.load(_isDemo ? "RESOURCE/GENERAL/SUBTFONT.NUT" : "RESOURCE/GENERAL/ESTRO20.NUT")) {
 		_error = "Missing or invalid Mortimer font";
 		return false;
 	}
@@ -162,6 +167,8 @@ bool MortimerInterview::loadResources() {
 		_error = "Missing or invalid Mortimer dialogue or riddle text";
 		return false;
 	}
+	if (!_isDemo)
+		return true;
 	if (!file.open("RESOURCE/DATA/SAV_SONG.TRS")) {
 		_error = "Missing Mortimer song captions";
 		return false;
@@ -183,13 +190,22 @@ bool MortimerInterview::loadResources() {
 }
 
 void MortimerInterview::resetRiddle() {
+	_previousAnswer = _answer;
 	_answer = -1;
+	resetAttempts();
+}
+
+void MortimerInterview::resetAttempts() {
+	_tried = 0;
+	_guesses = _isDemo ? 3 : mortimerGuessAllowance(_difficulty);
 }
 
 void MortimerInterview::start(uint species, uint stones, int scene) {
 	_species = species;
 	_stones = stones;
-	_guesses = 3;
+	if (_isDemo || _answer < 0)
+		_guesses = _isDemo ? 3 : mortimerGuessAllowance(_difficulty);
+	_restartRegion = false;
 	_frame = _payment = 0;
 	_switchFrame = 6;
 	_selected = -1;
@@ -198,13 +214,27 @@ void MortimerInterview::start(uint species, uint stones, int scene) {
 	_riddleView = _solving = _help = false;
 	_effect = kNoEffect;
 	if (_answer < 0) {
-		// SHELL 40cd65 restricts the demo to baboon, elephant and oxpecker.
-		const int answers[] = {1, 3, 6};
-		_answer = answers[_random.getRandomNumber(2)];
+		if (_isDemo) {
+			// SHELL 40cd65 restricts the demo to baboon, elephant and oxpecker.
+			const int answers[] = {1, 3, 6};
+			_answer = answers[_random.getRandomNumber(2)];
+			_textDifficulty = 0;
+		} else {
+			do {
+				_answer = _random.getRandomNumber(6);
+			} while (_answer == _previousAnswer);
+			const uint roll = _random.getRandomNumber(99);
+			_textDifficulty = _difficulty == 1 ? (roll < 10 ? 0 : roll < 60 ? 1 : 2) : _difficulty;
+		}
 	}
 	for (int i = 0; i < 7 && _selected < 0; ++i)
 		if (_species & (1 << i))
 			_selected = i;
+	if (!_isDemo) {
+		enter(scene == int(4 + _region) ? kPopup : scene == int(8 + _region) ? kIdle :
+			scene == int(17 + _region) ? kSong : kGate);
+		return;
+	}
 	switch (scene) {
 	case 4: enter(kIdle); break;
 	case 8: enter(kPopup); break;
@@ -238,9 +268,38 @@ void MortimerInterview::enter(Stage stage) {
 	case kEnding: _movieName = "RESOURCE/ENDING/DEMOENDH.SAN"; break;
 	default: break;
 	}
+	if (!_isDemo) {
+		const MortimerRegion &r = mortimerRegion(_region);
+		switch (stage) {
+		case kGate: _movieName = Common::String::format("%s/%cGATE.SAN", r.directory, r.prefix); break;
+		case kSong: _movieName = Common::String::format("%s/%c_SONG_%d.SAN", r.directory, r.prefix, _answer < 4 ? 1 : 2); break;
+		case kPopup: _movieName = Common::String::format("%s/%c_POPUPH.SAN", r.directory, r.prefix); break;
+		case kAnswer: _movieName = Common::String::format("%s/%c_%s_%d.SAN", r.directory, r.prefix, r.animals[_selected], _question + 1); break;
+		case kRiddle: _movieName = Common::String::format("%s/%c_RIDD_%d.SAN", r.directory, r.prefix, _answer < 4 ? 1 : 2); break;
+		case kSolve: _movieName = Common::String::format("%s/%c_ANSWER.SAN", r.directory, r.prefix); break;
+		case kCost: _movieName = Common::String::format("%s/%c_GLO_%u.SAN", r.directory, r.prefix, _difficulty); break;
+		case kGuess: _movieName = Common::String::format("%s/ANS_%sH.SAN", r.directory, r.animals[_guess]); break;
+		case kCorrect: _movieName = Common::String::format("%s/ANS%s_%u.SAN", r.directory, r.animals[_answer], _textDifficulty); break;
+		case kWrong: _movieName = Common::String::format("%s/%c_WRONGH.SAN", r.directory, r.prefix); break;
+		case kOpenGate: _movieName = Common::String::format("%s/%cGYESH.SAN", r.directory, r.prefix); break;
+		case kNoAnimals: _movieName = "RESOURCE/GENERAL/NOSCOPE.SAN"; break;
+		case kTried: _movieName = "RESOURCE/GENERAL/TRIEDIT.SAN"; break;
+		default: break;
+		}
+	}
 	_moviePending = !_movieName.empty();
 }
 
+int MortimerInterview::sceneId() const {
+	if (_stage == kGate)
+		return 12 + _region;
+	if (_stage == kSong)
+		return 17 + _region;
+	if (_stage == kPopup)
+		return 4 + _region;
+	return 8 + _region;
+}
+
 bool MortimerInterview::fullScreen() const {
 	return _stage == kGate || _stage == kSong || _stage == kPopup || _stage == kOpenGate || _stage == kEnding || _stage == kFinished;
 }
@@ -259,7 +318,7 @@ int MortimerInterview::hitControl(const Common::Point &point) const {
 	for (uint i = 0; i < ARRAYSIZE(kControls); ++i) {
 		const Control &c = kControls[i];
 		if (point.x >= c.left && point.x <= c.right && point.y >= c.top && point.y <= c.bottom) {
-			if (!_help && c.id >= 55 && c.id <= 61 && !(_species & (1 << (c.id - 55))))
+			if (_isDemo && !_help && c.id >= 55 && c.id <= 61 && !(_species & (1 << (c.id - 55))))
 				return -1;
 			if (!_help && c.id >= 46 && c.id <= 48 && (_riddleView || _selected < 0))
 				return -1;
@@ -282,7 +341,7 @@ void MortimerInterview::selectAnimal(int animal) {
 }
 
 void MortimerInterview::click(const Common::Point &point) {
-	if (fullScreen() || _stage == kPay || _stage == kGuess || _stage == kCorrect || _stage == kWrong || _stage == kSolve || _stage == kNoStones)
+	if (fullScreen() || _stage == kPay || _stage == kGuess || _stage == kCorrect || _stage == kWrong || _stage == kSolve || _stage == kNoStones || _stage == kCost || _stage == kNoAnimals)
 		return;
 	int action = hitControl(point);
 	if (action < 0)
@@ -293,7 +352,7 @@ void MortimerInterview::click(const Common::Point &point) {
 	if (action == 50) {
 		_help = !_help;
 		enter(kIdle);
-		if (_help && !_helpExplained) {
+		if (_isDemo && _help && !_helpExplained) {
 			_helpExplained = true;
 			enter(kHelp);
 			_movieName = "RESOURCE/GENERAL/HELP/HBUTTON1.SAN";
@@ -315,16 +374,31 @@ void MortimerInterview::click(const Common::Point &point) {
 		enter(kFlyAway);
 		return;
 	}
-	if (_selected < 0)
+	if (_selected < 0 && _isDemo)
 		return;
 	if (action == 62 || action == 63)
-		action = _lastAction;
+		action = _selected < 0 ? 52 : _lastAction;
 	if (action >= 55 && action <= 61) {
-		if (_solving) {
+		if (!_isDemo && !(_species & (1 << (action - 55)))) {
+			enter(kHelp);
+			_movieName = "RESOURCE/GENERAL/FLYAGAIN.SAN";
+			_moviePending = true;
+		} else if (_solving) {
 			_guess = action - 55;
+			if (!_isDemo && _difficulty == 0 && (_tried & (1 << _guess))) {
+				enter(kTried);
+				return;
+			}
 			_solving = false;
-			_payment = 5;
-			--_guesses;
+			_payment = _isDemo ? 5 : mortimerGuessCost(_region, _difficulty);
+			if (_stones < _payment) {
+				_payment = 0;
+				enter(kNoStones);
+				return;
+			}
+			_tried |= 1 << _guess;
+			if (_guesses > 0)
+				--_guesses;
 			enter(kPay);
 		} else {
 			selectAnimal(action - 55);
@@ -340,7 +414,7 @@ void MortimerInterview::click(const Common::Point &point) {
 		_riddleView = true;
 		_textScroll = 0;
 		_lastAction = 52;
-		enter(action == 52 ? kRiddle : kSolve);
+		enter(action == 52 ? kRiddle : (!_isDemo && !_species ? kNoAnimals : kSolve));
 	}
 }
 
@@ -372,7 +446,13 @@ void MortimerInterview::movieFinished() {
 	case kGate: enter(kSong); break;
 	case kSong: enter(kPopup); break;
 	case kSolve:
-		if (_stones < 5) {
+		if (!_isDemo) {
+			enter(kCost);
+			break;
+		}
+		// Fall through.
+	case kCost:
+		if (_stones < (_isDemo ? 5 : mortimerGuessCost(_region, _difficulty))) {
 			enter(kNoStones);
 		} else {
 			_solving = true;
@@ -382,12 +462,15 @@ void MortimerInterview::movieFinished() {
 	case kGuess: enter(_guess == _answer ? kCorrect : kWrong); break;
 	case kCorrect: enter(kOpenGate); break;
 	case kWrong:
-		if (!_guesses)
+		if (!_guesses) {
+			_restartRegion = !_isDemo;
+			if (_restartRegion)
+				resetRiddle();
 			enter(kFlyAway);
-		else
+		} else
 			enter(kIdle);
 		break;
-	case kOpenGate: enter(kEnding); break;
+	case kOpenGate: enter(_isDemo ? kEnding : kFinished); break;
 	case kEnding: enter(kFinished); break;
 	default: enter(kIdle); break;
 	}
@@ -408,13 +491,13 @@ void MortimerInterview::drawText(Graphics::Surface &screen, const Common::String
 	const int first = MIN<int>(_textScroll, lines.size() - visible);
 	const int top = 96 - visible * height / 2;
 	for (int i = 0; i < visible; ++i)
-		_font.drawString(&screen, lines[first + i], 29, top + i * height, 330, 0, Graphics::kTextAlignCenter);
+		_font.drawString(&screen, lines[first + i], 29, top + i * height, 330, _isDemo ? 0 : 1, Graphics::kTextAlignCenter);
 }
 
 void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &movie) const {
 	if (fullScreen()) {
 		screen.copyRectToSurface(movie.pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
-		for (uint i = 0; i < movie.captions().size(); ++i) {
+		for (uint i = 0; _isDemo && i < movie.captions().size(); ++i) {
 			const MortimerMovie::Caption &caption = movie.captions()[i];
 			if (caption.id < ARRAYSIZE(_captions))
 				_captionFont.drawString(&screen, _captions[caption.id], 0, caption.y, MIN<int>(640, caption.x * 2), 0, Graphics::kTextAlignCenter);
@@ -445,8 +528,8 @@ void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &mov
 		blit(screen, _panel[9], 400, 6);
 	if (_solving)
 		blit(screen, _panel[2], 11, 174);
-	if (_selected >= 0)
-		drawText(screen, _riddleView ? _riddles[_answer] : _dialogue[_selected][_question]);
+	if (_selected >= 0 || _riddleView)
+		drawText(screen, _riddleView ? _riddles[_answer][_textDifficulty] : _dialogue[_selected][_question]);
 }
 
 void MortimerInterview::drawCursor(Graphics::Surface &screen, const Common::Point &point) const {
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index 4487c948ba9..38e63f1e183 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -36,9 +36,11 @@ class MortimerInterview {
 public:
 	enum Effect { kNoEffect, kButtonEffect, kPayoutEffect, kSwitchEffect };
 
-	MortimerInterview();
-	bool loadResources();
+	explicit MortimerInterview(bool isDemo = true);
+	bool loadResources(uint region = 0);
+	void setDifficulty(uint difficulty) { _difficulty = difficulty; }
 	void resetRiddle();
+	void resetAttempts();
 	void start(uint species, uint stones, int scene = 10);
 	void click(const Common::Point &point);
 	void scroll(int direction);
@@ -53,9 +55,14 @@ public:
 	bool playingMovie() const { return !_movieName.empty(); }
 	bool fullScreen() const;
 	bool showDashboard() const { return !fullScreen() || _stage == kPopup; }
+	bool forceSubtitles() const { return _stage == kSong; }
 	bool movieDoubled() const;
 	bool wantsFlight() const { return _stage == kFlyAway; }
 	bool finished() const { return _stage == kFinished; }
+	bool restartsRegion() const { return _restartRegion; }
+	int sceneId() const;
+	int riddleId() const { return _answer * 10 + _textDifficulty; }
+	bool outOfGuesses() const { return _guesses == 0; }
 	const Common::String &movieName() const { return _movieName; }
 	const Common::String &errorMessage() const { return _error; }
 	const byte *palette(const MortimerMovie &movie) const;
@@ -68,7 +75,7 @@ private:
 	enum Stage {
 		kGate, kSong, kPopup, kIdle, kAnswer, kRiddle, kSolve,
 		kNoStones, kPay, kGuess, kCorrect, kWrong, kOpenGate,
-		kEnding, kHelp, kFlyAway, kFinished
+		kEnding, kHelp, kFlyAway, kFinished, kCost, kNoAnimals, kTried
 	};
 
 	void enter(Stage stage);
@@ -79,16 +86,18 @@ private:
 	Common::String animalMovie(const char *pattern, int animal) const;
 
 	Common::RandomSource _random;
+	const bool _isDemo;
+	uint _region, _difficulty, _textDifficulty, _tried;
 	Common::Array<MortimerSprite> _panel, _portraits, _gate, _cursor;
 	MortimerFont _font, _captionFont;
-	Common::String _dialogue[7][3], _riddles[7], _captions[16];
+	Common::String _dialogue[7][3], _riddles[7][3], _captions[16];
 	Common::String _movieName, _error;
 	byte _palette[768], _gatePalette[768];
 	Stage _stage;
 	Effect _effect;
-	int _answer, _selected, _question, _guess, _lastAction, _textScroll;
-	uint _species, _stones, _guesses, _frame, _switchFrame, _payment;
-	bool _moviePending, _riddleView, _solving, _help, _helpExplained;
+	int _answer, _previousAnswer, _selected, _question, _guess, _lastAction, _textScroll, _guesses;
+	uint _species, _stones, _frame, _switchFrame, _payment;
+	bool _moviePending, _riddleView, _solving, _help, _helpExplained, _restartRegion;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index f620dbdd974..fc07f950470 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -24,8 +24,10 @@
 #include "audio/decoders/raw.h"
 #include "backends/keymapper/action.h"
 #include "common/config-manager.h"
+#include "common/endian.h"
 #include "common/events.h"
 #include "common/file.h"
+#include "common/memstream.h"
 #include "common/system.h"
 #include "common/translation.h"
 #include "common/util.h"
@@ -40,7 +42,7 @@ namespace Scumm {
 
 // SHELL's demo scene list at 444220 plays all five introductions before
 // TITLE.SAN. The title continues directly into the first playable scene.
-static const char *const kDemoIntros[] = {
+const char *const kDemoIntros[] = {
 	"RESOURCE/BEGIN/INTRO1_H.SAN",
 	"RESOURCE/BEGIN/INTRO2_H.SAN",
 	"RESOURCE/BEGIN/INTRO3_H.SAN",
@@ -48,17 +50,7 @@ static const char *const kDemoIntros[] = {
 	"RESOURCE/BEGIN/INTRO5_H.SAN"
 };
 
-const char *const kRetailIntros[] = {
-	"RESOURCE/BEGIN/GOLDGUY.SAN",
-	"RESOURCE/BEGIN/INTRO1_H.SAN",
-	"RESOURCE/BEGIN/INTRO2_H.SAN",
-	"RESOURCE/BEGIN/INTRO3_H.SAN",
-	"RESOURCE/BEGIN/INTRO4_H.SAN",
-	"RESOURCE/BEGIN/INTRO5HB.SAN",
-	"RESOURCE/BEGIN/TITLE.SAN"
-};
-
-static const char *const kDemoFlights[] = {
+const char *const kDemoFlights[] = {
 	"RESOURCE/DEMO/TITLE.SAN",
 	"RESOURCE/DEMO/DEMO_PL.SAN",
 	"RESOURCE/DEMO/DEMO_BT.SAN"
@@ -119,13 +111,15 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 	return keymaps;
 }
 
-MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
+MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo), _flight(isDemo), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
-	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _segment(0), _carriedStones(0), _completedSpecies(0),
+	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0), _segment(0), _carriedStones(0), _completedSpecies(0),
 	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
-	_pendingGlowStones(0), _finished(false), _controllerMode(false), _interviewActive(false) {
+	_pendingGlowStones(0), _finished(false), _controllerMode(false), _interviewActive(false), _resourcesLoaded(false), _playingCached(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
+	ConfMan.registerDefault("mortimer_flight_difficulty", 0);
+	ConfMan.registerDefault("mortimer_riddle_difficulty", 0);
 }
 
 MortimerEngine::~MortimerEngine() {
@@ -187,7 +181,7 @@ bool MortimerEngine::loadResources() {
 		}
 		file.close();
 	}
-	if (!_interview.loadResources()) {
+	if (_isDemo && !_interview.loadResources()) {
 		_loadError = _interview.errorMessage();
 		return false;
 	}
@@ -207,20 +201,20 @@ const MortimerMovie &MortimerEngine::currentMovie() const {
 }
 
 uint MortimerEngine::introCount() const {
-	return _isDemo ? ARRAYSIZE(kDemoIntros) : ARRAYSIZE(kRetailIntros);
+	return ARRAYSIZE(kDemoIntros);
 }
 
 bool MortimerEngine::loadIntro(uint index) {
 	stopSounds();
 	_movieHandles.clear();
-	const char *name = _isDemo ? kDemoIntros[index] : kRetailIntros[index];
+	const char *name = kDemoIntros[index];
 	Common::File file;
 	if (!file.open(Common::Path(name))) {
 		_loadError = Common::String::format("Missing %s", name);
 		return false;
 	}
 	// The fifth introduction starts with native 640 x 480 animation.
-	if (!_intro.load(file, index != (_isDemo ? 4U : 5U))) {
+	if (!_intro.load(file, index != 4)) {
 		_loadError = Common::String::format("%s: %s", name, _intro.errorMessage().c_str());
 		return false;
 	}
@@ -248,6 +242,140 @@ bool MortimerEngine::loadFlight(uint segment) {
 	return true;
 }
 
+bool MortimerEngine::loadSubtitles(const char *name) {
+	if (!name || _subtitleName == name)
+		return true;
+	Common::File file;
+	if (!file.open(Common::Path(name)) || !_subtitles.loadText(file)) {
+		_loadError = Common::String::format("Missing or invalid %s", name);
+		return false;
+	}
+	_subtitleName = name;
+	return true;
+}
+
+bool MortimerEngine::loadRegion(uint region) {
+	if (_loadedRegion == int(region))
+		return true;
+	stopSounds();
+	if (!_resourcesLoaded) {
+		if (!loadResources())
+			return false;
+		_resourcesLoaded = true;
+	}
+	const MortimerRegion &resources = mortimerRegion(region);
+	const Common::String name = Common::String::format("%s/%c_ICON_H.NUT", resources.directory, resources.prefix);
+	Common::File file;
+	if (!file.open(Common::Path(name)) || !loadMortimerSprites(file, _icons) || _icons.size() < 14) {
+		_loadError = Common::String::format("Missing or invalid %s", name.c_str());
+		return false;
+	}
+	if (!_interview.loadResources(region)) {
+		_loadError = _interview.errorMessage();
+		return false;
+	}
+	_loadedRegion = region;
+	_flight.clearProgress();
+	_flight.setCollisionRadius(20 * (CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2) + 1));
+	_interview.resetRiddle();
+	_carriedStones = _completedSpecies = _creditedRescues = _shellShocks = 0;
+	return true;
+}
+
+bool MortimerEngine::loadRetailFlight(const Common::String &name, bool cached) {
+	stopSounds();
+	_movieHandles.clear();
+	bool loaded;
+	if (cached) {
+		// Loading a movie releases its LOAD buffer, so retain our own copy.
+		const Common::Array<byte> data = _flight.cachedMovie();
+		if (data.size() < 8 || READ_BE_UINT32(data.begin() + 4) != data.size() - 8) {
+			_loadError = "Incomplete Mortimer cached flight transition";
+			return false;
+		}
+		Common::MemoryReadStream stream(data.begin(), data.size());
+		loaded = _flight.load(stream, true, true);
+	} else {
+		Common::File file;
+		if (!file.open(Common::Path(name))) {
+			_loadError = Common::String::format("Missing %s", name.c_str());
+			return false;
+		}
+		loaded = _flight.load(file);
+	}
+	if (!loaded) {
+		_loadError = Common::String::format("%s: %s", name.c_str(), _flight.errorMessage().c_str());
+		return false;
+	}
+	_playingCached = cached;
+	_introIndex = -1;
+	_movieHandles.resize(_flight.sounds().size());
+	return true;
+}
+
+bool MortimerEngine::followFlightBranch() {
+	const Common::String next = _flight.nextFlight();
+	const bool cached = !_flight.cachedMovie().empty();
+	_pendingFlight = cached ? next : Common::String();
+	return loadRetailFlight(next, cached) && startMovie();
+}
+
+void MortimerEngine::updateMovieContext() {
+	_flight.setSteering(moviePoint(_aim));
+	if (_isDemo)
+		return;
+	MortimerMovieContext &context = _flight.context();
+	context.conditions[MortimerMovieContext::kHasStones] = _interviewActive ? _interview.stones() != 0 :
+		_carriedStones + _flight.rescuedCount() - _creditedRescues != 0;
+	context.conditions[MortimerMovieContext::kDamaged] = _shellShocks != 0;
+	context.conditions[MortimerMovieContext::kOutOfGuesses] = _interview.outOfGuesses();
+	context.riddle = _interview.riddleId();
+	_intro.context() = context;
+}
+
+bool MortimerEngine::enterCampaignScene() {
+	stopSounds();
+	const MortimerCampaign::Scene &scene = _campaign.scene();
+	_interviewActive = false;
+	_pendingFlight.clear();
+	_playingCached = false;
+	if (!loadSubtitles(scene.subtitles))
+		return false;
+	if (scene.kind == MortimerCampaign::kFlight) {
+		if (!loadRegion(scene.region))
+			return false;
+		_flight.context().conditions[MortimerMovieContext::kAtGate] = false;
+		return loadRetailFlight(scene.movie) && startFlight();
+	}
+	if (scene.kind == MortimerCampaign::kInterview) {
+		if (!loadRegion(scene.region))
+			return false;
+		_interviewActive = true;
+		_flight.context().conditions[MortimerMovieContext::kAtGate] = true;
+		_interview.start(_completedSpecies, _carriedStones, scene.id);
+		_glowStones = _carriedStones;
+		return processInterview();
+	}
+	if (scene.kind == MortimerCampaign::kCastle || scene.kind == MortimerCampaign::kCastleRiddle) {
+		_loadError = "Mortimer castle gameplay is not implemented yet";
+		return false;
+	}
+	_movieHandles.clear();
+	const Common::String name = _campaign.movieName(_intro.context().girl);
+	Common::File file;
+	if (!file.open(Common::Path(name))) {
+		_loadError = Common::String::format("Missing %s", name.c_str());
+		return false;
+	}
+	if (!_intro.load(file, scene.doubled)) {
+		_loadError = Common::String::format("%s: %s", name.c_str(), _intro.errorMessage().c_str());
+		return false;
+	}
+	_introIndex = scene.id;
+	_movieHandles.resize(_intro.sounds().size());
+	return startMovie();
+}
+
 void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset) {
 	_mixer->stopHandle(handle);
 	// The engine owns the samples until all its handles have been stopped.
@@ -260,7 +388,7 @@ void MortimerEngine::playMovieSounds(bool seek) {
 	const MortimerMovie &movie = currentMovie();
 	for (uint i = 0; i < movie.sounds().size(); ++i) {
 		const MortimerSound &sound = movie.sounds()[i];
-		if (sound.frame > movie.frame() || (!seek && sound.frame != movie.frame()))
+		if (!movie.soundEnabled(i) || sound.frame > movie.frame() || (!seek && sound.frame != movie.frame()))
 			continue;
 		const uint64 offset = uint64(movie.frame() - sound.frame) * sound.rate / movie.frameRate();
 		if (offset < sound.samples.size())
@@ -281,9 +409,11 @@ void MortimerEngine::stopSounds() {
 bool MortimerEngine::startMovie(bool preserveScreen) {
 	stopSounds();
 	MortimerMovie &movie = currentMovie();
-	movie.reset();
+	if (movie.started())
+		movie.reset();
 	if (preserveScreen)
 		movie.setBackground((const byte *)_screen.getPixels());
+	updateMovieContext();
 	if (!movie.advance()) {
 		_loadError = movie.errorMessage();
 		return false;
@@ -296,10 +426,11 @@ bool MortimerEngine::startMovie(bool preserveScreen) {
 }
 
 bool MortimerEngine::startFlight() {
+	_flight.resetWeapon();
 	_uiFrame = 0;
 	_shotFrame = 3;
 	_glowFrame = 10;
-	_glowStones = MIN(_carriedStones, 999U);
+	_glowStones = MIN(_carriedStones + (_isDemo ? 0 : _flight.rescuedCount() - _creditedRescues), 999U);
 	_pendingGlowStones = 0;
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 	for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
@@ -310,9 +441,30 @@ bool MortimerEngine::startFlight() {
 	return startMovie();
 }
 
-bool MortimerEngine::restart(bool all) {
-	if (!_isDemo)
-		return all ? loadIntro(0) && startMovie() : startMovie();
+bool MortimerEngine::restart(bool all, bool crashed) {
+	if (!_isDemo) {
+		_finished = false;
+		if (all) {
+			_loadedRegion = -1;
+			_campaign.start();
+			return enterCampaignScene();
+		}
+		if (!_interviewActive && _introIndex >= 0)
+			return startMovie();
+		const uint region = _campaign.scene().region;
+		// Easy flight keeps rescues after a crash. Other flight difficulties
+		// clear the region's rescues and payments, but keep its current riddle.
+		if (!crashed || ConfMan.getInt("mortimer_flight_difficulty") > 0) {
+			_flight.clearProgress();
+			_carriedStones = _completedSpecies = _creditedRescues = 0;
+			if (crashed)
+				_interview.resetAttempts();
+			else
+				_interview.resetRiddle();
+		}
+		_shellShocks = 0;
+		return _campaign.start(region) && enterCampaignScene();
+	}
 	if (_interviewActive)
 		all = true;
 	if (_introIndex >= 0 && !all)
@@ -334,12 +486,29 @@ bool MortimerEngine::nextScene(bool skip) {
 		_interview.skip();
 		return processInterview();
 	}
+	if (!_isDemo) {
+		if (_introIndex < 0) {
+			if (!skip && !_flight.nextFlight().empty())
+				return followFlightBranch();
+			if (!skip && _playingCached) {
+				const Common::String next = _pendingFlight;
+				_pendingFlight.clear();
+				return loadRetailFlight(next) && startMovie();
+			}
+			_carriedStones = MIN(999U, _carriedStones + _flight.rescuedCount() - _creditedRescues);
+			_creditedRescues = _flight.rescuedCount();
+			_completedSpecies |= _flight.rescuedSpecies();
+			_flight.context().conditions[MortimerMovieContext::kAtGate] = true;
+		}
+		if (!_campaign.advance()) {
+			_finished = true;
+			return true;
+		}
+		return enterCampaignScene();
+	}
 	if (_introIndex >= 0) {
 		if (uint(_introIndex + 1) < introCount())
 			return loadIntro(_introIndex + 1) && startMovie();
-		// Retail gameplay follows the title once its campaign is implemented.
-		if (!_isDemo)
-			return false;
 		return loadFlight(0) && startFlight();
 	}
 	if (skip && !_flight.interactive()) {
@@ -388,13 +557,34 @@ bool MortimerEngine::processInterview() {
 		_completedSpecies = _interview.species();
 		_interviewActive = false;
 		_shellShocks = 0;
+		if (!_isDemo) {
+			if (_interview.restartsRegion()) {
+				_flight.clearProgress();
+				_carriedStones = _completedSpecies = _creditedRescues = 0;
+			}
+			return _campaign.returnToFlight() && enterCampaignScene();
+		}
 		return loadFlight(0) && startFlight();
 	}
 	if (_interview.finished()) {
 		stopSounds();
+		if (!_isDemo) {
+			_interviewActive = false;
+			_carriedStones = 0;
+			_campaign.start(8 + _loadedRegion);
+			_campaign.advance();
+			return enterCampaignScene();
+		}
 		_finished = true;
 		return true;
 	}
+	if (!_isDemo) {
+		_campaign.start(_interview.sceneId());
+		const char *subtitles = _interview.fullScreen() ? mortimerRegion(_loadedRegion).subtitles : "RESOURCE/DATA/MORTIMER.TRS";
+		if (!loadSubtitles(subtitles))
+			return false;
+		updateMovieContext();
+	}
 	if (_interview.needsMovie()) {
 		stopSounds();
 		_movieHandles.clear();
@@ -438,6 +628,7 @@ bool MortimerEngine::updateScene() {
 		updateSteering();
 		updateInterface();
 	}
+	updateMovieContext();
 	MortimerMovie &movie = currentMovie();
 	if (!movie.advance()) {
 		if (!movie.errorMessage().empty()) {
@@ -451,14 +642,16 @@ bool MortimerEngine::updateScene() {
 		}
 		return nextScene();
 	}
+	if (!_isDemo && !_interviewActive && _introIndex < 0 && !_flight.nextFlight().empty())
+		return followFlightBranch();
 	playMovieSounds();
 	_system->getPaletteManager()->setPalette(_interviewActive ? _interview.palette(movie) : movie.palette(), 0, 256);
 	if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
 		++_shellShocks;
 		playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
-		// SHELL 40be36 resets the flight after eighteen shell shocks.
-		if (_shellShocks >= 18)
-			return restart();
+		// Retail crashes on the fifteenth hit; the demo allows eighteen.
+		if (_shellShocks >= (_isDemo ? 18U : 15U))
+			return restart(false, true);
 	}
 	return true;
 }
@@ -561,7 +754,7 @@ void MortimerEngine::pauseEngineIntern(bool pause) {
 }
 
 Common::Error MortimerEngine::run() {
-	if (_isDemo && !loadResources())
+	if (_isDemo && !(_resourcesLoaded = loadResources()))
 		return Common::Error(Common::kReadingFailed, _loadError);
 	if (!_isDemo && !_subtitles.load("RESOURCE/DATA/BEGIN.TRS", "RESOURCE/GENERAL/ESTRO20.NUT", "RESOURCE/GENERAL/MORTFNTH.NUT"))
 		return Common::Error(Common::kReadingFailed, "Missing or invalid Mortimer subtitle resources");
@@ -576,7 +769,11 @@ Common::Error MortimerEngine::run() {
 	// Interview entry points supply rescued animals and glowstones.
 	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : -1;
 	if (!_isDemo) {
-		exit = !(loadIntro(boot >= 24 && boot <= 30 ? boot - 24 : 0) && startMovie());
+		_subtitleName = "RESOURCE/DATA/BEGIN.TRS";
+		if (!_campaign.start(boot < 0 ? 24 : boot))
+			_campaign.start();
+		_interview.setDifficulty(CLIP(ConfMan.getInt("mortimer_riddle_difficulty"), 0, 2));
+		exit = !enterCampaignScene();
 	} else if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
 		stopSounds();
 		_introIndex = -1;
@@ -704,7 +901,8 @@ void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y, bool
 void MortimerEngine::drawTargets() {
 	for (uint i = 0; i < _flight.targets().size(); ++i) {
 		const MortimerFlight::Target &target = _flight.targets()[i];
-		if (!target.active || !target.id || target.id >= 800)
+		const bool hostile = (target.id >= 700 && target.id < 800) || (target.id >= 9000 && target.id < 9400);
+		if (!target.active || !target.id || (target.id >= 800 && !hostile))
 			continue;
 		if (target.hit) {
 			const Common::Array<MortimerSprite> &effect = target.hitWeapon == MortimerFlight::kSlimeGun ? _slimeHit : _scopeHit;
@@ -715,7 +913,7 @@ void MortimerEngine::drawTargets() {
 				drawSprite(effect[frame], center.x, center.y, true, &_viewport);
 			}
 		} else if ((target.id < 700 && _flight.weapon() == MortimerFlight::kRevitascope) ||
-			(target.id >= 700 && _flight.weapon() == MortimerFlight::kSlimeGun)) {
+			(hostile && _flight.weapon() == MortimerFlight::kSlimeGun)) {
 			const Common::Point top = screenPoint(Common::Point(target.rect.left, target.rect.top));
 			const Common::Point bottom = screenPoint(Common::Point(target.rect.right, target.rect.bottom));
 			drawSprite(_corners[0], top.x, top.y, true, &_viewport);
@@ -809,9 +1007,10 @@ void MortimerEngine::draw() {
 		drawProjectile();
 	} else {
 		_screen.copyRectToSurface(currentMovie().pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
-		if (!_isDemo)
-			_subtitles.draw(_screen, currentMovie().captions(), ConfMan.getBool("subtitles"));
 	}
+	if (!_isDemo)
+		_subtitles.draw(_screen, currentMovie().captions(), ConfMan.getBool("subtitles") ||
+			(_interviewActive && _interview.forceSubtitles()), &_intro.context());
 	if (isPaused()) {
 		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 3fbaa77f00c..7ef7240a716 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -27,6 +27,7 @@
 #include "graphics/surface.h"
 #include "scumm/insane/mortimer/flight.h"
 #include "scumm/insane/mortimer/interview.h"
+#include "scumm/insane/mortimer/campaign.h"
 
 namespace Scumm {
 
@@ -48,12 +49,18 @@ private:
 	uint introCount() const;
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
+	bool enterCampaignScene();
+	bool loadRegion(uint region);
+	bool loadRetailFlight(const Common::String &name, bool cached = false);
+	bool followFlightBranch();
+	bool loadSubtitles(const char *name);
+	void updateMovieContext();
 	bool startMovie(bool preserveScreen = false);
 	bool startFlight();
 	bool processInterview();
 	bool updateScene();
 	bool nextScene(bool skip = false);
-	bool restart(bool all = false);
+	bool restart(bool all = false, bool crashed = false);
 	MortimerMovie &currentMovie();
 	const MortimerMovie &currentMovie() const;
 	bool interactive() const { return !_interviewActive && _introIndex < 0 && _flight.interactive(); }
@@ -78,6 +85,7 @@ private:
 	MortimerFlight _flight;
 	MortimerInterview _interview;
 	MortimerSubtitles _subtitles;
+	MortimerCampaign _campaign;
 	Graphics::Surface _screen;
 	Common::Array<MortimerSprite> _cockpit, _dashboard, _reticles, _icons, _head;
 	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
@@ -86,6 +94,7 @@ private:
 	Common::Array<Audio::SoundHandle> _movieHandles;
 	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle, _glowHandle;
 	Common::String _loadError;
+	Common::String _pendingFlight, _subtitleName;
 	Common::Rect _viewport;
 	Common::Point _mouse, _view, _aim, _shot;
 	Common::Point _steeringHistory[10];
@@ -93,10 +102,13 @@ private:
 	MortimerFlight::Weapon _shotWeapon;
 	uint32 _nextFrameTime, _pauseTime;
 	int _introIndex;
+	int _loadedRegion;
+	uint _creditedRescues;
 	uint _segment, _carriedStones, _completedSpecies, _shellShocks;
 	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
 	uint _iconFlash[7];
 	bool _finished, _controllerMode, _interviewActive;
+	bool _resourcesLoaded, _playingCached;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 850686f1bbd..12147ddeafc 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -94,9 +94,9 @@ bool validRows(const byte *src, uint32 size, int height, bool remap) {
 }
 
 // Codec37's existing decoder has no input-length argument. Validate the
-// encodings present in the demo movies before passing any data to it.
+// encodings present in the movies before passing any data to it.
 bool validBackground(const byte *src, uint32 size, int width, int height) {
-	if (size < 16 || src[1] != 1 || (src[12] & ~6))
+	if (size < 16 || src[1] != 1 || (src[12] & ~7))
 		return false;
 	uint32 pos = 16;
 	if (src[0] == 2) {
@@ -152,7 +152,7 @@ bool validObject(const byte *data, uint32 size, bool sprite = false) {
 	if (sprite)
 		return codec == 44 || (codec == 1 && validRows(data + 14, size - 14, height, false));
 	if (codec == 37)
-		return !(width & 3) && !(height & 3) && validBackground(data + 14, size - 14, width, height);
+		return validBackground(data + 14, size - 14, (width + 3) & ~3, (height + 3) & ~3);
 	if (codec != 1 && codec != 3 && codec != 23)
 		return false;
 	uint32 offset = 14;
@@ -172,12 +172,12 @@ bool validPalette(const byte *data, uint32 size) {
 }
 
 bool validTransition(const byte *data, uint32 size) {
-	// IACT 8/46 contains a length-prefixed filename followed by CR/LF/EOF.
+	// Text packets contain either a filename or a list of target IDs.
 	if (size < 22 || READ_LE_UINT16(data + 14) != size - 18 ||
 		data[size - 3] != '\r' || data[size - 2] != '\n' || data[size - 1] != 0x1a)
 		return false;
 	for (uint i = 18; i < size - 3; ++i)
-		if (data[i] < 32 || data[i] > 126)
+		if ((data[i] < 32 && data[i] != '\r' && data[i] != '\n' && data[i] != '\t') || data[i] > 126)
 			return false;
 	return true;
 }
@@ -317,13 +317,29 @@ bool MortimerMovie::fail(const char *message) {
 	return false;
 }
 
-bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
+bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled, bool continuation) {
 	const uint32 started = g_system->getMillis();
+	// A LOAD bridge can begin with delta blocks referring to the flight.
+	SmushDeltaBlocksDecoder *previousDecoder = continuation ? _decoder : nullptr;
+	const int previousWidth = _decoderWidth, previousHeight = _decoderHeight;
+	Common::Array<byte> previousPixels;
+	if (continuation) {
+		_decoder = nullptr;
+		previousPixels = _pixels;
+	}
 	_initialDoubled = doubled;
 	_frames.clear();
 	_sounds.clear();
+	_soundOffsets.clear();
+	_soundEnabled.clear();
 	_error.clear();
 	reset();
+	if (continuation) {
+		_decoder = previousDecoder;
+		_decoderWidth = previousWidth;
+		_decoderHeight = previousHeight;
+		_pixels = Common::move(previousPixels);
+	}
 	if (!readData(stream, _data) || !isContainer(_data, MKTAG('A', 'N', 'I', 'M')))
 		return fail("Invalid Mortimer movie ANIM container");
 
@@ -336,7 +352,7 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 	Common::Array<Track> tracks;
 	uint32 pos = 8;
 	uint expectedFrames = 0;
-	int decoderWidth = 0, decoderHeight = 0;
+	int decoderWidth = _decoderWidth, decoderHeight = _decoderHeight;
 	while (pos < _data.size()) {
 		Chunk chunk;
 		if (!nextChunk(_data.begin(), _data.size(), pos, chunk))
@@ -366,26 +382,29 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 						return fail("Unsupported or truncated Mortimer movie image");
 					if (body[0] == 37) {
 						++backgrounds;
-						const int width = READ_LE_UINT16(body + 6), height = READ_LE_UINT16(body + 8);
+						const int width = (READ_LE_UINT16(body + 6) + 3) & ~3;
+						const int height = (READ_LE_UINT16(body + 8) + 3) & ~3;
 						if ((width != decoderWidth || height != decoderHeight) && body[14] != 2)
 							return fail("Mortimer movie changes dimensions without a keyframe");
 						decoderWidth = width;
 						decoderHeight = height;
 					}
 				} else if (sub.tag == MKTAG('S', 'K', 'I', 'P')) {
-					if (sub.size != 4 || READ_LE_UINT16(body) >= 256 || READ_LE_UINT16(body + 2) >= 256)
+					if (sub.size != 4)
 						return fail("Invalid Mortimer object toggle");
 				} else if (sub.tag == MKTAG('I', 'A', 'C', 'T')) {
 					if (sub.size < 8)
 						return fail("Truncated Mortimer movie action");
 					const uint16 code = READ_LE_UINT16(body);
-					if ((code >= 2 && code <= 4 && (sub.size < (code == 4 ? 18U : 12U) || READ_LE_UINT16(body + 8) >= 256)) ||
+					if ((code >= 2 && code <= 4 && sub.size < (code == 4 ? 18U : 12U)) ||
 						(code == 5 && sub.size < 28))
 						return fail("Invalid Mortimer movie action");
-					if (code == 2 && READ_LE_UINT16(body + 4) == 3 && READ_LE_UINT16(body + 10) >= 256)
-						return fail("Invalid Mortimer conditional object");
-					if (code == 8 && READ_LE_UINT16(body + 2) == 46 && !validTransition(body, sub.size))
+					if (code == 8 && READ_LE_UINT16(body + 2) == 46 &&
+						(sub.size < 18 || (READ_LE_UINT16(body + 6) / 1000 != 7 && !validTransition(body, sub.size))))
 						return fail("Invalid Mortimer movie transition");
+				} else if (sub.tag == MKTAG('L', 'O', 'A', 'D')) {
+					if (sub.size < 10)
+						return fail("Truncated Mortimer cached movie block");
 				} else if (sub.tag == MKTAG('X', 'P', 'A', 'L')) {
 					if (!validPalette(body, sub.size))
 						return fail("Invalid Mortimer palette change");
@@ -455,6 +474,8 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled) {
 		if (!decodeSound(bytes, track.sound))
 			return fail("Unsupported or incomplete Mortimer audio");
 		_sounds.push_back(Common::move(track.sound));
+		_soundOffsets.push_back(track.blocks[0].offset);
+		_soundEnabled.push_back(false);
 	}
 	debug(1, "Mortimer: loaded %u movie frames in %u ms", _frames.size(), g_system->getMillis() - started);
 	return true;
@@ -470,6 +491,10 @@ void MortimerMovie::reset() {
 	_storedObject = Object();
 	_objects.clear();
 	_captions.clear();
+	_cachedMovie.clear();
+	_cacheBlock = 0;
+	for (uint i = 0; i < _soundEnabled.size(); ++i)
+		_soundEnabled[i] = false;
 	_background.clear();
 	memcpy(_palette, _initialPalette, sizeof(_palette));
 	memset(_paletteDelta, 0, sizeof(_paletteDelta));
@@ -535,15 +560,20 @@ bool MortimerMovie::decodeObject(Object &object) {
 	}
 	object.pixels.resize(object.width * object.height);
 	if (object.codec == 37) {
-		if (_decoderWidth != object.width || _decoderHeight != object.height) {
+		// Interview clips crop the last 4x4 block to their portrait bounds.
+		const int width = (object.width + 3) & ~3, height = (object.height + 3) & ~3;
+		if (_decoderWidth != width || _decoderHeight != height) {
 			if (src[14] != 2)
 				return fail("Mortimer movie changes dimensions without a keyframe");
 			delete _decoder;
-			_decoderWidth = object.width;
-			_decoderHeight = object.height;
+			_decoderWidth = width;
+			_decoderHeight = height;
 			_decoder = new SmushDeltaBlocksDecoder(_decoderWidth, _decoderHeight);
 		}
-		_decoder->decode(object.pixels.begin(), src + 14);
+		_scratch.resize(width * height);
+		_decoder->decode(_scratch.begin(), src + 14);
+		for (int row = 0; row < object.height; ++row)
+			memcpy(object.pixels.begin() + row * object.width, _scratch.begin() + row * width, object.width);
 	} else {
 		memset(object.pixels.begin(), 0, object.pixels.size());
 		smushDecodeRLEOpaque(object.pixels.begin(), src + 14, 0, 0,
@@ -572,8 +602,31 @@ bool MortimerMovie::advance(bool renderFrame) {
 			skipB = READ_LE_UINT16(src + 2);
 			continue;
 		}
+		// Image toggles can change again when the player shoots this frame.
+		// Commands and sound starts must use the toggle at their position in it.
+		if (chunk.tag != MKTAG('F', 'O', 'B', 'J') && chunk.tag != MKTAG('F', 'T', 'C', 'H') && skipObject(skipA, skipB)) {
+			skipA = 0xffff;
+			skipB = 0;
+			continue;
+		}
 		if (chunk.tag == MKTAG('I', 'A', 'C', 'T')) {
 			handleAction(src, chunk.size);
+		} else if (chunk.tag == MKTAG('P', 'S', 'A', 'D')) {
+			if (!READ_LE_UINT16(src + 2))
+				for (uint i = 0; i < _soundOffsets.size(); ++i)
+					if (_soundOffsets[i] == chunk.offset)
+						_soundEnabled[i] = true;
+		} else if (chunk.tag == MKTAG('L', 'O', 'A', 'D')) {
+			const uint block = READ_LE_UINT16(src + 2);
+			if (!block) {
+				_cachedMovie.clear();
+				_cacheBlock = 0;
+			}
+			if (block != _cacheBlock++ || chunk.size - 10 > 32 * 1024 * 1024 - _cachedMovie.size())
+				return fail("Invalid Mortimer cached movie sequence");
+			const uint offset = _cachedMovie.size();
+			_cachedMovie.resize(offset + chunk.size - 10);
+			memcpy(_cachedMovie.begin() + offset, src + 10, chunk.size - 10);
 		} else if (chunk.tag == MKTAG('X', 'P', 'A', 'L')) {
 			handlePalette(src, chunk.size);
 		} else if (chunk.tag == MKTAG('N', 'P', 'A', 'L')) {
@@ -659,7 +712,17 @@ void MortimerMovie::render() {
 	}
 }
 
-MortimerCutscene::MortimerCutscene(bool girl) : _girl(girl) {
+bool MortimerMovieContext::matches(uint code) const {
+	for (uint i = 0; i < kConditionCount; ++i, code /= 3) {
+		const uint digit = code % 3;
+		if (digit && conditions[i] != (digit == 2))
+			return false;
+	}
+	return code == 0;
+}
+
+MortimerCutscene::MortimerCutscene(bool girl) {
+	_context.girl = girl;
 	reset();
 }
 
@@ -670,16 +733,24 @@ void MortimerCutscene::reset() {
 
 void MortimerCutscene::handleAction(const byte *data, uint32 size) {
 	MortimerMovie::handleAction(data, size);
-	if (READ_LE_UINT16(data) != 2 || READ_LE_UINT16(data + 4) != 1)
+	if (READ_LE_UINT16(data) != 2)
 		return;
 	const uint16 id = READ_LE_UINT16(data + 6), toggle = READ_LE_UINT16(data + 8);
-	// INTRO4_H contains both player characters under separate object toggles.
-	if (id == 3000 || id == 4000)
-		_toggles[toggle] = id == 3000 ? _girl : !_girl;
+	if (READ_LE_UINT16(data + 4) == 3) {
+		setToggle(toggle, !this->toggle(READ_LE_UINT16(data + 10)));
+	} else if (READ_LE_UINT16(data + 4) == 1) {
+		const uint family = id / 1000;
+		if (family == 2)
+			setToggle(toggle, false);
+		else if (family == 3 || family == 4 || family == 6)
+			setToggle(toggle, (family == 3 && _context.girl) || (family == 4 && !_context.girl) || !_context.matches(id % 1000));
+		else if (family == 5)
+			setToggle(toggle, _context.riddle != id - 5000);
+	}
 }
 
 bool MortimerCutscene::skipObject(uint16 a, uint16 b) const {
-	return a != 0xffff && (b ? _toggles[a] != _toggles[b] : _toggles[a]);
+	return a != 0xffff && (b ? toggle(a) != toggle(b) : toggle(a));
 }
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index cb75407320b..ec54f7b0aa5 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -50,6 +50,14 @@ struct MortimerSound {
 	Common::Array<byte> samples;
 };
 
+struct MortimerMovieContext {
+	enum Condition { kAtGate, kHasStones, kHostilesRemain, kAnimalsRemain, kDamaged, kOutOfGuesses, kConditionCount };
+	bool girl = false;
+	int riddle = -1;
+	bool conditions[kConditionCount] = {};
+	bool matches(uint code) const;
+};
+
 bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<MortimerSprite> &sprites, byte *palette = nullptr);
 bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound);
 
@@ -64,7 +72,7 @@ public:
 
 	MortimerMovie();
 	virtual ~MortimerMovie();
-	bool load(Common::SeekableReadStream &stream, bool doubled = true);
+	bool load(Common::SeekableReadStream &stream, bool doubled = true, bool continuation = false);
 	virtual void reset();
 	// Suppress compositing while seeking to a frame that replaces the image.
 	virtual bool advance(bool renderFrame = true);
@@ -74,9 +82,12 @@ public:
 	const byte *palette() const { return _palette; }
 	uint frame() const { return _nextFrame ? _nextFrame - 1 : 0; }
 	uint frameCount() const { return _frames.size(); }
+	bool started() const { return _nextFrame != 0; }
 	uint frameRate() const { return _frameRate; }
 	const Common::Array<MortimerSound> &sounds() const { return _sounds; }
 	const Common::Array<Caption> &captions() const { return _captions; }
+	bool soundEnabled(uint index) const { return _soundEnabled[index]; }
+	const Common::Array<byte> &cachedMovie() const { return _cachedMovie; }
 	const Common::String &errorMessage() const { return _error; }
 
 protected:
@@ -107,6 +118,10 @@ private:
 	Common::Array<Frame> _frames;
 	Common::Array<Object> _objects;
 	Common::Array<MortimerSound> _sounds;
+	Common::Array<uint32> _soundOffsets;
+	Common::Array<bool> _soundEnabled;
+	Common::Array<byte> _cachedMovie;
+	uint _cacheBlock;
 	Common::Array<Caption> _captions;
 	Object _storedObject;
 	SmushDeltaBlocksDecoder *_decoder;
@@ -123,14 +138,18 @@ class MortimerCutscene : public MortimerMovie {
 public:
 	explicit MortimerCutscene(bool girl = false);
 	void reset() override;
+	MortimerMovieContext &context() { return _context; }
+	const MortimerMovieContext &context() const { return _context; }
 
 protected:
 	void handleAction(const byte *data, uint32 size) override;
 	bool skipObject(uint16 a, uint16 b) const override;
+	bool toggle(uint16 id) const { return id > 0 && id <= 1024 && _toggles[id - 1]; }
+	void setToggle(uint16 id, bool value) { if (id > 0 && id <= 1024) _toggles[id - 1] = value; }
 
 private:
-	const bool _girl;
-	bool _toggles[256];
+	MortimerMovieContext _context;
+	bool _toggles[1024];
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/text.cpp b/engines/scumm/insane/mortimer/text.cpp
index bda3d4fe25a..9b07ee0ae5f 100644
--- a/engines/scumm/insane/mortimer/text.cpp
+++ b/engines/scumm/insane/mortimer/text.cpp
@@ -99,9 +99,14 @@ bool MortimerSubtitles::loadText(Common::SeekableReadStream &stream) {
 			if (end == token.c_str() || *end || value < 0 || value > 65535)
 				return false;
 			id = value;
-		} else if (line.hasPrefix("//") && id >= 0) {
-			line = line.substr(2);
+		} else if (id >= 0 && (line.hasPrefix("//") || line.hasPrefix("^c"))) {
+			if (line.hasPrefix("//"))
+				line = line.substr(2);
 			Entry &entry = _entries[id];
+			if (entry.text.empty() && line.hasPrefix("!")) {
+				entry.conditional = true;
+				line = line.substr(1);
+			}
 			if (entry.text.empty() && line.hasPrefix("$")) {
 				entry.information = true;
 				line = line.substr(1);
@@ -119,13 +124,17 @@ bool MortimerSubtitles::loadText(Common::SeekableReadStream &stream) {
 	return !stream.err() && !_entries.empty();
 }
 
-void MortimerSubtitles::draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled) const {
+void MortimerSubtitles::draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled,
+		const MortimerMovieContext *context) const {
 	for (uint i = 0; i < captions.size(); ++i) {
 		const MortimerMovie::Caption &caption = captions[i];
 		const auto found = _entries.find(caption.id);
 		if (found == _entries.end())
 			continue;
 		const Entry &entry = found->_value;
+		if (entry.conditional && context && ((caption.id < 1000 && context->riddle != caption.id) ||
+			(caption.id / 1000 == 3 && context->girl) || (caption.id / 1000 == 4 && !context->girl)))
+			continue;
 		if ((!enabled && !entry.information) || entry.text.empty())
 			continue;
 		const MortimerFont &font = entry.information ? _infoFont : _font;
diff --git a/engines/scumm/insane/mortimer/text.h b/engines/scumm/insane/mortimer/text.h
index 53cbf1dbe05..e4abc3c8354 100644
--- a/engines/scumm/insane/mortimer/text.h
+++ b/engines/scumm/insane/mortimer/text.h
@@ -45,13 +45,15 @@ class MortimerSubtitles {
 public:
 	bool load(const char *textPath, const char *fontPath, const char *infoFontPath);
 	bool loadText(Common::SeekableReadStream &stream);
-	void draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled) const;
+	void draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled,
+		const MortimerMovieContext *context = nullptr) const;
 
 private:
 	struct Entry {
 		Common::String text;
 		byte color = 1;
 		bool information = false;
+		bool conditional = false;
 	};
 
 	MortimerFont _font, _infoFont;
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 68d96c91920..0d7adbfa221 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -147,6 +147,7 @@ MODULE_OBJS += \
 	insane/insane_enemy.o \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
+	insane/mortimer/campaign.o \
 	insane/mortimer/flight.o \
 	insane/mortimer/interview.o \
 	insane/mortimer/movie.o \


Commit: 4f4543fa81f399e98fe706b19e8cec281cdce4bf
    https://github.com/scummvm/scummvm/commit/4f4543fa81f399e98fe706b19e8cec281cdce4bf
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: use copyRectToSurfaceWithKey for sprite drawing

Changed paths:
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h


diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 896fab43901..2c9c0d60717 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -64,22 +64,6 @@ const Control kControls[] = {
 
 const Common::Point kQuestionOrigins[] = {{122, 191}, {120, 234}, {124, 278}};
 
-void blit(Graphics::Surface &screen, const MortimerSprite &sprite, int x, int y) {
-	x += sprite.x;
-	y += sprite.y;
-	Common::Rect clip(x, y, x + sprite.width, y + sprite.height);
-	clip.clip(Common::Rect(screen.w, screen.h));
-	if (clip.isEmpty())
-		return;
-	for (int row = clip.top; row < clip.bottom; ++row) {
-		const byte *src = sprite.pixels.begin() + (row - y) * sprite.width;
-		byte *dst = (byte *)screen.getBasePtr(0, row);
-		for (int col = clip.left; col < clip.right; ++col)
-			if (src[col - x])
-				dst[col] = src[col - x];
-	}
-}
-
 MortimerInterview::MortimerInterview(bool isDemo) : _random("mortimer"), _isDemo(isDemo),
 	_region(0), _difficulty(0), _textDifficulty(0), _tried(0), _stage(kIdle), _effect(kNoEffect),
 	_answer(-1), _previousAnswer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0), _guesses(3),
@@ -505,29 +489,28 @@ void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &mov
 		return;
 	}
 	screen.fillRect(Common::Rect(640, 480), 10);
-	blit(screen, _riddleView ? _gate[0] : _portraits[_selected < 0 ? 7 : _selected], 354, 69);
+	const MortimerSprite &portrait = _riddleView ? _gate[0] : _portraits[_selected < 0 ? 7 : _selected];
+	portrait.draw(screen, 354, 69);
 	if (playingMovie() && _stage != kHelp && _stage != kNoStones) {
-		for (int y = 0; y < 320 && y + 69 < screen.h; ++y) {
-			const byte *src = movie.pixels() + y * MortimerMovie::kScreenWidth;
-			byte *dst = (byte *)screen.getBasePtr(354, y + 69);
-			for (int x = 0; x < 640 - 354; ++x)
-				if (src[x])
-					dst[x] = src[x];
-		}
+		Common::Rect clip(354, 69, 640, 389);
+		clip.clip(Common::Rect(screen.w, screen.h));
+		if (!clip.isEmpty())
+			screen.copyRectToSurfaceWithKey(movie.pixels(), MortimerMovie::kScreenWidth,
+				clip.left, clip.top, clip.width(), clip.height(), 0);
 	}
 	if (_switchFrame < 6)
-		blit(screen, _panel[10 + _switchFrame], 354, 69);
-	blit(screen, _panel[0], 0, 0);
+		_panel[10 + _switchFrame].draw(screen, 354, 69);
+	_panel[0].draw(screen, 0, 0);
 	if (_riddleView || _selected < 0) {
-		blit(screen, _panel[7], 123, 175);
-		blit(screen, _panel[1], (_frame / 4) % 2 ? 102 : 30, (_frame / 4) % 2 ? 282 : 286);
+		_panel[7].draw(screen, 123, 175);
+		_panel[1].draw(screen, (_frame / 4) % 2 ? 102 : 30, (_frame / 4) % 2 ? 282 : 286);
 	} else if (_selected >= 0) {
-		blit(screen, _panel[4 + _question], kQuestionOrigins[_question].x, kQuestionOrigins[_question].y);
+		_panel[4 + _question].draw(screen, kQuestionOrigins[_question].x, kQuestionOrigins[_question].y);
 	}
 	if (_help)
-		blit(screen, _panel[9], 400, 6);
+		_panel[9].draw(screen, 400, 6);
 	if (_solving)
-		blit(screen, _panel[2], 11, 174);
+		_panel[2].draw(screen, 11, 174);
 	if (_selected >= 0 || _riddleView)
 		drawText(screen, _riddleView ? _riddles[_answer][_textDifficulty] : _dialogue[_selected][_question]);
 }
@@ -535,9 +518,9 @@ void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &mov
 void MortimerInterview::drawCursor(Graphics::Surface &screen, const Common::Point &point) const {
 	if (!fullScreen()) {
 		if (_stage == kPay)
-			blit(screen, _panel[19 + _frame % 2], 508, 451);
+			_panel[19 + _frame % 2].draw(screen, 508, 451);
 		const int cursor = (_help ? 2 : 0) + (hitControl(point) < 0 ? 1 : 0);
-		blit(screen, _cursor[cursor], point.x, point.y);
+		_cursor[cursor].draw(screen, point.x, point.y);
 	}
 }
 
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index fc07f950470..7ebeaa177be 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -875,27 +875,7 @@ Common::Error MortimerEngine::run() {
 }
 
 void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y, bool centered, const Common::Rect *bounds) {
-	// NUT offsets are relative to the animation's origin, including when its
-	// frames have different bounding boxes (SHELL 402c83/4141bc).
-	if (centered) {
-		x -= (sprite.x + sprite.width) / 2;
-		y -= (sprite.y + sprite.height) / 2;
-	}
-	x += sprite.x;
-	y += sprite.y;
-	Common::Rect clip(x, y, x + sprite.width, y + sprite.height);
-	clip.clip(Common::Rect(_screen.w, _screen.h));
-	if (bounds)
-		clip.clip(*bounds);
-	if (clip.isEmpty())
-		return;
-	for (int row = clip.top; row < clip.bottom; ++row) {
-		const byte *src = sprite.pixels.begin() + (row - y) * sprite.width + clip.left - x;
-		byte *dst = (byte *)_screen.getBasePtr(clip.left, row);
-		for (int col = clip.left; col < clip.right; ++col, ++src, ++dst)
-			if (*src)
-				*dst = *src;
-	}
+	sprite.draw(_screen, x, y, centered, bounds);
 }
 
 void MortimerEngine::drawTargets() {
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 12147ddeafc..03facdbd32f 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -26,6 +26,7 @@
 #include "common/stream.h"
 #include "common/system.h"
 #include "common/util.h"
+#include "graphics/surface.h"
 
 namespace Scumm {
 
@@ -208,6 +209,24 @@ bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
 
 } // End of anonymous namespace
 
+void MortimerSprite::draw(Graphics::Surface &screen, int dstX, int dstY, bool centered, const Common::Rect *bounds) const {
+	// Sprite offsets are relative to the animation's origin, even when centered.
+	if (centered) {
+		dstX -= (x + width) / 2;
+		dstY -= (y + height) / 2;
+	}
+	dstX += x;
+	dstY += y;
+	Common::Rect clip(dstX, dstY, dstX + width, dstY + height);
+	clip.clip(Common::Rect(screen.w, screen.h));
+	if (bounds)
+		clip.clip(*bounds);
+	if (clip.isEmpty())
+		return;
+	const byte *src = pixels.begin() + (clip.top - dstY) * width + clip.left - dstX;
+	screen.copyRectToSurfaceWithKey(src, width, clip.left, clip.top, clip.width(), clip.height(), 0);
+}
+
 bool decodeMortimerGlyph(const byte *src, uint32 size, MortimerSprite &sprite) {
 	sprite.mask.resize(sprite.pixels.size());
 	memset(sprite.mask.begin(), 0, sprite.mask.size());
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index ec54f7b0aa5..605784740f6 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -29,6 +29,10 @@ namespace Common {
 class SeekableReadStream;
 }
 
+namespace Graphics {
+struct Surface;
+}
+
 namespace Scumm {
 
 class SmushDeltaBlocksDecoder;
@@ -38,6 +42,8 @@ struct MortimerSprite {
 	Common::Array<byte> pixels;
 	// Codec 44 uses skipped pixels for transparency and relative palette colors.
 	Common::Array<byte> mask;
+
+	void draw(Graphics::Surface &screen, int dstX, int dstY, bool centered = false, const Common::Rect *bounds = nullptr) const;
 };
 
 struct MortimerSound {


Commit: 5996c0daa7cb1fcb0e220cc7cc969fea79585fa0
    https://github.com/scummvm/scummvm/commit/5996c0daa7cb1fcb0e220cc7cc969fea79585fa0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: shared SAUD/SAD reader for RA1 and mortimer

Changed paths:
  A engines/scumm/smush/saud.cpp
  A engines/scumm/smush/saud.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/rebel1/audio.cpp
    engines/scumm/insane/rebel2/audio.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 03facdbd32f..6d68a9f0ba7 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -21,8 +21,10 @@
 #include "scumm/insane/mortimer/movie.h"
 #include "scumm/smush/codec37.h"
 #include "scumm/smush/rebel/codec_ra2.h"
+#include "scumm/smush/saud.h"
 
 #include "common/endian.h"
+#include "common/memstream.h"
 #include "common/stream.h"
 #include "common/system.h"
 #include "common/util.h"
@@ -36,7 +38,7 @@ struct Chunk {
 	uint32 tag, offset, size;
 };
 
-bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk, bool padded = true) {
+bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk) {
 	if (pos > end || end - pos < 8)
 		return false;
 	chunk.tag = READ_BE_UINT32(data + pos);
@@ -45,7 +47,7 @@ bool nextChunk(const byte *data, uint32 end, uint32 &pos, Chunk &chunk, bool pad
 	if (chunk.size > end - chunk.offset)
 		return false;
 	pos = chunk.offset + chunk.size;
-	if (padded && (chunk.size & 1)) {
+	if (chunk.size & 1) {
 		if (pos == end)
 			return false;
 		++pos;
@@ -183,30 +185,6 @@ bool validTransition(const byte *data, uint32 size) {
 	return true;
 }
 
-bool decodeSound(const Common::Array<byte> &data, MortimerSound &sound) {
-	if (!isContainer(data, MKTAG('S', 'A', 'U', 'D')))
-		return false;
-	sound.samples.clear();
-	uint32 pos = 8;
-	while (pos < data.size()) {
-		Chunk chunk;
-		// SAUD chunks are not word-aligned, unlike the surrounding SMUSH file.
-		if (!nextChunk(data.begin(), data.size(), pos, chunk, false))
-			return false;
-		const byte *src = data.begin() + chunk.offset;
-		if (chunk.tag == MKTAG('S', 'D', 'A', 'T')) {
-			if (!sound.samples.empty())
-				return false;
-			sound.samples.assign(src, src + chunk.size);
-		} else if (chunk.tag == MKTAG('S', 'H', 'D', 'R') && chunk.size == 4) {
-			sound.rate = READ_LE_UINT32(src);
-		} else if (chunk.tag == MKTAG('S', 'T', 'R', 'K') && chunk.size >= 14 && src[0] == 6 && src[1] == 12) {
-			sound.rate = READ_BE_UINT32(src + 10);
-		}
-	}
-	return !sound.samples.empty() && sound.rate >= 4000 && sound.rate <= 48000;
-}
-
 } // End of anonymous namespace
 
 void MortimerSprite::draw(Graphics::Surface &screen, int dstX, int dstY, bool centered, const Common::Rect *bounds) const {
@@ -314,8 +292,18 @@ bool loadMortimerSprites(Common::SeekableReadStream &stream, Common::Array<Morti
 }
 
 bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound) {
-	Common::Array<byte> data;
-	return readData(stream, data) && decodeSound(data, sound);
+	if (stream.size() > 64 * 1024 * 1024 || !stream.seek(0))
+		return false;
+	SaudHeader header;
+	if (!readSaudHeader(stream, header) || header.sampleRate < 4000 || header.sampleRate > 48000)
+		return false;
+	sound.samples.resize(header.dataSize);
+	if (stream.read(sound.samples.begin(), header.dataSize) != header.dataSize || stream.err()) {
+		sound.samples.clear();
+		return false;
+	}
+	sound.rate = header.sampleRate;
+	return true;
 }
 
 MortimerMovie::MortimerMovie() : _decoder(nullptr), _decoderWidth(0), _decoderHeight(0),
@@ -490,7 +478,8 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled, bool
 			memcpy(bytes.begin() + offset, _data.begin() + block.offset + 10, block.size - 10);
 			offset += block.size - 10;
 		}
-		if (!decodeSound(bytes, track.sound))
+		Common::MemoryReadStream soundStream(bytes.begin(), bytes.size());
+		if (!loadMortimerSound(soundStream, track.sound))
 			return fail("Unsupported or incomplete Mortimer audio");
 		_sounds.push_back(Common::move(track.sound));
 		_soundOffsets.push_back(track.blocks[0].offset);
diff --git a/engines/scumm/insane/rebel1/audio.cpp b/engines/scumm/insane/rebel1/audio.cpp
index cc885a00ae4..da61d39708c 100644
--- a/engines/scumm/insane/rebel1/audio.cpp
+++ b/engines/scumm/insane/rebel1/audio.cpp
@@ -30,6 +30,7 @@
 
 #include "scumm/scumm_v7.h"
 #include "scumm/insane/rebel1/rebel.h"
+#include "scumm/smush/saud.h"
 
 namespace Scumm {
 
@@ -99,45 +100,26 @@ void InsaneRebel1::loadSfx() {
 			continue;
 		}
 
-		const uint32 fileSize = file->size();
-		if (fileSize < 16) {
-			debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: %s too small (%u bytes)", kRA1SfxFiles[i], fileSize);
+		SaudHeader header;
+		if (!readSaudHeader(*file, header, true)) {
+			debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: invalid SAUD file %s", kRA1SfxFiles[i]);
 			file->close();
 			delete file;
 			continue;
 		}
 
-		const uint32 tag = file->readUint32BE();
-		if (tag != MKTAG('S', 'A', 'U', 'D')) {
-			debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: %s not a SAUD file (tag=0x%08x)", kRA1SfxFiles[i], tag);
-			file->close();
-			delete file;
-			continue;
-		}
-		file->readUint32BE();
-
-		bool foundSdat = false;
-		while (file->pos() + 8 <= (int64)fileSize) {
-			const uint32 chunkTag = file->readUint32BE();
-			const uint32 chunkSize = file->readUint32BE();
-			if (chunkTag == MKTAG('S', 'D', 'A', 'T')) {
-				const uint32 pcmSize = MIN(chunkSize, fileSize - (uint32)file->pos());
-				byte *pcm = (byte *)malloc(pcmSize);
-				if (pcm) {
-					file->read(pcm, pcmSize);
-					_sfxData[i] = pcm;
-					_sfxSize[i] = pcmSize;
-					debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: loaded %s (%u bytes PCM)", kRA1SfxFiles[i], pcmSize);
-				}
-				foundSdat = true;
-				break;
+		byte *pcm = (byte *)malloc(header.dataSize);
+		if (pcm) {
+			if (file->read(pcm, header.dataSize) == header.dataSize && !file->err()) {
+				_sfxData[i] = pcm;
+				_sfxSize[i] = header.dataSize;
+				debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: loaded %s (%u bytes PCM)", kRA1SfxFiles[i], header.dataSize);
+			} else {
+				debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: could not read PCM from %s", kRA1SfxFiles[i]);
+				free(pcm);
 			}
-			file->seek(chunkSize, SEEK_CUR);
 		}
 
-		if (!foundSdat)
-			debugC(DEBUG_INSANE, "InsaneRebel1::loadSfx: no SDAT chunk in %s", kRA1SfxFiles[i]);
-
 		file->close();
 		delete file;
 	}
diff --git a/engines/scumm/insane/rebel2/audio.cpp b/engines/scumm/insane/rebel2/audio.cpp
index 663413424b0..fe1be7b4895 100644
--- a/engines/scumm/insane/rebel2/audio.cpp
+++ b/engines/scumm/insane/rebel2/audio.cpp
@@ -19,12 +19,14 @@
  *
  */
 
+#include "common/memstream.h"
 #include "common/system.h"
 #include "common/util.h"
 
 #include "scumm/file.h"
 #include "scumm/scumm_v7.h"
 
+#include "scumm/smush/saud.h"
 #include "scumm/smush/smush_player.h"
 
 #include "scumm/insane/rebel2/rebel.h"
@@ -79,47 +81,26 @@ void InsaneRebel2::loadSfx() {
 			continue;
 		}
 
-		uint32 fileSize = file->size();
-		if (fileSize < 38) {  // Minimum: 8 (SAUD) + 22 (STRK) + 8 (SDAT header)
-			debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: %s too small (%d bytes)", kRA2SfxFiles[i], fileSize);
+		SaudHeader header;
+		if (!readSaudHeader(*file, header, true)) {
+			debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: Invalid SAUD file %s", kRA2SfxFiles[i]);
 			file->close();
 			delete file;
 			continue;
 		}
 
-		uint32 tag = file->readUint32BE();
-		if (tag != MKTAG('S', 'A', 'U', 'D')) {
-			debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: %s not a SAUD file (tag=0x%08x)", kRA2SfxFiles[i], tag);
-			file->close();
-			delete file;
-			continue;
-		}
-		file->readUint32BE();
-
-		bool foundSdat = false;
-		while (file->pos() + 8 <= (int64)fileSize) {
-			uint32 chunkTag = file->readUint32BE();
-			uint32 chunkSize = file->readUint32BE();
-
-			if (chunkTag == MKTAG('S', 'D', 'A', 'T')) {
-				uint32 pcmSize = MIN(chunkSize, fileSize - (uint32)file->pos());
-				_sfxData[i] = (byte *)malloc(pcmSize);
-				if (_sfxData[i]) {
-					file->read(_sfxData[i], pcmSize);
-					_sfxSize[i] = pcmSize;
-					debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: Loaded %s (%d bytes PCM)", kRA2SfxFiles[i], pcmSize);
-				}
-				foundSdat = true;
-				break;
+		byte *pcm = (byte *)malloc(header.dataSize);
+		if (pcm) {
+			if (file->read(pcm, header.dataSize) == header.dataSize && !file->err()) {
+				_sfxData[i] = pcm;
+				_sfxSize[i] = header.dataSize;
+				debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: Loaded %s (%u bytes PCM)", kRA2SfxFiles[i], header.dataSize);
 			} else {
-				file->seek(chunkSize, SEEK_CUR);
+				debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: Could not read PCM from %s", kRA2SfxFiles[i]);
+				free(pcm);
 			}
 		}
 
-		if (!foundSdat) {
-			debugC(DEBUG_INSANE, "InsaneRebel2::loadSfx: No SDAT chunk in %s", kRA2SfxFiles[i]);
-		}
-
 		file->close();
 		delete file;
 	}
@@ -200,18 +181,15 @@ void InsaneRebel2::playAuxSfx(int buffer, int volume, int pan) {
 	const byte *pcmStart = _auxSfxData[buffer];
 	uint32 pcmSize = _auxSfxSize[buffer];
 
-	if (pcmSize > 8 && READ_BE_UINT32(pcmStart) == MKTAG('S', 'A', 'U', 'D')) {
-		uint32 pos = 8;
-		while (pos + 8 <= pcmSize) {
-			uint32 chunkTag = READ_BE_UINT32(pcmStart + pos);
-			uint32 chunkSize = READ_BE_UINT32(pcmStart + pos + 4);
-			if (chunkTag == MKTAG('S', 'D', 'A', 'T')) {
-				pcmStart = pcmStart + pos + 8;
-				pcmSize = MIN(chunkSize, pcmSize - pos - 8);
-				break;
-			}
-			pos += 8 + chunkSize;
+	if (pcmSize >= 4 && READ_BE_UINT32(pcmStart) == MKTAG('S', 'A', 'U', 'D')) {
+		Common::MemoryReadStream soundStream(pcmStart, pcmSize);
+		SaudHeader header;
+		if (!readSaudHeader(soundStream, header, true)) {
+			debugC(DEBUG_INSANE, "InsaneRebel2::playAuxSfx: Invalid SAUD in buffer %d", buffer);
+			return;
 		}
+		pcmStart += soundStream.pos();
+		pcmSize = header.dataSize;
 	}
 
 	byte *pcmCopy = (byte *)malloc(pcmSize);
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 0d7adbfa221..b340a3c4a71 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -178,6 +178,7 @@ MODULE_OBJS += \
 	smush/codec20.o \
 	smush/codec37.o \
 	smush/codec47.o \
+	smush/saud.o \
 	smush/smush_player.o \
 	smush/rebel/codec_ra1.o \
 	smush/rebel/codec_ra2.o \
diff --git a/engines/scumm/smush/saud.cpp b/engines/scumm/smush/saud.cpp
new file mode 100644
index 00000000000..28ec9195a50
--- /dev/null
+++ b/engines/scumm/smush/saud.cpp
@@ -0,0 +1,78 @@
+/* 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 "scumm/smush/saud.h"
+
+#include "common/endian.h"
+#include "common/stream.h"
+#include "common/util.h"
+
+namespace Scumm {
+
+bool readSaudHeader(Common::SeekableReadStream &stream, SaudHeader &header, bool allowTruncated) {
+	int64 end = stream.size();
+	if (stream.pos() < 0 || end - stream.pos() < 8 || stream.readUint32BE() != MKTAG('S', 'A', 'U', 'D'))
+		return false;
+	const uint32 size = stream.readUint32BE();
+	if (!allowTruncated && size != end - stream.pos())
+		return false;
+	end = MIN(end, stream.pos() + size);
+
+	SaudHeader parsed;
+	int64 dataOffset = -1;
+	while (stream.pos() < end) {
+		if (end - stream.pos() < 8)
+			return false;
+		const uint32 tag = stream.readUint32BE();
+		uint32 chunkSize = stream.readUint32BE();
+		const int64 offset = stream.pos();
+		if (chunkSize > end - offset) {
+			if (!allowTruncated || tag != MKTAG('S', 'D', 'A', 'T'))
+				return false;
+			chunkSize = end - offset;
+		}
+		if (tag == MKTAG('S', 'D', 'A', 'T')) {
+			if (dataOffset >= 0 || !chunkSize)
+				return false;
+			dataOffset = offset;
+			parsed.dataSize = chunkSize;
+			if (allowTruncated)
+				break;
+		} else if (tag == MKTAG('S', 'H', 'D', 'R') && chunkSize == 4) {
+			parsed.sampleRate = stream.readUint32LE();
+		} else if (tag == MKTAG('S', 'T', 'R', 'K') && chunkSize >= 14) {
+			const byte code = stream.readByte(), length = stream.readByte();
+			if (code == 6 && length == 12) {
+				if (!stream.skip(8))
+					return false;
+				parsed.sampleRate = stream.readUint32BE();
+			}
+		}
+		// SAUD chunks have no word-alignment padding.
+		if (stream.err() || stream.eos() || !stream.seek(offset + chunkSize))
+			return false;
+	}
+	if (dataOffset < 0 || stream.err() || stream.eos() || !stream.seek(dataOffset))
+		return false;
+	header = parsed;
+	return true;
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/smush/saud.h b/engines/scumm/smush/saud.h
new file mode 100644
index 00000000000..65e9f55563b
--- /dev/null
+++ b/engines/scumm/smush/saud.h
@@ -0,0 +1,46 @@
+/* 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 SCUMM_SMUSH_SAUD_H
+#define SCUMM_SMUSH_SAUD_H
+
+#include "common/scummsys.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace Scumm {
+
+struct SaudHeader {
+	uint32 dataSize = 0;
+	uint32 sampleRate = 11025;
+};
+
+// Read a SAUD container spanning the rest of the stream, leaving it at its PCM.
+// The sample rate defaults to 11025 Hz when no rate metadata is present.
+// allowTruncated stops at the first SDAT, accepting the available PCM prefix
+// without reading trailing metadata. This supports Rebel SFX and auxiliary buffers.
+// The header is only updated on success.
+bool readSaudHeader(Common::SeekableReadStream &stream, SaudHeader &header, bool allowTruncated = false);
+
+} // End of namespace Scumm
+
+#endif


Commit: 6a4ad1273ec6d4643567b1c8040fba8765418f2b
    https://github.com/scummvm/scummvm/commit/6a4ad1273ec6d4643567b1c8040fba8765418f2b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: implement shellshock-related code and indicators

Changed paths:
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h


diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index c05f5e83d8d..e4141345ef5 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -44,6 +44,12 @@ void MortimerFlight::clearProgress() {
 	context() = MortimerMovieContext();
 }
 
+void MortimerFlight::clearHostiles() {
+	// Retail hornet hits also suppress their salt projectiles.
+	memset(_hitTargets + 800, 0, 100 * sizeof(_hitTargets[0]));
+	memset(_hitTargets + 1000, 0, 400 * sizeof(_hitTargets[0]));
+}
+
 void MortimerFlight::reset() {
 	MortimerCutscene::reset();
 	if (_isDemo)
@@ -171,7 +177,10 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 			_weapon = context().conditions[MortimerMovieContext::kHostilesRemain] ? kSlimeGun : kRevitascope;
 		} else if (id / 1000 == 12) {
 			context().conditions[MortimerMovieContext::kAnimalsRemain] = targetsRemain(text);
-		} else if (_inBranch && !text.hasPrefix("@riddles")) {
+		} else if ((_isDemo || _inBranch) && !text.hasPrefix("@riddles")) {
+			// The demo has a fixed route. TITLE.SAN gives its destination
+			// before the full-frame branch rectangle, so it cannot use the
+			// retail steering predicate at this point in the frame.
 			_nextFlight = text;
 			for (uint i = 0; i < _nextFlight.size(); ++i)
 				if (_nextFlight[i] == '\\')
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 901255dc63f..47e072750db 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -41,6 +41,7 @@ public:
 	explicit MortimerFlight(bool isDemo = true);
 	bool load(Common::SeekableReadStream &stream, bool interactive = true, bool continuation = false);
 	void clearProgress();
+	void clearHostiles();
 	void reset() override;
 	bool advance(bool renderFrame = true) override;
 	ShotResult shoot(const Common::Point &point);
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 7ebeaa177be..27fc51be18c 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -111,11 +111,12 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 	return keymaps;
 }
 
-MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo), _flight(isDemo), _interview(isDemo),
+MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo), _random("mortimer-flight"), _flight(isDemo), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
-	_nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0), _segment(0), _carriedStones(0), _completedSpecies(0),
-	_shellShocks(0), _uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0),
-	_pendingGlowStones(0), _finished(false), _controllerMode(false), _interviewActive(false), _resourcesLoaded(false), _playingCached(false) {
+	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
+	_segment(0), _carriedStones(0), _completedSpecies(0), _shellShocks(0), _shockFrame(0), _crashCount(0),
+	_uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0), _pendingGlowStones(0), _finished(false),
+	_controllerMode(false), _interviewActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 	ConfMan.registerDefault("mortimer_flight_difficulty", 0);
@@ -181,6 +182,8 @@ bool MortimerEngine::loadResources() {
 		}
 		file.close();
 	}
+	// The hit sound animation uses volume 50 on the original 0..127 scale.
+	_collisionSound.volume = 100;
 	if (_isDemo && !_interview.loadResources()) {
 		_loadError = _interview.errorMessage();
 		return false;
@@ -189,13 +192,13 @@ bool MortimerEngine::loadResources() {
 }
 
 MortimerMovie &MortimerEngine::currentMovie() {
-	if (_interviewActive || _introIndex >= 0)
+	if (_crashStage != kNoCrash || _interviewActive || _introIndex >= 0)
 		return _intro;
 	return _flight;
 }
 
 const MortimerMovie &MortimerEngine::currentMovie() const {
-	if (_interviewActive || _introIndex >= 0)
+	if (_crashStage != kNoCrash || _interviewActive || _introIndex >= 0)
 		return _intro;
 	return _flight;
 }
@@ -279,6 +282,7 @@ bool MortimerEngine::loadRegion(uint region) {
 	_flight.setCollisionRadius(20 * (CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2) + 1));
 	_interview.resetRiddle();
 	_carriedStones = _completedSpecies = _creditedRescues = _shellShocks = 0;
+	_shockFrame = _crashCount = 0;
 	return true;
 }
 
@@ -308,6 +312,7 @@ bool MortimerEngine::loadRetailFlight(const Common::String &name, bool cached) {
 		return false;
 	}
 	_playingCached = cached;
+	_flightName = name;
 	_introIndex = -1;
 	_movieHandles.resize(_flight.sounds().size());
 	return true;
@@ -397,12 +402,23 @@ void MortimerEngine::playMovieSounds(bool seek) {
 	}
 }
 
+void MortimerEngine::playCollisionSound() {
+	// Each hit queues a new sample; earlier impacts can still be playing.
+	uint channel = 0;
+	while (channel < _collisionHandles.size() && _mixer->isSoundHandleActive(_collisionHandles[channel]))
+		++channel;
+	if (channel == _collisionHandles.size())
+		_collisionHandles.push_back(Audio::SoundHandle());
+	playSound(_collisionSound, _collisionHandles[channel], Audio::Mixer::kSFXSoundType);
+}
+
 void MortimerEngine::stopSounds() {
 	for (uint i = 0; i < _movieHandles.size(); ++i)
 		_mixer->stopHandle(_movieHandles[i]);
+	for (uint i = 0; i < _collisionHandles.size(); ++i)
+		_mixer->stopHandle(_collisionHandles[i]);
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
-	_mixer->stopHandle(_collisionHandle);
 	_mixer->stopHandle(_glowHandle);
 }
 
@@ -427,6 +443,7 @@ bool MortimerEngine::startMovie(bool preserveScreen) {
 
 bool MortimerEngine::startFlight() {
 	_flight.resetWeapon();
+	_hitJolt = false;
 	_uiFrame = 0;
 	_shotFrame = 3;
 	_glowFrame = 10;
@@ -441,7 +458,64 @@ bool MortimerEngine::startFlight() {
 	return startMovie();
 }
 
-bool MortimerEngine::restart(bool all, bool crashed) {
+bool MortimerEngine::startCrash() {
+	// SHELL's difficulty table permits these crashes before a full repair.
+	const uint allowances[] = {4, 3, 1};
+	const uint difficulty = CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2);
+	_needsRepair = ++_crashCount > allowances[difficulty];
+	if (_needsRepair)
+		_crashCount = 0;
+	_hitJolt = false;
+	_flight.resetWeapon();
+	return loadCrashMovie(false);
+}
+
+bool MortimerEngine::loadCrashMovie(bool repair) {
+	stopSounds();
+	_movieHandles.clear();
+	const char *name = repair ? "RESOURCE/GENERAL/REPAIR.SAN" :
+		(_campaign.scene().region == 3 ? "RESOURCE/GENERAL/D_CRASH.SAN" : "RESOURCE/GENERAL/CRASH.SAN");
+	Common::File file;
+	if (!file.open(Common::Path(name))) {
+		_loadError = Common::String::format("Missing %s", name);
+		return false;
+	}
+	if (!_intro.load(file)) {
+		_loadError = Common::String::format("%s: %s", name, _intro.errorMessage().c_str());
+		return false;
+	}
+	_crashStage = repair ? kRepairMovie : kCrashMovie;
+	_movieHandles.resize(_intro.sounds().size());
+	return startMovie();
+}
+
+bool MortimerEngine::finishCrash() {
+	const uint difficulty = CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2);
+	_crashStage = kNoCrash;
+	_shellShocks = _shockFrame = 0;
+	_pendingFlight.clear();
+	if (_needsRepair) {
+		_needsRepair = false;
+		// Easy flight keeps rescues and stones even after a full repair.
+		if (difficulty != 0) {
+			const bool girl = _flight.context().girl;
+			_flight.clearProgress();
+			_flight.context().girl = girl;
+			_carriedStones = _completedSpecies = _creditedRescues = 0;
+			_interview.resetAttempts();
+		}
+		return _campaign.returnToFlight() && enterCampaignScene();
+	}
+	if (difficulty == 2)
+		_flight.clearHostiles();
+	// Ordinary crashes replay the current branch, including a cached branch.
+	return loadRetailFlight(_flightName) && startFlight();
+}
+
+bool MortimerEngine::restart(bool all) {
+	_crashStage = kNoCrash;
+	_needsRepair = _hitJolt = false;
+	_shockFrame = _crashCount = 0;
 	if (!_isDemo) {
 		_finished = false;
 		if (all) {
@@ -452,16 +526,9 @@ bool MortimerEngine::restart(bool all, bool crashed) {
 		if (!_interviewActive && _introIndex >= 0)
 			return startMovie();
 		const uint region = _campaign.scene().region;
-		// Easy flight keeps rescues after a crash. Other flight difficulties
-		// clear the region's rescues and payments, but keep its current riddle.
-		if (!crashed || ConfMan.getInt("mortimer_flight_difficulty") > 0) {
-			_flight.clearProgress();
-			_carriedStones = _completedSpecies = _creditedRescues = 0;
-			if (crashed)
-				_interview.resetAttempts();
-			else
-				_interview.resetRiddle();
-		}
+		_flight.clearProgress();
+		_carriedStones = _completedSpecies = _creditedRescues = 0;
+		_interview.resetRiddle();
 		_shellShocks = 0;
 		return _campaign.start(region) && enterCampaignScene();
 	}
@@ -482,6 +549,11 @@ bool MortimerEngine::restart(bool all, bool crashed) {
 
 bool MortimerEngine::nextScene(bool skip) {
 	stopSounds();
+	if (_crashStage != kNoCrash) {
+		if (_crashStage == kCrashMovie && _needsRepair)
+			return loadCrashMovie(true);
+		return finishCrash();
+	}
 	if (_interviewActive) {
 		_interview.skip();
 		return processInterview();
@@ -543,9 +615,13 @@ bool MortimerEngine::nextScene(bool skip) {
 	}
 	// The demo's flight movies must follow their expected SAN branches.
 	if (!skip && !_flight.nextFlight().equalsIgnoreCase(kDemoFlights[_segment + 1])) {
-		_loadError = Common::String::format("Unexpected flight transition in %s", kDemoFlights[_segment]);
+		_loadError = Common::String::format("Unexpected flight transition in %s at frame %u/%u: expected '%s', got '%s'",
+			kDemoFlights[_segment], _flight.frame() + 1, _flight.frameCount(), kDemoFlights[_segment + 1],
+			_flight.nextFlight().empty() ? "<none>" : _flight.nextFlight().c_str());
 		return false;
 	}
+	debug(1, "Mortimer: demo flight transition %s -> %s at frame %u/%u%s",
+		kDemoFlights[_segment], kDemoFlights[_segment + 1], _flight.frame() + 1, _flight.frameCount(), skip ? " (skipped)" : "");
 	_carriedStones += _flight.rescuedCount();
 	_completedSpecies |= _flight.rescuedSpecies();
 	return loadFlight(_segment + 1) && startFlight();
@@ -557,10 +633,12 @@ bool MortimerEngine::processInterview() {
 		_completedSpecies = _interview.species();
 		_interviewActive = false;
 		_shellShocks = 0;
+		_shockFrame = 0;
 		if (!_isDemo) {
 			if (_interview.restartsRegion()) {
 				_flight.clearProgress();
 				_carriedStones = _completedSpecies = _creditedRescues = 0;
+				_crashCount = 0;
 			}
 			return _campaign.returnToFlight() && enterCampaignScene();
 		}
@@ -625,6 +703,13 @@ bool MortimerEngine::updateScene() {
 		if (changed || !_interview.playingMovie())
 			return true;
 	} else if (interactive()) {
+		if (_hitJolt) {
+			const int amount = 64 * (CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2) + 1);
+			const int y = _random.getRandomBit() ? amount : -amount;
+			const int x = _random.getRandomBit() ? amount : -amount;
+			updateView(x, y);
+			_hitJolt = false;
+		}
 		updateSteering();
 		updateInterface();
 	}
@@ -642,16 +727,22 @@ bool MortimerEngine::updateScene() {
 		}
 		return nextScene();
 	}
-	if (!_isDemo && !_interviewActive && _introIndex < 0 && !_flight.nextFlight().empty())
+	if (!_isDemo && _crashStage == kNoCrash && !_interviewActive && _introIndex < 0 && !_flight.nextFlight().empty())
 		return followFlightBranch();
 	playMovieSounds();
 	_system->getPaletteManager()->setPalette(_interviewActive ? _interview.palette(movie) : movie.palette(), 0, 256);
 	if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
-		++_shellShocks;
-		playSound(_collisionSound, _collisionHandle, Audio::Mixer::kSFXSoundType);
 		// Retail crashes on the fifteenth hit; the demo allows eighteen.
-		if (_shellShocks >= (_isDemo ? 18U : 15U))
-			return restart(false, true);
+		if (_shellShocks + 1 >= (_isDemo ? 18U : 15U)) {
+			if (!(_isDemo ? restart() : startCrash()))
+				return false;
+		} else {
+			++_shellShocks;
+			_shockFrame = 0;
+			_hitJolt = !_isDemo;
+		}
+		// Movie changes stop audio, so enqueue the fatal impact afterwards.
+		playCollisionSound();
 	}
 	return true;
 }
@@ -667,6 +758,16 @@ void MortimerEngine::updateSteering() {
 		x = (_steering[1] - _steering[0]) * 128 / Common::JOYAXIS_MAX;
 		y = (_steering[2] - _steering[3]) * 128 / Common::JOYAXIS_MAX;
 	}
+	updateView(x, y);
+	if (ABS(x) < 3)
+		x = 0;
+	if (ABS(y) < 3)
+		y = 0;
+	_aim.x = 320 + CLIP(x * 296 / 109, -296, 295);
+	_aim.y = 190 - CLIP(y * 170 / 128, -169, 170);
+}
+
+void MortimerEngine::updateView(int x, int y) {
 	// SHELL 40db90 averages ten input samples before moving the viewport.
 	const int weights[] = {1, 1, 3, 3, 8, 8, 14, 16, 16, 14};
 	int averageX = 0, averageY = 0;
@@ -680,12 +781,6 @@ void MortimerEngine::updateSteering() {
 	// The movie is doubled, and its translation is aligned to four pixels.
 	_view.x = ((88 + CLIP(averageX * 88 / 128, -88, 88) + 3) & ~3) / 2;
 	_view.y = ((118 - CLIP(averageY * 118 / 128, -118, 118) + 3) & ~3) / 2;
-	if (ABS(x) < 3)
-		x = 0;
-	if (ABS(y) < 3)
-		y = 0;
-	_aim.x = 320 + CLIP(x * 296 / 109, -296, 295);
-	_aim.y = 190 - CLIP(y * 170 / 128, -169, 170);
 }
 
 Common::Point MortimerEngine::moviePoint(const Common::Point &point) const {
@@ -720,13 +815,14 @@ void MortimerEngine::fire() {
 	} else if (result == MortimerFlight::kHornetHit) {
 		playSound(_slimeHitSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
 	} else if (result == MortimerFlight::kShellRestored) {
-		_shellShocks = 0;
+		_shellShocks = _shockFrame = 0;
 		playSound(_balloonSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
 	}
 }
 
 void MortimerEngine::updateInterface() {
 	++_uiFrame;
+	_shockFrame ^= 1;
 	if (_shotFrame < 3)
 		++_shotFrame;
 	for (uint i = 0; i < ARRAYSIZE(_iconFlash); ++i)
@@ -871,6 +967,8 @@ Common::Error MortimerEngine::run() {
 	}
 	stopSounds();
 	CursorMan.showMouse(oldCursor);
+	if (!_loadError.empty())
+		warning("Mortimer: %s", _loadError.c_str());
 	return _loadError.empty() ? Common::kNoError : Common::Error(Common::kReadingFailed, _loadError);
 }
 
@@ -936,7 +1034,9 @@ void MortimerEngine::drawDashboard() {
 	drawSprite(_dashboard[35], 0, 0);
 	drawSprite(_dashboard[29 + _uiFrame % 6], 0, 439);
 	drawSprite(_dashboard[speechPlaying() ? 16 + _uiFrame % 3 : 16], 21, 312);
-	const uint gauge = _shellShocks < 16 ? 19 + _shellShocks / 2 : 27 + _uiFrame % 2;
+	// Retail advances every hit after ten shocks and flashes from thirteen.
+	const uint level = _isDemo || _shellShocks <= 10 ? _shellShocks / 2 : _shellShocks - 5;
+	const uint gauge = level < 8 ? 19 + level : 27 + _shockFrame;
 	drawSprite(_dashboard[gauge], 180, 442);
 	const Common::Point iconOrigins[] = {{158, 364}, {203, 358}, {248, 358}, {293, 354}, {339, 355}, {383, 360}, {428, 361}};
 	const Common::Point flashOrigins[] = {{154, 345}, {198, 338}, {243, 334}, {288, 332}, {333, 334}, {378, 338}, {422, 345}};
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 7ef7240a716..040143dc985 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -24,6 +24,7 @@
 #include "engines/engine.h"
 #include "backends/keymapper/keymap.h"
 #include "audio/mixer.h"
+#include "common/random.h"
 #include "graphics/surface.h"
 #include "scumm/insane/mortimer/flight.h"
 #include "scumm/insane/mortimer/interview.h"
@@ -45,6 +46,8 @@ protected:
 	void pauseEngineIntern(bool pause) override;
 
 private:
+	enum CrashStage { kNoCrash, kCrashMovie, kRepairMovie };
+
 	bool loadResources();
 	uint introCount() const;
 	bool loadIntro(uint index);
@@ -57,15 +60,19 @@ private:
 	void updateMovieContext();
 	bool startMovie(bool preserveScreen = false);
 	bool startFlight();
+	bool startCrash();
+	bool loadCrashMovie(bool repair);
+	bool finishCrash();
 	bool processInterview();
 	bool updateScene();
 	bool nextScene(bool skip = false);
-	bool restart(bool all = false, bool crashed = false);
+	bool restart(bool all = false);
 	MortimerMovie &currentMovie();
 	const MortimerMovie &currentMovie() const;
-	bool interactive() const { return !_interviewActive && _introIndex < 0 && _flight.interactive(); }
+	bool interactive() const { return _crashStage == kNoCrash && !_interviewActive && _introIndex < 0 && _flight.interactive(); }
 	void setMouse(const Common::Point &position);
 	void updateSteering();
+	void updateView(int x, int y);
 	void updateInterface();
 	void fire();
 	Common::Point moviePoint(const Common::Point &point) const;
@@ -78,9 +85,11 @@ private:
 	bool speechPlaying() const;
 	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset = 0);
 	void playMovieSounds(bool seek = false);
+	void playCollisionSound();
 	void stopSounds();
 
 	const bool _isDemo;
+	Common::RandomSource _random;
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
 	MortimerInterview _interview;
@@ -92,23 +101,26 @@ private:
 	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound, _glowSound, _balloonSound;
 	MortimerSound _buttonSound, _payoutSound;
 	Common::Array<Audio::SoundHandle> _movieHandles;
-	Audio::SoundHandle _scopeHandle, _rescueHandle, _collisionHandle, _glowHandle;
+	Common::Array<Audio::SoundHandle> _collisionHandles;
+	Audio::SoundHandle _scopeHandle, _rescueHandle, _glowHandle;
 	Common::String _loadError;
-	Common::String _pendingFlight, _subtitleName;
+	Common::String _flightName, _pendingFlight, _subtitleName;
 	Common::Rect _viewport;
 	Common::Point _mouse, _view, _aim, _shot;
 	Common::Point _steeringHistory[10];
 	int _steering[4];
 	MortimerFlight::Weapon _shotWeapon;
+	CrashStage _crashStage;
 	uint32 _nextFrameTime, _pauseTime;
 	int _introIndex;
 	int _loadedRegion;
 	uint _creditedRescues;
-	uint _segment, _carriedStones, _completedSpecies, _shellShocks;
+	uint _segment, _carriedStones, _completedSpecies, _shellShocks, _shockFrame, _crashCount;
 	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
 	uint _iconFlash[7];
 	bool _finished, _controllerMode, _interviewActive;
 	bool _resourcesLoaded, _playingCached;
+	bool _needsRepair, _hitJolt;
 };
 
 } // End of namespace Scumm


Commit: 625e660068e930fac480f47fef51fd7658993e00
    https://github.com/scummvm/scummvm/commit/625e660068e930fac480f47fef51fd7658993e00
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: added restore content option for the demo with some extra unused videos

Changed paths:
    engines/scumm/detection.h
    engines/scumm/detection_tables.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/metaengine.cpp


diff --git a/engines/scumm/detection.h b/engines/scumm/detection.h
index 4cb7b85d199..c0df2e1ab35 100644
--- a/engines/scumm/detection.h
+++ b/engines/scumm/detection.h
@@ -46,6 +46,7 @@ namespace Scumm {
 #define GAMEOPTION_REBEL1_NO_DAMAGE                          GUIO_GAMEOPTIONS15
 #define GAMEOPTION_REBEL1_RESTORED_CONTENT                   GUIO_GAMEOPTIONS16
 #define GAMEOPTION_REBEL2_RESTORED_CONTENT                   GUIO_GAMEOPTIONS17
+#define GAMEOPTION_MORTIMER_RESTORED_CONTENT                 GUIO_GAMEOPTIONS18
 
 /**
  * Descriptor of a specific SCUMM game. Used internally to store
diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index d3ebd48c32b..6ba62a46092 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -252,7 +252,7 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
 	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO3(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOASPECT)},
-	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT)},
+	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO5(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT, GAMEOPTION_MORTIMER_RESTORED_CONTENT)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Demo", 0, GID_DIG, 7, 0, MDT_NONE, GF_DEMO, UNK, GUIO4(GUIO_NOMIDI, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index e4141345ef5..57b3728fa55 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -26,7 +26,7 @@
 
 namespace Scumm {
 
-MortimerFlight::MortimerFlight(bool isDemo) : _isDemo(isDemo), _initialInteractive(true), _interactive(true),
+MortimerFlight::MortimerFlight(bool isDemo, bool keepProgress) : _isDemo(isDemo), _keepProgress(keepProgress), _initialInteractive(true), _interactive(true),
 	_inBranch(false), _steering(kWidth / 2, kHeight / 2), _collisionRadius(25) {
 	clearProgress();
 	reset();
@@ -45,14 +45,16 @@ void MortimerFlight::clearProgress() {
 }
 
 void MortimerFlight::clearHostiles() {
-	// Retail hornet hits also suppress their salt projectiles.
+	if (_isDemo)
+		memset(_hitTargets + 700, 0, 100 * sizeof(_hitTargets[0]));
+	// Hornet hits also suppress their salt projectiles.
 	memset(_hitTargets + 800, 0, 100 * sizeof(_hitTargets[0]));
 	memset(_hitTargets + 1000, 0, 400 * sizeof(_hitTargets[0]));
 }
 
 void MortimerFlight::reset() {
 	MortimerCutscene::reset();
-	if (_isDemo)
+	if (_isDemo && !_keepProgress)
 		clearProgress();
 	_nextFlight.clear();
 	_interactive = _initialInteractive;
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 47e072750db..84f6c9c1a94 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -38,7 +38,7 @@ public:
 		bool active, hit;
 	};
 
-	explicit MortimerFlight(bool isDemo = true);
+	explicit MortimerFlight(bool isDemo = true, bool keepProgress = false);
 	bool load(Common::SeekableReadStream &stream, bool interactive = true, bool continuation = false);
 	void clearProgress();
 	void clearHostiles();
@@ -73,7 +73,7 @@ private:
 	Common::Array<Target> _targets;
 	Common::Array<Obstacle> _obstacles;
 	Common::String _nextFlight;
-	const bool _isDemo;
+	const bool _isDemo, _keepProgress;
 	bool _hitTargets[1400];
 	bool _initialInteractive, _interactive, _inBranch;
 	Common::Point _steering;
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 27fc51be18c..c512932b53d 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -111,7 +111,8 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 	return keymaps;
 }
 
-MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo), _random("mortimer-flight"), _flight(isDemo), _interview(isDemo),
+MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
+	_restoredContent(isDemo && ConfMan.getBool("mortimer_restored_content")), _random("mortimer-flight"), _flight(isDemo, _restoredContent), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
 	_segment(0), _carriedStones(0), _completedSpecies(0), _shellShocks(0), _shockFrame(0), _crashCount(0),
@@ -447,7 +448,7 @@ bool MortimerEngine::startFlight() {
 	_uiFrame = 0;
 	_shotFrame = 3;
 	_glowFrame = 10;
-	_glowStones = MIN(_carriedStones + (_isDemo ? 0 : _flight.rescuedCount() - _creditedRescues), 999U);
+	_glowStones = MIN(_carriedStones + (_isDemo && !_restoredContent ? 0 : _flight.rescuedCount() - _creditedRescues), 999U);
 	_pendingGlowStones = 0;
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 	for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
@@ -474,7 +475,7 @@ bool MortimerEngine::loadCrashMovie(bool repair) {
 	stopSounds();
 	_movieHandles.clear();
 	const char *name = repair ? "RESOURCE/GENERAL/REPAIR.SAN" :
-		(_campaign.scene().region == 3 ? "RESOURCE/GENERAL/D_CRASH.SAN" : "RESOURCE/GENERAL/CRASH.SAN");
+		(!_isDemo && _campaign.scene().region == 3 ? "RESOURCE/GENERAL/D_CRASH.SAN" : "RESOURCE/GENERAL/CRASH.SAN");
 	Common::File file;
 	if (!file.open(Common::Path(name))) {
 		_loadError = Common::String::format("Missing %s", name);
@@ -504,14 +505,26 @@ bool MortimerEngine::finishCrash() {
 			_carriedStones = _completedSpecies = _creditedRescues = 0;
 			_interview.resetAttempts();
 		}
+		if (_isDemo)
+			return loadFlight(0) && startFlight();
 		return _campaign.returnToFlight() && enterCampaignScene();
 	}
 	if (difficulty == 2)
 		_flight.clearHostiles();
+	if (_isDemo)
+		return loadFlight(_segment) && startFlight();
 	// Ordinary crashes replay the current branch, including a cached branch.
 	return loadRetailFlight(_flightName) && startFlight();
 }
 
+void MortimerEngine::creditRescues() {
+	// Restored demo flights retain target IDs across retries and repairs.
+	const uint credited = _isDemo && !_restoredContent ? 0 : _creditedRescues;
+	_carriedStones = MIN(999U, _carriedStones + _flight.rescuedCount() - credited);
+	_creditedRescues = _flight.rescuedCount();
+	_completedSpecies |= _flight.rescuedSpecies();
+}
+
 bool MortimerEngine::restart(bool all) {
 	_crashStage = kNoCrash;
 	_needsRepair = _hitJolt = false;
@@ -532,7 +545,7 @@ bool MortimerEngine::restart(bool all) {
 		_shellShocks = 0;
 		return _campaign.start(region) && enterCampaignScene();
 	}
-	if (_interviewActive)
+	if (_interviewActive || (_restoredContent && _introIndex < 0))
 		all = true;
 	if (_introIndex >= 0 && !all)
 		return startMovie();
@@ -540,7 +553,8 @@ bool MortimerEngine::restart(bool all) {
 	if (all) {
 		_interviewActive = false;
 		_interview.resetRiddle();
-		_carriedStones = _completedSpecies = 0;
+		_flight.clearProgress();
+		_carriedStones = _completedSpecies = _creditedRescues = 0;
 		if (!loadFlight(0))
 			return false;
 	}
@@ -567,9 +581,7 @@ bool MortimerEngine::nextScene(bool skip) {
 				_pendingFlight.clear();
 				return loadRetailFlight(next) && startMovie();
 			}
-			_carriedStones = MIN(999U, _carriedStones + _flight.rescuedCount() - _creditedRescues);
-			_creditedRescues = _flight.rescuedCount();
-			_completedSpecies |= _flight.rescuedSpecies();
+			creditRescues();
 			_flight.context().conditions[MortimerMovieContext::kAtGate] = true;
 		}
 		if (!_campaign.advance()) {
@@ -604,8 +616,7 @@ bool MortimerEngine::nextScene(bool skip) {
 		return true;
 	}
 	if (_segment + 1 == ARRAYSIZE(kDemoFlights)) {
-		_carriedStones += _flight.rescuedCount();
-		_completedSpecies |= _flight.rescuedSpecies();
+		creditRescues();
 		_glowStones = MIN(_carriedStones, 999U);
 		_pendingGlowStones = 0;
 		_glowFrame = 10;
@@ -622,8 +633,7 @@ bool MortimerEngine::nextScene(bool skip) {
 	}
 	debug(1, "Mortimer: demo flight transition %s -> %s at frame %u/%u%s",
 		kDemoFlights[_segment], kDemoFlights[_segment + 1], _flight.frame() + 1, _flight.frameCount(), skip ? " (skipped)" : "");
-	_carriedStones += _flight.rescuedCount();
-	_completedSpecies |= _flight.rescuedSpecies();
+	creditRescues();
 	return loadFlight(_segment + 1) && startFlight();
 }
 
@@ -642,6 +652,11 @@ bool MortimerEngine::processInterview() {
 			}
 			return _campaign.returnToFlight() && enterCampaignScene();
 		}
+		if (_restoredContent) {
+			// A new demo flight after an interview must offer rescues again.
+			_flight.clearProgress();
+			_creditedRescues = _crashCount = 0;
+		}
 		return loadFlight(0) && startFlight();
 	}
 	if (_interview.finished()) {
@@ -734,7 +749,7 @@ bool MortimerEngine::updateScene() {
 	if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
 		// Retail crashes on the fifteenth hit; the demo allows eighteen.
 		if (_shellShocks + 1 >= (_isDemo ? 18U : 15U)) {
-			if (!(_isDemo ? restart() : startCrash()))
+			if (!(_isDemo && !_restoredContent ? restart() : startCrash()))
 				return false;
 		} else {
 			++_shellShocks;
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 040143dc985..4ce6d7cbac0 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -63,6 +63,7 @@ private:
 	bool startCrash();
 	bool loadCrashMovie(bool repair);
 	bool finishCrash();
+	void creditRescues();
 	bool processInterview();
 	bool updateScene();
 	bool nextScene(bool skip = false);
@@ -88,7 +89,7 @@ private:
 	void playCollisionSound();
 	void stopSounds();
 
-	const bool _isDemo;
+	const bool _isDemo, _restoredContent;
 	Common::RandomSource _random;
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index 74534a55666..bdb1c553263 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -986,6 +986,15 @@ const ExtraGuiOption enableRebel1RestoredContent = {
 	0
 };
 
+const ExtraGuiOption enableMortimerRestoredContent = {
+	_s("Enable restored content"),
+	_s("Play the demo's unused crash and repair animations using the full game's recovery rules"),
+	"mortimer_restored_content",
+	false,
+	0,
+	0
+};
+
 const ExtraGuiOptions ScummMetaEngine::getExtraGuiOptions(const Common::String &target) const {
 	ExtraGuiOptions options;
 	// Query the GUI options
@@ -1051,6 +1060,10 @@ const ExtraGuiOptions ScummMetaEngine::getExtraGuiOptions(const Common::String &
 	if (target.empty() || guiOptions.contains(GAMEOPTION_REBEL1_RESTORED_CONTENT)) {
 		options.push_back(enableRebel1RestoredContent);
 	}
+	if (target.empty() || guiOptions.contains(GAMEOPTION_MORTIMER_RESTORED_CONTENT) ||
+		(gameid == "mortimer" && extra == "Demo")) {
+		options.push_back(enableMortimerRestoredContent);
+	}
 	if (target.empty() || gameid == "comi") {
 		options.push_back(comiObjectLabelsOption);
 


Commit: eaa45832a073b17e8926cff9d8addec4368192da
    https://github.com/scummvm/scummvm/commit/eaa45832a073b17e8926cff9d8addec4368192da
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: implemented dificulty levels

Changed paths:
  A engines/scumm/insane/mortimer/dialogs.cpp
  A engines/scumm/insane/mortimer/dialogs.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/dialogs.cpp b/engines/scumm/insane/mortimer/dialogs.cpp
new file mode 100644
index 00000000000..c9ffa44b701
--- /dev/null
+++ b/engines/scumm/insane/mortimer/dialogs.cpp
@@ -0,0 +1,95 @@
+/* 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 "scumm/insane/mortimer/dialogs.h"
+
+#include "common/translation.h"
+#include "common/util.h"
+#include "gui/gui-manager.h"
+#include "gui/ThemeEval.h"
+#include "gui/widgets/popup.h"
+
+namespace Scumm {
+
+MortimerDifficultyDialog::MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty)
+	: GUI::Dialog("MortimerDifficultyDialog") {
+	new GUI::StaticTextWidget(this, _name + ".Title", _("Select difficulty"));
+	new GUI::StaticTextWidget(this, _name + ".FlightLabel", _("Flight difficulty:"));
+	new GUI::StaticTextWidget(this, _name + ".RiddleLabel", _("Riddle difficulty:"));
+	_flight = new GUI::PopUpWidget(this, _name + ".Flight", _("Choose how difficult flying and recovering from crashes should be."));
+	_riddle = new GUI::PopUpWidget(this, _name + ".Riddle", isDemo ?
+		_("The demo includes only one set of riddles.") : _("Choose the riddle difficulty and the cost and number of guesses."));
+	const char *const levels[] = {_s("Easy"), _s("Medium"), _s("Hard")};
+	for (uint i = 0; i < ARRAYSIZE(levels); ++i) {
+		_flight->appendEntry(_(levels[i]), i);
+		if (!isDemo)
+			_riddle->appendEntry(_(levels[i]), i);
+	}
+	if (isDemo) {
+		_riddle->appendEntry(_("Fixed (demo)"), 0);
+		_riddle->setEnabled(false);
+	}
+	_flight->setSelectedTag(MIN(flightDifficulty, 2U));
+	_riddle->setSelectedTag(isDemo ? 0 : MIN(riddleDifficulty, 2U));
+	new GUI::ButtonWidget(this, _name + ".Cancel", _("~C~ancel"), Common::U32String(), GUI::kCloseCmd);
+	new GUI::ButtonWidget(this, _name + ".Start", _("Start"), Common::U32String(), GUI::kOKCmd, Common::ASCII_RETURN);
+}
+
+uint MortimerDifficultyDialog::flightDifficulty() const {
+	return _flight->getSelectedTag();
+}
+
+uint MortimerDifficultyDialog::riddleDifficulty() const {
+	return _riddle->getSelectedTag();
+}
+
+void MortimerDifficultyDialog::reflowLayout() {
+	GUI::ThemeEval &layouts = *g_gui.xmlEval();
+	const int lineHeight = layouts.getVar("Globals.Line.Height");
+	const int width = 240 * g_gui.getScaleFactor();
+	layouts.addDialog(_name, "screen_center")
+		.addLayout(GUI::ThemeLayout::kLayoutVertical, 8)
+			.addPadding(8, 8, 8, 8)
+			.addWidget("Title", "", width, lineHeight, Graphics::kTextAlignCenter)
+			.addWidget("FlightLabel", "", -1, lineHeight)
+			.addWidget("Flight", "PopUp")
+			.addWidget("RiddleLabel", "", -1, lineHeight)
+			.addWidget("Riddle", "PopUp")
+			.addLayout(GUI::ThemeLayout::kLayoutHorizontal, 8, GUI::ThemeLayout::kItemAlignEnd)
+				.addPadding(0, 0, 0, 0)
+				.addSpace()
+				.addWidget("Cancel", "Button")
+				.addWidget("Start", "Button")
+			.closeLayout()
+		.closeLayout()
+	.closeDialog();
+	GUI::Dialog::reflowLayout();
+}
+
+void MortimerDifficultyDialog::handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) {
+	if (cmd == GUI::kOKCmd) {
+		setResult(1);
+		close();
+	} else {
+		GUI::Dialog::handleCommand(sender, cmd, data);
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/dialogs.h b/engines/scumm/insane/mortimer/dialogs.h
new file mode 100644
index 00000000000..b69188539e5
--- /dev/null
+++ b/engines/scumm/insane/mortimer/dialogs.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 SCUMM_INSANE_MORTIMER_DIALOGS_H
+#define SCUMM_INSANE_MORTIMER_DIALOGS_H
+
+#include "gui/dialog.h"
+
+namespace GUI {
+class PopUpWidget;
+}
+
+namespace Scumm {
+
+class MortimerDifficultyDialog : public GUI::Dialog {
+public:
+	MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty);
+	uint flightDifficulty() const;
+	uint riddleDifficulty() const;
+	void reflowLayout() override;
+
+protected:
+	void handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) override;
+
+private:
+	GUI::PopUpWidget *_flight, *_riddle;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index c512932b53d..a82d4a4777e 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -19,6 +19,7 @@
  */
 
 #include "scumm/insane/mortimer/mortimer.h"
+#include "scumm/insane/mortimer/dialogs.h"
 
 #include "audio/audiostream.h"
 #include "audio/decoders/raw.h"
@@ -112,7 +113,8 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 }
 
 MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
-	_restoredContent(isDemo && ConfMan.getBool("mortimer_restored_content")), _random("mortimer-flight"), _flight(isDemo, _restoredContent), _interview(isDemo),
+	_restoredContent(isDemo && ConfMan.getBool("mortimer_restored_content")), _flightDifficulty(0), _riddleDifficulty(0),
+	_random("mortimer-flight"), _flight(isDemo, _restoredContent), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
 	_segment(0), _carriedStones(0), _completedSpecies(0), _shellShocks(0), _shockFrame(0), _crashCount(0),
@@ -120,8 +122,6 @@ MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _
 	_controllerMode(false), _interviewActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
-	ConfMan.registerDefault("mortimer_flight_difficulty", 0);
-	ConfMan.registerDefault("mortimer_riddle_difficulty", 0);
 }
 
 MortimerEngine::~MortimerEngine() {
@@ -133,6 +133,23 @@ bool MortimerEngine::hasFeature(EngineFeature feature) const {
 	return feature == kSupportsReturnToLauncher;
 }
 
+bool MortimerEngine::selectDifficulty() {
+	if (_isDemo && !_restoredContent)
+		return true;
+	MortimerDifficultyDialog dialog(_isDemo, _flightDifficulty, _riddleDifficulty);
+	if (runDialog(dialog) != 1 || shouldQuit())
+		return false;
+	setDifficulty(dialog.flightDifficulty(), dialog.riddleDifficulty());
+	return true;
+}
+
+void MortimerEngine::setDifficulty(uint flight, uint riddle) {
+	_flightDifficulty = MIN(flight, 2U);
+	_riddleDifficulty = MIN(riddle, 2U);
+	_flight.setCollisionRadius(_isDemo && !_restoredContent ? 25 : 20 * (_flightDifficulty + 1));
+	_interview.setDifficulty(_riddleDifficulty);
+}
+
 bool MortimerEngine::loadResources() {
 	Common::File file;
 
@@ -280,7 +297,6 @@ bool MortimerEngine::loadRegion(uint region) {
 	}
 	_loadedRegion = region;
 	_flight.clearProgress();
-	_flight.setCollisionRadius(20 * (CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2) + 1));
 	_interview.resetRiddle();
 	_carriedStones = _completedSpecies = _creditedRescues = _shellShocks = 0;
 	_shockFrame = _crashCount = 0;
@@ -462,8 +478,7 @@ bool MortimerEngine::startFlight() {
 bool MortimerEngine::startCrash() {
 	// SHELL's difficulty table permits these crashes before a full repair.
 	const uint allowances[] = {4, 3, 1};
-	const uint difficulty = CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2);
-	_needsRepair = ++_crashCount > allowances[difficulty];
+	_needsRepair = ++_crashCount > allowances[_flightDifficulty];
 	if (_needsRepair)
 		_crashCount = 0;
 	_hitJolt = false;
@@ -491,14 +506,13 @@ bool MortimerEngine::loadCrashMovie(bool repair) {
 }
 
 bool MortimerEngine::finishCrash() {
-	const uint difficulty = CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2);
 	_crashStage = kNoCrash;
 	_shellShocks = _shockFrame = 0;
 	_pendingFlight.clear();
 	if (_needsRepair) {
 		_needsRepair = false;
 		// Easy flight keeps rescues and stones even after a full repair.
-		if (difficulty != 0) {
+		if (_flightDifficulty != 0) {
 			const bool girl = _flight.context().girl;
 			_flight.clearProgress();
 			_flight.context().girl = girl;
@@ -509,7 +523,7 @@ bool MortimerEngine::finishCrash() {
 			return loadFlight(0) && startFlight();
 		return _campaign.returnToFlight() && enterCampaignScene();
 	}
-	if (difficulty == 2)
+	if (_flightDifficulty == 2)
 		_flight.clearHostiles();
 	if (_isDemo)
 		return loadFlight(_segment) && startFlight();
@@ -719,7 +733,7 @@ bool MortimerEngine::updateScene() {
 			return true;
 	} else if (interactive()) {
 		if (_hitJolt) {
-			const int amount = 64 * (CLIP(ConfMan.getInt("mortimer_flight_difficulty"), 0, 2) + 1);
+			const int amount = 64 * (_flightDifficulty + 1);
 			const int y = _random.getRandomBit() ? amount : -amount;
 			const int x = _random.getRandomBit() ? amount : -amount;
 			updateView(x, y);
@@ -873,6 +887,8 @@ Common::Error MortimerEngine::run() {
 	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
 	_screen.fillRect(Common::Rect(640, 480), 10);
 	syncSoundSettings();
+	if (!selectDifficulty())
+		return Common::kNoError;
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
 	bool exit;
@@ -883,7 +899,6 @@ Common::Error MortimerEngine::run() {
 		_subtitleName = "RESOURCE/DATA/BEGIN.TRS";
 		if (!_campaign.start(boot < 0 ? 24 : boot))
 			_campaign.start();
-		_interview.setDifficulty(CLIP(ConfMan.getInt("mortimer_riddle_difficulty"), 0, 2));
 		exit = !enterCampaignScene();
 	} else if (boot == 4 || boot == 10 || boot == 18 || boot == 8 || boot == 25) {
 		stopSounds();
@@ -975,7 +990,7 @@ Common::Error MortimerEngine::run() {
 		draw();
 		if (_finished) {
 			GUI::MessageDialog dialog(_("Play again?"), _("Restart"), _("Quit"));
-			if (dialog.runModal() != GUI::kMessageOK || !restart(true))
+			if (dialog.runModal() != GUI::kMessageOK || !selectDifficulty() || !restart(true))
 				break;
 		}
 		_system->delayMillis(10);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 4ce6d7cbac0..2937ad9f5be 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -49,6 +49,8 @@ private:
 	enum CrashStage { kNoCrash, kCrashMovie, kRepairMovie };
 
 	bool loadResources();
+	bool selectDifficulty();
+	void setDifficulty(uint flight, uint riddle);
 	uint introCount() const;
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
@@ -90,6 +92,7 @@ private:
 	void stopSounds();
 
 	const bool _isDemo, _restoredContent;
+	uint _flightDifficulty, _riddleDifficulty;
 	Common::RandomSource _random;
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index b340a3c4a71..e651899e80e 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -148,6 +148,7 @@ MODULE_OBJS += \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
 	insane/mortimer/campaign.o \
+	insane/mortimer/dialogs.o \
 	insane/mortimer/flight.o \
 	insane/mortimer/interview.o \
 	insane/mortimer/movie.o \


Commit: 972d6b1a9a1a5fd5c88661354cf38bc425327649
    https://github.com/scummvm/scummvm/commit/972d6b1a9a1a5fd5c88661354cf38bc425327649
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: enable view jolt when taking damage in demo

Changed paths:
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/metaengine.cpp


diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index a82d4a4777e..809055acb53 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -768,7 +768,7 @@ bool MortimerEngine::updateScene() {
 		} else {
 			++_shellShocks;
 			_shockFrame = 0;
-			_hitJolt = !_isDemo;
+			_hitJolt = !_isDemo || _restoredContent;
 		}
 		// Movie changes stop audio, so enqueue the fatal impact afterwards.
 		playCollisionSound();
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index bdb1c553263..422b8e9d353 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -988,7 +988,7 @@ const ExtraGuiOption enableRebel1RestoredContent = {
 
 const ExtraGuiOption enableMortimerRestoredContent = {
 	_s("Enable restored content"),
-	_s("Play the demo's unused crash and repair animations using the full game's recovery rules"),
+	_s("Enable the full game's view jolt and recovery rules, including the demo's unused crash and repair animations"),
 	"mortimer_restored_content",
 	false,
 	0,


Commit: 09f67f2a8e034423fe3b700eef3345c3b42c0ec7
    https://github.com/scummvm/scummvm/commit/09f67f2a8e034423fe3b700eef3345c3b42c0ec7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: abstracted SAUD code and use it for Mortimer and RA

Changed paths:
  A engines/scumm/smush/saud_stream.cpp
  A engines/scumm/smush/saud_stream.h
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/insane/mortimer/movie.h
    engines/scumm/insane/rebel/rebel_audio.cpp
    engines/scumm/insane/rebel/rebel_audio.h
    engines/scumm/module.mk
    engines/scumm/smush/saud.cpp
    engines/scumm/smush/saud.h
    engines/scumm/smush/smush_player.h


diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 2c9c0d60717..5a832cf4284 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -143,6 +143,15 @@ bool MortimerInterview::loadResources(uint region) {
 		}
 		file.close();
 	}
+	_mouth.clear();
+	if (!_isDemo) {
+		const Common::String mouth = Common::String::format("%s/%cG_MOUTH.NUT", resources.directory, resources.prefix);
+		if (!file.open(Common::Path(mouth)) || !loadMortimerSprites(file, _mouth) || _mouth.size() < 6) {
+			_error = Common::String::format("Missing or invalid %s", mouth.c_str());
+			return false;
+		}
+		file.close();
+	}
 	if (!_font.load("RESOURCE/GENERAL/MORTFNTH.NUT") || !_captionFont.load(_isDemo ? "RESOURCE/GENERAL/SUBTFONT.NUT" : "RESOURCE/GENERAL/ESTRO20.NUT")) {
 		_error = "Missing or invalid Mortimer font";
 		return false;
@@ -478,9 +487,14 @@ void MortimerInterview::drawText(Graphics::Surface &screen, const Common::String
 		_font.drawString(&screen, lines[first + i], 29, top + i * height, 330, _isDemo ? 0 : 1, Graphics::kTextAlignCenter);
 }
 
-void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &movie) const {
+void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &movie, byte mouth) const {
 	if (fullScreen()) {
 		screen.copyRectToSurface(movie.pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
+		if (!_isDemo && _stage == kSong) {
+			// Song callback 4119a0 and tables 4438a0/442d4c use STRK variable 1.
+			const Common::Point origins[] = {{97, 63}, {88, 17}, {181, 0}, {100, 33}};
+			_mouth[MIN<uint>(mouth, 5)].draw(screen, origins[_region].x * 2, origins[_region].y * 2, false, nullptr, 2);
+		}
 		for (uint i = 0; _isDemo && i < movie.captions().size(); ++i) {
 			const MortimerMovie::Caption &caption = movie.captions()[i];
 			if (caption.id < ARRAYSIZE(_captions))
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index 38e63f1e183..b1e75453a0d 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -48,7 +48,7 @@ public:
 	void movieFinished();
 	void movieLoaded() { _moviePending = false; }
 	void skip();
-	void draw(Graphics::Surface &screen, const MortimerMovie &movie) const;
+	void draw(Graphics::Surface &screen, const MortimerMovie &movie, byte mouth) const;
 	void drawCursor(Graphics::Surface &screen, const Common::Point &point) const;
 
 	bool needsMovie() const { return _moviePending; }
@@ -88,7 +88,7 @@ private:
 	Common::RandomSource _random;
 	const bool _isDemo;
 	uint _region, _difficulty, _textDifficulty, _tried;
-	Common::Array<MortimerSprite> _panel, _portraits, _gate, _cursor;
+	Common::Array<MortimerSprite> _panel, _portraits, _gate, _cursor, _mouth;
 	MortimerFont _font, _captionFont;
 	Common::String _dialogue[7][3], _riddles[7][3], _captions[16];
 	Common::String _movieName, _error;
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 809055acb53..9dc2e35efed 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -22,7 +22,6 @@
 #include "scumm/insane/mortimer/dialogs.h"
 
 #include "audio/audiostream.h"
-#include "audio/decoders/raw.h"
 #include "backends/keymapper/action.h"
 #include "common/config-manager.h"
 #include "common/endian.h"
@@ -201,7 +200,7 @@ bool MortimerEngine::loadResources() {
 		file.close();
 	}
 	// The hit sound animation uses volume 50 on the original 0..127 scale.
-	_collisionSound.volume = 100;
+	_collisionSound.volume = 50;
 	if (_isDemo && !_interview.loadResources()) {
 		_loadError = _interview.errorMessage();
 		return false;
@@ -398,24 +397,28 @@ bool MortimerEngine::enterCampaignScene() {
 	return startMovie();
 }
 
-void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset) {
+void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint64 skipFrames) {
 	_mixer->stopHandle(handle);
-	// The engine owns the samples until all its handles have been stopped.
-	Audio::AudioStream *stream = Audio::makeRawStream(sound.samples.begin() + offset, sound.samples.size() - offset, sound.rate,
-		Audio::FLAG_UNSIGNED, DisposeAfterUse::NO);
-	_mixer->playStream(type, &handle, stream, -1, sound.volume, sound.balance);
+	SaudStream *stream = new SaudStream(sound.samples, sound.script, sound.rate, _soundState, sound.volume, sound.balance);
+	stream->skip(skipFrames);
+	if (stream->endOfData()) {
+		delete stream;
+		return;
+	}
+	_mixer->playStream(type, &handle, stream);
 }
 
 void MortimerEngine::playMovieSounds(bool seek) {
+	if (seek)
+		stopSounds();
 	const MortimerMovie &movie = currentMovie();
 	for (uint i = 0; i < movie.sounds().size(); ++i) {
 		const MortimerSound &sound = movie.sounds()[i];
 		if (!movie.soundEnabled(i) || sound.frame > movie.frame() || (!seek && sound.frame != movie.frame()))
 			continue;
-		const uint64 offset = uint64(movie.frame() - sound.frame) * sound.rate / movie.frameRate();
-		if (offset < sound.samples.size())
-			playSound(sound, _movieHandles[i], sound.music ? Audio::Mixer::kMusicSoundType :
-				(sound.speech ? Audio::Mixer::kSpeechSoundType : Audio::Mixer::kSFXSoundType), offset);
+		const uint64 elapsed = uint64(movie.frame() - sound.frame) * SaudStream::kRate / movie.frameRate();
+		playSound(sound, _movieHandles[i], sound.music ? Audio::Mixer::kMusicSoundType :
+			(sound.speech ? Audio::Mixer::kSpeechSoundType : Audio::Mixer::kSFXSoundType), elapsed);
 	}
 }
 
@@ -437,6 +440,7 @@ void MortimerEngine::stopSounds() {
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
 	_mixer->stopHandle(_glowHandle);
+	_soundState.reset();
 }
 
 bool MortimerEngine::startMovie(bool preserveScreen) {
@@ -864,6 +868,8 @@ void MortimerEngine::updateInterface() {
 }
 
 bool MortimerEngine::speechPlaying() const {
+	if (!_soundState.variable(2))
+		return false;
 	for (uint i = 0; i < _movieHandles.size(); ++i)
 		if (currentMovie().sounds()[i].speech && _mixer->isSoundHandleActive(_movieHandles[i]))
 			return true;
@@ -1095,7 +1101,7 @@ void MortimerEngine::drawDashboard() {
 
 void MortimerEngine::draw() {
 	if (_interviewActive) {
-		_interview.draw(_screen, _intro);
+		_interview.draw(_screen, _intro, _soundState.variable(1));
 		if (_interview.showDashboard())
 			drawDashboard();
 		_interview.drawCursor(_screen, _mouse);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 2937ad9f5be..24401d946b5 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -29,6 +29,7 @@
 #include "scumm/insane/mortimer/flight.h"
 #include "scumm/insane/mortimer/interview.h"
 #include "scumm/insane/mortimer/campaign.h"
+#include "scumm/smush/saud_stream.h"
 
 namespace Scumm {
 
@@ -86,7 +87,7 @@ private:
 	void drawTargets();
 	void drawDashboard();
 	bool speechPlaying() const;
-	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint offset = 0);
+	void playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint64 skipFrames = 0);
 	void playMovieSounds(bool seek = false);
 	void playCollisionSound();
 	void stopSounds();
@@ -104,6 +105,7 @@ private:
 	Common::Array<MortimerSprite> _scopeHit, _slimeHit, _scopeBeam, _slimeBall, _numbers, _corners;
 	MortimerSound _scopeSound, _rescueSound, _collisionSound, _slimeSound, _slimeHitSound, _glowSound, _balloonSound;
 	MortimerSound _buttonSound, _payoutSound;
+	SaudStreamState _soundState;
 	Common::Array<Audio::SoundHandle> _movieHandles;
 	Common::Array<Audio::SoundHandle> _collisionHandles;
 	Audio::SoundHandle _scopeHandle, _rescueHandle, _glowHandle;
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 6d68a9f0ba7..7b374fc7214 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -153,7 +153,7 @@ bool validObject(const byte *data, uint32 size, bool sprite = false) {
 		return false;
 	const byte codec = data[0];
 	if (sprite)
-		return codec == 44 || (codec == 1 && validRows(data + 14, size - 14, height, false));
+		return codec == 44 || ((codec == 1 || codec == 3) && validRows(data + 14, size - 14, height, false));
 	if (codec == 37)
 		return validBackground(data + 14, size - 14, (width + 3) & ~3, (height + 3) & ~3);
 	if (codec != 1 && codec != 3 && codec != 23)
@@ -187,20 +187,32 @@ bool validTransition(const byte *data, uint32 size) {
 
 } // End of anonymous namespace
 
-void MortimerSprite::draw(Graphics::Surface &screen, int dstX, int dstY, bool centered, const Common::Rect *bounds) const {
+void MortimerSprite::draw(Graphics::Surface &screen, int dstX, int dstY, bool centered, const Common::Rect *bounds, int scale) const {
 	// Sprite offsets are relative to the animation's origin, even when centered.
 	if (centered) {
-		dstX -= (x + width) / 2;
-		dstY -= (y + height) / 2;
+		dstX -= (x + width) * scale / 2;
+		dstY -= (y + height) * scale / 2;
 	}
-	dstX += x;
-	dstY += y;
-	Common::Rect clip(dstX, dstY, dstX + width, dstY + height);
+	dstX += x * scale;
+	dstY += y * scale;
+	Common::Rect clip(dstX, dstY, dstX + width * scale, dstY + height * scale);
 	clip.clip(Common::Rect(screen.w, screen.h));
 	if (bounds)
 		clip.clip(*bounds);
 	if (clip.isEmpty())
 		return;
+	if (scale != 1) {
+		for (int row = clip.top; row < clip.bottom; ++row) {
+			const byte *src = pixels.begin() + (row - dstY) / scale * width;
+			byte *dst = (byte *)screen.getBasePtr(clip.left, row);
+			for (int col = clip.left; col < clip.right; ++col, ++dst) {
+				const byte color = src[(col - dstX) / scale];
+				if (color)
+					*dst = color;
+			}
+		}
+		return;
+	}
 	const byte *src = pixels.begin() + (clip.top - dstY) * width + clip.left - dstX;
 	screen.copyRectToSurfaceWithKey(src, width, clip.left, clip.top, clip.width(), clip.height(), 0);
 }
@@ -302,6 +314,16 @@ bool loadMortimerSound(Common::SeekableReadStream &stream, MortimerSound &sound)
 		sound.samples.clear();
 		return false;
 	}
+	sound.script.clear();
+	if (header.scriptOffset >= 0) {
+		sound.script.resize(header.scriptSize);
+		if (!stream.seek(header.scriptOffset) || stream.read(sound.script.begin(), header.scriptSize) != header.scriptSize || stream.err())
+			return false;
+		if (sound.script.empty()) {
+			sound.script.push_back(SAUD_OP_STOP);
+			sound.script.push_back(0);
+		}
+	}
 	sound.rate = header.sampleRate;
 	return true;
 }
@@ -449,8 +471,8 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled, bool
 						track.sound.frame = _frames.size();
 						track.sound.music = (flags & 64) != 0;
 						track.sound.speech = (flags & 128) != 0;
-						track.sound.volume = MIN(255, body[8] * 2);
-						track.sound.balance = body[9] == 128 ? 0 : (int(body[9]) - 64) * 2;
+						track.sound.volume = MIN(127, body[8] * 127 >> 7);
+						track.sound.balance = body[9] == 128 ? 0 : int8(body[9]);
 						index = tracks.size();
 						tracks.push_back(track);
 					}
diff --git a/engines/scumm/insane/mortimer/movie.h b/engines/scumm/insane/mortimer/movie.h
index 605784740f6..121824f0fe6 100644
--- a/engines/scumm/insane/mortimer/movie.h
+++ b/engines/scumm/insane/mortimer/movie.h
@@ -43,17 +43,18 @@ struct MortimerSprite {
 	// Codec 44 uses skipped pixels for transparency and relative palette colors.
 	Common::Array<byte> mask;
 
-	void draw(Graphics::Surface &screen, int dstX, int dstY, bool centered = false, const Common::Rect *bounds = nullptr) const;
+	void draw(Graphics::Surface &screen, int dstX, int dstY, bool centered = false, const Common::Rect *bounds = nullptr, int scale = 1) const;
 };
 
 struct MortimerSound {
 	uint frame = 0;
 	uint rate = 11025;
-	byte volume = 255;
+	byte volume = 127;
 	int8 balance = 0;
 	bool music = false;
 	bool speech = true;
 	Common::Array<byte> samples;
+	Common::Array<byte> script;
 };
 
 struct MortimerMovieContext {
diff --git a/engines/scumm/insane/rebel/rebel_audio.cpp b/engines/scumm/insane/rebel/rebel_audio.cpp
index e5779d9f79b..1d9bd065e69 100644
--- a/engines/scumm/insane/rebel/rebel_audio.cpp
+++ b/engines/scumm/insane/rebel/rebel_audio.cpp
@@ -315,192 +315,37 @@ void RebelAudio::setGroupPan(SmushPlayer *player, int groupId, int pan) {
 }
 
 bool RebelAudio::processAudioCodes(SmushPlayer *player, int idx, int32 &tmpFeedSize, int &mixVolume) {
-	uint8 *code, *buf, subcode, value;
-	int chunk;
-
-	while (tmpFeedSize) {
-		code = player->_smushDispatch[idx].headerPtr;
-
-		switch (code[0]) {
-		case SAUD_OP_INIT:
-			player->_smushDispatch[idx].audioLength = 0;
-			buf = player->_smushDispatch[idx].headerPtr;
-			player->_smushDispatch[idx].audioRemaining = READ_BE_UINT32(buf + 2);
-			player->_smushDispatch[idx].currentOffset = READ_BE_UINT32(buf + 6);
-			player->_smushDispatch[idx].sampleRate = player->_smushAudioSampleRate;
-			player->_smushDispatch[idx].headerPtr += player->_smushDispatch[idx].headerPtr[1] + 2;
-			if (player->_smushDispatch[idx].audioRemaining < player->_smushTracks[idx].availableSize + (player->_smushTracks[idx].availableSize >= player->_smushTracks[idx].sdatSize ? 0 : 15000) - player->_smushTracks[idx].dataSize) {
-				chunk = player->_smushTracks[idx].availableSize - player->_smushTracks[idx].dataSize - player->_smushDispatch[idx].audioRemaining + 15000;
-				if (chunk > player->_smushDispatch[idx].currentOffset) {
-					player->_smushTracks[idx].state = TRK_STATE_INACTIVE;
-					player->_smushTracks[idx].groupId = GRP_MASTER;
-					tmpFeedSize = 0;
-					break;
-				}
-
-				player->_smushDispatch[idx].audioRemaining += chunk;
-				player->_smushDispatch[idx].currentOffset -= chunk;
-			}
-			break;
-
-		case SAUD_OP_UPDATE_HEADER:
-		case SAUD_OP_COMPARE_GT:
-		case SAUD_OP_COMPARE_LT:
-		case SAUD_OP_COMPARE_EQ:
-		case SAUD_OP_COMPARE_NE:
-			subcode = code[4];
-			switch (subcode) {
-			case SAUD_VALUEID_ALL_VOLS:
-				value = player->_smushTrackVols[0];
-				break;
-			case SAUD_VALUEID_TRK_VOL:
-				value = player->_smushTracks[idx].volume;
-				break;
-			case SAUD_VALUEID_TRK_PAN:
-				value = player->_smushTracks[idx].pan;
-				break;
-			default:
-				value = player->_smushAudioTable[subcode];
-				break;
-			}
-
-			switch (code[0]) {
-			case SAUD_OP_UPDATE_HEADER:
-				value = value || (subcode == 0);
-				break;
-			case SAUD_OP_COMPARE_GT:
-				value = value > code[5];
-				break;
-			case SAUD_OP_COMPARE_LT:
-				value = value < code[5];
-				break;
-			case SAUD_OP_COMPARE_EQ:
-				value = value == code[5];
-				break;
-			case SAUD_OP_COMPARE_NE:
-				value = value != code[5];
-				break;
-			default:
-				break;
-			}
-
-			if (!value) {
-				player->_smushDispatch[idx].headerPtr = &code[code[1] + 2];
-			} else {
-				// Rebel Assault SAUD branch targets are signed relative displacements.
-				player->_smushDispatch[idx].headerPtr = code + READ_BE_INT16(&code[2]);
-			}
-			break;
-
-		case SAUD_OP_SET_PARAM:
-			switch (code[2]) {
-			case SAUD_VALUEID_ALL_VOLS:
-				player->_smushTrackVols[0] = code[3];
-				mixVolume = computeMixVolume(player, idx);
-				break;
-			case SAUD_VALUEID_TRK_VOL:
-				player->_smushTracks[idx].volume = code[3];
-				mixVolume = computeMixVolume(player, idx);
-				break;
-			case SAUD_VALUEID_TRK_PAN:
-				player->_smushTracks[idx].pan = code[3];
-				break;
-			default:
-				player->_smushAudioTable[code[2]] = code[3];
-				break;
-			}
-			player->_smushDispatch[idx].headerPtr = &code[code[1] + 2];
+	if (tmpFeedSize <= 0)
+		return true;
+	SmushPlayer::SmushAudioTrack &track = player->_smushTracks[idx];
+	SmushPlayer::SmushAudioDispatch &dispatch = player->_smushDispatch[idx];
+	SaudSpan span;
+	uint skippedSpans = 0;
+	while (tmpFeedSize > 0 && _scripts[idx].nextSpan(span, player->_smushAudioTable,
+			player->_smushTrackVols[0], track.volume, track.pan)) {
+		if (++skippedSpans > 65536) {
+			warning("SAUD: track %d loops over replaced audio", idx);
 			break;
-
-		case SAUD_OP_INCR_PARAM:
-			switch (code[2]) {
-			case SAUD_VALUEID_ALL_VOLS:
-				player->_smushTrackVols[0] += code[3];
-				mixVolume = computeMixVolume(player, idx);
-				break;
-			case SAUD_VALUEID_TRK_VOL:
-				player->_smushTracks[idx].volume += code[3];
-				mixVolume = computeMixVolume(player, idx);
-				break;
-			case SAUD_VALUEID_TRK_PAN:
-				player->_smushTracks[idx].pan += code[3];
-				break;
-			default:
-				player->_smushAudioTable[code[2]] += code[3];
+		}
+		mixVolume = computeMixVolume(player, idx);
+		dispatch.audioRemaining = span.offset;
+		dispatch.currentOffset = span.length;
+		dispatch.sampleRate = span.rate;
+
+		// A backward branch may refer to samples already replaced in the ring buffer.
+		if (dispatch.audioRemaining < track.availableSize + (track.availableSize >= track.sdatSize ? 0 : 15000) - track.dataSize) {
+			const int32 skipped = track.availableSize - track.dataSize - dispatch.audioRemaining + 15000;
+			if (skipped > dispatch.currentOffset)
 				break;
-			}
-			player->_smushDispatch[idx].headerPtr = &code[code[1] + 2];
-			break;
-
-		case SAUD_OP_SET_OFFSET:
-			player->_smushDispatch[idx].audioLength = 0;
-			buf = player->_smushDispatch[idx].headerPtr;
-			player->_smushDispatch[idx].audioRemaining = READ_BE_UINT32(buf + 2);
-			player->_smushDispatch[idx].currentOffset = READ_BE_UINT32(buf + 6);
-			player->_smushDispatch[idx].sampleRate = READ_BE_UINT32(buf + 10);
-
-			player->_smushDispatch[idx].headerPtr += player->_smushDispatch[idx].headerPtr[1] + 2;
-			if (player->_smushDispatch[idx].audioRemaining < player->_smushTracks[idx].availableSize + (player->_smushTracks[idx].availableSize >= player->_smushTracks[idx].sdatSize ? 0 : 15000) - player->_smushTracks[idx].dataSize) {
-				chunk = player->_smushTracks[idx].availableSize - player->_smushTracks[idx].dataSize - player->_smushDispatch[idx].audioRemaining + 15000;
-				if (chunk > player->_smushDispatch[idx].currentOffset) {
-					player->_smushTracks[idx].state = TRK_STATE_INACTIVE;
-					player->_smushTracks[idx].groupId = GRP_MASTER;
-					tmpFeedSize = 0;
-					break;
-				}
-
-				player->_smushDispatch[idx].audioRemaining += chunk;
-				player->_smushDispatch[idx].currentOffset -= chunk;
-			}
-			break;
-
-		case SAUD_OP_SET_LENGTH:
-			if (!player->_smushDispatch[idx].audioLength) {
-				player->_smushDispatch[idx].audioLength = READ_BE_UINT32(&code[6]);
-				player->_smushDispatch[idx].elapsedAudio = 0;
-			}
-
-			buf = player->_smushDispatch[idx].headerPtr;
-			player->_smushDispatch[idx].audioRemaining = player->_smushDispatch[idx].elapsedAudio + READ_BE_UINT32(buf + 2);
-
-			player->_smushDispatch[idx].currentOffset = READ_BE_UINT32(buf + 14);
-			if (player->_smushDispatch[idx].currentOffset > player->_smushDispatch[idx].audioLength)
-				player->_smushDispatch[idx].currentOffset = player->_smushDispatch[idx].audioLength;
-
-			player->_smushDispatch[idx].sampleRate = READ_BE_UINT32(buf + 10);
-
-			player->_smushDispatch[idx].audioLength -= player->_smushDispatch[idx].currentOffset;
-			player->_smushDispatch[idx].elapsedAudio += player->_smushDispatch[idx].currentOffset;
-
-			if (player->_smushDispatch[idx].audioLength) {
-				player->_smushDispatch[idx].headerPtr = &code[code[1] + 2];
-			} else {
-				player->_smushDispatch[idx].headerPtr = code + READ_BE_INT16(&code[18]);
-			}
-
-			if (player->_smushDispatch[idx].audioRemaining >= player->_smushTracks[idx].availableSize + (player->_smushTracks[idx].availableSize >= player->_smushTracks[idx].sdatSize ? 0 : 15000) - player->_smushTracks[idx].dataSize) {
-				chunk = player->_smushTracks[idx].availableSize - player->_smushTracks[idx].dataSize - player->_smushDispatch[idx].audioRemaining + 15000;
-				if (chunk > player->_smushDispatch[idx].currentOffset) {
-					player->_smushTracks[idx].state = TRK_STATE_INACTIVE;
-					player->_smushTracks[idx].groupId = GRP_MASTER;
-					tmpFeedSize = 0;
-				} else {
-					player->_smushDispatch[idx].audioRemaining += chunk;
-					player->_smushDispatch[idx].currentOffset -= chunk;
-				}
-			}
-			break;
-
-		default:
-			player->_smushTracks[idx].state = TRK_STATE_INACTIVE;
-			player->_smushTracks[idx].groupId = GRP_MASTER;
-			tmpFeedSize = 0;
+			dispatch.audioRemaining += skipped;
+			dispatch.currentOffset -= skipped;
 		}
-
-		if (player->_smushDispatch[idx].currentOffset > 0)
+		if (dispatch.currentOffset > 0)
 			return false;
 	}
-
+	track.state = TRK_STATE_INACTIVE;
+	track.groupId = GRP_MASTER;
+	tmpFeedSize = 0;
 	return true;
 }
 
@@ -628,7 +473,7 @@ void RebelAudio::processFrame(SmushPlayer *player, int16 feedSize) {
 				dispatch.volumeStep = 16;
 
 			stopStream(i);
-			dispatch.headerPtr = track.dataBuf;
+			_scripts[i].reset(track.dataBuf, track.subChunkPtr - track.dataBuf - 8, player->_smushAudioSampleRate);
 			dispatch.dataBuf = track.subChunkPtr;
 			dispatch.dataSize = track.dataSize;
 			dispatch.currentOffset = 0;
diff --git a/engines/scumm/insane/rebel/rebel_audio.h b/engines/scumm/insane/rebel/rebel_audio.h
index 20ae8d10a0d..856bb0d75eb 100644
--- a/engines/scumm/insane/rebel/rebel_audio.h
+++ b/engines/scumm/insane/rebel/rebel_audio.h
@@ -24,6 +24,7 @@
 
 #include "audio/mixer.h"
 #include "common/scummsys.h"
+#include "scumm/smush/saud.h"
 
 namespace Audio {
 class QueuingAudioStream;
@@ -70,6 +71,7 @@ private:
 	bool _fadeTrackActive[kMaxTracks];
 	Audio::Mixer::SoundType _streamTypes[kMaxTracks];
 	Audio::Mixer::SoundType _fadeStreamTypes[kMaxTracks];
+	SaudScript _scripts[kMaxTracks];
 	int _sampleRate;
 };
 
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index e651899e80e..faabd6bb7c3 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -180,6 +180,7 @@ MODULE_OBJS += \
 	smush/codec37.o \
 	smush/codec47.o \
 	smush/saud.o \
+	smush/saud_stream.o \
 	smush/smush_player.o \
 	smush/rebel/codec_ra1.o \
 	smush/rebel/codec_ra2.o \
diff --git a/engines/scumm/smush/saud.cpp b/engines/scumm/smush/saud.cpp
index 28ec9195a50..0a890a19667 100644
--- a/engines/scumm/smush/saud.cpp
+++ b/engines/scumm/smush/saud.cpp
@@ -22,6 +22,7 @@
 
 #include "common/endian.h"
 #include "common/stream.h"
+#include "common/textconsole.h"
 #include "common/util.h"
 
 namespace Scumm {
@@ -57,12 +58,18 @@ bool readSaudHeader(Common::SeekableReadStream &stream, SaudHeader &header, bool
 				break;
 		} else if (tag == MKTAG('S', 'H', 'D', 'R') && chunkSize == 4) {
 			parsed.sampleRate = stream.readUint32LE();
-		} else if (tag == MKTAG('S', 'T', 'R', 'K') && chunkSize >= 14) {
-			const byte code = stream.readByte(), length = stream.readByte();
-			if (code == 6 && length == 12) {
-				if (!stream.skip(8))
-					return false;
-				parsed.sampleRate = stream.readUint32BE();
+		} else if (tag == MKTAG('S', 'T', 'R', 'K')) {
+			if (parsed.scriptOffset >= 0)
+				return false;
+			parsed.scriptOffset = offset;
+			parsed.scriptSize = chunkSize;
+			if (chunkSize >= 14) {
+				const byte code = stream.readByte(), length = stream.readByte();
+				if (code == SAUD_OP_SET_OFFSET && length == 12) {
+					if (!stream.skip(8))
+						return false;
+					parsed.sampleRate = stream.readUint32BE();
+				}
 			}
 		}
 		// SAUD chunks have no word-alignment padding.
@@ -75,4 +82,132 @@ bool readSaudHeader(Common::SeekableReadStream &stream, SaudHeader &header, bool
 	return true;
 }
 
+SaudScript::SaudScript() {
+	reset(nullptr, 0, 11025);
+}
+
+void SaudScript::reset(const byte *data, uint32 size, uint32 defaultRate) {
+	_data = data;
+	_size = data ? size : 0;
+	_position = 0;
+	_defaultRate = defaultRate;
+	_remaining = _elapsed = 0;
+}
+
+bool SaudScript::fail(const char *message) {
+	warning("SAUD: %s at STRK offset %u", message, _position);
+	_position = _size;
+	return false;
+}
+
+bool SaudScript::branch(int16 displacement) {
+	const int64 target = int64(_position) + displacement;
+	if (target < 0 || target > _size)
+		return fail("branch outside script");
+	_position = target;
+	return true;
+}
+
+bool SaudScript::nextSpan(SaudSpan &span, byte *variables, int &masterVolume, byte &volume, byte &pan) {
+	// A loop must eventually produce samples or terminate.
+	uint instructions = 0;
+	while (_position < _size) {
+		if (++instructions > 65536)
+			return fail("script loop without audio");
+		if (_size - _position < 2)
+			return fail("truncated instruction");
+		const byte *code = _data + _position;
+		const uint length = code[1] + 2;
+		if (length > _size - _position)
+			return fail("truncated instruction operands");
+
+		switch (code[0]) {
+		case SAUD_OP_INIT:
+		case SAUD_OP_SET_OFFSET:
+		case SAUD_OP_SET_LENGTH: {
+			const bool sliced = code[0] == SAUD_OP_SET_LENGTH;
+			const uint required = sliced ? 20 : (code[0] == SAUD_OP_INIT ? 10 : 14);
+			if (length < required)
+				return fail("short sample instruction");
+			span.offset = READ_BE_UINT32(code + 2);
+			span.length = READ_BE_UINT32(code + 6);
+			span.rate = code[0] == SAUD_OP_INIT ? _defaultRate : READ_BE_UINT32(code + 10);
+			if (span.offset > 0x7fffffff || span.length > 0x7fffffff || !span.rate || span.rate > 192000)
+				return fail("invalid sample span or rate");
+			if (sliced) {
+				if (!_remaining) {
+					_remaining = span.length;
+					_elapsed = 0;
+				}
+				if (_elapsed > 0x7fffffff - span.offset)
+					return fail("sample offset overflow");
+				span.offset += _elapsed;
+				span.length = MIN(_remaining, READ_BE_UINT32(code + 14));
+				_remaining -= span.length;
+				_elapsed += span.length;
+			} else {
+				_remaining = _elapsed = 0;
+			}
+			if (span.length > 0x7fffffff - span.offset)
+				return fail("sample span overflow");
+			if (sliced && !_remaining) {
+				if (!branch(READ_BE_INT16(code + 18)))
+					return false;
+			} else {
+				_position += length;
+			}
+			if (span.length)
+				return true;
+			break;
+		}
+		case SAUD_OP_UPDATE_HEADER:
+		case SAUD_OP_COMPARE_GT:
+		case SAUD_OP_COMPARE_LT:
+		case SAUD_OP_COMPARE_EQ:
+		case SAUD_OP_COMPARE_NE: {
+			if (length < 6)
+				return fail("short branch instruction");
+			const byte id = code[4];
+			const byte value = id == SAUD_VALUEID_ALL_VOLS ? masterVolume :
+				(id == SAUD_VALUEID_TRK_VOL ? volume : (id == SAUD_VALUEID_TRK_PAN ? pan : variables[id]));
+			bool take = false;
+			switch (code[0]) {
+			case SAUD_OP_UPDATE_HEADER: take = value || id == 0; break;
+			case SAUD_OP_COMPARE_GT: take = value > code[5]; break;
+			case SAUD_OP_COMPARE_LT: take = value < code[5]; break;
+			case SAUD_OP_COMPARE_EQ: take = value == code[5]; break;
+			case SAUD_OP_COMPARE_NE: take = value != code[5]; break;
+			}
+			if (take) {
+				if (!branch(READ_BE_INT16(code + 2)))
+					return false;
+			} else {
+				_position += length;
+			}
+			break;
+		}
+		case SAUD_OP_SET_PARAM:
+		case SAUD_OP_INCR_PARAM: {
+			if (length < 4)
+				return fail("short parameter instruction");
+			const byte id = code[2], value = code[3];
+			if (id == SAUD_VALUEID_ALL_VOLS) {
+				masterVolume = code[0] == SAUD_OP_SET_PARAM ? value : (masterVolume + value) & 0xffff;
+			} else {
+				byte &parameter = id == SAUD_VALUEID_TRK_VOL ? volume : (id == SAUD_VALUEID_TRK_PAN ? pan : variables[id]);
+				parameter = code[0] == SAUD_OP_SET_PARAM ? value : parameter + value;
+			}
+			_position += length;
+			break;
+		}
+		case SAUD_OP_STOP:
+			_position = _size;
+			return false;
+		default:
+			return fail("unknown instruction");
+		}
+	}
+	return false;
+}
+
 } // End of namespace Scumm
diff --git a/engines/scumm/smush/saud.h b/engines/scumm/smush/saud.h
index 65e9f55563b..5d6f0b1b095 100644
--- a/engines/scumm/smush/saud.h
+++ b/engines/scumm/smush/saud.h
@@ -32,6 +32,8 @@ namespace Scumm {
 struct SaudHeader {
 	uint32 dataSize = 0;
 	uint32 sampleRate = 11025;
+	int64 scriptOffset = -1;
+	uint32 scriptSize = 0;
 };
 
 // Read a SAUD container spanning the rest of the stream, leaving it at its PCM.
@@ -41,6 +43,49 @@ struct SaudHeader {
 // The header is only updated on success.
 bool readSaudHeader(Common::SeekableReadStream &stream, SaudHeader &header, bool allowTruncated = false);
 
+enum SaudOpcode {
+	SAUD_OP_INIT = 1,
+	SAUD_OP_UPDATE_HEADER = 2,
+	SAUD_OP_SET_PARAM = 3,
+	SAUD_OP_INCR_PARAM = 4,
+	SAUD_OP_STOP = 5,
+	SAUD_OP_SET_OFFSET = 6,
+	SAUD_OP_SET_LENGTH = 7,
+	SAUD_OP_COMPARE_GT = 8,
+	SAUD_OP_COMPARE_LT = 9,
+	SAUD_OP_COMPARE_EQ = 10,
+	SAUD_OP_COMPARE_NE = 11
+};
+
+enum SaudValueId {
+	SAUD_VALUEID_ALL_VOLS = 0xff,
+	SAUD_VALUEID_TRK_VOL = 0xfe,
+	SAUD_VALUEID_TRK_PAN = 0xfd
+};
+
+struct SaudSpan {
+	uint32 offset = 0;
+	uint32 length = 0;
+	uint32 rate = 0;
+};
+
+// STRK interpreter shared by the standalone INSANE sound players.
+// Script storage and the 256 global variables belong to the caller.
+class SaudScript {
+public:
+	SaudScript();
+	void reset(const byte *data, uint32 size, uint32 defaultRate);
+	bool nextSpan(SaudSpan &span, byte *variables, int &masterVolume, byte &volume, byte &pan);
+
+private:
+	bool fail(const char *message);
+	bool branch(int16 displacement);
+
+	const byte *_data;
+	uint32 _size, _position, _defaultRate;
+	uint32 _remaining, _elapsed;
+};
+
 } // End of namespace Scumm
 
 #endif
diff --git a/engines/scumm/smush/saud_stream.cpp b/engines/scumm/smush/saud_stream.cpp
new file mode 100644
index 00000000000..8f18f0582a5
--- /dev/null
+++ b/engines/scumm/smush/saud_stream.cpp
@@ -0,0 +1,127 @@
+/* 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 "scumm/smush/saud_stream.h"
+
+#include "audio/decoders/raw.h"
+#include "audio/mixer.h"
+#include "audio/rate.h"
+#include "common/textconsole.h"
+#include "common/util.h"
+
+namespace Scumm {
+
+SaudStreamState::SaudStreamState() {
+	reset();
+}
+
+void SaudStreamState::reset() {
+	Common::StackLock lock(_mutex);
+	memset(_variables, 0, sizeof(_variables));
+	_masterVolume = 127;
+}
+
+byte SaudStreamState::variable(byte id) const {
+	Common::StackLock lock(_mutex);
+	return id == SAUD_VALUEID_ALL_VOLS ? _masterVolume : _variables[id];
+}
+
+SaudStream::SaudStream(const Common::Array<byte> &samples, const Common::Array<byte> &script,
+	uint rate, SaudStreamState &state, byte volume, int8 pan) :
+	_samples(samples), _state(state), _raw(nullptr), _converter(nullptr), _volume(volume), _pan(pan), _ended(false) {
+	_script.reset(script.begin(), script.size(), kRate);
+	if (script.empty()) {
+		SaudSpan span;
+		span.length = samples.size();
+		span.rate = rate;
+		_ended = !startSpan(span);
+	}
+}
+
+SaudStream::~SaudStream() {
+	delete _raw;
+	delete _converter;
+}
+
+bool SaudStream::startSpan(const SaudSpan &span) {
+	if (span.offset >= _samples.size() || !span.length) {
+		warning("SAUD: sample span %u + %u exceeds SDAT size %u", span.offset, span.length, _samples.size());
+		return false;
+	}
+	const uint32 length = MIN<uint32>(span.length, _samples.size() - span.offset);
+	if (length < span.length) {
+		// DUST.SAN contains a speech script extending past its SDAT.
+		warning("SAUD: truncating sample span %u + %u to SDAT size %u", span.offset, span.length, _samples.size());
+		_script.reset(nullptr, 0, kRate);
+	}
+	_raw = Audio::makeRawStream(_samples.begin() + span.offset, length, span.rate, Audio::FLAG_UNSIGNED, DisposeAfterUse::NO);
+	if (!_converter || _converter->getInputRate() != span.rate) {
+		delete _converter;
+		_converter = Audio::makeRateConverter(span.rate, kRate, false, true, false);
+	}
+	return true;
+}
+
+int SaudStream::readBuffer(int16 *buffer, int numSamples) {
+	Common::StackLock lock(_state._mutex);
+	const int frames = numSamples / 2;
+	memset(buffer, 0, frames * 2 * sizeof(*buffer));
+	int done = 0;
+	uint emptySpans = 0;
+	while (done < frames && !_ended) {
+		if (!_raw) {
+			if (++emptySpans > 65536) {
+				warning("SAUD: script loop without output samples");
+				_ended = true;
+				break;
+			}
+			SaudSpan span;
+			if (!_script.nextSpan(span, _state._variables, _state._masterVolume, _volume, _pan) || !startSpan(span)) {
+				_ended = true;
+				break;
+			}
+		}
+		const int volume = CLIP<int>(_volume * _state._masterVolume / 127, 0, 127) * Audio::Mixer::kMaxMixerVolume / 127;
+		const int pan = CLIP<int>(int8(_pan), -127, 127);
+		const int left = pan > 0 ? volume * (127 - pan) / 127 : volume;
+		const int right = pan < 0 ? volume * (127 + pan) / 127 : volume;
+		const int count = _converter->convert(*_raw, (byte *)(buffer + done * 2), sizeof(*buffer), frames - done, left, right, Audio::MIX_ADD);
+		done += count;
+		if (count == 0) {
+			delete _raw;
+			_raw = nullptr;
+		} else {
+			emptySpans = 0;
+		}
+	}
+	return done * 2;
+}
+
+void SaudStream::skip(uint64 frames) {
+	int16 buffer[2048];
+	while (frames && !_ended) {
+		const int count = readBuffer(buffer, MIN<uint64>(frames, ARRAYSIZE(buffer) / 2) * 2) / 2;
+		if (!count)
+			break;
+		frames -= count;
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/smush/saud_stream.h b/engines/scumm/smush/saud_stream.h
new file mode 100644
index 00000000000..3cd0c4ac8f9
--- /dev/null
+++ b/engines/scumm/smush/saud_stream.h
@@ -0,0 +1,78 @@
+/* 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 SCUMM_SMUSH_SAUD_STREAM_H
+#define SCUMM_SMUSH_SAUD_STREAM_H
+
+#include "audio/audiostream.h"
+#include "common/array.h"
+#include "common/mutex.h"
+#include "scumm/smush/saud.h"
+
+namespace Audio {
+class RateConverter;
+}
+
+namespace Scumm {
+
+class SaudStreamState {
+public:
+	SaudStreamState();
+	void reset();
+	byte variable(byte id) const;
+
+private:
+	friend class SaudStream;
+	Common::Mutex _mutex;
+	byte _variables[256];
+	int _masterVolume;
+};
+
+// Plays an in-memory SAUD through the same STRK interpreter as RebelAudio.
+// The caller keeps samples, script and shared state alive until playback stops.
+class SaudStream : public Audio::AudioStream {
+public:
+	enum { kRate = 22050 };
+	SaudStream(const Common::Array<byte> &samples, const Common::Array<byte> &script,
+		uint rate, SaudStreamState &state, byte volume, int8 pan);
+	~SaudStream() override;
+
+	int readBuffer(int16 *buffer, int numSamples) override;
+	bool isStereo() const override { return true; }
+	int getRate() const override { return kRate; }
+	bool endOfData() const override { return _ended; }
+	// Advance the script and audio together before handing the stream to the mixer.
+	void skip(uint64 frames);
+
+private:
+	bool startSpan(const SaudSpan &span);
+
+	const Common::Array<byte> &_samples;
+	SaudStreamState &_state;
+	SaudScript _script;
+	Audio::AudioStream *_raw;
+	Audio::RateConverter *_converter;
+	byte _volume, _pan;
+	bool _ended;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/smush/smush_player.h b/engines/scumm/smush/smush_player.h
index 0889ae373fa..15975a2aa2d 100644
--- a/engines/scumm/smush/smush_player.h
+++ b/engines/scumm/smush/smush_player.h
@@ -26,6 +26,7 @@
 #include "common/list.h"
 #include "common/rect.h"
 #include "scumm/charset_v7.h"
+#include "scumm/smush/saud.h"
 
 namespace Audio {
 class SoundHandle;
@@ -57,21 +58,6 @@ namespace Scumm {
 
 #define TRK_TYPE_MASK 0xC0
 
-#define SAUD_OP_INIT          1
-#define SAUD_OP_UPDATE_HEADER 2
-#define SAUD_OP_SET_PARAM     3
-#define SAUD_OP_INCR_PARAM    4
-#define SAUD_OP_SET_OFFSET    6
-#define SAUD_OP_SET_LENGTH    7
-#define SAUD_OP_COMPARE_GT    8
-#define SAUD_OP_COMPARE_LT    9
-#define SAUD_OP_COMPARE_EQ    10
-#define SAUD_OP_COMPARE_NE    11
-
-#define SAUD_VALUEID_ALL_VOLS 0xFF
-#define SAUD_VALUEID_TRK_VOL  0xFE
-#define SAUD_VALUEID_TRK_PAN  0xFD
-
 #define TRK_USERID_SPEECH 1
 #define TRK_USERID_MUSIC  2
 #define TRK_USERID_SFX    3


Commit: f8a95c9e4c8e61cdaa0346ccef1de974928fcb94
    https://github.com/scummvm/scummvm/commit/f8a95c9e4c8e61cdaa0346ccef1de974928fcb94
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: initial implementation of castle gameplay

Changed paths:
  A engines/scumm/insane/mortimer/castle.cpp
  A engines/scumm/insane/mortimer/castle.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/flight.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/movie.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/mortimer/castle.cpp b/engines/scumm/insane/mortimer/castle.cpp
new file mode 100644
index 00000000000..81d14d23e22
--- /dev/null
+++ b/engines/scumm/insane/mortimer/castle.cpp
@@ -0,0 +1,545 @@
+/* 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 "scumm/insane/mortimer/castle.h"
+#include "scumm/insane/mortimer/campaign.h"
+
+#include "common/endian.h"
+#include "common/file.h"
+#include "common/memstream.h"
+#include "common/textconsole.h"
+#include "common/util.h"
+#include "graphics/surface.h"
+
+namespace Scumm {
+
+// SHELL 4439e0: explanation, starting animal, then five correct/distractor pairs.
+const byte kCastlePuzzles[3][8][12] = {
+	{
+		{0, 34, 13, 11, 6, 1, 12, 0, 25, 4, 31, 21},
+		{1, 30, 24, 6, 32, 35, 0, 25, 13, 12, 2, 22},
+		{3, 24, 22, 32, 12, 5, 26, 4, 10, 36, 25, 0},
+		{5, 11, 22, 25, 26, 13, 5, 36, 23, 0, 35, 32},
+		{6, 10, 13, 21, 11, 32, 26, 35, 5, 12, 34, 33},
+		{8, 34, 16, 2, 3, 24, 15, 32, 0, 31, 20, 13},
+		{9, 21, 26, 32, 3, 13, 15, 25, 4, 31, 24, 11},
+		{10, 32, 33, 21, 13, 10, 31, 26, 11, 15, 6, 0}
+	},
+	{
+		{0, 34, 13, 30, 6, 4, 12, 35, 25, 2, 31, 20},
+		{1, 30, 24, 36, 32, 22, 0, 35, 13, 6, 2, 12},
+		{4, 22, 13, 5, 25, 20, 21, 14, 12, 2, 11, 15},
+		{5, 11, 22, 13, 26, 30, 5, 0, 23, 13, 35, 21},
+		{6, 10, 13, 22, 11, 12, 26, 36, 5, 23, 34, 25},
+		{7, 26, 11, 35, 21, 32, 24, 12, 22, 36, 23, 33},
+		{8, 34, 16, 13, 3, 10, 15, 32, 0, 11, 20, 5},
+		{10, 32, 33, 0, 13, 12, 31, 15, 11, 3, 6, 10}
+	},
+	{
+		{3, 24, 22, 3, 12, 14, 26, 6, 10, 0, 25, 32},
+		{4, 22, 13, 3, 25, 20, 21, 36, 12, 0, 11, 10},
+		{7, 26, 11, 20, 21, 0, 24, 25, 22, 2, 23, 12},
+		{11, 21, 20, 3, 6, 15, 16, 34, 10, 13, 12, 14},
+		{12, 24, 35, 22, 4, 10, 33, 3, 2, 26, 5, 0},
+		{13, 5, 13, 0, 32, 15, 24, 35, 16, 14, 2, 3},
+		{9, 21, 26, 20, 3, 11, 15, 2, 4, 16, 24, 1},
+		{11, 24, 20, 34, 6, 15, 16, 13, 10, 4, 12, 0}
+	}
+};
+
+MortimerCastle::MortimerCastle() : MortimerFlight(false), _random("mortimer-castle"),
+	_nextVoice(0), _flightDifficulty(0), _riddleDifficulty(0), _puzzle(0), _step(0), _damage(0), _checkpointDamage(0),
+	_wrongChoices(0), _crashes(0), _frame(0), _choiceFrame(0), _hintFrames(0), _holdFrames(0), _answer(0), _selection(-1),
+	_stage(kEntry), _moviePending(false), _choosing(false), _leaving(false), _correct(false), _resourcesLoaded(false) {
+	memset(_rooms, 0, sizeof(_rooms));
+	memset(_sides, 0, sizeof(_sides));
+}
+
+bool MortimerCastle::loadText() {
+	Common::File file;
+	if (!file.open("RESOURCE/DATA/RIDDLE_F.DAT"))
+		return false;
+	int answer = -1, difficulty = -1;
+	while (!file.eos() && !file.err()) {
+		Common::String line = file.readLine();
+		line.trim();
+		if (line.empty())
+			continue;
+		if (line[0] == '#') {
+			const Common::String heading = line.substr(1);
+			if (heading == "Medium" || heading == "Hard" || heading == "Easy") {
+				difficulty = heading == "Medium" ? 0 : heading == "Hard" ? 1 : -1;
+			} else {
+				answer = heading == "Mortimer" ? 0 : heading == "Lodius" ? 1 : -1;
+				difficulty = -1;
+			}
+		} else if (answer >= 0 && difficulty >= 0 && line[0] != 0x1a) {
+			Common::String &text = _riddles[answer][difficulty];
+			if (!text.empty())
+				text += '\n';
+			text += line;
+		}
+	}
+	if (file.err())
+		return false;
+	file.close();
+	if (!file.open("RESOURCE/DATA/CASTLE.DAT"))
+		return false;
+	bool hint = false;
+	while (!file.eos() && !file.err()) {
+		Common::String line = file.readLine();
+		line.trim();
+		if (line.empty())
+			continue;
+		if (line[0] == '#') {
+			const Common::String heading = line.substr(1);
+			hint = heading == (context().girl ? "In Common g" : "In Common b");
+		} else if (hint && line[0] != 0x1a) {
+			if (!_hint.empty())
+				_hint += '\n';
+			_hint += line;
+		}
+	}
+	for (uint i = 0; i < 2; ++i)
+		for (uint j = 0; j < 2; ++j)
+			if (_riddles[i][j].empty())
+				return false;
+	return !file.err() && !_hint.empty();
+}
+
+bool MortimerCastle::loadResources() {
+	if (_resourcesLoaded)
+		return true;
+	struct Bank {
+		const char *name;
+		Common::Array<MortimerSprite> *sprites;
+		uint count;
+	};
+	const Bank banks[] = {
+		{"RESOURCE/CASTLE/GOGGLES.NUT", &_goggles, 11},
+		{"RESOURCE/CASTLE/C_ICON_H.NUT", &_icons, 28},
+		{"RESOURCE/CASTLE/SPIN.NUT", &_spin, 4},
+		{"RESOURCE/CASTLE/FLAME1.NUT", &_flames[0], 5},
+		{"RESOURCE/CASTLE/FLAME2.NUT", &_flames[1], 5},
+		{"RESOURCE/CASTLE/FLAME3.NUT", &_flames[2], 5},
+		{"RESOURCE/CASTLE/FLAME4.NUT", &_flames[3], 5},
+		{"RESOURCE/CASTLE/CG_MOUTH.NUT", &_mouth, 7},
+		{"RESOURCE/GENERAL/ARROWS.NUT", &_cursor, 4}
+	};
+	Common::File file;
+	for (uint i = 0; i < ARRAYSIZE(banks); ++i) {
+		if (!file.open(Common::Path(banks[i].name)) ||
+			!loadMortimerSprites(file, *banks[i].sprites) || banks[i].sprites->size() < banks[i].count) {
+			_error = Common::String::format("Missing or invalid %s", banks[i].name);
+			return false;
+		}
+		file.close();
+	}
+	for (uint region = 0; region < 4; ++region) {
+		for (uint i = 0; i < 7; ++i) {
+			const Common::String name = Common::String::format("RESOURCE/CASTLE/PLAQ_%s.NUT", mortimerRegion(region).animals[i]);
+			if (!file.open(Common::Path(name)) || !loadMortimerSprites(file, _plaques[region * 7 + i]) || _plaques[region * 7 + i].size() < 2) {
+				_error = Common::String::format("Missing or invalid %s", name.c_str());
+				return false;
+			}
+			file.close();
+		}
+	}
+	if (!_font.load("RESOURCE/GENERAL/MORTFNTH.NUT") || !_captionFont.load("RESOURCE/GENERAL/ESTRO20.NUT")) {
+		_error = "Missing or invalid Mortimer castle fonts";
+		return false;
+	}
+	_resourcesLoaded = true;
+	return true;
+}
+
+bool MortimerCastle::start(int scene, uint flightDifficulty, uint riddleDifficulty, bool girl) {
+	_error.clear();
+	context().girl = girl;
+	_flightDifficulty = MIN(flightDifficulty, 2U);
+	_riddleDifficulty = MIN(riddleDifficulty, 2U);
+	setCollisionRadius(20 * (_flightDifficulty + 1));
+	for (uint i = 0; i < 2; ++i)
+		for (uint j = 0; j < 2; ++j)
+			_riddles[i][j].clear();
+	_hint.clear();
+	if (!loadResources())
+		return false;
+	if (!loadText()) {
+		_error = "Missing or invalid Mortimer castle text";
+		return false;
+	}
+	const Common::String name = Common::String::format("RESOURCE/CASTLE/CAS_A_H%c.SAD", girl ? 'G' : 'B');
+	Common::File file;
+	if (!file.open(Common::Path(name)) || !loadMortimerSound(file, _hintSound)) {
+		_error = Common::String::format("Missing or invalid %s", name.c_str());
+		return false;
+	}
+	_frame = _hintFrames = 0;
+	_voices.clear();
+	_nextVoice = 0;
+	_answer = _random.getRandomBit();
+	context().riddle = _answer * 10 + (_riddleDifficulty != 0);
+	if (scene == 21)
+		return newPuzzle();
+	enter(scene == 16 ? kGate : scene == 22 ? kSong : kIdle);
+	return true;
+}
+
+bool MortimerCastle::newPuzzle() {
+	_puzzle = _random.getRandomNumber(7);
+	_step = _damage = _checkpointDamage = _wrongChoices = _crashes = 0;
+	uint rooms[] = {0, 1, 2, 3, 4};
+	for (uint i = 0; i < 5; ++i) {
+		const uint choice = _random.getRandomNumber(4 - i);
+		_rooms[i] = rooms[choice];
+		for (uint j = choice; j < 4 - i; ++j)
+			rooms[j] = rooms[j + 1];
+		_sides[i] = _random.getRandomBit();
+	}
+	// SHELL includes the entry in its side slots, leaving the last pair on the right.
+	_sides[5] = 0;
+	const Common::String name = Common::String::format("RESOURCE/CASTLE/C_CUZ%02u%c.SAD",
+		kCastlePuzzles[_riddleDifficulty][_puzzle][0], context().girl ? 'G' : 'B');
+	Common::File file;
+	if (!file.open(Common::Path(name)) || !loadMortimerSound(file, _explanation)) {
+		_error = Common::String::format("Missing or invalid %s", name.c_str());
+		return false;
+	}
+	enter(kEntry);
+	debug(1, "Mortimer castle: puzzle %u, flight difficulty %u, riddle difficulty %u", _puzzle, _flightDifficulty, _riddleDifficulty);
+	return true;
+}
+
+void MortimerCastle::enter(Stage stage) {
+	_stage = stage;
+	_choosing = _leaving = false;
+	_selection = -1;
+	_choiceFrame = 0;
+	_holdFrames = 0;
+	_voices.clear();
+	_nextVoice = 0;
+	const char *name = nullptr;
+	switch (stage) {
+	case kEntry: name = "CS_ENTRY.SAN"; break;
+	case kRoom:
+		_movieName = Common::String::format("RESOURCE/CASTLE/CS_%c.SAN", 'A' + _rooms[_step ? _step - 1 : 0]);
+		break;
+	case kLeft: name = "CS_LEFT.SAN"; break;
+	case kDrop: name = "CS_DROP.SAN"; break;
+	case kExit: name = "CS_EXIT.SAN"; _voices.push_back(3); break;
+	case kGate:
+		_answer = _random.getRandomBit();
+		context().riddle = _answer * 10 + (_riddleDifficulty != 0);
+		name = "CS_GATE.SAN";
+		break;
+	case kSong: name = "C_SONG_1.SAN"; break;
+	case kIdle: name = "CASTLE_I.NUT"; break;
+	case kRiddle: name = "C_RIDD_1.SAN"; break;
+	case kWrong: name = "C_WRONGH.SAN"; break;
+	case kCorrect: name = "CGYESH.SAN"; break;
+	case kFinished: break;
+	}
+	if (name)
+		_movieName = Common::String("RESOURCE/CASTLE/") + name;
+	_moviePending = stage != kFinished;
+}
+
+bool MortimerCastle::loadMovie() {
+	const MortimerMovieContext savedContext = context();
+	clearProgress();
+	context() = savedContext;
+	for (uint i = 0; i < 2; ++i) {
+		_speechBlocks[i] = SpeechBlock();
+		_speech[i] = MortimerSound();
+	}
+	Common::File file;
+	if (!file.open(Common::Path(_movieName))) {
+		_error = Common::String::format("Missing %s", _movieName.c_str());
+		return false;
+	}
+	if (!MortimerFlight::load(file, _stage <= kExit && _stage != kEntry)) {
+		_error = Common::String::format("%s: %s", _movieName.c_str(), MortimerMovie::errorMessage().c_str());
+		return false;
+	}
+	setWeapon(kSlimeGun);
+	_moviePending = false;
+	debug(1, "Mortimer castle: %s, step %u, damage %u, wrong choices %u, crashes %u",
+		_movieName.c_str(), _step, _damage, _wrongChoices, _crashes);
+	return true;
+}
+
+int MortimerCastle::animal(uint side) const {
+	const byte *puzzle = kCastlePuzzles[_riddleDifficulty][_puzzle];
+	if (!_step)
+		return side ? -1 : puzzle[1];
+	return _step <= 5 ? puzzle[_step * 2 + (_sides[_step] != side)] : -1;
+}
+
+void MortimerCastle::handleAction(const byte *data, uint32 size) {
+	MortimerFlight::handleAction(data, size);
+	const uint code = READ_LE_UINT16(data), flags = READ_LE_UINT16(data + 2);
+	if (code == 6 && flags == 39 && (_stage == kEntry || _stage == kRoom)) {
+		showChoices();
+	} else if (code == 8 && flags == 46 && size >= 18) {
+		const uint id = READ_LE_UINT16(data + 6);
+		if (id / 1000 != 7 || id / 100 - 70 != uint(context().girl))
+			return;
+		const uint block = READ_LE_UINT16(data + 10), count = READ_LE_UINT16(data + 12);
+		const uint remaining = READ_LE_UINT32(data + 14);
+		const uint length = size - 18;
+		if (block >= count || length > remaining || remaining > 25000) {
+			_error = Common::String::format("Invalid castle speech %u block %u/%u in %s at frame %u",
+				id, block, count, _movieName.c_str(), frame());
+			return;
+		}
+		for (uint side = 0; side < 2; ++side) {
+			if (int(id % 100) != animal(side))
+				continue;
+			SpeechBlock &speech = _speechBlocks[side];
+			if (!block) {
+				speech = SpeechBlock();
+				speech.count = count;
+				speech.size = remaining;
+			}
+			if (speech.next != block || speech.count != count || speech.size - speech.data.size() != remaining) {
+				_error = Common::String::format("Incomplete castle speech %u block %u/%u in %s at frame %u",
+					id, block, count, _movieName.c_str(), frame());
+				return;
+			}
+			const uint offset = speech.data.size();
+			speech.data.resize(offset + length);
+			memcpy(speech.data.begin() + offset, data + 18, length);
+			++speech.next;
+		}
+	}
+}
+
+void MortimerCastle::showChoices() {
+	_choosing = true;
+	_choiceFrame = 0;
+	_selection = -1;
+	_voices.clear();
+	_nextVoice = 0;
+	// The left name is spoken first; the entry has only a right plaque.
+	for (int side = 1; side >= 0; --side) {
+		if (animal(side) < 0)
+			continue;
+		const SpeechBlock &speech = _speechBlocks[side];
+		const uint size = speech.data.size() >= 8 ? READ_BE_UINT32(speech.data.begin() + 4) : 0;
+		if (!speech.count || speech.next != speech.count || speech.data.size() != speech.size ||
+			speech.data.size() < 8 || size != speech.data.size() - 8) {
+			_error = Common::String::format("Incomplete castle plaque speech in %s for animal %d", _movieName.c_str(), animal(side));
+			return;
+		}
+		Common::MemoryReadStream stream(speech.data.begin(), size + 8);
+		if (!loadMortimerSound(stream, _speech[side])) {
+			_error = Common::String::format("Missing or invalid castle plaque speech in %s for animal %d", _movieName.c_str(), animal(side));
+			return;
+		}
+		_voices.push_back(side);
+	}
+	if (_step == 1)
+		_voices.push_back(2);
+}
+
+const MortimerSound *MortimerCastle::takeSpeech(bool playing) {
+	if (playing || !hasSpeech())
+		return nullptr;
+	const uint voice = _voices[_nextVoice++];
+	if (voice == 2) {
+		_hintFrames = 60;
+		return &_hintSound;
+	}
+	return voice == 3 ? &_explanation : &_speech[voice];
+}
+
+void MortimerCastle::update() {
+	++_frame;
+	if (_holdFrames)
+		--_holdFrames;
+	if (_hintFrames)
+		--_hintFrames;
+	if (!_choosing)
+		return;
+	++_choiceFrame;
+	if (_selection < 0 || _choiceFrame < 2)
+		return;
+	_choosing = false;
+	_leaving = true;
+	_hintFrames = 0;
+	if (_selection == 1)
+		enter(kLeft);
+}
+
+void MortimerCastle::click(const Common::Point &point) {
+	if (_choosing && _selection < 0) {
+		_selection = _step ? (point.x <= 310 ? 1 : 0) : 0;
+		_correct = !_step || uint(_selection) == _sides[_step];
+		_choiceFrame = 0;
+		_voices.clear();
+		_nextVoice = 0;
+	} else if (_stage == kIdle) {
+		if (Common::Rect(184, 341, 459, 447).contains(point)) {
+			enter(kRiddle);
+		} else if (Common::Rect(0, 70, 85, 372).contains(point)) {
+			enter(_answer == 0 ? kCorrect : kWrong);
+		} else if (Common::Rect(558, 70, 640, 371).contains(point)) {
+			enter(_answer == 1 ? kCorrect : kWrong);
+		} else if (Common::Rect(295, 201, 353, 245).contains(point) ||
+			Common::Rect(385, 205, 422, 247).contains(point) || Common::Rect(225, 208, 260, 247).contains(point)) {
+			enter(kWrong);
+		}
+	}
+}
+
+void MortimerCastle::resolveChoice() {
+	if (!_correct) {
+		enter(kDrop);
+		return;
+	}
+	_damage -= _damage / (2 + _flightDifficulty);
+	_checkpointDamage = _damage;
+	++_step;
+	enter(_step < 6 ? kRoom : kExit);
+}
+
+void MortimerCastle::retry(bool crash) {
+	const uint allowances[] = {5, 3, 3};
+	const bool resetPuzzle = crash ? ++_crashes >= 4 : ++_wrongChoices >= allowances[_flightDifficulty];
+	if (resetPuzzle) {
+		newPuzzle();
+	} else {
+		_damage = _checkpointDamage;
+		enter(kRoom);
+		if (crash)
+			_holdFrames = 7;
+	}
+}
+
+void MortimerCastle::hit() {
+	if (_damage < 14)
+		++_damage;
+	else
+		retry(true);
+}
+
+void MortimerCastle::movieFinished() {
+	switch (_stage) {
+	case kEntry:
+	case kRoom:
+		if (_leaving)
+			resolveChoice();
+		else
+			_error = Common::String::format("Castle movie %s ended without a plaque choice", _movieName.c_str());
+		break;
+	case kLeft: resolveChoice(); break;
+	case kDrop: retry(false); break;
+	case kExit: enter(kGate); break;
+	case kGate: enter(kSong); break;
+	case kSong:
+	case kRiddle: enter(kIdle); break;
+	case kWrong: newPuzzle(); break;
+	case kCorrect: enter(kFinished); break;
+	case kIdle:
+	case kFinished: break;
+	}
+}
+
+bool MortimerCastle::skip() {
+	if (_choosing || _stage == kIdle)
+		return true;
+	if ((_stage == kEntry || _stage == kRoom) && !_leaving) {
+		while (!_choosing) {
+			if (!advance()) {
+				if (errorMessage().empty())
+					movieFinished();
+				break;
+			}
+			if (!_error.empty())
+				break;
+		}
+	} else {
+		movieFinished();
+	}
+	return errorMessage().empty();
+}
+
+int MortimerCastle::sceneId() const {
+	return _stage <= kExit ? 21 : _stage == kGate ? 16 : _stage == kSong ? 22 : 23;
+}
+
+const char *MortimerCastle::subtitleName() const {
+	return _stage == kGate || _stage == kCorrect ? "RESOURCE/DATA/ENDTEXT.TRS" : "RESOURCE/DATA/CAS_SONG.TRS";
+}
+
+void MortimerCastle::drawText(Graphics::Surface &screen, const Common::String &text, int x, int y, int width, byte color) const {
+	Common::Array<Common::String> lines;
+	_font.wordWrapText(text, width, lines);
+	y -= lines.size() * _font.getFontHeight() / 2;
+	for (uint i = 0; i < lines.size(); ++i)
+		_font.drawString(&screen, lines[i], x, y + i * _font.getFontHeight(), width, color, Graphics::kTextAlignCenter);
+}
+
+void MortimerCastle::drawOverlay(Graphics::Surface &screen, const Common::Point &mouse, const Common::Point &origin, byte mouth) const {
+	if (courseView()) {
+		_goggles[0].draw(screen, 0, 0);
+		const uint gauge = _damage >= 13 ? 9 + _frame % 2 : _damage >= 10 ? _damage - 4 : 1 + _damage / 2;
+		_goggles[gauge].draw(screen, 0, 0);
+		const Common::Point origins[] = {{355, 85}, {413, 86}, {470, 87}, {527, 88}, {584, 90}};
+		const byte *puzzle = kCastlePuzzles[_riddleDifficulty][_puzzle];
+		for (uint i = 0; i < MIN(_step, 5U); ++i) {
+			const uint code = puzzle[i ? i * 2 : 1];
+			_icons[code / 10 * 7 + code % 10].draw(screen, origins[i].x, origins[i].y, true);
+		}
+		if (_choosing) {
+			const Common::Rect clip(8, 104, 632, 448);
+			for (uint side = 0; side < 2; ++side) {
+				const int code = animal(side);
+				if (code < 0)
+					continue;
+				const Common::Array<MortimerSprite> &plaque = _plaques[code / 10 * 7 + code % 10];
+				const MortimerSprite &sprite = _selection >= 0 ? _spin[2 + MIN(_choiceFrame, 1U)] :
+					_choiceFrame < 2 ? _spin[_choiceFrame] : plaque[(_choiceFrame / 2) % 2];
+				sprite.draw(screen, origin.x + (side ? 248 : 404), origin.y + 132, false, &clip, 2);
+				drawText(screen, mortimerRegion(code / 10).names[code % 10], side ? 26 : 416, 35, 208, 5);
+			}
+			const Common::Point flames[] = {{43, 131}, {101, 68}, {258, 68}, {325, 131}};
+			for (uint i = 0; i < 4; ++i)
+				if (_step || (i != 0 && i != 3))
+					_flames[i][_frame % 5].draw(screen, origin.x + flames[i].x * 2, origin.y + flames[i].y * 2, false, &clip, 2);
+		}
+		if (_hintFrames) {
+			Common::Array<Common::String> lines;
+			_captionFont.wordWrapText(_hint, 600, lines);
+			for (uint i = 0; i < lines.size(); ++i)
+				_captionFont.drawString(&screen, lines[i], 20, 480 - (lines.size() - i) * _captionFont.getFontHeight(), 600,
+					context().girl ? 253 : 5, Graphics::kTextAlignCenter);
+		}
+	} else if (_stage == kIdle || _stage == kRiddle || _stage == kSong) {
+		_mouth[MIN<uint>(mouth, 6)].draw(screen, 199, _stage == kSong ? 263 : 264);
+		if (_stage == kIdle) {
+			drawText(screen, _riddles[_answer][_riddleDifficulty != 0], 184, 394, 274, 5);
+			_cursor[0].draw(screen, mouse.x, mouse.y);
+		}
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/castle.h b/engines/scumm/insane/mortimer/castle.h
new file mode 100644
index 00000000000..00cfc5d5b75
--- /dev/null
+++ b/engines/scumm/insane/mortimer/castle.h
@@ -0,0 +1,95 @@
+/* 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 SCUMM_INSANE_MORTIMER_CASTLE_H
+#define SCUMM_INSANE_MORTIMER_CASTLE_H
+
+#include "common/random.h"
+#include "scumm/insane/mortimer/flight.h"
+#include "scumm/insane/mortimer/text.h"
+
+namespace Scumm {
+
+class MortimerCastle : public MortimerFlight {
+public:
+	MortimerCastle();
+	bool start(int scene, uint flightDifficulty, uint riddleDifficulty, bool girl);
+	bool loadMovie();
+	void update();
+	void click(const Common::Point &point);
+	bool skip();
+	void movieFinished();
+	void hit();
+	void drawOverlay(Graphics::Surface &screen, const Common::Point &mouse, const Common::Point &origin, byte mouth) const;
+	const MortimerSound *takeSpeech(bool playing);
+
+	bool needsMovie() const { return _moviePending; }
+	bool courseView() const { return _stage <= kExit && interactive(); }
+	bool acceptsShots() const { return (_stage == kEntry || _stage == kRoom) && !_choosing && !_leaving && !_holdFrames; }
+	bool choosing() const { return _choosing; }
+	bool paused() const { return _choosing || _stage == kIdle || _holdFrames; }
+	bool finished() const { return _stage == kFinished; }
+	bool hasSpeech() const { return _nextVoice < _voices.size(); }
+	bool forceSubtitles() const { return _stage == kSong; }
+	uint damage() const { return _damage; }
+	int sceneId() const;
+	const char *subtitleName() const;
+	const Common::String &movieName() const { return _movieName; }
+	const Common::String &errorMessage() const { return _error.empty() ? MortimerMovie::errorMessage() : _error; }
+
+protected:
+	void handleAction(const byte *data, uint32 size) override;
+
+private:
+	enum Stage { kEntry, kRoom, kLeft, kDrop, kExit, kGate, kSong, kIdle, kRiddle, kWrong, kCorrect, kFinished };
+	struct SpeechBlock {
+		Common::Array<byte> data;
+		uint next = 0, count = 0, size = 0;
+	};
+
+	bool loadResources();
+	bool loadText();
+	bool newPuzzle();
+	void enter(Stage stage);
+	void retry(bool crash);
+	void resolveChoice();
+	void showChoices();
+	int animal(uint side) const;
+	void drawText(Graphics::Surface &screen, const Common::String &text, int x, int y, int width, byte color) const;
+
+	Common::RandomSource _random;
+	Common::Array<MortimerSprite> _goggles, _icons, _spin, _flames[4], _plaques[28], _mouth, _cursor;
+	MortimerFont _font, _captionFont;
+	Common::String _riddles[2][2], _hint;
+	Common::String _movieName, _error;
+	SpeechBlock _speechBlocks[2];
+	MortimerSound _speech[2], _hintSound, _explanation;
+	Common::Array<uint> _voices;
+	uint _nextVoice, _flightDifficulty, _riddleDifficulty, _puzzle, _step, _damage, _checkpointDamage;
+	uint _wrongChoices, _crashes, _frame, _choiceFrame, _hintFrames, _holdFrames;
+	uint _rooms[5], _sides[6];
+	int _answer, _selection;
+	Stage _stage;
+	bool _moviePending, _choosing, _leaving, _correct, _resourcesLoaded;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index 57b3728fa55..489ea5d5135 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -61,9 +61,11 @@ void MortimerFlight::reset() {
 	_targets.clear();
 	_obstacles.clear();
 	_inBranch = false;
+	_ignoreCollisions = false;
 }
 
 bool MortimerFlight::advance(bool renderFrame) {
+	_ignoreCollisions = false;
 	_obstacles.clear();
 	for (uint i = 0; i < _targets.size(); ++i)
 		_targets[i].active = false;
@@ -172,6 +174,8 @@ void MortimerFlight::handleAction(const byte *data, uint32 size) {
 		_weapon = flags == 9 ? kSlimeGun : kRevitascope;
 	} else if (code == 6 && (flags == 11 || flags == 25)) {
 		_interactive = flags == 11;
+	} else if (code == 6 && flags == 40) {
+		_ignoreCollisions = true;
 	} else if (code == 8 && flags == 46 && size >= 21 && id / 1000 != 7) {
 		Common::String text((const char *)data + 18, size - 21);
 		if (id / 1000 == 11) {
@@ -227,6 +231,8 @@ MortimerFlight::ShotResult MortimerFlight::shoot(const Common::Point &point) {
 }
 
 bool MortimerFlight::hitsObstacle(const Common::Point &point) const {
+	if (_ignoreCollisions)
+		return false;
 	const int radius = _collisionRadius;
 	const Common::Rect footprint(point.x - radius, point.y - radius, point.x + radius, point.y + radius);
 	for (uint i = 0; i < _targets.size(); ++i) {
diff --git a/engines/scumm/insane/mortimer/flight.h b/engines/scumm/insane/mortimer/flight.h
index 84f6c9c1a94..d14e23b00ca 100644
--- a/engines/scumm/insane/mortimer/flight.h
+++ b/engines/scumm/insane/mortimer/flight.h
@@ -51,19 +51,22 @@ public:
 	uint rescuedSpecies() const { return _rescuedSpecies; }
 	Weapon weapon() const { return _weapon; }
 	void resetWeapon() { _weapon = kRevitascope; }
+	void setWeapon(Weapon weapon) { _weapon = weapon; }
 	const Common::String &nextFlight() const { return _nextFlight; }
 	const Common::Array<Target> &targets() const { return _targets; }
 	bool interactive() const { return _interactive; }
 	void setSteering(const Common::Point &point) { _steering = point; }
 	void setCollisionRadius(uint radius) { _collisionRadius = radius; }
 
+protected:
+	void handleAction(const byte *data, uint32 size) override;
+
 private:
 	struct Obstacle {
 		Common::Point corners[4];
 		bool outside;
 	};
 
-	void handleAction(const byte *data, uint32 size) override;
 	int objectScale(byte codec) const override;
 	Target *findTarget(uint16 id);
 	bool targetHit(uint16 id) const;
@@ -75,7 +78,7 @@ private:
 	Common::String _nextFlight;
 	const bool _isDemo, _keepProgress;
 	bool _hitTargets[1400];
-	bool _initialInteractive, _interactive, _inBranch;
+	bool _initialInteractive, _interactive, _inBranch, _ignoreCollisions;
 	Common::Point _steering;
 	Weapon _weapon;
 	uint _rescuedCount, _rescuedSpecies, _collisionRadius;
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 9dc2e35efed..9abda778787 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -118,7 +118,7 @@ MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
 	_segment(0), _carriedStones(0), _completedSpecies(0), _shellShocks(0), _shockFrame(0), _crashCount(0),
 	_uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0), _pendingGlowStones(0), _finished(false),
-	_controllerMode(false), _interviewActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false) {
+	_controllerMode(false), _interviewActive(false), _castleActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 }
@@ -209,12 +209,16 @@ bool MortimerEngine::loadResources() {
 }
 
 MortimerMovie &MortimerEngine::currentMovie() {
+	if (_castleActive)
+		return _castle;
 	if (_crashStage != kNoCrash || _interviewActive || _introIndex >= 0)
 		return _intro;
 	return _flight;
 }
 
 const MortimerMovie &MortimerEngine::currentMovie() const {
+	if (_castleActive)
+		return _castle;
 	if (_crashStage != kNoCrash || _interviewActive || _introIndex >= 0)
 		return _intro;
 	return _flight;
@@ -342,6 +346,12 @@ bool MortimerEngine::followFlightBranch() {
 }
 
 void MortimerEngine::updateMovieContext() {
+	if (_castleActive) {
+		_castle.setSteering(moviePoint(_aim));
+		_castle.context().conditions[MortimerMovieContext::kDamaged] = _castle.damage() != 0;
+		_intro.context() = _castle.context();
+		return;
+	}
 	_flight.setSteering(moviePoint(_aim));
 	if (_isDemo)
 		return;
@@ -358,6 +368,8 @@ bool MortimerEngine::enterCampaignScene() {
 	stopSounds();
 	const MortimerCampaign::Scene &scene = _campaign.scene();
 	_interviewActive = false;
+	_castleActive = false;
+	_viewport = Common::Rect(24, 20, 616, 360);
 	_pendingFlight.clear();
 	_playingCached = false;
 	if (!loadSubtitles(scene.subtitles))
@@ -378,8 +390,18 @@ bool MortimerEngine::enterCampaignScene() {
 		return processInterview();
 	}
 	if (scene.kind == MortimerCampaign::kCastle || scene.kind == MortimerCampaign::kCastleRiddle) {
-		_loadError = "Mortimer castle gameplay is not implemented yet";
-		return false;
+		if (!_resourcesLoaded) {
+			if (!loadResources())
+				return false;
+			_resourcesLoaded = true;
+		}
+		_castleActive = true;
+		_viewport = Common::Rect(8, 104, 632, 448);
+		if (!_castle.start(scene.id, _flightDifficulty, _riddleDifficulty, _intro.context().girl)) {
+			_loadError = _castle.errorMessage();
+			return false;
+		}
+		return processCastle();
 	}
 	_movieHandles.clear();
 	const Common::String name = _campaign.movieName(_intro.context().girl);
@@ -440,6 +462,7 @@ void MortimerEngine::stopSounds() {
 	_mixer->stopHandle(_scopeHandle);
 	_mixer->stopHandle(_rescueHandle);
 	_mixer->stopHandle(_glowHandle);
+	_mixer->stopHandle(_castleHandle);
 	_soundState.reset();
 }
 
@@ -554,6 +577,8 @@ bool MortimerEngine::restart(bool all) {
 			_campaign.start();
 			return enterCampaignScene();
 		}
+		if (_castleActive)
+			return _campaign.start(21) && enterCampaignScene();
 		if (!_interviewActive && _introIndex >= 0)
 			return startMovie();
 		const uint region = _campaign.scene().region;
@@ -581,6 +606,17 @@ bool MortimerEngine::restart(bool all) {
 
 bool MortimerEngine::nextScene(bool skip) {
 	stopSounds();
+	if (_castleActive) {
+		if (!_castle.skip()) {
+			_loadError = _castle.errorMessage();
+			return false;
+		}
+		if (!_castle.needsMovie() && !_castle.finished()) {
+			_system->getPaletteManager()->setPalette(_castle.palette(), 0, 256);
+			playMovieSounds(true);
+		}
+		return processCastle();
+	}
 	if (_crashStage != kNoCrash) {
 		if (_crashStage == kCrashMovie && _needsRepair)
 			return loadCrashMovie(true);
@@ -722,7 +758,88 @@ bool MortimerEngine::processInterview() {
 	return true;
 }
 
+bool MortimerEngine::processCastle() {
+	if (!_castle.errorMessage().empty()) {
+		_loadError = _castle.errorMessage();
+		return false;
+	}
+	if (_castle.finished()) {
+		stopSounds();
+		_castleActive = false;
+		return _campaign.start(36) && enterCampaignScene();
+	}
+	_campaign.start(_castle.sceneId());
+	if (!loadSubtitles(_castle.subtitleName()))
+		return false;
+	if (_castle.needsMovie()) {
+		stopSounds();
+		_movieHandles.clear();
+		if (!_castle.loadMovie()) {
+			_loadError = _castle.errorMessage();
+			return false;
+		}
+		_introIndex = -1;
+		_movieHandles.resize(_castle.sounds().size());
+		_hitJolt = false;
+		_shotFrame = 3;
+		for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
+			_steeringHistory[i] = Common::Point();
+		setMouse(Common::Point(320, 240));
+		updateSteering();
+		_system->warpMouse(_mouse.x, _mouse.y);
+		if (!startMovie())
+			return false;
+	}
+	const MortimerSound *speech = _castle.takeSpeech(_mixer->isSoundHandleActive(_castleHandle));
+	if (speech)
+		playSound(*speech, _castleHandle, Audio::Mixer::kSpeechSoundType);
+	return true;
+}
+
+bool MortimerEngine::updateCastle() {
+	if (_controllerMode && (_castle.choosing() || !_castle.courseView()))
+		setMouse(Common::Point(_mouse.x + (_steering[1] - _steering[0]) * 18 / Common::JOYAXIS_MAX,
+			_mouse.y + (_steering[3] - _steering[2]) * 18 / Common::JOYAXIS_MAX));
+	_castle.update();
+	updateInterface();
+	if (_castle.needsMovie() || _castle.finished())
+		return processCastle();
+	if (_castle.courseView()) {
+		if (_hitJolt) {
+			const int amount = 64 * (_flightDifficulty + 1);
+			updateView(_random.getRandomBit() ? amount : -amount, _random.getRandomBit() ? amount : -amount);
+			_hitJolt = false;
+		}
+		updateSteering(!_castle.acceptsShots());
+	}
+	updateMovieContext();
+	if (_castle.paused())
+		return processCastle();
+	if (!_castle.advance()) {
+		if (!_castle.errorMessage().empty()) {
+			_loadError = _castle.errorMessage();
+			return false;
+		}
+		if (_castle.hasSpeech() || _mixer->isSoundHandleActive(_castleHandle))
+			return processCastle();
+		_castle.movieFinished();
+		return processCastle();
+	}
+	playMovieSounds();
+	_system->getPaletteManager()->setPalette(_castle.palette(), 0, 256);
+	if (_castle.courseView() && _castle.acceptsShots() && _castle.hitsObstacle(moviePoint(_aim))) {
+		_castle.hit();
+		_hitJolt = true;
+		if (!processCastle())
+			return false;
+		playCollisionSound();
+	}
+	return processCastle();
+}
+
 bool MortimerEngine::updateScene() {
+	if (_castleActive)
+		return updateCastle();
 	if (_interviewActive) {
 		if (_controllerMode) {
 			setMouse(Common::Point(_mouse.x + (_steering[1] - _steering[0]) * 18 / Common::JOYAXIS_MAX,
@@ -785,19 +902,20 @@ void MortimerEngine::setMouse(const Common::Point &position) {
 	_mouse.y = CLIP<int>(position.y, 0, 479);
 }
 
-void MortimerEngine::updateSteering() {
+void MortimerEngine::updateSteering(bool centerView) {
 	int x = (_mouse.x - 320) / 2, y = 240 - _mouse.y;
 	if (_controllerMode) {
 		x = (_steering[1] - _steering[0]) * 128 / Common::JOYAXIS_MAX;
 		y = (_steering[2] - _steering[3]) * 128 / Common::JOYAXIS_MAX;
 	}
-	updateView(x, y);
+	updateView(centerView ? 0 : x, centerView ? 0 : y);
 	if (ABS(x) < 3)
 		x = 0;
 	if (ABS(y) < 3)
 		y = 0;
-	_aim.x = 320 + CLIP(x * 296 / 109, -296, 295);
-	_aim.y = 190 - CLIP(y * 170 / 128, -169, 170);
+	const int halfWidth = _viewport.width() / 2, halfHeight = _viewport.height() / 2;
+	_aim.x = _viewport.left + halfWidth + CLIP(x * halfWidth / 109, -halfWidth, halfWidth - 1);
+	_aim.y = _viewport.top + halfHeight - CLIP(y * halfHeight / 128, 1 - halfHeight, halfHeight);
 }
 
 void MortimerEngine::updateView(int x, int y) {
@@ -812,8 +930,9 @@ void MortimerEngine::updateView(int x, int y) {
 	averageX /= 84;
 	averageY /= 84;
 	// The movie is doubled, and its translation is aligned to four pixels.
-	_view.x = ((88 + CLIP(averageX * 88 / 128, -88, 88) + 3) & ~3) / 2;
-	_view.y = ((118 - CLIP(averageY * 118 / 128, -118, 118) + 3) & ~3) / 2;
+	const int horizontal = _castleActive ? 72 : 88, vertical = _castleActive ? 116 : 118;
+	_view.x = ((horizontal + CLIP(averageX * horizontal / 128, -horizontal, horizontal) + 3) & ~3) / 2;
+	_view.y = ((vertical - CLIP(averageY * vertical / 128, -vertical, vertical) + 3) & ~3) / 2;
 }
 
 Common::Point MortimerEngine::moviePoint(const Common::Point &point) const {
@@ -825,14 +944,15 @@ Common::Point MortimerEngine::screenPoint(const Common::Point &point) const {
 }
 
 void MortimerEngine::fire() {
+	MortimerFlight &flight = currentFlight();
 	if (_shotFrame >= 3) {
 		_shot = _aim;
-		_shotWeapon = _flight.weapon();
+		_shotWeapon = flight.weapon();
 		_shotFrame = 0;
 		playSound(_shotWeapon == MortimerFlight::kSlimeGun ? _slimeSound : _scopeSound,
 			_scopeHandle, Audio::Mixer::kSFXSoundType);
 	}
-	const MortimerFlight::ShotResult result = _flight.shoot(moviePoint(_aim));
+	const MortimerFlight::ShotResult result = flight.shoot(moviePoint(_aim));
 	if (result == MortimerFlight::kAnimalRescued) {
 		playSound(_rescueSound, _rescueHandle, Audio::Mixer::kSFXSoundType);
 		++_pendingGlowStones;
@@ -840,9 +960,9 @@ void MortimerEngine::fire() {
 			_glowFrame = 0;
 			playSound(_glowSound, _glowHandle, Audio::Mixer::kSFXSoundType);
 		}
-		for (uint i = 0; i < _flight.targets().size(); ++i) {
-			const MortimerFlight::Target &target = _flight.targets()[i];
-			if (target.hit && target.rescuedAt == _flight.frame() && target.id / 100 < 7)
+		for (uint i = 0; i < flight.targets().size(); ++i) {
+			const MortimerFlight::Target &target = flight.targets()[i];
+			if (target.hit && target.rescuedAt == flight.frame() && target.id / 100 < 7)
 				_iconFlash[target.id / 100] = 6;
 		}
 	} else if (result == MortimerFlight::kHornetHit) {
@@ -937,7 +1057,13 @@ Common::Error MortimerEngine::run() {
 				switch (event.customType) {
 				case kActionFire:
 					if (!isPaused() && !_finished) {
-						if (_interviewActive) {
+						if (_castleActive) {
+							if (_castle.courseView() && _castle.acceptsShots())
+								fire();
+							else
+								_castle.click(_mouse);
+							exit = !processCastle();
+						} else if (_interviewActive) {
 							const bool playing = _interview.playingMovie();
 							_interview.click(_mouse);
 							if (playing && !_interview.playingMovie())
@@ -1013,21 +1139,22 @@ void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y, bool
 }
 
 void MortimerEngine::drawTargets() {
-	for (uint i = 0; i < _flight.targets().size(); ++i) {
-		const MortimerFlight::Target &target = _flight.targets()[i];
+	MortimerFlight &flight = currentFlight();
+	for (uint i = 0; i < flight.targets().size(); ++i) {
+		const MortimerFlight::Target &target = flight.targets()[i];
 		const bool hostile = (target.id >= 700 && target.id < 800) || (target.id >= 9000 && target.id < 9400);
 		if (!target.active || !target.id || (target.id >= 800 && !hostile))
 			continue;
 		if (target.hit) {
 			const Common::Array<MortimerSprite> &effect = target.hitWeapon == MortimerFlight::kSlimeGun ? _slimeHit : _scopeHit;
-			const uint frame = _flight.frame() - target.rescuedAt;
+			const uint frame = flight.frame() - target.rescuedAt;
 			if (frame < effect.size()) {
 				const Common::Point center = screenPoint(Common::Point((target.rect.left + target.rect.right) / 2,
 					(target.rect.top + target.rect.bottom) / 2));
 				drawSprite(effect[frame], center.x, center.y, true, &_viewport);
 			}
-		} else if ((target.id < 700 && _flight.weapon() == MortimerFlight::kRevitascope) ||
-			(hostile && _flight.weapon() == MortimerFlight::kSlimeGun)) {
+		} else if ((target.id < 700 && flight.weapon() == MortimerFlight::kRevitascope) ||
+			(hostile && flight.weapon() == MortimerFlight::kSlimeGun)) {
 			const Common::Point top = screenPoint(Common::Point(target.rect.left, target.rect.top));
 			const Common::Point bottom = screenPoint(Common::Point(target.rect.right, target.rect.bottom));
 			drawSprite(_corners[0], top.x, top.y, true, &_viewport);
@@ -1108,25 +1235,33 @@ void MortimerEngine::draw() {
 	} else if (interactive()) {
 		_screen.fillRect(Common::Rect(640, 480), 10);
 		for (int y = _viewport.top; y < _viewport.bottom; ++y) {
-			const byte *src = _flight.pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerMovie::kScreenWidth + _view.x;
+			const byte *src = currentFlight().pixels() + (_view.y + (y - _viewport.top) / 2) * MortimerMovie::kScreenWidth + _view.x;
 			byte *dst = (byte *)_screen.getBasePtr(_viewport.left, y);
 			for (int x = 0; x < _viewport.width(); ++x)
 				dst[x] = src[x / 2];
 		}
 		drawTargets();
-		drawSprite(_head[_uiFrame % 4], 270, 200);
-		drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
-		drawSprite(_cockpit[0], 0, 0);
-		drawSprite(_cockpit[1], 567, 0);
-		drawSprite(_cockpit[2], 0, 0);
-		drawDashboard();
+		if (_castleActive) {
+			_castle.drawOverlay(_screen, _mouse, screenPoint(Common::Point()), _soundState.variable(1));
+			drawSprite(_reticles[0], _castle.choosing() ? _mouse.x : _aim.x,
+				_castle.choosing() ? _mouse.y : _aim.y, true, &_viewport);
+		} else {
+			drawSprite(_head[_uiFrame % 4], 270, 200);
+			drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
+			drawSprite(_cockpit[0], 0, 0);
+			drawSprite(_cockpit[1], 567, 0);
+			drawSprite(_cockpit[2], 0, 0);
+			drawDashboard();
+		}
 		drawProjectile();
 	} else {
 		_screen.copyRectToSurface(currentMovie().pixels(), MortimerMovie::kScreenWidth, 0, 0, 640, 480);
+		if (_castleActive)
+			_castle.drawOverlay(_screen, _mouse, Common::Point(), _soundState.variable(1));
 	}
 	if (!_isDemo)
 		_subtitles.draw(_screen, currentMovie().captions(), ConfMan.getBool("subtitles") ||
-			(_interviewActive && _interview.forceSubtitles()), &_intro.context());
+			(_interviewActive && _interview.forceSubtitles()) || (_castleActive && _castle.forceSubtitles()), &_intro.context());
 	if (isPaused()) {
 		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 24401d946b5..0a8e917009c 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -29,6 +29,7 @@
 #include "scumm/insane/mortimer/flight.h"
 #include "scumm/insane/mortimer/interview.h"
 #include "scumm/insane/mortimer/campaign.h"
+#include "scumm/insane/mortimer/castle.h"
 #include "scumm/smush/saud_stream.h"
 
 namespace Scumm {
@@ -68,14 +69,18 @@ private:
 	bool finishCrash();
 	void creditRescues();
 	bool processInterview();
+	bool processCastle();
+	bool updateCastle();
 	bool updateScene();
 	bool nextScene(bool skip = false);
 	bool restart(bool all = false);
 	MortimerMovie &currentMovie();
 	const MortimerMovie &currentMovie() const;
-	bool interactive() const { return _crashStage == kNoCrash && !_interviewActive && _introIndex < 0 && _flight.interactive(); }
+	MortimerFlight &currentFlight() { return _castleActive ? _castle : _flight; }
+	bool interactive() const { return _castleActive ? _castle.courseView() :
+		_crashStage == kNoCrash && !_interviewActive && _introIndex < 0 && _flight.interactive(); }
 	void setMouse(const Common::Point &position);
-	void updateSteering();
+	void updateSteering(bool centerView = false);
 	void updateView(int x, int y);
 	void updateInterface();
 	void fire();
@@ -98,6 +103,7 @@ private:
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
 	MortimerInterview _interview;
+	MortimerCastle _castle;
 	MortimerSubtitles _subtitles;
 	MortimerCampaign _campaign;
 	Graphics::Surface _screen;
@@ -108,7 +114,7 @@ private:
 	SaudStreamState _soundState;
 	Common::Array<Audio::SoundHandle> _movieHandles;
 	Common::Array<Audio::SoundHandle> _collisionHandles;
-	Audio::SoundHandle _scopeHandle, _rescueHandle, _glowHandle;
+	Audio::SoundHandle _scopeHandle, _rescueHandle, _glowHandle, _castleHandle;
 	Common::String _loadError;
 	Common::String _flightName, _pendingFlight, _subtitleName;
 	Common::Rect _viewport;
@@ -124,7 +130,7 @@ private:
 	uint _segment, _carriedStones, _completedSpecies, _shellShocks, _shockFrame, _crashCount;
 	uint _uiFrame, _shotFrame, _glowFrame, _glowStones, _pendingGlowStones;
 	uint _iconFlash[7];
-	bool _finished, _controllerMode, _interviewActive;
+	bool _finished, _controllerMode, _interviewActive, _castleActive;
 	bool _resourcesLoaded, _playingCached;
 	bool _needsRepair, _hitJolt;
 };
diff --git a/engines/scumm/insane/mortimer/movie.cpp b/engines/scumm/insane/mortimer/movie.cpp
index 7b374fc7214..6bcb084d3cd 100644
--- a/engines/scumm/insane/mortimer/movie.cpp
+++ b/engines/scumm/insane/mortimer/movie.cpp
@@ -392,6 +392,9 @@ bool MortimerMovie::load(Common::SeekableReadStream &stream, bool doubled, bool
 				return fail("Unsupported Mortimer movie header");
 			expectedFrames = READ_LE_UINT16(src + 2);
 			_frameRate = READ_LE_UINT32(src + 774);
+			// Single-frame NUT backgrounds have no playback clock.
+			if (!_frameRate && expectedFrames == 1)
+				_frameRate = 10;
 			if (!expectedFrames || _frameRate != 10)
 				return fail("Unsupported Mortimer movie timing");
 			memcpy(_initialPalette, src + 6, sizeof(_initialPalette));
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index faabd6bb7c3..4ae124ae527 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -148,6 +148,7 @@ MODULE_OBJS += \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
 	insane/mortimer/campaign.o \
+	insane/mortimer/castle.o \
 	insane/mortimer/dialogs.o \
 	insane/mortimer/flight.o \
 	insane/mortimer/interview.o \


Commit: e5c3219592ac48768d45cdd7c3b884951a9af65f
    https://github.com/scummvm/scummvm/commit/e5c3219592ac48768d45cdd7c3b884951a9af65f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: implemented cheats and easter eggs

Changed paths:
    engines/scumm/detection.h
    engines/scumm/detection_tables.h
    engines/scumm/insane/mortimer/castle.cpp
    engines/scumm/insane/mortimer/castle.h
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/metaengine.cpp


diff --git a/engines/scumm/detection.h b/engines/scumm/detection.h
index c0df2e1ab35..4f1be4f71d6 100644
--- a/engines/scumm/detection.h
+++ b/engines/scumm/detection.h
@@ -47,6 +47,8 @@ namespace Scumm {
 #define GAMEOPTION_REBEL1_RESTORED_CONTENT                   GUIO_GAMEOPTIONS16
 #define GAMEOPTION_REBEL2_RESTORED_CONTENT                   GUIO_GAMEOPTIONS17
 #define GAMEOPTION_MORTIMER_RESTORED_CONTENT                 GUIO_GAMEOPTIONS18
+#define GAMEOPTION_MORTIMER_CHEATS                           GUIO_GAMEOPTIONS19
+#define GAMEOPTION_MORTIMER_PROGRAMMER                       GUIO_GAMEOPTIONS20
 
 /**
  * Descriptor of a specific SCUMM game. Used internally to store
diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index 6ba62a46092..78ad699908e 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -251,8 +251,8 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
-	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO3(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOASPECT)},
-	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO5(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT, GAMEOPTION_MORTIMER_RESTORED_CONTENT)},
+	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO5(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOASPECT, GAMEOPTION_MORTIMER_CHEATS, GAMEOPTION_MORTIMER_PROGRAMMER)},
+	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO6(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT, GAMEOPTION_MORTIMER_RESTORED_CONTENT, GAMEOPTION_MORTIMER_CHEATS)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
 	{"dig",  "Demo", 0, GID_DIG, 7, 0, MDT_NONE, GF_DEMO, UNK, GUIO4(GUIO_NOMIDI, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/insane/mortimer/castle.cpp b/engines/scumm/insane/mortimer/castle.cpp
index 81d14d23e22..f6220dc8db6 100644
--- a/engines/scumm/insane/mortimer/castle.cpp
+++ b/engines/scumm/insane/mortimer/castle.cpp
@@ -66,8 +66,8 @@ const byte kCastlePuzzles[3][8][12] = {
 
 MortimerCastle::MortimerCastle() : MortimerFlight(false), _random("mortimer-castle"),
 	_nextVoice(0), _flightDifficulty(0), _riddleDifficulty(0), _puzzle(0), _step(0), _damage(0), _checkpointDamage(0),
-	_wrongChoices(0), _crashes(0), _frame(0), _choiceFrame(0), _hintFrames(0), _holdFrames(0), _answer(0), _selection(-1),
-	_stage(kEntry), _moviePending(false), _choosing(false), _leaving(false), _correct(false), _resourcesLoaded(false) {
+	_wrongChoices(0), _crashes(0), _frame(0), _choiceFrame(0), _hintFrames(0), _holdFrames(0), _answer(0), _selection(-1), _forcedChoice(0),
+	_stage(kEntry), _moviePending(false), _choosing(false), _leaving(false), _correct(false), _resourcesLoaded(false), _programmer(false) {
 	memset(_rooms, 0, sizeof(_rooms));
 	memset(_sides, 0, sizeof(_sides));
 }
@@ -172,6 +172,7 @@ bool MortimerCastle::loadResources() {
 
 bool MortimerCastle::start(int scene, uint flightDifficulty, uint riddleDifficulty, bool girl) {
 	_error.clear();
+	_forcedChoice = 0;
 	context().girl = girl;
 	_flightDifficulty = MIN(flightDifficulty, 2U);
 	_riddleDifficulty = MIN(riddleDifficulty, 2U);
@@ -379,6 +380,10 @@ void MortimerCastle::update() {
 		--_hintFrames;
 	if (!_choosing)
 		return;
+	if (_forcedChoice && _selection < 0) {
+		selectSide(!_step ? 0 : _sides[_step] ^ (_forcedChoice == 2));
+		_forcedChoice = 0;
+	}
 	++_choiceFrame;
 	if (_selection < 0 || _choiceFrame < 2)
 		return;
@@ -389,13 +394,17 @@ void MortimerCastle::update() {
 		enter(kLeft);
 }
 
+void MortimerCastle::selectSide(uint side) {
+	_selection = side;
+	_correct = !_step || side == _sides[_step];
+	_choiceFrame = 0;
+	_voices.clear();
+	_nextVoice = 0;
+}
+
 void MortimerCastle::click(const Common::Point &point) {
 	if (_choosing && _selection < 0) {
-		_selection = _step ? (point.x <= 310 ? 1 : 0) : 0;
-		_correct = !_step || uint(_selection) == _sides[_step];
-		_choiceFrame = 0;
-		_voices.clear();
-		_nextVoice = 0;
+		selectSide(_step && point.x <= 310 ? 1 : 0);
 	} else if (_stage == kIdle) {
 		if (Common::Rect(184, 341, 459, 447).contains(point)) {
 			enter(kRiddle);
@@ -519,7 +528,8 @@ void MortimerCastle::drawOverlay(Graphics::Surface &screen, const Common::Point
 				const MortimerSprite &sprite = _selection >= 0 ? _spin[2 + MIN(_choiceFrame, 1U)] :
 					_choiceFrame < 2 ? _spin[_choiceFrame] : plaque[(_choiceFrame / 2) % 2];
 				sprite.draw(screen, origin.x + (side ? 248 : 404), origin.y + 132, false, &clip, 2);
-				drawText(screen, mortimerRegion(code / 10).names[code % 10], side ? 26 : 416, 35, 208, 5);
+				const char *name = _programmer && !code ? "Programmer" : mortimerRegion(code / 10).names[code % 10];
+				drawText(screen, name, side ? 26 : 416, 35, 208, 5);
 			}
 			const Common::Point flames[] = {{43, 131}, {101, 68}, {258, 68}, {325, 131}};
 			for (uint i = 0; i < 4; ++i)
diff --git a/engines/scumm/insane/mortimer/castle.h b/engines/scumm/insane/mortimer/castle.h
index 00cfc5d5b75..b3706b01e0d 100644
--- a/engines/scumm/insane/mortimer/castle.h
+++ b/engines/scumm/insane/mortimer/castle.h
@@ -37,6 +37,9 @@ public:
 	bool skip();
 	void movieFinished();
 	void hit();
+	void clearDamage() { _damage = 0; }
+	void forceChoice(bool correct) { _forcedChoice = correct ? 1 : 2; }
+	void setProgrammer(bool enabled) { _programmer = enabled; }
 	void drawOverlay(Graphics::Surface &screen, const Common::Point &mouse, const Common::Point &origin, byte mouth) const;
 	const MortimerSound *takeSpeech(bool playing);
 
@@ -71,6 +74,7 @@ private:
 	void retry(bool crash);
 	void resolveChoice();
 	void showChoices();
+	void selectSide(uint side);
 	int animal(uint side) const;
 	void drawText(Graphics::Surface &screen, const Common::String &text, int x, int y, int width, byte color) const;
 
@@ -85,9 +89,9 @@ private:
 	uint _nextVoice, _flightDifficulty, _riddleDifficulty, _puzzle, _step, _damage, _checkpointDamage;
 	uint _wrongChoices, _crashes, _frame, _choiceFrame, _hintFrames, _holdFrames;
 	uint _rooms[5], _sides[6];
-	int _answer, _selection;
+	int _answer, _selection, _forcedChoice;
 	Stage _stage;
-	bool _moviePending, _choosing, _leaving, _correct, _resourcesLoaded;
+	bool _moviePending, _choosing, _leaving, _correct, _resourcesLoaded, _programmer;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 5a832cf4284..605fed8fea7 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -68,7 +68,7 @@ MortimerInterview::MortimerInterview(bool isDemo) : _random("mortimer"), _isDemo
 	_region(0), _difficulty(0), _textDifficulty(0), _tried(0), _stage(kIdle), _effect(kNoEffect),
 	_answer(-1), _previousAnswer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0), _guesses(3),
 	_species(0), _stones(0), _frame(0), _switchFrame(6), _payment(0),
-	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false), _restartRegion(false) {
+	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false), _restartRegion(false), _programmer(false) {
 	memset(_palette, 0, sizeof(_palette));
 	memset(_gatePalette, 0, sizeof(_gatePalette));
 }
@@ -93,9 +93,12 @@ bool MortimerInterview::loadText(const char *path, bool riddles) {
 				answer = heading == "Easy" ? 0 : heading == "Medium" ? 1 : 2;
 			} else {
 				animal = answer = -1;
-				for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i)
-					if (heading.equalsIgnoreCase(_isDemo ? kAnimals[i] : mortimerRegion(_region).names[i]))
+				for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i) {
+					const char *name = _programmer && !i ? "Programmer" :
+						_isDemo ? kAnimals[i] : mortimerRegion(_region).names[i];
+					if (heading.equalsIgnoreCase(name))
 						animal = i;
+				}
 			}
 		} else if (animal >= 0 && answer >= 0 && answer < 3) {
 			Common::String &text = riddles ? _riddles[animal][answer] : _dialogue[animal][answer];
@@ -105,6 +108,9 @@ bool MortimerInterview::loadText(const char *path, bool riddles) {
 		}
 	}
 	for (uint i = 0; i < ARRAYSIZE(kAnimals); ++i) {
+		// The alternate resource table has dialogue, but no Programmer riddle.
+		if (riddles && _programmer && !i)
+			continue;
 		for (uint j = 0; j < (riddles && _isDemo ? 1U : 3U); ++j)
 			if ((riddles ? _riddles[i][j] : _dialogue[i][j]).empty())
 				return false;
@@ -112,8 +118,9 @@ bool MortimerInterview::loadText(const char *path, bool riddles) {
 	return !file.err();
 }
 
-bool MortimerInterview::loadResources(uint region) {
+bool MortimerInterview::loadResources(uint region, bool programmer) {
 	_region = region;
+	_programmer = programmer && !_isDemo && !region;
 	for (uint i = 0; i < 7; ++i)
 		for (uint j = 0; j < 3; ++j) {
 			_dialogue[i][j].clear();
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index b1e75453a0d..3393cf97fa7 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -37,11 +37,12 @@ public:
 	enum Effect { kNoEffect, kButtonEffect, kPayoutEffect, kSwitchEffect };
 
 	explicit MortimerInterview(bool isDemo = true);
-	bool loadResources(uint region = 0);
+	bool loadResources(uint region = 0, bool programmer = false);
 	void setDifficulty(uint difficulty) { _difficulty = difficulty; }
 	void resetRiddle();
 	void resetAttempts();
 	void start(uint species, uint stones, int scene = 10);
+	void rescueAll() { _species = 0x7f; _selected = 0; }
 	void click(const Common::Point &point);
 	void scroll(int direction);
 	void update();
@@ -97,7 +98,7 @@ private:
 	Effect _effect;
 	int _answer, _previousAnswer, _selected, _question, _guess, _lastAction, _textScroll, _guesses;
 	uint _species, _stones, _frame, _switchFrame, _payment;
-	bool _moviePending, _riddleView, _solving, _help, _helpExplained, _restartRegion;
+	bool _moviePending, _riddleView, _solving, _help, _helpExplained, _restartRegion, _programmer;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 9abda778787..5576f16b3e0 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -61,6 +61,8 @@ enum MortimerAction {
 	kActionMenu, kActionMouse, kActionJoystick
 };
 
+enum { kBootProgrammer = 1000 };
+
 Common::KeymapArray MortimerEngine::initKeymaps() {
 	Common::Keymap *keymap = new Common::Keymap(Common::Keymap::kKeymapTypeGame, "mortimer", "Mortimer");
 	keymap->setPartialMatchAllowed(false);
@@ -73,7 +75,7 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 		{"FIRE", _s("Fire"), kActionFire, {"MOUSE_LEFT", "MOUSE_RIGHT", "JOY_A", "JOY_B"}},
 		{"PAUSE", _s("Pause"), kActionPause, {"SPACE", "JOY_START", nullptr, nullptr}},
 		{"SKIP", _s("Skip"), kActionSkip, {"ESCAPE", "JOY_Y", nullptr, nullptr}},
-		{"RESTART", _s("Restart"), kActionRestart, {"r", "JOY_X", nullptr, nullptr}},
+		{"RESTART", _s("Restart"), kActionRestart, {"C+r", "JOY_X", nullptr, nullptr}},
 		{"QUIT", _s("Quit"), kActionQuit, {"q", "A+q", nullptr, nullptr}},
 		{"MENU", _s("Game menu"), kActionMenu, {"F5", "JOY_LEFT_SHOULDER", nullptr, nullptr}},
 		{"MOUSE", _s("Mouse control"), kActionMouse, {"A+m", nullptr, nullptr, nullptr}},
@@ -118,7 +120,8 @@ MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
 	_segment(0), _carriedStones(0), _completedSpecies(0), _shellShocks(0), _shockFrame(0), _crashCount(0),
 	_uiFrame(0), _shotFrame(3), _glowFrame(10), _glowStones(0), _pendingGlowStones(0), _finished(false),
-	_controllerMode(false), _interviewActive(false), _castleActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false) {
+	_controllerMode(false), _interviewActive(false), _castleActive(false), _resourcesLoaded(false), _playingCached(false), _needsRepair(false), _hitJolt(false),
+	_cheats(ConfMan.getBool("mortimer_cheats")), _programmer(!isDemo && ConfMan.getBool("mortimer_programmer_easter_egg")), _invulnerable(false) {
 	memset(_steering, 0, sizeof(_steering));
 	memset(_iconFlash, 0, sizeof(_iconFlash));
 }
@@ -294,7 +297,7 @@ bool MortimerEngine::loadRegion(uint region) {
 		_loadError = Common::String::format("Missing or invalid %s", name.c_str());
 		return false;
 	}
-	if (!_interview.loadResources(region)) {
+	if (!_interview.loadResources(region, _programmer)) {
 		_loadError = _interview.errorMessage();
 		return false;
 	}
@@ -397,6 +400,7 @@ bool MortimerEngine::enterCampaignScene() {
 		}
 		_castleActive = true;
 		_viewport = Common::Rect(8, 104, 632, 448);
+		_castle.setProgrammer(_programmer);
 		if (!_castle.start(scene.id, _flightDifficulty, _riddleDifficulty, _intro.context().girl)) {
 			_loadError = _castle.errorMessage();
 			return false;
@@ -827,13 +831,8 @@ bool MortimerEngine::updateCastle() {
 	}
 	playMovieSounds();
 	_system->getPaletteManager()->setPalette(_castle.palette(), 0, 256);
-	if (_castle.courseView() && _castle.acceptsShots() && _castle.hitsObstacle(moviePoint(_aim))) {
-		_castle.hit();
-		_hitJolt = true;
-		if (!processCastle())
-			return false;
-		playCollisionSound();
-	}
+	if (_castle.courseView() && _castle.acceptsShots() && _castle.hitsObstacle(moviePoint(_aim)) && !addShellShock())
+		return false;
 	return processCastle();
 }
 
@@ -881,18 +880,100 @@ bool MortimerEngine::updateScene() {
 		return followFlightBranch();
 	playMovieSounds();
 	_system->getPaletteManager()->setPalette(_interviewActive ? _interview.palette(movie) : movie.palette(), 0, 256);
-	if (interactive() && _flight.hitsObstacle(moviePoint(_aim))) {
+	if (interactive() && _flight.hitsObstacle(moviePoint(_aim)) && !addShellShock())
+		return false;
+	return true;
+}
+
+bool MortimerEngine::addShellShock() {
+	if (_invulnerable || isPaused() || !interactive())
+		return true;
+	if (_castleActive) {
+		if (!_castle.acceptsShots())
+			return true;
+		_castle.hit();
+		_hitJolt = true;
+		if (!processCastle())
+			return false;
+	} else if (_shellShocks + 1 >= (_isDemo ? 18U : 15U)) {
 		// Retail crashes on the fifteenth hit; the demo allows eighteen.
-		if (_shellShocks + 1 >= (_isDemo ? 18U : 15U)) {
-			if (!(_isDemo && !_restoredContent ? restart() : startCrash()))
-				return false;
-		} else {
-			++_shellShocks;
-			_shockFrame = 0;
-			_hitJolt = !_isDemo || _restoredContent;
+		if (!(_isDemo && !_restoredContent ? restart() : startCrash()))
+			return false;
+	} else {
+		++_shellShocks;
+		_shockFrame = 0;
+		_hitJolt = !_isDemo || _restoredContent;
+	}
+	// Movie changes stop audio, so enqueue the fatal impact afterwards.
+	playCollisionSound();
+	return true;
+}
+
+void MortimerEngine::clearShellShock() {
+	if (_castleActive)
+		_castle.clearDamage();
+	else
+		_shellShocks = _shockFrame = 0;
+	_hitJolt = false;
+	updateMovieContext();
+}
+
+bool MortimerEngine::skipRegion() {
+	const uint region = _campaign.scene().region;
+	const uint next = region < 5 ? (region + 1) % 5 : 0;
+	_loadedRegion = -1;
+	_crashStage = kNoCrash;
+	_needsRepair = _hitJolt = false;
+	_flight.clearProgress();
+	_interview.resetRiddle();
+	_carriedStones = _completedSpecies = _creditedRescues = _shellShocks = 0;
+	_shockFrame = _crashCount = 0;
+	return _campaign.start(next == 4 ? 34 : next) && enterCampaignScene();
+}
+
+bool MortimerEngine::handleCheatKey(const Common::KeyState &key) {
+	if (!_cheats || _finished || (key.flags & (Common::KBD_CTRL | Common::KBD_META)))
+		return false;
+	const bool alt = key.flags & Common::KBD_ALT;
+	const bool plus = key.keycode == Common::KEYCODE_EQUALS || key.keycode == Common::KEYCODE_PLUS ||
+		key.keycode == Common::KEYCODE_KP_PLUS;
+	const bool minus = key.keycode == Common::KEYCODE_MINUS || key.keycode == Common::KEYCODE_UNDERSCORE ||
+		key.keycode == Common::KEYCODE_KP_MINUS;
+	if ((plus || minus) && alt == !_isDemo) {
+		if (interactive()) {
+			if (plus)
+				clearShellShock();
+			else
+				addShellShock();
 		}
-		// Movie changes stop audio, so enqueue the fatal impact afterwards.
-		playCollisionSound();
+		return true;
+	}
+	if (alt)
+		return false;
+	if (key.keycode == Common::KEYCODE_a) {
+		_completedSpecies = 0x7f;
+		if (_interviewActive)
+			_interview.rescueAll();
+		return true;
+	}
+	if (key.keycode == Common::KEYCODE_i) {
+		_invulnerable = !_invulnerable;
+		return true;
+	}
+	if (_isDemo)
+		return false;
+	switch (key.keycode) {
+	case Common::KEYCODE_s:
+		if (!isPaused())
+			skipRegion();
+		break;
+	case Common::KEYCODE_r:
+	case Common::KEYCODE_w:
+		if (_castleActive && _castle.sceneId() == 21)
+			_castle.forceChoice(key.keycode == Common::KEYCODE_r);
+		break;
+	default:
+		return false;
 	}
 	return true;
 }
@@ -1005,6 +1086,9 @@ void MortimerEngine::pauseEngineIntern(bool pause) {
 }
 
 Common::Error MortimerEngine::run() {
+	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : -1;
+	if (_isDemo && boot == kBootProgrammer)
+		return Common::Error(Common::kUnsupportedGameidError, "The Programmer interview requires the full Mortimer game data");
 	if (_isDemo && !(_resourcesLoaded = loadResources()))
 		return Common::Error(Common::kReadingFailed, _loadError);
 	if (!_isDemo && !_subtitles.load("RESOURCE/DATA/BEGIN.TRS", "RESOURCE/GENERAL/ESTRO20.NUT", "RESOURCE/GENERAL/MORTFNTH.NUT"))
@@ -1018,10 +1102,17 @@ Common::Error MortimerEngine::run() {
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
 	bool exit;
-	// Use the original scene IDs for ScummVM's developer startup shortcut.
-	// Interview entry points supply rescued animals and glowstones.
-	const int boot = ConfMan.hasKey("boot_param") ? ConfMan.getInt("boot_param") : -1;
-	if (!_isDemo) {
+	// 1000 opens the hidden interview; other values select original scene IDs.
+	if (boot == kBootProgrammer) {
+		_programmer = true;
+		exit = !loadRegion(0);
+		if (!exit) {
+			_completedSpecies = 0x7f;
+			_carriedStones = 30;
+			_campaign.start(8);
+			exit = !enterCampaignScene();
+		}
+	} else if (!_isDemo) {
 		_subtitleName = "RESOURCE/DATA/BEGIN.TRS";
 		if (!_campaign.start(boot < 0 ? 24 : boot))
 			_campaign.start();
@@ -1041,6 +1132,17 @@ Common::Error MortimerEngine::run() {
 	while (!shouldQuit() && !exit) {
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
+			if (event.type == Common::EVENT_KEYDOWN) {
+				if (event.kbdRepeat)
+					continue;
+				if (handleCheatKey(event.kbd)) {
+					if (!_loadError.empty()) {
+						exit = true;
+						break;
+					}
+					continue;
+				}
+			}
 			if (event.type == Common::EVENT_MOUSEMOVE) {
 				if (!isPaused()) {
 					setMouse(event.mouse);
@@ -1077,7 +1179,7 @@ Common::Error MortimerEngine::run() {
 				case kActionSkip:
 					if (pause.isActive())
 						pause.clear();
-					else
+					else if (!interactive() || _cheats)
 						exit = !nextScene(true);
 					break;
 				case kActionQuit:
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 0a8e917009c..8e2618ecd58 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -96,6 +96,10 @@ private:
 	void playMovieSounds(bool seek = false);
 	void playCollisionSound();
 	void stopSounds();
+	bool handleCheatKey(const Common::KeyState &key);
+	bool skipRegion();
+	bool addShellShock();
+	void clearShellShock();
 
 	const bool _isDemo, _restoredContent;
 	uint _flightDifficulty, _riddleDifficulty;
@@ -133,6 +137,7 @@ private:
 	bool _finished, _controllerMode, _interviewActive, _castleActive;
 	bool _resourcesLoaded, _playingCached;
 	bool _needsRepair, _hitJolt;
+	bool _cheats, _programmer, _invulnerable;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/metaengine.cpp b/engines/scumm/metaengine.cpp
index 422b8e9d353..53fc9986041 100644
--- a/engines/scumm/metaengine.cpp
+++ b/engines/scumm/metaengine.cpp
@@ -995,6 +995,24 @@ const ExtraGuiOption enableMortimerRestoredContent = {
 	0
 };
 
+const ExtraGuiOption enableMortimerCheats = {
+	_s("Enable cheats"),
+	_s("Enable the original game's cheat shortcuts for rescuing animals, changing shellshock and skipping flights"),
+	"mortimer_cheats",
+	false,
+	0,
+	0
+};
+
+const ExtraGuiOption enableMortimerProgrammer = {
+	_s("Programmer's Easter egg"),
+	_s("Replace the rhinoceros interview text with the hidden programmer dialogue"),
+	"mortimer_programmer_easter_egg",
+	false,
+	0,
+	0
+};
+
 const ExtraGuiOptions ScummMetaEngine::getExtraGuiOptions(const Common::String &target) const {
 	ExtraGuiOptions options;
 	// Query the GUI options
@@ -1064,6 +1082,13 @@ const ExtraGuiOptions ScummMetaEngine::getExtraGuiOptions(const Common::String &
 		(gameid == "mortimer" && extra == "Demo")) {
 		options.push_back(enableMortimerRestoredContent);
 	}
+	if (target.empty() || guiOptions.contains(GAMEOPTION_MORTIMER_CHEATS) || gameid == "mortimer") {
+		options.push_back(enableMortimerCheats);
+	}
+	if (target.empty() || guiOptions.contains(GAMEOPTION_MORTIMER_PROGRAMMER) ||
+		(gameid == "mortimer" && extra != "Demo" && !guiOptions.contains(GAMEOPTION_MORTIMER_RESTORED_CONTENT))) {
+		options.push_back(enableMortimerProgrammer);
+	}
 	if (target.empty() || gameid == "comi") {
 		options.push_back(comiObjectLabelsOption);
 


Commit: 30cdd215a4af0855c125ea7d07dffee6c4aa06eb
    https://github.com/scummvm/scummvm/commit/30cdd215a4af0855c125ea7d07dffee6c4aa06eb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: adjusted the position of some elements in the full game

Changed paths:
    engines/scumm/insane/mortimer/mortimer.cpp


diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 5576f16b3e0..820db146f5d 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -1247,18 +1247,18 @@ void MortimerEngine::drawTargets() {
 		const bool hostile = (target.id >= 700 && target.id < 800) || (target.id >= 9000 && target.id < 9400);
 		if (!target.active || !target.id || (target.id >= 800 && !hostile))
 			continue;
+		const Common::Point top = screenPoint(Common::Point(target.rect.left, target.rect.top));
+		const Common::Point bottom = screenPoint(Common::Point(target.rect.right, target.rect.bottom));
 		if (target.hit) {
 			const Common::Array<MortimerSprite> &effect = target.hitWeapon == MortimerFlight::kSlimeGun ? _slimeHit : _scopeHit;
 			const uint frame = flight.frame() - target.rescuedAt;
 			if (frame < effect.size()) {
-				const Common::Point center = screenPoint(Common::Point((target.rect.left + target.rect.right) / 2,
-					(target.rect.top + target.rect.bottom) / 2));
+				// Average after scaling to retain the midpoint of odd-sized targets.
+				const Common::Point center((top.x + bottom.x) / 2, (top.y + bottom.y) / 2);
 				drawSprite(effect[frame], center.x, center.y, true, &_viewport);
 			}
 		} else if ((target.id < 700 && flight.weapon() == MortimerFlight::kRevitascope) ||
 			(hostile && flight.weapon() == MortimerFlight::kSlimeGun)) {
-			const Common::Point top = screenPoint(Common::Point(target.rect.left, target.rect.top));
-			const Common::Point bottom = screenPoint(Common::Point(target.rect.right, target.rect.bottom));
 			drawSprite(_corners[0], top.x, top.y, true, &_viewport);
 			drawSprite(_corners[1], bottom.x, bottom.y, true, &_viewport);
 		}
@@ -1303,14 +1303,17 @@ void MortimerEngine::drawDashboard() {
 	const uint level = _isDemo || _shellShocks <= 10 ? _shellShocks / 2 : _shellShocks - 5;
 	const uint gauge = level < 8 ? 19 + level : 27 + _shockFrame;
 	drawSprite(_dashboard[gauge], 180, 442);
-	const Common::Point iconOrigins[] = {{158, 364}, {203, 358}, {248, 358}, {293, 354}, {339, 355}, {383, 360}, {428, 361}};
+	const Common::Point demoIconOrigins[] = {{158, 364}, {203, 358}, {248, 358}, {293, 354}, {339, 355}, {383, 360}, {428, 361}};
+	// Retail uses centered icons (SHELL 43f12c, 44304c); the demo uses sprite origins.
+	const Common::Point iconCenters[] = {{176, 384}, {222, 378}, {265, 378}, {310, 376}, {355, 379}, {399, 380}, {444, 384}};
 	const Common::Point flashOrigins[] = {{154, 345}, {198, 338}, {243, 334}, {288, 332}, {333, 334}, {378, 338}, {422, 345}};
 	for (uint species = 0; species < 7; ++species) {
 		if ((_iconFlash[species] & 1) || (_interviewActive && int(species) == _interview.highlightedAnimal()))
 			drawSprite(_dashboard[species], flashOrigins[species].x, flashOrigins[species].y);
 		const uint available = _interviewActive ? _interview.species() : (_completedSpecies | _flight.rescuedSpecies());
 		const bool rescued = available & (1 << species);
-		drawSprite(_icons[species * 2 + (rescued ? 0 : 1)], iconOrigins[species].x, iconOrigins[species].y);
+		const Common::Point &origin = _isDemo ? demoIconOrigins[species] : iconCenters[species];
+		drawSprite(_icons[species * 2 + (rescued ? 0 : 1)], origin.x, origin.y, !_isDemo);
 	}
 	const uint thresholds[] = {1, 2, 3, 4, 7, 20, 30, 40, 50};
 	for (int i = ARRAYSIZE(thresholds) - 1; i >= 0; --i) {
@@ -1348,8 +1351,8 @@ void MortimerEngine::draw() {
 			drawSprite(_reticles[0], _castle.choosing() ? _mouse.x : _aim.x,
 				_castle.choosing() ? _mouse.y : _aim.y, true, &_viewport);
 		} else {
-			drawSprite(_head[_uiFrame % 4], 270, 200);
 			drawSprite(_reticles[_flight.weapon() == MortimerFlight::kSlimeGun ? 0 : 1], _aim.x, _aim.y, true, &_viewport);
+			drawSprite(_head[_uiFrame % 4], 270, 200);
 			drawSprite(_cockpit[0], 0, 0);
 			drawSprite(_cockpit[1], 567, 0);
 			drawSprite(_cockpit[2], 0, 0);


Commit: bc691b52cadef313fe437f368486f68676342096
    https://github.com/scummvm/scummvm/commit/bc691b52cadef313fe437f368486f68676342096
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: automatic saving per profile

Changed paths:
  A engines/scumm/insane/mortimer/saveload.cpp
  A engines/scumm/insane/saveload.cpp
  A engines/scumm/insane/saveload.h
    engines/scumm/detection_tables.h
    engines/scumm/insane/mortimer/castle.h
    engines/scumm/insane/mortimer/dialogs.cpp
    engines/scumm/insane/mortimer/dialogs.h
    engines/scumm/insane/mortimer/flight.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/rebel1/saveload.cpp
    engines/scumm/insane/rebel2/rebel.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/detection_tables.h b/engines/scumm/detection_tables.h
index 78ad699908e..3eac7ed12e5 100644
--- a/engines/scumm/detection_tables.h
+++ b/engines/scumm/detection_tables.h
@@ -251,7 +251,7 @@ static const GameSettings gameVariantsTable[] = {
 	{"rebel2", "Demo v1.0", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_DEMO | GF_TESTING, Common::kPlatformMacintosh, GUIO6(GUIO_NOMIDI, GAMEOPTION_REBEL2_HIRES, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE, GAMEOPTION_REBEL2_RESTORED_CONTENT)},
 	{"rebel2", "PlayStation", 0, GID_REBEL2, 7, 0, MDT_NONE, GF_UNSTABLE | GF_16BIT_COLOR, Common::kPlatformPSX, GUIO5(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_REBEL2_UNLOCK_ALL, GAMEOPTION_REBEL2_NO_DAMAGE, GAMEOPTION_REBEL2_YODA_MODE)},
 
-	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO5(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOASPECT, GAMEOPTION_MORTIMER_CHEATS, GAMEOPTION_MORTIMER_PROGRAMMER)},
+	{"mortimer", "", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_UNSTABLE, Common::kPlatformWindows, GUIO4(GUIO_NOMIDI, GUIO_NOASPECT, GAMEOPTION_MORTIMER_CHEATS, GAMEOPTION_MORTIMER_PROGRAMMER)},
 	{"mortimer", "Demo", 0, GID_MORTIMER, 7, 0, MDT_NONE, GF_DEMO | GF_UNSTABLE, Common::kPlatformWindows, GUIO6(GUIO_NOMIDI, GUIO_NOLAUNCHLOAD, GUIO_NOSUBTITLES, GUIO_NOASPECT, GAMEOPTION_MORTIMER_RESTORED_CONTENT, GAMEOPTION_MORTIMER_CHEATS)},
 
 	{"dig",  "", 0, GID_DIG, 7, 0, MDT_NONE, 0, UNK, GUIO5(GUIO_NOMIDI, GAMEOPTION_ENHANCEMENTS, GAMEOPTION_ORIGINALGUI, GAMEOPTION_LOWLATENCYAUDIO, GAMEOPTION_TTS)},
diff --git a/engines/scumm/insane/mortimer/castle.h b/engines/scumm/insane/mortimer/castle.h
index b3706b01e0d..ddae2b0fd74 100644
--- a/engines/scumm/insane/mortimer/castle.h
+++ b/engines/scumm/insane/mortimer/castle.h
@@ -49,6 +49,7 @@ public:
 	bool choosing() const { return _choosing; }
 	bool paused() const { return _choosing || _stage == kIdle || _holdFrames; }
 	bool finished() const { return _stage == kFinished; }
+	bool solved() const { return _stage == kCorrect || _stage == kFinished; }
 	bool hasSpeech() const { return _nextVoice < _voices.size(); }
 	bool forceSubtitles() const { return _stage == kSong; }
 	uint damage() const { return _damage; }
diff --git a/engines/scumm/insane/mortimer/dialogs.cpp b/engines/scumm/insane/mortimer/dialogs.cpp
index c9ffa44b701..7390371caff 100644
--- a/engines/scumm/insane/mortimer/dialogs.cpp
+++ b/engines/scumm/insane/mortimer/dialogs.cpp
@@ -24,13 +24,26 @@
 #include "common/util.h"
 #include "gui/gui-manager.h"
 #include "gui/ThemeEval.h"
+#include "gui/message.h"
+#include "gui/widgets/edittext.h"
 #include "gui/widgets/popup.h"
 
 namespace Scumm {
 
-MortimerDifficultyDialog::MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty)
-	: GUI::Dialog("MortimerDifficultyDialog") {
-	new GUI::StaticTextWidget(this, _name + ".Title", _("Select difficulty"));
+MortimerDifficultyDialog::MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty, bool girl, bool newPilot)
+	: GUI::Dialog("MortimerDifficultyDialog"), _character(nullptr), _pilotName(nullptr) {
+	new GUI::StaticTextWidget(this, _name + ".Title", newPilot ? _("New pilot") : _("Select difficulty"));
+	if (newPilot) {
+		new GUI::StaticTextWidget(this, _name + ".NameLabel", _("Pilot name:"));
+		_pilotName = new GUI::EditTextWidget(this, _name + ".Name", Common::U32String());
+	}
+	if (!isDemo) {
+		new GUI::StaticTextWidget(this, _name + ".CharacterLabel", _("Character:"));
+		_character = new GUI::PopUpWidget(this, _name + ".Character");
+		_character->appendEntry(_("Sid"), 0);
+		_character->appendEntry(_("Sally"), 1);
+		_character->setSelectedTag(girl ? 1 : 0);
+	}
 	new GUI::StaticTextWidget(this, _name + ".FlightLabel", _("Flight difficulty:"));
 	new GUI::StaticTextWidget(this, _name + ".RiddleLabel", _("Riddle difficulty:"));
 	_flight = new GUI::PopUpWidget(this, _name + ".Flight", _("Choose how difficult flying and recovering from crashes should be."));
@@ -60,6 +73,16 @@ uint MortimerDifficultyDialog::riddleDifficulty() const {
 	return _riddle->getSelectedTag();
 }
 
+bool MortimerDifficultyDialog::girl() const {
+	return _character && _character->getSelectedTag() == 1;
+}
+
+Common::String MortimerDifficultyDialog::pilotName() const {
+	Common::String name = _pilotName ? _pilotName->getEditString().encode() : Common::String();
+	name.trim();
+	return name;
+}
+
 void MortimerDifficultyDialog::reflowLayout() {
 	GUI::ThemeEval &layouts = *g_gui.xmlEval();
 	const int lineHeight = layouts.getVar("Globals.Line.Height");
@@ -67,8 +90,12 @@ void MortimerDifficultyDialog::reflowLayout() {
 	layouts.addDialog(_name, "screen_center")
 		.addLayout(GUI::ThemeLayout::kLayoutVertical, 8)
 			.addPadding(8, 8, 8, 8)
-			.addWidget("Title", "", width, lineHeight, Graphics::kTextAlignCenter)
-			.addWidget("FlightLabel", "", -1, lineHeight)
+			.addWidget("Title", "", width, lineHeight, Graphics::kTextAlignCenter);
+	if (_pilotName)
+		layouts.addWidget("NameLabel", "", -1, lineHeight).addWidget("Name", "EditTextWidget");
+	if (_character)
+		layouts.addWidget("CharacterLabel", "", -1, lineHeight).addWidget("Character", "PopUp");
+	layouts.addWidget("FlightLabel", "", -1, lineHeight)
 			.addWidget("Flight", "PopUp")
 			.addWidget("RiddleLabel", "", -1, lineHeight)
 			.addWidget("Riddle", "PopUp")
@@ -84,6 +111,57 @@ void MortimerDifficultyDialog::reflowLayout() {
 }
 
 void MortimerDifficultyDialog::handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) {
+	if (cmd == GUI::kOKCmd) {
+		if (_pilotName && (pilotName().empty() || pilotName().size() > 31)) {
+			GUI::MessageDialog message(pilotName().empty() ? _("Please enter a pilot name.") : _("Please enter a shorter pilot name."));
+			message.runModal();
+			return;
+		}
+		setResult(1);
+		close();
+	} else {
+		GUI::Dialog::handleCommand(sender, cmd, data);
+	}
+}
+
+MortimerPilotDialog::MortimerPilotDialog(const SaveStateList &pilots) : GUI::Dialog("MortimerPilotDialog") {
+	new GUI::StaticTextWidget(this, _name + ".Title", _("Select pilot"));
+	new GUI::StaticTextWidget(this, _name + ".Hint", _("Progress is saved after each riddle is solved."));
+	_pilot = new GUI::PopUpWidget(this, _name + ".Pilot");
+	_pilot->appendEntry(_("New pilot"), kNewPilot);
+	for (uint i = 0; i < pilots.size(); ++i)
+		_pilot->appendEntry(pilots[i].getDescription(), pilots[i].getSaveSlot());
+	_pilot->setSelected(pilots.empty() ? 0 : 1);
+	new GUI::ButtonWidget(this, _name + ".Cancel", _("~C~ancel"), Common::U32String(), GUI::kCloseCmd);
+	new GUI::ButtonWidget(this, _name + ".Start", _("Start"), Common::U32String(), GUI::kOKCmd, Common::ASCII_RETURN);
+}
+
+int MortimerPilotDialog::selectedPilot() const {
+	return _pilot->getSelectedTag();
+}
+
+void MortimerPilotDialog::reflowLayout() {
+	GUI::ThemeEval &layouts = *g_gui.xmlEval();
+	const int lineHeight = layouts.getVar("Globals.Line.Height");
+	const int width = 280 * g_gui.getScaleFactor();
+	layouts.addDialog(_name, "screen_center")
+		.addLayout(GUI::ThemeLayout::kLayoutVertical, 8)
+			.addPadding(8, 8, 8, 8)
+			.addWidget("Title", "", width, lineHeight, Graphics::kTextAlignCenter)
+			.addWidget("Pilot", "PopUp")
+			.addWidget("Hint", "", -1, lineHeight, Graphics::kTextAlignCenter)
+			.addLayout(GUI::ThemeLayout::kLayoutHorizontal, 8, GUI::ThemeLayout::kItemAlignEnd)
+				.addPadding(0, 0, 0, 0)
+				.addSpace()
+				.addWidget("Cancel", "Button")
+				.addWidget("Start", "Button")
+			.closeLayout()
+		.closeLayout()
+	.closeDialog();
+	GUI::Dialog::reflowLayout();
+}
+
+void MortimerPilotDialog::handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) {
 	if (cmd == GUI::kOKCmd) {
 		setResult(1);
 		close();
diff --git a/engines/scumm/insane/mortimer/dialogs.h b/engines/scumm/insane/mortimer/dialogs.h
index b69188539e5..b7233e1950f 100644
--- a/engines/scumm/insane/mortimer/dialogs.h
+++ b/engines/scumm/insane/mortimer/dialogs.h
@@ -22,18 +22,22 @@
 #define SCUMM_INSANE_MORTIMER_DIALOGS_H
 
 #include "gui/dialog.h"
+#include "engines/savestate.h"
 
 namespace GUI {
 class PopUpWidget;
+class EditTextWidget;
 }
 
 namespace Scumm {
 
 class MortimerDifficultyDialog : public GUI::Dialog {
 public:
-	MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty);
+	MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty, bool girl = false, bool newPilot = false);
 	uint flightDifficulty() const;
 	uint riddleDifficulty() const;
+	bool girl() const;
+	Common::String pilotName() const;
 	void reflowLayout() override;
 
 protected:
@@ -41,6 +45,22 @@ protected:
 
 private:
 	GUI::PopUpWidget *_flight, *_riddle;
+	GUI::PopUpWidget *_character;
+	GUI::EditTextWidget *_pilotName;
+};
+
+class MortimerPilotDialog : public GUI::Dialog {
+public:
+	enum { kNewPilot = 100 };
+	explicit MortimerPilotDialog(const SaveStateList &pilots);
+	int selectedPilot() const;
+	void reflowLayout() override;
+
+protected:
+	void handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) override;
+
+private:
+	GUI::PopUpWidget *_pilot;
 };
 
 } // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/flight.cpp b/engines/scumm/insane/mortimer/flight.cpp
index 489ea5d5135..97a35f47011 100644
--- a/engines/scumm/insane/mortimer/flight.cpp
+++ b/engines/scumm/insane/mortimer/flight.cpp
@@ -41,7 +41,9 @@ void MortimerFlight::clearProgress() {
 	_rescuedCount = _rescuedSpecies = 0;
 	_weapon = kRevitascope;
 	memset(_hitTargets, 0, sizeof(_hitTargets));
+	const bool girl = context().girl;
 	context() = MortimerMovieContext();
+	context().girl = girl;
 }
 
 void MortimerFlight::clearHostiles() {
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index 3393cf97fa7..a3a87c37fd6 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -60,6 +60,7 @@ public:
 	bool movieDoubled() const;
 	bool wantsFlight() const { return _stage == kFlyAway; }
 	bool finished() const { return _stage == kFinished; }
+	bool solved() const { return _stage == kCorrect || _stage == kOpenGate || _stage == kFinished; }
 	bool restartsRegion() const { return _restartRegion; }
 	int sceneId() const;
 	int riddleId() const { return _answer * 10 + _textDifficulty; }
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index 820db146f5d..f0afc121021 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -115,6 +115,7 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 
 MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
 	_restoredContent(isDemo && ConfMan.getBool("mortimer_restored_content")), _flightDifficulty(0), _riddleDifficulty(0),
+	_pilotSlot(-1), _checkpoint(0), _girl(false), _pilotLoadRequested(false),
 	_random("mortimer-flight"), _flight(isDemo, _restoredContent), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
@@ -132,16 +133,18 @@ MortimerEngine::~MortimerEngine() {
 }
 
 bool MortimerEngine::hasFeature(EngineFeature feature) const {
-	return feature == kSupportsReturnToLauncher;
+	return feature == kSupportsReturnToLauncher || (!_isDemo && feature == kSupportsLoadingDuringRuntime);
 }
 
 bool MortimerEngine::selectDifficulty() {
 	if (_isDemo && !_restoredContent)
 		return true;
-	MortimerDifficultyDialog dialog(_isDemo, _flightDifficulty, _riddleDifficulty);
+	MortimerDifficultyDialog dialog(_isDemo, _flightDifficulty, _riddleDifficulty, _girl);
 	if (runDialog(dialog) != 1 || shouldQuit())
 		return false;
 	setDifficulty(dialog.flightDifficulty(), dialog.riddleDifficulty());
+	_girl = dialog.girl();
+	_intro.context().girl = _flight.context().girl = _girl;
 	return true;
 }
 
@@ -401,14 +404,14 @@ bool MortimerEngine::enterCampaignScene() {
 		_castleActive = true;
 		_viewport = Common::Rect(8, 104, 632, 448);
 		_castle.setProgrammer(_programmer);
-		if (!_castle.start(scene.id, _flightDifficulty, _riddleDifficulty, _intro.context().girl)) {
+		if (!_castle.start(scene.id, _flightDifficulty, _riddleDifficulty, _girl)) {
 			_loadError = _castle.errorMessage();
 			return false;
 		}
 		return processCastle();
 	}
 	_movieHandles.clear();
-	const Common::String name = _campaign.movieName(_intro.context().girl);
+	const Common::String name = _campaign.movieName(_girl);
 	Common::File file;
 	if (!file.open(Common::Path(name))) {
 		_loadError = Common::String::format("Missing %s", name.c_str());
@@ -544,9 +547,7 @@ bool MortimerEngine::finishCrash() {
 		_needsRepair = false;
 		// Easy flight keeps rescues and stones even after a full repair.
 		if (_flightDifficulty != 0) {
-			const bool girl = _flight.context().girl;
 			_flight.clearProgress();
-			_flight.context().girl = girl;
 			_carriedStones = _completedSpecies = _creditedRescues = 0;
 			_interview.resetAttempts();
 		}
@@ -696,6 +697,8 @@ bool MortimerEngine::nextScene(bool skip) {
 }
 
 bool MortimerEngine::processInterview() {
+	if (!_isDemo && _interview.solved())
+		saveCheckpoint(_loadedRegion + 1);
 	if (_interview.wantsFlight()) {
 		_carriedStones = _interview.stones();
 		_completedSpecies = _interview.species();
@@ -767,6 +770,8 @@ bool MortimerEngine::processCastle() {
 		_loadError = _castle.errorMessage();
 		return false;
 	}
+	if (_castle.solved())
+		saveCheckpoint(5);
 	if (_castle.finished()) {
 		stopSounds();
 		_castleActive = false;
@@ -1097,13 +1102,23 @@ Common::Error MortimerEngine::run() {
 	_screen.create(640, 480, Graphics::PixelFormat::createFormatCLUT8());
 	_screen.fillRect(Common::Rect(640, 480), 10);
 	syncSoundSettings();
-	if (!selectDifficulty())
+	if (!_isDemo && ConfMan.hasKey("save_slot") && ConfMan.getInt("save_slot") >= 0) {
+		const Common::Error result = loadGameState(ConfMan.getInt("save_slot"));
+		if (result.getCode() != Common::kNoError)
+			return result;
+	} else if (!_isDemo && boot < 0) {
+		if (!selectPilot())
+			return Common::kNoError;
+	} else if (!selectDifficulty()) {
 		return Common::kNoError;
+	}
 	const bool oldCursor = CursorMan.showMouse(false);
 	PauseToken pause;
 	bool exit;
 	// 1000 opens the hidden interview; other values select original scene IDs.
-	if (boot == kBootProgrammer) {
+	if (_pilotLoadRequested) {
+		exit = !startPilot();
+	} else if (boot == kBootProgrammer) {
 		_programmer = true;
 		exit = !loadRegion(0);
 		if (!exit) {
@@ -1130,6 +1145,12 @@ Common::Error MortimerEngine::run() {
 		exit = !(loadIntro(boot >= 19 && boot <= 23 ? boot - 19 : 0) && startMovie());
 	}
 	while (!shouldQuit() && !exit) {
+		if (_pilotLoadRequested) {
+			if (pause.isActive())
+				pause.clear();
+			if (!startPilot())
+				break;
+		}
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
 			if (event.type == Common::EVENT_KEYDOWN) {
@@ -1210,9 +1231,13 @@ Common::Error MortimerEngine::run() {
 					break;
 				}
 			}
+			if (_pilotLoadRequested)
+				break;
 		}
 		if (exit)
 			break;
+		if (_pilotLoadRequested)
+			continue;
 		if (!isPaused() && !_finished && int32(_system->getMillis() - _nextFrameTime) >= 0) {
 			_nextFrameTime += 1000 / currentMovie().frameRate();
 			if (!updateScene())
@@ -1224,8 +1249,14 @@ Common::Error MortimerEngine::run() {
 		draw();
 		if (_finished) {
 			GUI::MessageDialog dialog(_("Play again?"), _("Restart"), _("Quit"));
-			if (dialog.runModal() != GUI::kMessageOK || !selectDifficulty() || !restart(true))
+			if (runDialog(dialog) != GUI::kMessageOK)
+				break;
+			if (_isDemo) {
+				if (!selectDifficulty() || !restart(true))
+					break;
+			} else if (!selectPilot()) {
 				break;
+			}
 		}
 		_system->delayMillis(10);
 	}
@@ -1233,7 +1264,7 @@ Common::Error MortimerEngine::run() {
 	CursorMan.showMouse(oldCursor);
 	if (!_loadError.empty())
 		warning("Mortimer: %s", _loadError.c_str());
-	return _loadError.empty() ? Common::kNoError : Common::Error(Common::kReadingFailed, _loadError);
+	return _loadError.empty() ? savePilot() : Common::Error(Common::kReadingFailed, _loadError);
 }
 
 void MortimerEngine::drawSprite(const MortimerSprite &sprite, int x, int y, bool centered, const Common::Rect *bounds) {
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 8e2618ecd58..792966ac085 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -42,6 +42,10 @@ public:
 	~MortimerEngine() override;
 	Common::Error run() override;
 	bool hasFeature(EngineFeature feature) const override;
+	Common::String getSaveStateName(int slot) const override;
+	Common::Error loadGameState(int slot) override;
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 	static Common::KeymapArray initKeymaps();
 
 protected:
@@ -53,6 +57,10 @@ private:
 	bool loadResources();
 	bool selectDifficulty();
 	void setDifficulty(uint flight, uint riddle);
+	bool selectPilot();
+	bool startPilot();
+	Common::Error savePilot();
+	void saveCheckpoint(uint checkpoint);
 	uint introCount() const;
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
@@ -103,6 +111,10 @@ private:
 
 	const bool _isDemo, _restoredContent;
 	uint _flightDifficulty, _riddleDifficulty;
+	Common::String _pilotName;
+	int _pilotSlot;
+	uint _checkpoint;
+	bool _girl, _pilotLoadRequested;
 	Common::RandomSource _random;
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
diff --git a/engines/scumm/insane/mortimer/saveload.cpp b/engines/scumm/insane/mortimer/saveload.cpp
new file mode 100644
index 00000000000..5e436eb22f5
--- /dev/null
+++ b/engines/scumm/insane/mortimer/saveload.cpp
@@ -0,0 +1,185 @@
+/* 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 "scumm/insane/mortimer/mortimer.h"
+#include "scumm/insane/mortimer/dialogs.h"
+#include "scumm/insane/saveload.h"
+#include "scumm/scumm.h"
+
+#include "common/config-manager.h"
+#include "common/endian.h"
+#include "common/ptr.h"
+#include "common/savefile.h"
+#include "common/translation.h"
+#include "engines/metaengine.h"
+#include "gui/message.h"
+
+namespace Scumm {
+
+const uint32 kMortimerPilotMagic = MKTAG('M', 'O', 'R', 'P');
+const uint16 kMortimerPilotVersion = 1;
+
+Common::String MortimerEngine::getSaveStateName(int slot) const {
+	return getMetaEngine()->getSavegameFile(slot, _targetName.c_str());
+}
+
+bool MortimerEngine::canLoadGameStateCurrently(Common::U32String *msg) {
+	return !_isDemo && _screen.getPixels();
+}
+
+bool MortimerEngine::canSaveGameStateCurrently(Common::U32String *msg) {
+	if (msg && !_isDemo)
+		*msg = _("Progress is saved automatically when a riddle is solved.");
+	return false;
+}
+
+Common::Error MortimerEngine::savePilot() {
+	if (_isDemo || _pilotSlot < 0)
+		return Common::kNoError;
+	Common::ScopedPtr<Common::OutSaveFile> out(_saveFileMan->openForSaving(getSaveStateName(_pilotSlot), false));
+	if (!out || !writeInsaneSavegameHeader(out.get(), _pilotName, getTotalPlayTime()))
+		return Common::kWritingFailed;
+	out->writeUint32BE(kMortimerPilotMagic);
+	out->writeUint16LE(kMortimerPilotVersion);
+	out->writeByte(_girl);
+	out->writeByte(_flightDifficulty);
+	out->writeByte(_riddleDifficulty);
+	out->writeByte(_checkpoint);
+	out->writeByte(ConfMan.getBool("subtitles"));
+	out->writeByte(_controllerMode);
+	out->finalize();
+	return out->err() ? Common::kWritingFailed : Common::kNoError;
+}
+
+Common::Error MortimerEngine::loadGameState(int slot) {
+	if (_isDemo || slot < 0 || slot > getMetaEngine()->getMaximumSaveSlot())
+		return Common::kReadingFailed;
+	Common::ScopedPtr<Common::InSaveFile> in(_saveFileMan->openForLoading(getSaveStateName(slot)));
+	Common::String name;
+	SaveStateMetaInfos infos;
+	if (!in || !readInsaneSavegameHeader(in.get(), &name, infos) ||
+		in->readUint32BE() != kMortimerPilotMagic || in->readUint16LE() != kMortimerPilotVersion)
+		return Common::kReadingFailed;
+	const byte girl = in->readByte();
+	const byte flight = in->readByte(), riddle = in->readByte(), checkpoint = in->readByte();
+	const byte subtitles = in->readByte(), controller = in->readByte();
+	if (in->err() || in->eos() || name.empty() || girl > 1 || flight > 2 || riddle > 2 ||
+		checkpoint > 5 || subtitles > 1 || controller > 1)
+		return Common::kReadingFailed;
+
+	_pilotName = name;
+	_pilotSlot = slot;
+	_checkpoint = checkpoint;
+	_girl = girl;
+	_controllerMode = controller;
+	ConfMan.setBool("subtitles", subtitles);
+	setDifficulty(flight, riddle);
+	setTotalPlayTime(infos.playtime * 1000);
+	// Leave the menu before switching movies, as in RA2's pilot loading.
+	_pilotLoadRequested = true;
+	return Common::kNoError;
+}
+
+void MortimerEngine::saveCheckpoint(uint checkpoint) {
+	if (_isDemo || _pilotSlot < 0 || checkpoint <= _checkpoint)
+		return;
+	_checkpoint = checkpoint;
+	if (savePilot().getCode() != Common::kNoError) {
+		GUI::MessageDialog message(_("Could not save your pilot. Progress will be saved again when you finish another riddle or quit."));
+		runDialog(message);
+	}
+}
+
+bool MortimerEngine::selectPilot() {
+	while (!shouldQuit()) {
+		const SaveStateList pilots = getMetaEngine()->listSaves(_targetName.c_str());
+		MortimerPilotDialog dialog(pilots);
+		if (runDialog(dialog) != 1 || shouldQuit())
+			return false;
+		if (dialog.selectedPilot() != MortimerPilotDialog::kNewPilot) {
+			const Common::Error result = loadGameState(dialog.selectedPilot());
+			if (result.getCode() == Common::kNoError)
+				return true;
+			GUI::MessageDialog message(result.getTranslatedDesc());
+			runDialog(message);
+			continue;
+		}
+		int slot = 1;
+		while (slot <= getMetaEngine()->getMaximumSaveSlot() && _saveFileMan->exists(getSaveStateName(slot)))
+			++slot;
+		if (slot > getMetaEngine()->getMaximumSaveSlot()) {
+			GUI::MessageDialog message(_("There are no free pilot slots. Delete a saved pilot from the launcher before creating another."));
+			runDialog(message);
+			continue;
+		}
+		MortimerDifficultyDialog options(false, 0, 0, false, true);
+		if (runDialog(options) != 1 || shouldQuit())
+			continue;
+		_pilotName = options.pilotName();
+		_pilotSlot = slot;
+		_checkpoint = 0;
+		_girl = options.girl();
+		_controllerMode = false;
+		setDifficulty(options.flightDifficulty(), options.riddleDifficulty());
+		setTotalPlayTime();
+		const Common::Error result = savePilot();
+		if (result.getCode() == Common::kNoError) {
+			_pilotLoadRequested = true;
+			return true;
+		}
+		_saveFileMan->removeSavefile(getSaveStateName(slot));
+		_pilotSlot = -1;
+		GUI::MessageDialog message(result.getTranslatedDesc());
+		runDialog(message);
+	}
+	return false;
+}
+
+bool MortimerEngine::startPilot() {
+	_pilotLoadRequested = false;
+	stopSounds();
+	_loadError.clear();
+	_loadedRegion = -1;
+	_finished = _interviewActive = _castleActive = _playingCached = false;
+	_crashStage = kNoCrash;
+	_needsRepair = _hitJolt = _invulnerable = false;
+	_shockFrame = _crashCount = _shellShocks = 0;
+	_carriedStones = _completedSpecies = _creditedRescues = 0;
+	_uiFrame = _glowStones = _pendingGlowStones = 0;
+	_shotFrame = 3;
+	_glowFrame = 10;
+	_pendingFlight.clear();
+	_flightName.clear();
+	memset(_steering, 0, sizeof(_steering));
+	memset(_iconFlash, 0, sizeof(_iconFlash));
+	for (uint i = 0; i < ARRAYSIZE(_steeringHistory); ++i)
+		_steeringHistory[i] = Common::Point();
+	_flight.clearProgress();
+	_castle.clearProgress();
+	_intro.context() = MortimerMovieContext();
+	_intro.context().girl = _flight.context().girl = _girl;
+	_interview.resetRiddle();
+	_programmer = ConfMan.getBool("mortimer_programmer_easter_egg");
+	// SHELL's region entry table resumes the castle at END_1. Completed pilots show credits.
+	const int scenes[] = {24, 1, 2, 3, 34, 39};
+	return _campaign.start(scenes[_checkpoint]) && enterCampaignScene();
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/rebel1/saveload.cpp b/engines/scumm/insane/rebel1/saveload.cpp
index 4bc3cac8c42..6645baaac48 100644
--- a/engines/scumm/insane/rebel1/saveload.cpp
+++ b/engines/scumm/insane/rebel1/saveload.cpp
@@ -23,61 +23,15 @@
 #include "common/stream.h"
 #include "common/endian.h"
 
-#include "graphics/thumbnail.h"
-
 #include "scumm/scumm.h"
 #include "scumm/scumm_v7.h"
+#include "scumm/insane/saveload.h"
 #include "scumm/insane/rebel1/rebel.h"
 
 namespace Scumm {
 
 const uint32 kRA1SaveTag = MKTAG('R', 'A', '1', 'S');
 const uint32 kRA1SaveVersion = 1;
-const uint32 kRA1ScummSaveVersion = 124;
-
-struct Rebel1SavegameHeader {
-	uint32 type;
-	uint32 size;
-	uint32 version;
-	char name[32];
-};
-
-bool writeRebel1SavegameHeader(Common::WriteStream *out, const Common::String &desc) {
-	Rebel1SavegameHeader hdr;
-	hdr.type = MKTAG('S', 'C', 'V', 'M');
-	hdr.size = 0;
-	hdr.version = kRA1ScummSaveVersion;
-	memset(hdr.name, 0, sizeof(hdr.name));
-	Common::strlcpy(hdr.name, desc.c_str(), sizeof(hdr.name));
-
-	out->writeUint32BE(hdr.type);
-	out->writeUint32LE(hdr.size);
-	out->writeUint32LE(hdr.version);
-	out->write(hdr.name, sizeof(hdr.name));
-	return !out->err();
-}
-
-bool readRebel1SavegameHeader(Common::SeekableReadStream *in, Common::String *desc, uint32 *version) {
-	Rebel1SavegameHeader hdr;
-	hdr.type = in->readUint32BE();
-	hdr.size = in->readUint32LE();
-	hdr.version = in->readUint32LE();
-	in->read(hdr.name, sizeof(hdr.name));
-	if (in->err() || hdr.type != MKTAG('S', 'C', 'V', 'M'))
-		return false;
-
-	if (hdr.version > 0xFFFFFF)
-		hdr.version = SWAP_BYTES_32(hdr.version);
-	if (hdr.version < VER(52) || hdr.version > kRA1ScummSaveVersion)
-		return false;
-
-	hdr.name[sizeof(hdr.name) - 1] = 0;
-	if (desc)
-		*desc = hdr.name;
-	if (version)
-		*version = hdr.version;
-	return true;
-}
 
 int InsaneRebel1::getCurrentSaveLevel() const {
 	return CLIP<int>(_resumeLevel, 1, kNumLevels);
@@ -93,9 +47,10 @@ Common::Error InsaneRebel1::writeSaveState(int slot, const Common::String &desc,
 	if (!out)
 		return Common::kWritingFailed;
 
-	writeRebel1SavegameHeader(out, desc);
-	Graphics::saveThumbnail(*out);
-	_vm->saveInfos(out);
+	if (!writeInsaneSavegameHeader(out, desc, _vm->getTotalPlayTime())) {
+		delete out;
+		return Common::kWritingFailed;
+	}
 
 	out->writeUint32BE(kRA1SaveTag);
 	out->writeUint32LE(kRA1SaveVersion);
@@ -126,27 +81,13 @@ bool InsaneRebel1::readSaveState(int slot, SaveState &state, Common::String *des
 	if (!in)
 		return false;
 
-	uint32 headerVersion = 0;
-	if (!readRebel1SavegameHeader(in.get(), desc, &headerVersion)) {
+	SaveStateMetaInfos infos;
+	if (!readInsaneSavegameHeader(in.get(), desc, infos)) {
 		warning("RA1: invalid save header in '%s'", filename.c_str());
 		return false;
 	}
 
-	if (headerVersion >= VER(52) && !Graphics::skipThumbnail(*in)) {
-		warning("RA1: save thumbnail could not be skipped in '%s'", filename.c_str());
-		return false;
-	}
-
-	if (headerVersion >= VER(56)) {
-		SaveStateMetaInfos infos;
-		if (!_vm->loadInfos(in.get(), &infos)) {
-			warning("RA1: save info section could not be found in '%s'", filename.c_str());
-			return false;
-		}
-		_vm->setTotalPlayTime(infos.playtime * 1000);
-	} else {
-		_vm->setTotalPlayTime();
-	}
+	_vm->setTotalPlayTime(infos.playtime * 1000);
 
 	if (in->readUint32BE() != kRA1SaveTag) {
 		warning("RA1: missing RA1 save data in '%s'", filename.c_str());
diff --git a/engines/scumm/insane/rebel2/rebel.cpp b/engines/scumm/insane/rebel2/rebel.cpp
index 9e0e2ec0b06..c70d29c3646 100644
--- a/engines/scumm/insane/rebel2/rebel.cpp
+++ b/engines/scumm/insane/rebel2/rebel.cpp
@@ -36,6 +36,7 @@
 #include "scumm/actor.h"
 #include "scumm/file.h"
 #include "scumm/resource.h"
+#include "scumm/insane/saveload.h"
 #include "scumm/scumm_v7.h"
 #include "scumm/sound.h"
 
@@ -1407,51 +1408,6 @@ void InsaneRebel2::renderScoreHUD(byte *renderBitmap, int pitch, int width, int
 const uint32 kPilotSaveMagic = MKTAG('R', 'A', '2', 'P');
 const uint16 kPilotSaveVersion = 2;
 
-// Pilots occupy the regular ScummVM save slots, so they need the standard SCUMM
-// header too: without it every slot reads as an invalid savegame.
-const uint32 kPilotScummSaveVersion = 124;
-
-bool writeRebel2SavegameHeader(Common::WriteStream *out, const Common::String &desc) {
-	char name[32];
-	memset(name, 0, sizeof(name));
-	Common::strlcpy(name, desc.c_str(), sizeof(name));
-
-	out->writeUint32BE(MKTAG('S', 'C', 'V', 'M'));
-	out->writeUint32LE(0);
-	out->writeUint32LE(kPilotScummSaveVersion);
-	out->write(name, sizeof(name));
-	return !out->err();
-}
-
-// A stream starting with the pilot magic is a pre-header file; rewind it.
-bool readRebel2SavegameHeader(Common::SeekableReadStream *in, uint32 *version) {
-	const uint32 tag = in->readUint32BE();
-	if (tag == kPilotSaveMagic) {
-		in->seek(0);
-		if (version)
-			*version = 0;
-		return true;
-	}
-	if (tag != MKTAG('S', 'C', 'V', 'M'))
-		return false;
-
-	in->readUint32LE(); // size, unused
-	uint32 hdrVersion = in->readUint32LE();
-	if (hdrVersion > 0xFFFFFF)
-		hdrVersion = SWAP_BYTES_32(hdrVersion);
-	if (hdrVersion < VER(52) || hdrVersion > kPilotScummSaveVersion)
-		return false;
-
-	char name[32];
-	in->read(name, sizeof(name));
-	if (in->err())
-		return false;
-
-	if (version)
-		*version = hdrVersion;
-	return true;
-}
-
 bool InsaneRebel2::loadPilots() {
 	_numPilots = 0;
 
@@ -1461,25 +1417,15 @@ bool InsaneRebel2::loadPilots() {
 		if (!sf)
 			break; // Slots are contiguous
 
-		uint32 scummVersion = 0;
-		if (!readRebel2SavegameHeader(sf, &scummVersion)) {
+		SaveStateMetaInfos infos;
+		// Early pilot files have no SCUMM envelope.
+		const bool legacy = sf->readUint32BE() == kPilotSaveMagic;
+		sf->seek(0);
+		if (!legacy && !readInsaneSavegameHeader(sf, nullptr, infos)) {
 			delete sf;
 			break;
 		}
 
-		// Only present once the slot carries a SCUMM header.
-		if (scummVersion >= VER(52) && !Graphics::skipThumbnail(*sf)) {
-			delete sf;
-			break;
-		}
-		if (scummVersion >= VER(56)) {
-			SaveStateMetaInfos infos;
-			if (!_vm->loadInfos(sf, &infos)) {
-				delete sf;
-				break;
-			}
-		}
-
 		uint32 magic = sf->readUint32BE();
 		if (magic != kPilotSaveMagic) {
 			delete sf;
@@ -1527,9 +1473,11 @@ bool InsaneRebel2::savePilots() {
 		Common::String desc(_pilots[i].name);
 		if (desc.empty())
 			desc = Common::String::format("Pilot %d", i + 1);
-		writeRebel2SavegameHeader(sf, desc);
-		Graphics::saveThumbnail(*sf);
-		_vm->saveInfos(sf);
+		if (!writeInsaneSavegameHeader(sf, desc, _vm->getTotalPlayTime())) {
+			delete sf;
+			ok = false;
+			continue;
+		}
 
 		sf->writeUint32BE(kPilotSaveMagic);
 		sf->writeUint16LE(kPilotSaveVersion);
@@ -1547,6 +1495,7 @@ bool InsaneRebel2::savePilots() {
 			sf->writeSint16LE(p.rating[j]);
 
 		sf->finalize();
+		ok = !sf->err() && ok;
 		delete sf;
 	}
 
diff --git a/engines/scumm/insane/saveload.cpp b/engines/scumm/insane/saveload.cpp
new file mode 100644
index 00000000000..67be54bb9db
--- /dev/null
+++ b/engines/scumm/insane/saveload.cpp
@@ -0,0 +1,92 @@
+/* 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 "scumm/insane/saveload.h"
+
+#include "common/endian.h"
+#include "common/stream.h"
+#include "common/system.h"
+#include "graphics/thumbnail.h"
+#include "scumm/scumm.h"
+
+namespace Scumm {
+
+const uint32 kInsaneScummSaveVersion = 124;
+
+bool writeInsaneSavegameHeader(Common::WriteStream *out, const Common::String &desc, uint32 playTime) {
+	char name[32] = {};
+	Common::strlcpy(name, desc.c_str(), sizeof(name));
+	out->writeUint32BE(MKTAG('S', 'C', 'V', 'M'));
+	out->writeUint32LE(0);
+	out->writeUint32LE(kInsaneScummSaveVersion);
+	out->write(name, sizeof(name));
+	if (!Graphics::saveThumbnail(*out))
+		return false;
+
+	TimeDate now;
+	g_system->getTimeAndDate(now);
+	out->writeUint32BE(MKTAG('I', 'N', 'F', 'O'));
+	out->writeUint32BE(2);
+	out->writeUint32BE(26);
+	out->writeUint32BE(0);
+	out->writeUint32BE(playTime / 1000);
+	out->writeUint32BE(((now.tm_mday & 0xff) << 24) | (((now.tm_mon + 1) & 0xff) << 16) | ((now.tm_year + 1900) & 0xffff));
+	out->writeUint16BE(((now.tm_hour & 0xff) << 8) | (now.tm_min & 0xff));
+	return !out->err();
+}
+
+bool readInsaneSavegameHeader(Common::SeekableReadStream *in, Common::String *desc, SaveStateMetaInfos &infos) {
+	memset(&infos, 0, sizeof(infos));
+	if (in->readUint32BE() != MKTAG('S', 'C', 'V', 'M'))
+		return false;
+	in->readUint32LE();
+	uint32 version = in->readUint32LE();
+	if (version > 0xffffff)
+		version = SWAP_BYTES_32(version);
+	if (version < 52 || version > kInsaneScummSaveVersion)
+		return false;
+	char name[32];
+	if (in->read(name, sizeof(name)) != sizeof(name))
+		return false;
+	name[sizeof(name) - 1] = 0;
+	if (desc)
+		*desc = name;
+	if (!Graphics::skipThumbnail(*in))
+		return false;
+	if (version >= 56) {
+		const int64 infoStart = in->pos();
+		if (in->readUint32BE() != MKTAG('I', 'N', 'F', 'O'))
+			return false;
+		const uint32 infoVersion = in->readUint32BE(), size = in->readUint32BE();
+		if (!infoVersion || size < (infoVersion == 1 ? 20U : 26U) || size > in->size() - infoStart)
+			return false;
+		in->readUint32BE();
+		infos.playtime = in->readUint32BE();
+		if (infoVersion >= 2) {
+			infos.date = in->readUint32BE();
+			infos.time = in->readUint16BE();
+		}
+		if (!in->seek(infoStart + size))
+			return false;
+	}
+	return !in->err() && !in->eos();
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/saveload.h b/engines/scumm/insane/saveload.h
new file mode 100644
index 00000000000..ec63cc842a0
--- /dev/null
+++ b/engines/scumm/insane/saveload.h
@@ -0,0 +1,41 @@
+/* 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 SCUMM_INSANE_SAVELOAD_H
+#define SCUMM_INSANE_SAVELOAD_H
+
+#include "common/str.h"
+
+namespace Common {
+class SeekableReadStream;
+class WriteStream;
+}
+
+namespace Scumm {
+
+struct SaveStateMetaInfos;
+
+// The movie games share the SCUMM save envelope and supply their own payloads.
+bool writeInsaneSavegameHeader(Common::WriteStream *out, const Common::String &desc, uint32 playTime);
+bool readInsaneSavegameHeader(Common::SeekableReadStream *in, Common::String *desc, SaveStateMetaInfos &infos);
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 4ae124ae527..55e4d6decc4 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -147,6 +147,7 @@ MODULE_OBJS += \
 	insane/insane_enemy.o \
 	insane/insane_scenes.o \
 	insane/insane_iact.o \
+	insane/saveload.o \
 	insane/mortimer/campaign.o \
 	insane/mortimer/castle.o \
 	insane/mortimer/dialogs.o \
@@ -154,6 +155,7 @@ MODULE_OBJS += \
 	insane/mortimer/interview.o \
 	insane/mortimer/movie.o \
 	insane/mortimer/mortimer.o \
+	insane/mortimer/saveload.o \
 	insane/mortimer/text.o \
 	insane/rebel/rebel_audio.o \
 	insane/rebel/rebel_gamepad.o \


Commit: 81d3f76d302b9c28be438dfab4f76a93b2e9c662
    https://github.com/scummvm/scummvm/commit/81d3f76d302b9c28be438dfab4f76a93b2e9c662
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-10-14T20:54:43+02:00

Commit Message:
SCUMM: MORTIMER: added support for high score save/load

Changed paths:
  A engines/scumm/insane/highscores.h
  A engines/scumm/insane/mortimer/scores.cpp
  A engines/scumm/insane/mortimer/scores.h
    engines/scumm/insane/mortimer/dialogs.cpp
    engines/scumm/insane/mortimer/dialogs.h
    engines/scumm/insane/mortimer/interview.cpp
    engines/scumm/insane/mortimer/interview.h
    engines/scumm/insane/mortimer/mortimer.cpp
    engines/scumm/insane/mortimer/mortimer.h
    engines/scumm/insane/mortimer/saveload.cpp
    engines/scumm/insane/mortimer/text.cpp
    engines/scumm/insane/mortimer/text.h
    engines/scumm/insane/rebel1/menu.cpp
    engines/scumm/insane/rebel2/menu.cpp
    engines/scumm/module.mk


diff --git a/engines/scumm/insane/highscores.h b/engines/scumm/insane/highscores.h
new file mode 100644
index 00000000000..3061d25ffd5
--- /dev/null
+++ b/engines/scumm/insane/highscores.h
@@ -0,0 +1,50 @@
+/* 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 SCUMM_INSANE_HIGHSCORES_H
+#define SCUMM_INSANE_HIGHSCORES_H
+
+#include "common/util.h"
+
+namespace Scumm {
+
+template<class Entry, class Score>
+int findHighScorePosition(const Entry *entries, int count, Score score, bool beforeEqual = false) {
+	int position = 0;
+	while (position < count && (entries[position].score > score ||
+		(!beforeEqual && entries[position].score == score)))
+		++position;
+	return position;
+}
+
+template<class Entry>
+int insertHighScoreEntry(Entry *entries, int count, int capacity, int position, const Entry &entry) {
+	assert(count >= 0 && count <= capacity && position >= 0 && position <= count);
+	if (position >= capacity)
+		return count;
+	for (int i = MIN(count, capacity - 1); i > position; --i)
+		entries[i] = entries[i - 1];
+	entries[position] = entry;
+	return MIN(count + 1, capacity);
+}
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/dialogs.cpp b/engines/scumm/insane/mortimer/dialogs.cpp
index 7390371caff..6eb4c104e15 100644
--- a/engines/scumm/insane/mortimer/dialogs.cpp
+++ b/engines/scumm/insane/mortimer/dialogs.cpp
@@ -30,6 +30,8 @@
 
 namespace Scumm {
 
+const uint32 kMortimerHighScoresCmd = 'MHSC';
+
 MortimerDifficultyDialog::MortimerDifficultyDialog(bool isDemo, uint flightDifficulty, uint riddleDifficulty, bool girl, bool newPilot)
 	: GUI::Dialog("MortimerDifficultyDialog"), _character(nullptr), _pilotName(nullptr) {
 	new GUI::StaticTextWidget(this, _name + ".Title", newPilot ? _("New pilot") : _("Select difficulty"));
@@ -132,6 +134,7 @@ MortimerPilotDialog::MortimerPilotDialog(const SaveStateList &pilots) : GUI::Dia
 	for (uint i = 0; i < pilots.size(); ++i)
 		_pilot->appendEntry(pilots[i].getDescription(), pilots[i].getSaveSlot());
 	_pilot->setSelected(pilots.empty() ? 0 : 1);
+	new GUI::ButtonWidget(this, _name + ".HighScores", _("High Scores"), Common::U32String(), kMortimerHighScoresCmd);
 	new GUI::ButtonWidget(this, _name + ".Cancel", _("~C~ancel"), Common::U32String(), GUI::kCloseCmd);
 	new GUI::ButtonWidget(this, _name + ".Start", _("Start"), Common::U32String(), GUI::kOKCmd, Common::ASCII_RETURN);
 }
@@ -150,6 +153,7 @@ void MortimerPilotDialog::reflowLayout() {
 			.addWidget("Title", "", width, lineHeight, Graphics::kTextAlignCenter)
 			.addWidget("Pilot", "PopUp")
 			.addWidget("Hint", "", -1, lineHeight, Graphics::kTextAlignCenter)
+			.addWidget("HighScores", "Button")
 			.addLayout(GUI::ThemeLayout::kLayoutHorizontal, 8, GUI::ThemeLayout::kItemAlignEnd)
 				.addPadding(0, 0, 0, 0)
 				.addSpace()
@@ -162,8 +166,8 @@ void MortimerPilotDialog::reflowLayout() {
 }
 
 void MortimerPilotDialog::handleCommand(GUI::CommandSender *sender, uint32 cmd, uint32 data) {
-	if (cmd == GUI::kOKCmd) {
-		setResult(1);
+	if (cmd == GUI::kOKCmd || cmd == kMortimerHighScoresCmd) {
+		setResult(cmd == GUI::kOKCmd ? kStartPilot : kViewHighScores);
 		close();
 	} else {
 		GUI::Dialog::handleCommand(sender, cmd, data);
diff --git a/engines/scumm/insane/mortimer/dialogs.h b/engines/scumm/insane/mortimer/dialogs.h
index b7233e1950f..29bcd5d2048 100644
--- a/engines/scumm/insane/mortimer/dialogs.h
+++ b/engines/scumm/insane/mortimer/dialogs.h
@@ -51,7 +51,7 @@ private:
 
 class MortimerPilotDialog : public GUI::Dialog {
 public:
-	enum { kNewPilot = 100 };
+	enum { kStartPilot = 1, kViewHighScores = 2, kNewPilot = 100 };
 	explicit MortimerPilotDialog(const SaveStateList &pilots);
 	int selectedPilot() const;
 	void reflowLayout() override;
diff --git a/engines/scumm/insane/mortimer/interview.cpp b/engines/scumm/insane/mortimer/interview.cpp
index 605fed8fea7..c3b6fe40c9a 100644
--- a/engines/scumm/insane/mortimer/interview.cpp
+++ b/engines/scumm/insane/mortimer/interview.cpp
@@ -67,7 +67,7 @@ const Common::Point kQuestionOrigins[] = {{122, 191}, {120, 234}, {124, 278}};
 MortimerInterview::MortimerInterview(bool isDemo) : _random("mortimer"), _isDemo(isDemo),
 	_region(0), _difficulty(0), _textDifficulty(0), _tried(0), _stage(kIdle), _effect(kNoEffect),
 	_answer(-1), _previousAnswer(-1), _selected(-1), _question(0), _guess(-1), _lastAction(46), _textScroll(0), _guesses(3),
-	_species(0), _stones(0), _frame(0), _switchFrame(6), _payment(0),
+	_species(0), _stones(0), _frame(0), _switchFrame(6), _payment(0), _bonusStones(0),
 	_moviePending(false), _riddleView(false), _solving(false), _help(false), _helpExplained(false), _restartRegion(false), _programmer(false) {
 	memset(_palette, 0, sizeof(_palette));
 	memset(_gatePalette, 0, sizeof(_gatePalette));
@@ -206,7 +206,7 @@ void MortimerInterview::start(uint species, uint stones, int scene) {
 	if (_isDemo || _answer < 0)
 		_guesses = _isDemo ? 3 : mortimerGuessAllowance(_difficulty);
 	_restartRegion = false;
-	_frame = _payment = 0;
+	_frame = _payment = _bonusStones = 0;
 	_switchFrame = 6;
 	_selected = -1;
 	_question = _textScroll = 0;
@@ -341,7 +341,7 @@ void MortimerInterview::selectAnimal(int animal) {
 }
 
 void MortimerInterview::click(const Common::Point &point) {
-	if (fullScreen() || _stage == kPay || _stage == kGuess || _stage == kCorrect || _stage == kWrong || _stage == kSolve || _stage == kNoStones || _stage == kCost || _stage == kNoAnimals)
+	if (fullScreen() || _stage == kPay || _stage == kBonus || _stage == kGuess || _stage == kCorrect || _stage == kWrong || _stage == kSolve || _stage == kNoStones || _stage == kCost || _stage == kNoAnimals)
 		return;
 	int action = hitControl(point);
 	if (action < 0)
@@ -428,6 +428,12 @@ MortimerInterview::Effect MortimerInterview::takeEffect() {
 	return result;
 }
 
+uint MortimerInterview::takeBonusStones() {
+	const uint result = _bonusStones;
+	_bonusStones = 0;
+	return result;
+}
+
 void MortimerInterview::update() {
 	++_frame;
 	if (_switchFrame < 6)
@@ -439,6 +445,19 @@ void MortimerInterview::update() {
 		if (!_payment)
 			enter(kGuess);
 	}
+	if (_stage == kBonus && (_frame % 3) == 0) {
+		if (_stones) {
+			const uint count = MIN(3U, _stones);
+			_stones -= count;
+			_bonusStones += count;
+			_effect = kPayoutEffect;
+			_payment = 15;
+		} else if (_payment > 3) {
+			_payment -= 3;
+		} else {
+			enter(kOpenGate);
+		}
+	}
 }
 
 void MortimerInterview::movieFinished() {
@@ -460,7 +479,11 @@ void MortimerInterview::movieFinished() {
 		}
 		break;
 	case kGuess: enter(_guess == _answer ? kCorrect : kWrong); break;
-	case kCorrect: enter(kOpenGate); break;
+	case kCorrect:
+		enter(_isDemo ? kOpenGate : kBonus);
+		_frame = 0;
+		_payment = 15;
+		break;
 	case kWrong:
 		if (!_guesses) {
 			_restartRegion = !_isDemo;
@@ -479,7 +502,7 @@ void MortimerInterview::movieFinished() {
 void MortimerInterview::skip() {
 	if (playingMovie())
 		movieFinished();
-	else if (_stage != kPay)
+	else if (_stage != kPay && _stage != kBonus)
 		enter(kFlyAway);
 }
 
@@ -538,7 +561,7 @@ void MortimerInterview::draw(Graphics::Surface &screen, const MortimerMovie &mov
 
 void MortimerInterview::drawCursor(Graphics::Surface &screen, const Common::Point &point) const {
 	if (!fullScreen()) {
-		if (_stage == kPay)
+		if (_stage == kPay || (_stage == kBonus && _stones))
 			_panel[19 + _frame % 2].draw(screen, 508, 451);
 		const int cursor = (_help ? 2 : 0) + (hitControl(point) < 0 ? 1 : 0);
 		_cursor[cursor].draw(screen, point.x, point.y);
diff --git a/engines/scumm/insane/mortimer/interview.h b/engines/scumm/insane/mortimer/interview.h
index a3a87c37fd6..7288f3f3e90 100644
--- a/engines/scumm/insane/mortimer/interview.h
+++ b/engines/scumm/insane/mortimer/interview.h
@@ -60,7 +60,10 @@ public:
 	bool movieDoubled() const;
 	bool wantsFlight() const { return _stage == kFlyAway; }
 	bool finished() const { return _stage == kFinished; }
-	bool solved() const { return _stage == kCorrect || _stage == kOpenGate || _stage == kFinished; }
+	bool solved() const { return _stage == kCorrect || _stage == kBonus || _stage == kOpenGate || _stage == kFinished; }
+	bool payingBonus() const { return _stage == kBonus; }
+	bool bonusFinished() const { return _stage == kOpenGate || _stage == kFinished; }
+	uint takeBonusStones();
 	bool restartsRegion() const { return _restartRegion; }
 	int sceneId() const;
 	int riddleId() const { return _answer * 10 + _textDifficulty; }
@@ -76,7 +79,7 @@ public:
 private:
 	enum Stage {
 		kGate, kSong, kPopup, kIdle, kAnswer, kRiddle, kSolve,
-		kNoStones, kPay, kGuess, kCorrect, kWrong, kOpenGate,
+		kNoStones, kPay, kGuess, kCorrect, kBonus, kWrong, kOpenGate,
 		kEnding, kHelp, kFlyAway, kFinished, kCost, kNoAnimals, kTried
 	};
 
@@ -98,7 +101,7 @@ private:
 	Stage _stage;
 	Effect _effect;
 	int _answer, _previousAnswer, _selected, _question, _guess, _lastAction, _textScroll, _guesses;
-	uint _species, _stones, _frame, _switchFrame, _payment;
+	uint _species, _stones, _frame, _switchFrame, _payment, _bonusStones;
 	bool _moviePending, _riddleView, _solving, _help, _helpExplained, _restartRegion, _programmer;
 };
 
diff --git a/engines/scumm/insane/mortimer/mortimer.cpp b/engines/scumm/insane/mortimer/mortimer.cpp
index f0afc121021..2fac84cc856 100644
--- a/engines/scumm/insane/mortimer/mortimer.cpp
+++ b/engines/scumm/insane/mortimer/mortimer.cpp
@@ -23,6 +23,7 @@
 
 #include "audio/audiostream.h"
 #include "backends/keymapper/action.h"
+#include "backends/keymapper/keymapper.h"
 #include "common/config-manager.h"
 #include "common/endian.h"
 #include "common/events.h"
@@ -115,7 +116,8 @@ Common::KeymapArray MortimerEngine::initKeymaps() {
 
 MortimerEngine::MortimerEngine(OSystem *system, bool isDemo) : Engine(system), _isDemo(isDemo),
 	_restoredContent(isDemo && ConfMan.getBool("mortimer_restored_content")), _flightDifficulty(0), _riddleDifficulty(0),
-	_pilotSlot(-1), _checkpoint(0), _girl(false), _pilotLoadRequested(false),
+	_pilotSlot(-1), _checkpoint(0), _score(0), _checkpointScore(0), _girl(false), _pilotLoadRequested(false),
+	_highScoresActive(false), _highScoreEligible(false), _viewingHighScores(false),
 	_random("mortimer-flight"), _flight(isDemo, _restoredContent), _interview(isDemo),
 	_viewport(24, 20, 616, 360), _mouse(320, 240), _view(44, 60), _aim(320, 190), _shotWeapon(MortimerFlight::kRevitascope),
 	_crashStage(kNoCrash), _nextFrameTime(0), _pauseTime(0), _introIndex(-1), _loadedRegion(-1), _creditedRescues(0),
@@ -372,6 +374,10 @@ void MortimerEngine::updateMovieContext() {
 
 bool MortimerEngine::enterCampaignScene() {
 	stopSounds();
+	if (_highScoresActive) {
+		setScoreNameInput(false);
+		_highScoresActive = false;
+	}
 	const MortimerCampaign::Scene &scene = _campaign.scene();
 	_interviewActive = false;
 	_castleActive = false;
@@ -423,9 +429,36 @@ bool MortimerEngine::enterCampaignScene() {
 	}
 	_introIndex = scene.id;
 	_movieHandles.resize(_intro.sounds().size());
+	if (scene.kind == MortimerCampaign::kCredits) {
+		if (!_highScores.load(*_saveFileMan, _targetName + ".hsf")) {
+			_loadError = "Missing or invalid Mortimer high-score resources";
+			return false;
+		}
+		_highScores.start(_score, _highScoreEligible);
+		_highScoreEligible = false;
+		_highScoresActive = true;
+		setScoreNameInput(_highScores.enteringName());
+	}
 	return startMovie();
 }
 
+void MortimerEngine::setScoreNameInput(bool enabled) {
+	_eventMan->getKeymapper()->setGameKeymapState("mortimer", !enabled);
+	if (_system->hasFeature(OSystem::kFeatureVirtualKeyboard))
+		_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, enabled);
+}
+
+bool MortimerEngine::finishHighScores() {
+	setScoreNameInput(false);
+	_highScoresActive = false;
+	if (_viewingHighScores) {
+		_finished = true;
+		return true;
+	}
+	_nextFrameTime = _system->getMillis() + 1000 / currentMovie().frameRate();
+	return loadSubtitles("RESOURCE/DATA/CREDITS.TRS");
+}
+
 void MortimerEngine::playSound(const MortimerSound &sound, Audio::SoundHandle &handle, Audio::Mixer::SoundType type, uint64 skipFrames) {
 	_mixer->stopHandle(handle);
 	SaudStream *stream = new SaudStream(sound.samples, sound.script, sound.rate, _soundState, sound.volume, sound.balance);
@@ -577,7 +610,10 @@ bool MortimerEngine::restart(bool all) {
 	_shockFrame = _crashCount = 0;
 	if (!_isDemo) {
 		_finished = false;
+		_score = _checkpointScore;
 		if (all) {
+			_score = _checkpointScore = _checkpoint = 0;
+			_highScoreEligible = false;
 			_loadedRegion = -1;
 			_campaign.start();
 			return enterCampaignScene();
@@ -697,8 +733,15 @@ bool MortimerEngine::nextScene(bool skip) {
 }
 
 bool MortimerEngine::processInterview() {
-	if (!_isDemo && _interview.solved())
-		saveCheckpoint(_loadedRegion + 1);
+	const uint bonusStones = _interview.takeBonusStones();
+	if (!_isDemo && _interview.solved() && (int)_checkpoint <= _loadedRegion) {
+		const MortimerScoring &scoring = mortimerScoring(_flightDifficulty, _riddleDifficulty);
+		if (!_score)
+			_score = scoring.initial;
+		_score += bonusStones * scoring.stone;
+		if (_interview.bonusFinished())
+			saveCheckpoint(_loadedRegion + 1);
+	}
 	if (_interview.wantsFlight()) {
 		_carriedStones = _interview.stones();
 		_completedSpecies = _interview.species();
@@ -770,8 +813,11 @@ bool MortimerEngine::processCastle() {
 		_loadError = _castle.errorMessage();
 		return false;
 	}
-	if (_castle.solved())
+	if (_castle.solved() && _checkpoint < 5) {
+		_score += mortimerScoring(_flightDifficulty, _riddleDifficulty).castle;
 		saveCheckpoint(5);
+		_highScoreEligible = true;
+	}
 	if (_castle.finished()) {
 		stopSounds();
 		_castleActive = false;
@@ -842,6 +888,8 @@ bool MortimerEngine::updateCastle() {
 }
 
 bool MortimerEngine::updateScene() {
+	if (_highScoresActive)
+		return true;
 	if (_castleActive)
 		return updateCastle();
 	if (_interviewActive) {
@@ -1153,6 +1201,23 @@ Common::Error MortimerEngine::run() {
 		}
 		Common::Event event;
 		while (_eventMan->pollEvent(event)) {
+			if (_highScoresActive && !isPaused()) {
+				if (_highScores.enteringName()) {
+					if (event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat && _highScores.handleKey(event.kbd)) {
+						if (_highScores.save(*_saveFileMan, _targetName + ".hsf")) {
+							setScoreNameInput(false);
+						} else {
+							GUI::MessageDialog message(_("Could not save the high scores. Please try again."));
+							runDialog(message);
+						}
+					}
+				} else if ((event.type == Common::EVENT_KEYDOWN && !event.kbdRepeat) ||
+					event.type == Common::EVENT_LBUTTONDOWN || event.type == Common::EVENT_RBUTTONDOWN ||
+					event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START) {
+					exit = !finishHighScores();
+				}
+				continue;
+			}
 			if (event.type == Common::EVENT_KEYDOWN) {
 				if (event.kbdRepeat)
 					continue;
@@ -1248,9 +1313,12 @@ Common::Error MortimerEngine::run() {
 		}
 		draw();
 		if (_finished) {
-			GUI::MessageDialog dialog(_("Play again?"), _("Restart"), _("Quit"));
-			if (runDialog(dialog) != GUI::kMessageOK)
-				break;
+			if (!_viewingHighScores) {
+				GUI::MessageDialog dialog(_("Play again?"), _("Restart"), _("Quit"));
+				if (runDialog(dialog) != GUI::kMessageOK)
+					break;
+			}
+			_viewingHighScores = false;
 			if (_isDemo) {
 				if (!selectDifficulty() || !restart(true))
 					break;
@@ -1261,6 +1329,8 @@ Common::Error MortimerEngine::run() {
 		_system->delayMillis(10);
 	}
 	stopSounds();
+	if (_highScoresActive)
+		setScoreNameInput(false);
 	CursorMan.showMouse(oldCursor);
 	if (!_loadError.empty())
 		warning("Mortimer: %s", _loadError.c_str());
@@ -1395,9 +1465,14 @@ void MortimerEngine::draw() {
 		if (_castleActive)
 			_castle.drawOverlay(_screen, _mouse, Common::Point(), _soundState.variable(1));
 	}
-	if (!_isDemo)
+	if (_highScoresActive) {
+		_highScores.draw(_screen, _system->getMillis());
+	} else if (!_isDemo) {
 		_subtitles.draw(_screen, currentMovie().captions(), ConfMan.getBool("subtitles") ||
 			(_interviewActive && _interview.forceSubtitles()) || (_castleActive && _castle.forceSubtitles()), &_intro.context());
+		if (_interviewActive && _interview.payingBonus())
+			_subtitles.drawScore(_screen, _score);
+	}
 	if (isPaused()) {
 		const Graphics::Font *font = FontMan.getFontByUsage(Graphics::FontManager::kConsoleFont);
 		_screen.fillRect(Common::Rect(90, 142, 550, 182), 10);
diff --git a/engines/scumm/insane/mortimer/mortimer.h b/engines/scumm/insane/mortimer/mortimer.h
index 792966ac085..0e51e33d958 100644
--- a/engines/scumm/insane/mortimer/mortimer.h
+++ b/engines/scumm/insane/mortimer/mortimer.h
@@ -30,6 +30,7 @@
 #include "scumm/insane/mortimer/interview.h"
 #include "scumm/insane/mortimer/campaign.h"
 #include "scumm/insane/mortimer/castle.h"
+#include "scumm/insane/mortimer/scores.h"
 #include "scumm/smush/saud_stream.h"
 
 namespace Scumm {
@@ -61,6 +62,8 @@ private:
 	bool startPilot();
 	Common::Error savePilot();
 	void saveCheckpoint(uint checkpoint);
+	void setScoreNameInput(bool enabled);
+	bool finishHighScores();
 	uint introCount() const;
 	bool loadIntro(uint index);
 	bool loadFlight(uint segment);
@@ -114,7 +117,10 @@ private:
 	Common::String _pilotName;
 	int _pilotSlot;
 	uint _checkpoint;
+	uint32 _score, _checkpointScore;
 	bool _girl, _pilotLoadRequested;
+	bool _highScoresActive, _highScoreEligible, _viewingHighScores;
+	MortimerHighScores _highScores;
 	Common::RandomSource _random;
 	MortimerCutscene _intro;
 	MortimerFlight _flight;
diff --git a/engines/scumm/insane/mortimer/saveload.cpp b/engines/scumm/insane/mortimer/saveload.cpp
index 5e436eb22f5..e44ce2ea127 100644
--- a/engines/scumm/insane/mortimer/saveload.cpp
+++ b/engines/scumm/insane/mortimer/saveload.cpp
@@ -34,7 +34,7 @@
 namespace Scumm {
 
 const uint32 kMortimerPilotMagic = MKTAG('M', 'O', 'R', 'P');
-const uint16 kMortimerPilotVersion = 1;
+const uint16 kMortimerPilotVersion = 2;
 
 Common::String MortimerEngine::getSaveStateName(int slot) const {
 	return getMetaEngine()->getSavegameFile(slot, _targetName.c_str());
@@ -64,6 +64,8 @@ Common::Error MortimerEngine::savePilot() {
 	out->writeByte(_checkpoint);
 	out->writeByte(ConfMan.getBool("subtitles"));
 	out->writeByte(_controllerMode);
+	// A quit during the glowstone payout must not save uncompleted-region points.
+	out->writeUint32LE(_checkpointScore);
 	out->finalize();
 	return out->err() ? Common::kWritingFailed : Common::kNoError;
 }
@@ -80,13 +82,16 @@ Common::Error MortimerEngine::loadGameState(int slot) {
 	const byte girl = in->readByte();
 	const byte flight = in->readByte(), riddle = in->readByte(), checkpoint = in->readByte();
 	const byte subtitles = in->readByte(), controller = in->readByte();
+	const uint32 score = in->readUint32LE();
 	if (in->err() || in->eos() || name.empty() || girl > 1 || flight > 2 || riddle > 2 ||
 		checkpoint > 5 || subtitles > 1 || controller > 1)
 		return Common::kReadingFailed;
 
 	_pilotName = name;
 	_pilotSlot = slot;
+	_viewingHighScores = false;
 	_checkpoint = checkpoint;
+	_score = _checkpointScore = score;
 	_girl = girl;
 	_controllerMode = controller;
 	ConfMan.setBool("subtitles", subtitles);
@@ -98,9 +103,10 @@ Common::Error MortimerEngine::loadGameState(int slot) {
 }
 
 void MortimerEngine::saveCheckpoint(uint checkpoint) {
-	if (_isDemo || _pilotSlot < 0 || checkpoint <= _checkpoint)
+	if (_isDemo || checkpoint <= _checkpoint)
 		return;
 	_checkpoint = checkpoint;
+	_checkpointScore = _score;
 	if (savePilot().getCode() != Common::kNoError) {
 		GUI::MessageDialog message(_("Could not save your pilot. Progress will be saved again when you finish another riddle or quit."));
 		runDialog(message);
@@ -111,7 +117,14 @@ bool MortimerEngine::selectPilot() {
 	while (!shouldQuit()) {
 		const SaveStateList pilots = getMetaEngine()->listSaves(_targetName.c_str());
 		MortimerPilotDialog dialog(pilots);
-		if (runDialog(dialog) != 1 || shouldQuit())
+		const int choice = runDialog(dialog);
+		if (shouldQuit())
+			return false;
+		if (choice == MortimerPilotDialog::kViewHighScores) {
+			_viewingHighScores = _pilotLoadRequested = true;
+			return true;
+		}
+		if (choice != MortimerPilotDialog::kStartPilot)
 			return false;
 		if (dialog.selectedPilot() != MortimerPilotDialog::kNewPilot) {
 			const Common::Error result = loadGameState(dialog.selectedPilot());
@@ -134,7 +147,9 @@ bool MortimerEngine::selectPilot() {
 			continue;
 		_pilotName = options.pilotName();
 		_pilotSlot = slot;
+		_viewingHighScores = false;
 		_checkpoint = 0;
+		_score = _checkpointScore = 0;
 		_girl = options.girl();
 		_controllerMode = false;
 		setDifficulty(options.flightDifficulty(), options.riddleDifficulty());
@@ -154,6 +169,10 @@ bool MortimerEngine::selectPilot() {
 
 bool MortimerEngine::startPilot() {
 	_pilotLoadRequested = false;
+	_score = _checkpointScore;
+	_highScoreEligible = false;
+	if (_viewingHighScores)
+		return _campaign.start(39) && enterCampaignScene();
 	stopSounds();
 	_loadError.clear();
 	_loadedRegion = -1;
diff --git a/engines/scumm/insane/mortimer/scores.cpp b/engines/scumm/insane/mortimer/scores.cpp
new file mode 100644
index 00000000000..9192801fedb
--- /dev/null
+++ b/engines/scumm/insane/mortimer/scores.cpp
@@ -0,0 +1,168 @@
+/* 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 "scumm/insane/mortimer/scores.h"
+#include "scumm/insane/highscores.h"
+
+#include "common/file.h"
+#include "common/ptr.h"
+#include "common/savefile.h"
+#include "graphics/surface.h"
+
+namespace Scumm {
+
+const MortimerScoring kMortimerScoring[] = {
+	{100, 0, 2000}, {1000, 3000, 12000}, {3500, 75000, 75000}
+};
+
+const MortimerScoring &mortimerScoring(uint flightDifficulty, uint riddleDifficulty) {
+	// Scoring uses the integer mean of the two difficulty settings.
+	return kMortimerScoring[(MIN(flightDifficulty, 2U) + MIN(riddleDifficulty, 2U)) / 2];
+}
+
+bool MortimerHighScores::read(Common::SeekableReadStream &stream) {
+	if (stream.size() != 240)
+		return false;
+	for (uint i = 0; i < ARRAYSIZE(_entries); ++i) {
+		Entry &entry = _entries[i];
+		if (stream.read(entry.name, sizeof(entry.name)) != sizeof(entry.name) ||
+			!memchr(entry.name, 0, sizeof(entry.name)))
+			return false;
+		entry.score = stream.readUint32LE();
+		if (stream.err() || stream.eos() || (i && entry.score > _entries[i - 1].score))
+			return false;
+	}
+	return true;
+}
+
+bool MortimerHighScores::load(Common::SaveFileManager &saveFileMan, const Common::String &fileName) {
+	Common::File text;
+	if (!text.open("RESOURCE/DATA/HISCORE.TRS") || !_text.loadText(text) ||
+		!_font.load("RESOURCE/GENERAL/ESTRO20.NUT"))
+		return false;
+	Common::ScopedPtr<Common::InSaveFile> saved(saveFileMan.openForLoading(fileName));
+	if (saved && read(*saved))
+		return true;
+	Common::File defaults;
+	return defaults.open("RESOURCE/DATA/MORTIMER.HSF") && read(defaults);
+}
+
+void MortimerHighScores::start(uint32 score, bool allowEntry) {
+	_score = score;
+	_selected = allowEntry ? findHighScorePosition(_entries, ARRAYSIZE(_entries), score, true) : -1;
+	_enteringName = _selected >= 0 && _selected < (int)ARRAYSIZE(_entries);
+	_didNotQualify = allowEntry && !_enteringName;
+	if (_enteringName) {
+		Entry entry = {};
+		entry.score = score;
+		insertHighScoreEntry(_entries, ARRAYSIZE(_entries), ARRAYSIZE(_entries), _selected, entry);
+	} else {
+		_selected = -1;
+	}
+}
+
+bool MortimerHighScores::handleKey(const Common::KeyState &key) {
+	if (!_enteringName || (key.flags & (Common::KBD_CTRL | Common::KBD_ALT | Common::KBD_META)))
+		return false;
+	char *name = _entries[_selected].name;
+	const uint length = strlen(name);
+	if (key.keycode == Common::KEYCODE_RETURN || key.keycode == Common::KEYCODE_KP_ENTER)
+		return true;
+	if (key.keycode == Common::KEYCODE_ESCAPE) {
+		name[0] = 0;
+	} else if (key.keycode == Common::KEYCODE_BACKSPACE) {
+		if (length)
+			name[length - 1] = 0;
+	} else if (key.ascii >= 32 && key.ascii < 127 && key.ascii != '%' && length < 10) {
+		name[length] = key.ascii;
+		name[length + 1] = 0;
+	}
+	return false;
+}
+
+bool MortimerHighScores::save(Common::SaveFileManager &saveFileMan, const Common::String &fileName) {
+	Common::ScopedPtr<Common::OutSaveFile> out(saveFileMan.openForSaving(fileName, false));
+	if (!out)
+		return false;
+	for (uint i = 0; i < ARRAYSIZE(_entries); ++i) {
+		out->write(_entries[i].name, sizeof(_entries[i].name));
+		out->writeUint32LE(_entries[i].score);
+	}
+	out->finalize();
+	if (out->err())
+		return false;
+	_enteringName = false;
+	return true;
+}
+
+Common::String MortimerHighScores::ranking(uint32 score) const {
+	const uint32 limits[] = {14999, 34999, 54999, 74999, 134999, 197999, 279999, 607499, 999999};
+	uint rank = 0;
+	while (rank < ARRAYSIZE(limits) && score > limits[rank])
+		++rank;
+	return _text.text(rank);
+}
+
+void MortimerHighScores::drawText(Graphics::Surface &screen, const Common::String &text, int x, int y,
+		byte color, Graphics::TextAlign align) const {
+	const int width = _font.getStringWidth(text);
+	if (align == Graphics::kTextAlignCenter)
+		x -= width / 2;
+	else if (align == Graphics::kTextAlignRight)
+		x -= width;
+	// SHELL centers these text objects vertically, including the table rows.
+	_font.drawString(&screen, text, x, y - (_font.getFontHeight() + 1) / 2, width, color, Graphics::kTextAlignLeft, 0, false);
+}
+
+void MortimerHighScores::draw(Graphics::Surface &screen, uint32 time) const {
+	const int height = _font.getFontHeight() + 1;
+	drawText(screen, _text.text(10), 320, 140, 253, Graphics::kTextAlignCenter);
+	drawText(screen, _text.text(11), 30, 175, 1, Graphics::kTextAlignLeft);
+	drawText(screen, _text.text(12), 350, 175, 1, Graphics::kTextAlignRight);
+	drawText(screen, _text.text(13), 610, 175, 1, Graphics::kTextAlignRight);
+	for (uint i = 0; i < ARRAYSIZE(_entries); ++i) {
+		const Entry &entry = _entries[i];
+		const byte color = (int)i == _selected ? 1 : 253;
+		const int y = 210 + i * height;
+		drawText(screen, entry.name, 30, y, color, Graphics::kTextAlignLeft);
+		drawText(screen, Common::String::format("%u", entry.score), 350, y, color, Graphics::kTextAlignRight);
+		drawText(screen, ranking(entry.score), 610, y, color, Graphics::kTextAlignRight);
+	}
+	if (_enteringName) {
+		drawMortimerMessage(screen, _font, _text.text(14), 253);
+		const Common::String name(_entries[_selected].name);
+		const int y = 240 + height + height / 2 + height / 4;
+		drawText(screen, name, 320, y, 253, Graphics::kTextAlignCenter);
+		if ((time / 300) % 2 == 0) {
+			const int x = 322 + _font.getStringWidth(name) / 2;
+			screen.fillRect(Common::Rect(x, y - height / 2, x + 2, y + height / 2), 253);
+		}
+	} else {
+		if (_didNotQualify)
+			drawMortimerMessage(screen, _font, Common::String::format(_text.text(16).c_str(), (int)_score), 253);
+		Common::Array<Common::String> lines;
+		_font.wordWrapText(_text.text(15), 640, lines, 0, Graphics::kWordWrapOnExplicitNewLines);
+		const int top = 457 - (lines.size() * _font.getFontHeight() + 1) / 2;
+		for (uint i = 0; i < lines.size(); ++i)
+			drawText(screen, lines[i], 320, top + height / 2 + i * height, 1, Graphics::kTextAlignCenter);
+	}
+}
+
+} // End of namespace Scumm
diff --git a/engines/scumm/insane/mortimer/scores.h b/engines/scumm/insane/mortimer/scores.h
new file mode 100644
index 00000000000..d09a32a52dd
--- /dev/null
+++ b/engines/scumm/insane/mortimer/scores.h
@@ -0,0 +1,69 @@
+/* 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 SCUMM_INSANE_MORTIMER_SCORES_H
+#define SCUMM_INSANE_MORTIMER_SCORES_H
+
+#include "common/keyboard.h"
+#include "scumm/insane/mortimer/text.h"
+
+namespace Common {
+class SaveFileManager;
+}
+
+namespace Scumm {
+
+struct MortimerScoring {
+	uint32 stone, initial, castle;
+};
+
+const MortimerScoring &mortimerScoring(uint flightDifficulty, uint riddleDifficulty);
+
+class MortimerHighScores {
+public:
+	bool load(Common::SaveFileManager &saveFileMan, const Common::String &fileName);
+	void start(uint32 score, bool allowEntry);
+	bool handleKey(const Common::KeyState &key);
+	bool save(Common::SaveFileManager &saveFileMan, const Common::String &fileName);
+	bool enteringName() const { return _enteringName; }
+	void draw(Graphics::Surface &screen, uint32 time) const;
+
+private:
+	struct Entry {
+		char name[20];
+		uint32 score;
+	};
+
+	bool read(Common::SeekableReadStream &stream);
+	Common::String ranking(uint32 score) const;
+	void drawText(Graphics::Surface &screen, const Common::String &text, int x, int y, byte color,
+		Graphics::TextAlign align) const;
+
+	Entry _entries[10];
+	MortimerFont _font;
+	MortimerSubtitles _text;
+	uint32 _score = 0;
+	int _selected = -1;
+	bool _enteringName = false, _didNotQualify = false;
+};
+
+} // End of namespace Scumm
+
+#endif
diff --git a/engines/scumm/insane/mortimer/text.cpp b/engines/scumm/insane/mortimer/text.cpp
index 9b07ee0ae5f..2ce99a8265f 100644
--- a/engines/scumm/insane/mortimer/text.cpp
+++ b/engines/scumm/insane/mortimer/text.cpp
@@ -124,6 +124,34 @@ bool MortimerSubtitles::loadText(Common::SeekableReadStream &stream) {
 	return !stream.err() && !_entries.empty();
 }
 
+Common::String MortimerSubtitles::text(uint16 id) const {
+	const auto found = _entries.find(id);
+	return found == _entries.end() ? Common::String() : found->_value.text;
+}
+
+void drawMortimerMessage(Graphics::Surface &screen, const MortimerFont &font, const Common::String &text, byte color) {
+	Common::Array<Common::String> lines;
+	const int width = font.wordWrapText(text, screen.w - 40, lines, 0, Graphics::kWordWrapOnExplicitNewLines);
+	const int lineHeight = font.getFontHeight() + 1;
+	const int height = lines.size() * font.getFontHeight() + 1;
+	const int x = (screen.w - width) / 2, y = (screen.h - height) / 2;
+	Common::Rect box(x - 10, y - 10, x + width + 10, y + height + 10);
+	screen.fillRect(box, 251);
+	for (int i = 0; i < 3; ++i) {
+		screen.hLine(box.left, box.top, box.right - 1, 250);
+		screen.vLine(box.left, box.top, box.bottom - 1, 250);
+		screen.hLine(box.left, box.bottom - 1, box.right - 1, 252);
+		screen.vLine(box.right - 1, box.top, box.bottom - 1, 252);
+		box.grow(-1);
+	}
+	for (uint i = 0; i < lines.size(); ++i)
+		font.drawString(&screen, lines[i], x, y + i * lineHeight, width, color, Graphics::kTextAlignCenter, 0, false);
+}
+
+void MortimerSubtitles::drawScore(Graphics::Surface &screen, uint32 score) const {
+	drawMortimerMessage(screen, _infoFont, Common::String::format(text(3).c_str(), (long)score), 231);
+}
+
 void MortimerSubtitles::draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled,
 		const MortimerMovieContext *context) const {
 	for (uint i = 0; i < captions.size(); ++i) {
diff --git a/engines/scumm/insane/mortimer/text.h b/engines/scumm/insane/mortimer/text.h
index e4abc3c8354..fc59b923f13 100644
--- a/engines/scumm/insane/mortimer/text.h
+++ b/engines/scumm/insane/mortimer/text.h
@@ -45,6 +45,8 @@ class MortimerSubtitles {
 public:
 	bool load(const char *textPath, const char *fontPath, const char *infoFontPath);
 	bool loadText(Common::SeekableReadStream &stream);
+	Common::String text(uint16 id) const;
+	void drawScore(Graphics::Surface &screen, uint32 score) const;
 	void draw(Graphics::Surface &screen, const Common::Array<MortimerMovie::Caption> &captions, bool enabled,
 		const MortimerMovieContext *context = nullptr) const;
 
@@ -60,6 +62,8 @@ private:
 	Common::HashMap<uint16, Entry> _entries;
 };
 
+void drawMortimerMessage(Graphics::Surface &screen, const MortimerFont &font, const Common::String &text, byte color);
+
 } // End of namespace Scumm
 
 #endif
diff --git a/engines/scumm/insane/rebel1/menu.cpp b/engines/scumm/insane/rebel1/menu.cpp
index 157d4d37c48..aff8c90b06d 100644
--- a/engines/scumm/insane/rebel1/menu.cpp
+++ b/engines/scumm/insane/rebel1/menu.cpp
@@ -31,6 +31,7 @@
 #include "scumm/scumm_v7.h"
 #include "scumm/smush/smush_player.h"
 #include "scumm/insane/rebel1/rebel.h"
+#include "scumm/insane/highscores.h"
 
 namespace Scumm {
 
@@ -1301,18 +1302,15 @@ int InsaneRebel1::runPasscodeEntryDialog() {
 }
 
 bool InsaneRebel1::runHighScoreNameEntry() {
-	int slot = 0;
-	while (slot < kHighScoreCount && _highScores[slot].score >= _score)
-		slot++;
+	const int slot = findHighScorePosition(_highScores, kHighScoreCount, _score);
 	if (slot >= kHighScoreCount)
 		return false;
 
-	for (int i = kHighScoreCount - 1; i > slot; i--)
-		_highScores[i] = _highScores[i - 1];
-
-	_highScores[slot].score = _score;
-	_highScores[slot].difficulty = _difficulty;
-	Common::strlcpy(_highScores[slot].name, "<", sizeof(_highScores[slot].name));
+	HighScoreEntry entry = {};
+	entry.score = _score;
+	entry.difficulty = _difficulty;
+	Common::strlcpy(entry.name, "<", sizeof(entry.name));
+	insertHighScoreEntry(_highScores, kHighScoreCount, kHighScoreCount, slot, entry);
 	_highScoreEntryIndex = slot;
 
 	beginTextEntry(false);
diff --git a/engines/scumm/insane/rebel2/menu.cpp b/engines/scumm/insane/rebel2/menu.cpp
index 92f064b51e1..53192318e36 100644
--- a/engines/scumm/insane/rebel2/menu.cpp
+++ b/engines/scumm/insane/rebel2/menu.cpp
@@ -39,6 +39,7 @@
 
 #include "scumm/insane/rebel2/rebel.h"
 #include "scumm/insane/rebel2/shared.h"
+#include "scumm/insane/highscores.h"
 
 namespace Scumm {
 
@@ -1447,10 +1448,7 @@ void InsaneRebel2::insertRanking(const char *name, int32 score, int32 rating,
 	if (score == 0)
 		return;
 
-	int insertPos = 0;
-	while (insertPos < _numRankings && score <= _rankings[insertPos].score) {
-		insertPos++;
-	}
+	int insertPos = findHighScorePosition(_rankings, _numRankings, score);
 	if (insertPos > kMaxRankings - 1)
 		return;
 
@@ -1467,19 +1465,14 @@ void InsaneRebel2::insertRanking(const char *name, int32 score, int32 rating,
 		}
 	}
 
-	int lastIdx = MIN(_numRankings, kMaxRankings - 1);
-	for (int i = lastIdx; i > insertPos; i--)
-		_rankings[i] = _rankings[i - 1];
-
-	RankingEntry &r = _rankings[insertPos];
+	RankingEntry r = {};
 	Common::strlcpy(r.name, name, sizeof(r.name));
 	r.score = score;
 	r.rating = rating;
 	r.difficulty = difficulty;
 	r.chapter = chapter;
 
-	if (_numRankings < kMaxRankings)
-		_numRankings++;
+	_numRankings = insertHighScoreEntry(_rankings, _numRankings, kMaxRankings, insertPos, r);
 }
 
 void InsaneRebel2::showTopPilots() {
diff --git a/engines/scumm/module.mk b/engines/scumm/module.mk
index 55e4d6decc4..5095cddc8dc 100644
--- a/engines/scumm/module.mk
+++ b/engines/scumm/module.mk
@@ -156,6 +156,7 @@ MODULE_OBJS += \
 	insane/mortimer/movie.o \
 	insane/mortimer/mortimer.o \
 	insane/mortimer/saveload.o \
+	insane/mortimer/scores.o \
 	insane/mortimer/text.o \
 	insane/rebel/rebel_audio.o \
 	insane/rebel/rebel_gamepad.o \




More information about the Scummvm-git-logs mailing list