[Scummvm-git-logs] scummvm master -> 111cc673fc7edb49681f4eddd0b2c1cd26b6f3e8

sev- noreply at scummvm.org
Sun Jun 21 22:23:44 UTC 2026


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

Summary:
9c9bc8afce EEM: initial code with detection
c271f98d82 EEM: image loading and first intro image
f6a6833108 EEM: basic per case flow, with initial code for font loading
8ccba0b347 EEM: correct font rendering using char to glyph table
f349111de1 EEM: use Font API to reduce code duplication
17d95660b8 EEM: completed kCharToGlyph
57dc88dafe EEM: show mouse when selecting character
84a82d5580 EEM: improved initial menu following the original implementation
17288a6014 EEM: update pointer during main menu
d3e517a575 EEM: correct ballon dialog placement
b60f6c5570 EEM: draw the big map and allow player to move between locations
3fe1b2e115 EEM: improved character animations when arriving to locations
30367c4690 EEM: zoom/overview map initial implementation
a4c4170605 EEM: load the correct sites and make sure they are labeled
83ab937183 EEM: show NPC on sites
a5cefd6871 EEM: implement clickable areas on maps
0fe0c95dfd EEM: improved NPC handling
ca825ec5b1 EEM: added more features to the PDA
b36297d2a5 EEM: polish PDA features: buttons now works correctly
7be086ee1b EEM: polish PDA features: help interface added
d075832f54 EEM: polish PDA features: acusations
2ae70bdb92 EEM: basic parners animations
30a356bc07 EEM: more parners animations, for intro and overview map
0f7bf385b7 EEM: refactored the code following the original symbols
7bb7d3b82e EEM: enforce code standards
a4dd19f31d EEM: removed lambdas
2fac0e6820 EEM: removed static and use only EEM namespace
d44daf096d EEM: implemented midi music
ffc4f5135b EEM: implemented digitalized sound
f7b94e5721 EEM: profile handling in save games
df22a74a07 EEM: improved animations and added missing UI features
9c88ee5bc9 EEM: fixed the map animation
ef46d69023 EEM: fixed missing frames in partner animations
d7268b5a91 EEM: better handling of saves
e87d591b96 EEM: remove unused screen
253e2f0bb2 EEM: improve the setup screen with actually triggers to the code relevant
ae83ff8a9f EEM: help screen in setup screen implemented
754e723072 EEM: setup screen accesible in zoom map screen
92c6607481 EEM: improved navigation on PDA
d87214f047 EEM: basic accusation flow in the PDA
3b1738c38b EEM: basic scrapbook code
b4dc4f6274 EEM: added more missing animations
256eff166c EEM: stop music/sound in certain points of the game
fb9922f887 EEM: added missing text bubbles here and there
27d73fdbfe EEM: dedicated code for accusation window
4b19ad71a3 EEM: missing background when accusation resulted in the correct answer
1e6b78b869 EEM: implement wrong accusation
ddb7949f39 EEM: fixed some issues in the win screen/newspaper
3761f15c6e EEM: fixed missing screen updates
f9a5f920da EEM: correctly load game when a mystery is solved
1131e4825f EEM: implemented screen to load mysteries from different books
c3f24c4280 EEM: unlock different scrapbooks until the end
ab8cd8380c EEM: fixed animation issue
62063295f0 EEM: unseen hotspot effect
8fa2ec9202 EEM: make sure sound effect played are correct (or don't play any)
15962d464e EEM: removed debug keys
7a9ad68ef6 EEM: use blit API
0ccaaeb7c4 EEM: initial support for floppy version
a55adf77b7 EEM: implement conversations for the floppy version
3ba1ac49ac EEM: npc conversations for the floppy version
20eba94c78 EEM: improved hints and audio in the floppy version
4992f3c704 EEM: text pagination for some conversations
fe87003b43 EEM: draw enter indicators for conversations in floppy version
bfdd2c33ec EEM: initial support for spanish release and fixes for the floppy version
9c41e30159 EEM: make sure all the clues are there
c5336a89fd EEM: setup screen for floppy screen
021009a600 EEM: correctly validate clues in floppy version
7aa9357fb6 EEM: simplify savegame handling
2b45b48e89 EEM: fix invalid state transition in the handling of the setup screen
1cf324b52d EEM: correctly save and reload viewed clues
ec75e6ec08 EEM: avoid saving slot 0
2a0ec6f7e2 EEM: bring virtual keyboard when typing name
8a575a0b4f EEM: correctly render background during enter animation
dcb37c0a03 EEM: original mouse pointer and new feature to hide highlight boxes
00c6438de3 EEM: correctly blit npc pictures
057711225d EEM: implemented the quit dialog
6ce915b5a9 EEM: implemented the (im)patience animation
3e9ba587e4 EEM: improved scrapbook code
5042edef3a EEM: highlight scrapbook interactive zones
916c34e2b2 EEM: highlight pda interactive zones
3354f1af87 EEM: handle initial clues for the CD version
7ac0106b65 EEM: improved animations
9653bf3792 EEM: do not close the game after the end of the scrapbook
5c5a2d1ccf EEM: honor the music config
a1b4c596bc EEM: added missing voices when selecting clues
6318196285 EEM: don't allow to select more than 5 clues in accusation mode
a0d9c69340 EEM: fixed regression on music handling during enter animation
33e2ff6fda EEM: implemented badge handling when a case is over
1423b3442b EEM: shiny map markers
9e532d8f96 EEM: allow to come back from zoomed map to the overview
ba0b1afbc3 EEM: mt32 music
fd806599d2 EEM: message sliding animation for some screens
6494a01769 EEM: complete handler for screen profile
a1da71fd4d EEM: missing button code to reload the current profile
d3fcc8c249 EEM: implement missing animations for floppy release
2e25ab2f46 EEM: corrected text size
e84bf0e798 EEM: keep proper track of clues in floppy version
821f9a0ba7 EEM: keep proper track of visited places in floppy version
f00c88734c EEM: blit accuse partner in floppy version
f2d1372a85 EEM: use the correct initial menu screens
2398f57221 EEM: refresh the correct part of the screen during case introductions
d00eeb0022 EEM: optional better fitted balloon dialog code
8ca7479633 EEM: only some keys will advance dialog
794eeec90c EEM: strips leading spaces
3a87e8fc48 EEM: refactory doGallery into several functions
b25891a687 EEM: openMainMenuDialog when using ESC in most of the screens
a6c48ae179 EEM: improved animation precision with correct framerate and full script table
28556038df EEM: use simpleBlitFrom
b47044e739 EEM: more simpleBlitFrom
e40f51e2dc EEM: use transBlitFrom
a2554f53bd EEM: stop conversations when you skip the last phrase
9ca6f602c4 EEM: fixed highlighting bug when the cursor was moved during conversations
b39c08a950 EEM: added palette fadeout for the intro
59304750cf EEM: reviewed and improved comments across the engine
37e6dc1ba6 EEM: enforced code standards, mostly removed one-lined statements
c3c78f3e10 EEM: removed lambdas in favor of explicit functions
b8ebea90ad EEM: correct implementation of OpenColorCycle
ad29adf750 EEM: fixed bug when picking up the phone earlier than expected
b69b12c244 EEM: fixed bug in Nancy animation
24a3e5a40c EEM: fixed global-constructors from kHappyZones
12776d655a EEM: removed the 'create new' workaround
613dac165b EEM: improved animation in the PDA
23baebb9a8 EEM: removed incorrect attribution in the headers
d901f38d66 EEM: reduced code duplication using transBlitFrom
e34c624ee4 EEM: reduced code duplication in the PDA rendering
40c2459237 EEM: some comment fixes and clarifications
0a11cfeede EEM: use named constants here and there
9cd879dd5c EEM: removed useless usage of static
f789071470 CI: temporary enable CI in github
ba065aa1a9 CI: reverted temporary enable CI in github commit
8bcc62ad5d EEM: removed duplicated definitions
79188e6bd2 EEM: renamed _ASM_Decompress to avoid confusion
2b9c008c66 EEM: add end of notes in the PDA rendering
c9a5bbc56c EEM: add an option to skip repeated cases
f2c5be745a EEM: make sure screenshots contain all the elements from sites
24fe380eed EEM: fixed invalid reference to a sound clip in practice mystery
32e5d42d20 EEM: implemented restored content from the floppy data into the CD one
02418babc4 EEM: fixes and missing code for the floppy release
558a00c546 EEM: use skip api instead of (void)
209d590655 EEM: moved cleanMysterySound after sdx/sdb declarations
a789df41d3 EEM: reduced some technical comments
c63d19aea9 EEM: remove dead code related with fallback pointer
79bf202d8c EEM: boy -> jake, girl -> jenny
8f54582d7d EEM: EEM2 proof-of-concept (intro)
ff0025ab96 EEM: Added a more complete intro and character selection for EEM2
3fb2a3ea44 EEM: load case menu is working in EEM2
a477a58ccf EEM: add more code in the partner selection screen
e69c835219 EEM: corrected palette
29bef8261a EEM: play correctly initial animation in London
24d75f3cc7 EEM: training case in London plays renders the first site
ea2453f9bc EEM: improved animation in London
dd0a8761aa EEM: added more animation from London
a5d0ea9233 EEM: mouse cursor change from London
27f48984c6 EEM: jump effect from applyClue in London
17cf0205f9 EEM: correct parner handling in London
b8f606891d EEM: implemented traveling animation in London
279d74a541 EEM: implemented edutaintment intro video of some locations in London
6ddfb2227b EEM: fix big map markers in London
a35a1dc2d5 EEM: implement player creation/loading in London
a252c9afa9 EEM: proper selection of partner in London
ad1a941360 EEM: proper case selection in London
3f0cccf90a EEM: more side effects for clues in London
66393f6f85 EEM: implement game progression in London
7ca4b9f330 EEM: added missing musical cue in London
53af04e55c EEM: added missing conversation opcodes in London
076d0f0633 EEM: improved TRAVIS UI in London
1f0ad1bf77 EEM: improved hints voices/text in London
d7a7f62671 EEM: new doPuzzle implementation for London
a029ae2241 EEM: doPuzzle highlights for London
c53f9e018e EEM: acusation workflow for London
aca47008bd EEM: doApproach fixes for London
b749d688e8 EEM: enter the name screen fixes for London
f490186308 EEM: make sure the screen is updated during animations
e9c44065c0 EEM: added missing NPC in London cases intro
2372aaf018 EEM: implement setup screen in London
44e61cbf7f EEM: fixed regression on clean background for London
a79edd41f2 EEM: missing music cue in London scrapbook
3555a18bdb EEM: reduce the verbosity of the comments accross the engine
215d297ebd EEM: enable fit dialog ballons for London
670bb5b820 EEM: fixed partner animation that was one frame longer in London
3b86f3eb9a EEM: do not render sublocations in London map
c021df15c9 EEM: added missing music in London
1234998c14 EEM: corrected palette for the water in London map
0e72f80441 EEM: autosave every time we talk or get a new clue in London
846a1b5452 EEM: show notes for each suspect in the gallery correctly in London
8d9720ef74 EEM: deduction flow fixes for London
cfed4b9586 EEM: play the correct animation before showing the scrapbook in London
1161071df3 EEM: concurrent partner animations during conversations
ce99471d14 EEM: improved partner animations reducing flickering
24173e3211 EEM: properly mark interactive zones in London
111cc673fc EEM: completed implementation for setup screen in London


Commit: 9c9bc8afce4d8d965e00ebe085f194fd4c5d2b03
    https://github.com/scummvm/scummvm/commit/9c9bc8afce4d8d965e00ebe085f194fd4c5d2b03
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:32+02:00

Commit Message:
EEM: initial code with detection

Changed paths:
  A engines/eem/POTFILES
  A engines/eem/configure.engine
  A engines/eem/console.cpp
  A engines/eem/console.h
  A engines/eem/detection.cpp
  A engines/eem/detection.h
  A engines/eem/eem.cpp
  A engines/eem/eem.h
  A engines/eem/metaengine.cpp
  A engines/eem/module.mk


diff --git a/engines/eem/POTFILES b/engines/eem/POTFILES
new file mode 100644
index 00000000000..df6847f5e2b
--- /dev/null
+++ b/engines/eem/POTFILES
@@ -0,0 +1 @@
+engines/eem/metaengine.cpp
diff --git a/engines/eem/configure.engine b/engines/eem/configure.engine
new file mode 100644
index 00000000000..fdf8aa7aeed
--- /dev/null
+++ b/engines/eem/configure.engine
@@ -0,0 +1,3 @@
+# This file is included from the main "configure" script
+# add_engine [name] [desc] [build-by-default] [subengines] [base games] [deps] [components]
+add_engine eem "Eagle Eye Mysteries" no
diff --git a/engines/eem/console.cpp b/engines/eem/console.cpp
new file mode 100644
index 00000000000..80501e6da06
--- /dev/null
+++ b/engines/eem/console.cpp
@@ -0,0 +1,29 @@
+/* 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 "eem/console.h"
+
+namespace EEM {
+
+Console::Console() : GUI::Debugger() {
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/console.h b/engines/eem/console.h
new file mode 100644
index 00000000000..04c06b19959
--- /dev/null
+++ b/engines/eem/console.h
@@ -0,0 +1,37 @@
+/* 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 EEM_CONSOLE_H
+#define EEM_CONSOLE_H
+
+#include "gui/debugger.h"
+
+namespace EEM {
+
+class Console : public GUI::Debugger {
+public:
+	Console();
+	~Console() override {}
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
new file mode 100644
index 00000000000..0666827a3db
--- /dev/null
+++ b/engines/eem/detection.cpp
@@ -0,0 +1,83 @@
+/* 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 "base/plugins.h"
+#include "engines/advancedDetector.h"
+
+#include "eem/detection.h"
+
+namespace EEM {
+
+static const PlainGameDescriptor eemGames[] = {
+	{ "eem", "Eagle Eye Mysteries" },
+	{ nullptr, nullptr }
+};
+
+const ADGameDescription gameDescriptions[] = {
+	{
+		"eem",
+		"CD",
+		AD_ENTRY2s("EEMCD.EXE", "286d586113863ceb0e12df5b36fd8504", 272608,
+				   "PICS.DBD",  "cc05ec256cd5a04df9019e9aebfb7c32", 994057),
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_NO_FLAGS,
+		GUIO1(GUIO_NONE)
+	},
+
+	AD_TABLE_END_MARKER
+};
+
+static const DebugChannelDef debugFlagList[] = {
+	{ kDebugGeneral, "general", "General debug" },
+	{ kDebugScript,  "script",  "Script execution" },
+	{ kDebugMystery, "mystery", "Mystery loading and state" },
+	{ kDebugSite,    "site",    "Site rendering and hot-spots" },
+	{ kDebugGfx,     "gfx",     "Graphics, palette, animations" },
+	{ kDebugSound,   "sound",   "Sound and music" },
+	DEBUG_CHANNEL_END
+};
+
+} // End of namespace EEM
+
+class EEMMetaEngineDetection : public AdvancedMetaEngineDetection<ADGameDescription> {
+public:
+	EEMMetaEngineDetection() : AdvancedMetaEngineDetection(EEM::gameDescriptions, EEM::eemGames) {
+	}
+
+	const char *getName() const override {
+		return "eem";
+	}
+
+	const char *getEngineName() const override {
+		return "Eagle Eye Mysteries";
+	}
+
+	const char *getOriginalCopyright() const override {
+		return "Eagle Eye Mysteries (C) 1994 EA Kids";
+	}
+
+	const DebugChannelDef *getDebugChannels() const override {
+		return EEM::debugFlagList;
+	}
+};
+
+REGISTER_PLUGIN_STATIC(EEM_DETECTION, PLUGIN_TYPE_ENGINE_DETECTION, EEMMetaEngineDetection);
diff --git a/engines/eem/detection.h b/engines/eem/detection.h
new file mode 100644
index 00000000000..395daab1c77
--- /dev/null
+++ b/engines/eem/detection.h
@@ -0,0 +1,42 @@
+/* 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 EEM_DETECTION_H
+#define EEM_DETECTION_H
+
+#include "engines/advancedDetector.h"
+
+namespace EEM {
+
+enum EEMDebugChannels {
+	kDebugGeneral = 1 << 0,
+	kDebugScript  = 1 << 1,
+	kDebugMystery = 1 << 2,
+	kDebugSite    = 1 << 3,
+	kDebugGfx     = 1 << 4,
+	kDebugSound   = 1 << 5
+};
+
+extern const ADGameDescription gameDescriptions[];
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
new file mode 100644
index 00000000000..365c73fa389
--- /dev/null
+++ b/engines/eem/eem.cpp
@@ -0,0 +1,108 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/debug-channels.h"
+#include "common/error.h"
+#include "common/events.h"
+#include "common/system.h"
+
+#include "engines/util.h"
+
+#include "graphics/paletteman.h"
+
+#include "eem/console.h"
+#include "eem/detection.h"
+#include "eem/eem.h"
+
+namespace EEM {
+
+EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
+	: Engine(syst), _gameDescription(gameDesc), _console(nullptr),
+	  _rng("eem"), _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle) {
+}
+
+EEMEngine::~EEMEngine() {
+	// _console is owned by the Engine base class.
+}
+
+Common::Error EEMEngine::run() {
+	// Original _main @ 1a35:0f59 enters mode 13h via _SetMode13X (320x200x256).
+	initGraphics(320, 200);
+
+	_console = new Console();
+	setDebugger(_console);
+
+	// _main's startup paints the screen black via _AllBlack @ 172b:0d4b before
+	// the first screen handler runs; we do the same here.
+	byte palette[3 * 256] = { 0 };
+	g_system->getPaletteManager()->setPalette(palette, 0, 256);
+
+	debugC(1, kDebugGeneral, "EEM engine starting; first screen = 0x%02X", _nextScreen);
+
+	screenDriver();
+
+	debugC(1, kDebugGeneral, "EEM engine exiting");
+	return Common::kNoError;
+}
+
+void EEMEngine::screenDriver() {
+	// Mirrors _ScreenDriver @ 1a35:0dc1. The original walks a 14-entry table at
+	// 1a35:0e5e of (id, handler) pairs; we use a switch as we port handlers in.
+	while (_nextScreen != kScreenInvalid && !shouldQuit()) {
+		ScreenId next = static_cast<ScreenId>(_nextScreen);
+		switch (next) {
+		case kScreenTitle:
+			// TODO(M3): port _ShowTitlePage @ 1a35:06b7
+			warning("Screen 0x%02X (title) not implemented yet", next);
+			_lastScreen = _nextScreen;
+			_nextScreen = kScreenInvalid;
+			break;
+		default:
+			warning("Unknown screen id 0x%02X; exiting", next);
+			_nextScreen = kScreenInvalid;
+			break;
+		}
+
+		// Until handlers run their own event loops, pump events here so the
+		// engine remains responsive and the user can quit.
+		if (!pollEvents())
+			break;
+	}
+}
+
+bool EEMEngine::pollEvents() {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		switch (event.type) {
+		case Common::EVENT_QUIT:
+		case Common::EVENT_RETURN_TO_LAUNCHER:
+			return false;
+		default:
+			break;
+		}
+	}
+	g_system->updateScreen();
+	g_system->delayMillis(10);
+	return true;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
new file mode 100644
index 00000000000..ac8d6b14931
--- /dev/null
+++ b/engines/eem/eem.h
@@ -0,0 +1,81 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Based on the original engine code by EA Kids / Storm Software (1994).
+ *
+ */
+
+#ifndef EEM_EEM_H
+#define EEM_EEM_H
+
+#include "common/platform.h"
+#include "common/random.h"
+#include "common/scummsys.h"
+
+#include "engines/advancedDetector.h"
+#include "engines/engine.h"
+
+namespace EEM {
+
+class Console;
+
+/**
+ * Screen IDs used by the original ScreenDriver dispatch table at 1a35:0e5e.
+ * The table holds 14 (id, handler) entries; the loop iterates until it finds
+ * a matching id and calls its handler. ID 0xFFFF is the exit sentinel.
+ */
+enum ScreenId {
+	kScreenInvalid    = 0xFFFF,
+	kScreenTitle      = 0x0B,  ///< _ShowTitlePage @ 1a35:06b7
+	kScreenNext       = 0x08   ///< follow-up after title (case selection); to be confirmed
+};
+
+class EEMEngine : public Engine {
+public:
+	EEMEngine(OSystem *syst, const ADGameDescription *gameDesc);
+	~EEMEngine() override;
+
+	Common::Error run() override;
+
+	const char *getGameId() const;
+	Common::Platform getPlatform() const;
+
+	const ADGameDescription *_gameDescription;
+
+private:
+	/**
+	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
+	 * Each iteration restores video mode and calls the screen handler that
+	 * matches _nextScreen. Handlers update _lastScreen / _nextScreen and
+	 * return; the loop exits when _nextScreen == kScreenInvalid.
+	 */
+	void screenDriver();
+
+	bool pollEvents();
+
+	Console *_console;
+	Common::RandomSource _rng;
+
+	uint16 _lastScreen;  ///< Mirrors _LastScreen @ 2d5d:3f24
+	uint16 _nextScreen;  ///< Mirrors _NextScreen @ 2d5d:3f26
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
new file mode 100644
index 00000000000..0d61cf6a944
--- /dev/null
+++ b/engines/eem/metaengine.cpp
@@ -0,0 +1,59 @@
+/* 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 "base/plugins.h"
+#include "engines/advancedDetector.h"
+
+#include "eem/eem.h"
+
+namespace EEM {
+
+const char *EEMEngine::getGameId() const {
+	return _gameDescription->gameId;
+}
+
+Common::Platform EEMEngine::getPlatform() const {
+	return _gameDescription->platform;
+}
+
+} // End of namespace EEM
+
+class EEMMetaEngine : public AdvancedMetaEngine<ADGameDescription> {
+public:
+	const char *getName() const override {
+		return "eem";
+	}
+
+	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override {
+		*engine = new EEM::EEMEngine(syst, desc);
+		return Common::kNoError;
+	}
+
+	bool hasFeature(MetaEngineFeature f) const override {
+		return false;
+	}
+};
+
+#if PLUGIN_ENABLED_DYNAMIC(EEM)
+REGISTER_PLUGIN_DYNAMIC(EEM, PLUGIN_TYPE_ENGINE, EEMMetaEngine);
+#else
+REGISTER_PLUGIN_STATIC(EEM, PLUGIN_TYPE_ENGINE, EEMMetaEngine);
+#endif
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
new file mode 100644
index 00000000000..792a53959ed
--- /dev/null
+++ b/engines/eem/module.mk
@@ -0,0 +1,17 @@
+MODULE := engines/eem
+
+MODULE_OBJS = \
+	console.o \
+	eem.o \
+	metaengine.o
+
+# This module can be built as a plugin
+ifeq ($(ENABLE_EEM), DYNAMIC_PLUGIN)
+PLUGIN := 1
+endif
+
+# Include common rules
+include $(srcdir)/rules.mk
+
+# Detection objects
+DETECT_OBJS += $(MODULE)/detection.o


Commit: c271f98d82c3cd62abf9b2f4d16b43c22eeaefb5
    https://github.com/scummvm/scummvm/commit/c271f98d82c3cd62abf9b2f4d16b43c22eeaefb5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:32+02:00

Commit Message:
EEM: image loading and first intro image

Changed paths:
  A engines/eem/resource.cpp
  A engines/eem/resource.h
    engines/eem/console.cpp
    engines/eem/console.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/module.mk


diff --git a/engines/eem/console.cpp b/engines/eem/console.cpp
index 80501e6da06..bddd6e98fac 100644
--- a/engines/eem/console.cpp
+++ b/engines/eem/console.cpp
@@ -20,10 +20,34 @@
  */
 
 #include "eem/console.h"
+#include "eem/eem.h"
+#include "eem/resource.h"
 
 namespace EEM {
 
-Console::Console() : GUI::Debugger() {
+Console::Console(EEMEngine *vm) : GUI::Debugger(), _vm(vm) {
+	registerCmd("pic", WRAP_METHOD(Console, cmdPic));
+}
+
+bool Console::cmdPic(int argc, const char **argv) {
+	if (argc < 2) {
+		debugPrintf("Usage: pic <number>\n");
+		debugPrintf("Loads picture <number> from PICS.DBD and reports its dimensions.\n");
+		debugPrintf("PICS.DBX has %u entries.\n", _vm->getPics().size());
+		return true;
+	}
+
+	const uint num = (uint)atoi(argv[1]);
+	Picture pic;
+	if (!_vm->getPics().getPicture(num, pic)) {
+		debugPrintf("pic %u: load failed\n", num);
+		return true;
+	}
+
+	debugPrintf("pic %u (idx %u): %dx%d, compsize=%u, flags=0x%04x, miscflags=0x%04x, rowoff=%u\n",
+				num, num - 1, pic.surface.w, pic.surface.h,
+				pic.compsize, pic.flags, pic.miscflags, pic.rowoff);
+	return true;
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/console.h b/engines/eem/console.h
index 04c06b19959..e3c89cdd54c 100644
--- a/engines/eem/console.h
+++ b/engines/eem/console.h
@@ -26,10 +26,17 @@
 
 namespace EEM {
 
+class EEMEngine;
+
 class Console : public GUI::Debugger {
 public:
-	Console();
+	explicit Console(EEMEngine *vm);
 	~Console() override {}
+
+private:
+	EEMEngine *_vm;
+
+	bool cmdPic(int argc, const char **argv);
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 365c73fa389..a58748773e3 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -23,7 +23,10 @@
 #include "common/debug-channels.h"
 #include "common/error.h"
 #include "common/events.h"
+#include "common/file.h"
+#include "common/path.h"
 #include "common/system.h"
+#include "common/textconsole.h"
 
 #include "engines/util.h"
 
@@ -33,6 +36,11 @@
 #include "eem/detection.h"
 #include "eem/eem.h"
 
+namespace {
+const uint kPalSize = 768;     ///< 256 colors * 3 bytes
+const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720)
+} // anonymous namespace
+
 namespace EEM {
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
@@ -48,7 +56,7 @@ Common::Error EEMEngine::run() {
 	// Original _main @ 1a35:0f59 enters mode 13h via _SetMode13X (320x200x256).
 	initGraphics(320, 200);
 
-	_console = new Console();
+	_console = new Console(this);
 	setDebugger(_console);
 
 	// _main's startup paints the screen black via _AllBlack @ 172b:0d4b before
@@ -56,8 +64,60 @@ Common::Error EEMEngine::run() {
 	byte palette[3 * 256] = { 0 };
 	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
+	// Mirrors _main's `_picsFile = _fopen("PICS.DBD", ...)` plus
+	// _InitGraphicsSystem's PICS.DBX index parse (172b:0145).
+	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
+		return Common::Error(Common::kReadingFailed, "PICS archive missing");
+	}
+
+	// Mirrors _ReadPalettes @ 172b:0d89 — slurp SITEPALS in one read.
+	Common::File palFile;
+	if (!palFile.open(Common::Path("SITEPALS"))) {
+		return Common::Error(Common::kReadingFailed, "SITEPALS missing");
+	}
+	_sitePals.resize(palFile.size());
+	if (palFile.read(_sitePals.data(), _sitePals.size()) != _sitePals.size()) {
+		return Common::Error(Common::kReadingFailed, "SITEPALS short read");
+	}
+	palFile.close();
+	debugC(1, kDebugGfx, "Loaded %u SITEPALS palettes", (uint)(_sitePals.size() / kPalSize));
+
 	debugC(1, kDebugGeneral, "EEM engine starting; first screen = 0x%02X", _nextScreen);
 
+	// Show the first intro image (EA Kids logo) — mirrors the opening of
+	// _ShowEAKids @ 2520:05f0: GetPicture(0x54), MemoryCopy to 0xa000:0,
+	// GetPalette(0x25), setmany(_fpal, 0). Skipped: color-cycle loop.
+	{
+		Picture eakids;
+		if (!_picsArchive.getPicture(0x54, eakids)) {
+			return Common::Error(Common::kReadingFailed, "EA Kids logo (picture #0x54) load failed");
+		}
+		debugC(1, kDebugGfx, "EA Kids logo: %dx%d", eakids.surface.w, eakids.surface.h);
+		blitFullScreen(eakids);
+		setSitePalette(0x25);
+		g_system->updateScreen();
+
+		// Hold the image for up to 3 s or until the user clicks/keys/quits.
+		const uint32 startMs = g_system->getMillis();
+		while (g_system->getMillis() - startMs < 3000) {
+			Common::Event event;
+			bool stop = false;
+			while (g_system->getEventManager()->pollEvent(event)) {
+				if (event.type == Common::EVENT_QUIT ||
+					event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					event.type == Common::EVENT_LBUTTONDOWN ||
+					event.type == Common::EVENT_KEYDOWN) {
+					stop = true;
+					break;
+				}
+			}
+			if (stop)
+				break;
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+	}
+
 	screenDriver();
 
 	debugC(1, kDebugGeneral, "EEM engine exiting");
@@ -89,6 +149,28 @@ void EEMEngine::screenDriver() {
 	}
 }
 
+void EEMEngine::setSitePalette(uint num) {
+	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize) {
+		warning("setSitePalette: index %u out of range", num);
+		return;
+	}
+	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects 8-bit
+	// (0..255), so left-shift by 2 like the original VGA hardware did.
+	const byte *src = _sitePals.data() + num * kPalSize;
+	byte expanded[kPalSize];
+	for (uint i = 0; i < kPalSize; i++) {
+		expanded[i] = (byte)(src[i] << 2);
+	}
+	g_system->getPaletteManager()->setPalette(expanded, 0, 256);
+}
+
+void EEMEngine::blitFullScreen(const Picture &pic) {
+	// _MemoryCopy(0, 0xa000, srcOff, srcSeg) in _ShowEAKids dumps the picture
+	// straight into VGA's 320x200 framebuffer.
+	g_system->copyRectToScreen(pic.surface.getPixels(), pic.surface.pitch,
+							   0, 0, pic.surface.w, pic.surface.h);
+}
+
 bool EEMEngine::pollEvents() {
 	Common::Event event;
 	while (g_system->getEventManager()->pollEvent(event)) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index ac8d6b14931..67a4b0967db 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -31,6 +31,8 @@
 #include "engines/advancedDetector.h"
 #include "engines/engine.h"
 
+#include "eem/resource.h"
+
 namespace EEM {
 
 class Console;
@@ -58,6 +60,18 @@ public:
 
 	const ADGameDescription *_gameDescription;
 
+	DBDArchive &getPics() { return _picsArchive; }
+
+	/**
+	 * Upload palette index @p num (one of 40 stored in SITEPALS) to the
+	 * screen, with the VGA-DAC 6-bit-to-8-bit shift. Mirrors _GetPalette
+	 * @ 172b:0e80 followed by _setmany @ 1000:0930.
+	 */
+	void setSitePalette(uint num);
+
+	/** Blit @p pic to the screen at (0,0), expecting a 320x200 picture. */
+	void blitFullScreen(const Picture &pic);
+
 private:
 	/**
 	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
@@ -72,6 +86,9 @@ private:
 	Console *_console;
 	Common::RandomSource _rng;
 
+	DBDArchive _picsArchive;  ///< PICS.DBD/.DBX (mouse, buttons, markers, balloons sprites)
+	Common::Array<byte> _sitePals; ///< 40 x 768 bytes of 6-bit VGA palettes from SITEPALS
+
 	uint16 _lastScreen;  ///< Mirrors _LastScreen @ 2d5d:3f24
 	uint16 _nextScreen;  ///< Mirrors _NextScreen @ 2d5d:3f26
 };
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index 792a53959ed..34247658998 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -3,7 +3,8 @@ MODULE := engines/eem
 MODULE_OBJS = \
 	console.o \
 	eem.o \
-	metaengine.o
+	metaengine.o \
+	resource.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_EEM), DYNAMIC_PLUGIN)
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
new file mode 100644
index 00000000000..67c9ab8a78e
--- /dev/null
+++ b/engines/eem/resource.cpp
@@ -0,0 +1,132 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/compression/dcl.h"
+#include "common/debug.h"
+#include "common/file.h"
+#include "common/textconsole.h"
+
+#include "graphics/pixelformat.h"
+
+#include "eem/detection.h"
+#include "eem/resource.h"
+
+namespace EEM {
+
+DBDArchive::DBDArchive() {
+}
+
+DBDArchive::~DBDArchive() {
+	close();
+}
+
+bool DBDArchive::open(const Common::Path &dbdName, const Common::Path &dbxName) {
+	close();
+
+	if (!_dbd.open(dbdName)) {
+		warning("DBDArchive: cannot open %s", dbdName.toString().c_str());
+		return false;
+	}
+
+	Common::File dbx;
+	if (!dbx.open(dbxName)) {
+		warning("DBDArchive: cannot open %s", dbxName.toString().c_str());
+		_dbd.close();
+		return false;
+	}
+
+	// _InitGraphicsSystem @ 172b:0145 reads 10 bytes per entry until EOF.
+	const int32 dbxSize = dbx.size();
+	_index.reserve(dbxSize / 10);
+	while (dbx.pos() + 10 <= dbxSize) {
+		DBEntry entry;
+		entry.offset     = dbx.readUint32LE();
+		entry.compressed = dbx.readUint16LE();
+		entry.size       = dbx.readUint32LE();
+		_index.push_back(entry);
+	}
+	dbx.close();
+
+	debugC(1, kDebugGfx, "DBDArchive: opened %s (%u entries)",
+		   dbdName.toString().c_str(), _index.size());
+	return true;
+}
+
+void DBDArchive::close() {
+	if (_dbd.isOpen())
+		_dbd.close();
+	_index.clear();
+}
+
+bool DBDArchive::loadEntry(uint num, Picture &out) {
+	if (num >= _index.size()) {
+		warning("DBDArchive::loadEntry: %u out of range (max %u)", num, _index.size());
+		return false;
+	}
+
+	const DBEntry &entry = _index[num];
+	if (!_dbd.seek(entry.offset)) {
+		warning("DBDArchive::loadEntry: seek to 0x%08x failed", entry.offset);
+		return false;
+	}
+
+	// Mirrors _GetFromDB @ 172b:105d:
+	//   _fread(i)            // 2-byte skip word (purpose unclear; always 0x0001)
+	//   _fread(pic, 1, 12)   // 12-byte header
+	(void)_dbd.readUint16LE();
+	out.flags             = _dbd.readUint16LE();
+	const uint16 height   = _dbd.readUint16LE();
+	const uint16 width    = _dbd.readUint16LE();
+	out.rowoff            = _dbd.readUint16LE();
+	out.miscflags         = _dbd.readUint16LE();
+	out.compsize          = _dbd.readUint16LE();
+
+	if (width == 0 || height == 0) {
+		warning("DBDArchive::loadEntry: %u has zero dimensions (%ux%u)",
+				num, width, height);
+		return false;
+	}
+
+	out.surface.create(width, height, Graphics::PixelFormat::createFormatCLUT8());
+
+	if (entry.compressed == 0) {
+		// Raw pixel data — read width*height bytes verbatim.
+		const uint32 pixelCount = (uint32)width * (uint32)height;
+		if (_dbd.read(out.surface.getPixels(), pixelCount) != pixelCount) {
+			warning("DBDArchive::loadEntry: short raw read on %u", num);
+			return false;
+		}
+		return true;
+	}
+
+	// Compressed payload: feed the .DBD stream straight into the DCL
+	// decoder, matching the pattern used by Neverhood's BLB archive.
+	const uint32 unpacked = (uint32)width * (uint32)height;
+	if (!Common::decompressDCL(&_dbd, (byte *)out.surface.getPixels(),
+							   out.compsize, unpacked)) {
+		warning("DBDArchive::loadEntry: DCL decompression failed on %u "
+				"(%u packed -> %u pixels)", num, out.compsize, unpacked);
+		return false;
+	}
+	return true;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/resource.h b/engines/eem/resource.h
new file mode 100644
index 00000000000..a48deb85518
--- /dev/null
+++ b/engines/eem/resource.h
@@ -0,0 +1,106 @@
+/* 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 EEM_RESOURCE_H
+#define EEM_RESOURCE_H
+
+#include "common/array.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+
+#include "graphics/managed_surface.h"
+
+namespace EEM {
+
+/**
+ * Index entry for a .DBD/.DBX archive pair (10 bytes on disk).
+ *
+ * Mirrors the original `dbi` struct read by _InitGraphicsSystem @ 172b:0145
+ * in 10-byte chunks until EOF. Each entry locates one resource blob in the
+ * companion .DBD container.
+ */
+struct DBEntry {
+	uint32 offset;     ///< Byte offset of the entry in the .DBD file.
+	uint16 compressed; ///< Non-zero if the payload is PKWARE DCL ("Implode") packed.
+	uint32 size;       ///< Total size of the entry on disk (including 14-byte header).
+};
+
+/**
+ * 8-bit indexed picture decoded from a .DBD entry.
+ *
+ * The original engine's PicData is a 16-byte struct; we keep the descriptive
+ * fields here and let `Graphics::ManagedSurface` own the pixel data so the
+ * rest of the engine can blit/scale/clip with the standard API.
+ */
+struct Picture {
+	uint16 flags     = 0; ///< +0  high byte = sub-mode used by some sprites
+	uint16 rowoff    = 0; ///< +6  row offset (used by some clipped sprites)
+	uint16 miscflags = 0; ///< +8  high byte = transparent-mask flag
+	uint16 compsize  = 0; ///< +10 packed payload size on disk
+	Graphics::ManagedSurface surface;
+};
+
+/**
+ * Reader for a .DBD + .DBX archive pair.
+ *
+ * The original engine has five such pairs: PICS, SITES, ANI, BALLOON, BUTTON.
+ * Each .DBX is parsed once into an in-memory `_index`; reads of individual
+ * entries seek into the .DBD on demand and (when flagged) decompress with
+ * `Common::decompressDCL`.
+ */
+class DBDArchive {
+public:
+	DBDArchive();
+	~DBDArchive();
+
+	/**
+	 * Open both halves of an archive. @p dbdName / @p dbxName are looked up
+	 * via SearchMan, so case is normalized for us. Returns false if either
+	 * file is missing or the index is malformed.
+	 */
+	bool open(const Common::Path &dbdName, const Common::Path &dbxName);
+	void close();
+
+	/** Number of entries in the index. */
+	uint32 size() const { return _index.size(); }
+
+	/**
+	 * Load entry @p num (0-based index), decompressing if needed.
+	 * Returns true on success. Mirrors _GetFromDB @ 172b:105d.
+	 */
+	bool loadEntry(uint num, Picture &out);
+
+	/**
+	 * Convenience wrapper that mirrors the engine's 1-based picture API:
+	 * `_GetPicture(num)` calls `_GetFromDB(..., num - 1)`. Use this when
+	 * porting code that references picture IDs by their original number.
+	 */
+	bool getPicture(uint num, Picture &out) { return loadEntry(num - 1, out); }
+
+private:
+	Common::File _dbd;
+	Common::Array<DBEntry> _index;
+};
+
+} // End of namespace EEM
+
+#endif


Commit: f6a6833108d61f79ddf03d743d9be80cc1d40a88
    https://github.com/scummvm/scummvm/commit/f6a6833108d61f79ddf03d743d9be80cc1d40a88
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:32+02:00

Commit Message:
EEM: basic per case flow, with initial code for font loading

Changed paths:
  A engines/eem/animation.cpp
  A engines/eem/animation.h
  A engines/eem/font.cpp
  A engines/eem/font.h
  A engines/eem/mystery.cpp
  A engines/eem/mystery.h
  A engines/eem/site.cpp
  A engines/eem/site.h
  R engines/eem/console.cpp
  R engines/eem/console.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/metaengine.cpp
    engines/eem/module.mk
    engines/eem/resource.cpp
    engines/eem/resource.h


diff --git a/engines/eem/animation.cpp b/engines/eem/animation.cpp
new file mode 100644
index 00000000000..929fc18025e
--- /dev/null
+++ b/engines/eem/animation.cpp
@@ -0,0 +1,162 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/textconsole.h"
+
+#include "eem/animation.h"
+#include "eem/detection.h"
+
+namespace EEM {
+
+void asmDecompress(const byte *src, uint srcSize, byte *dst, uint dstSize) {
+	const byte *srcEnd = src + srcSize;
+	byte *dstEnd = dst + dstSize;
+
+	while (dst < dstEnd && src < srcEnd) {
+		const byte op = *src++;
+
+		if (op == 0x80) {
+			// Extended opcode: read 16-bit length argument.
+			if (src + 2 > srcEnd)
+				break;
+			const uint16 n = (uint16)src[0] | ((uint16)src[1] << 8);
+			src += 2;
+
+			if ((n & 0x8000) == 0) {
+				// Skip n bytes — preserves previous-frame pixels.
+				dst += n;
+			} else if ((n & 0x4000) == 0) {
+				// Long literal copy: (n & 0x7FFF) bytes from src to dst.
+				uint16 cnt = n & 0x7FFF;
+				while (cnt-- && dst < dstEnd && src < srcEnd)
+					*dst++ = *src++;
+			} else {
+				// Long fill: (n & 0x3FFF) bytes with the next single byte.
+				if (src >= srcEnd)
+					break;
+				const byte val = *src++;
+				uint16 cnt = n & 0x3FFF;
+				while (cnt-- && dst < dstEnd)
+					*dst++ = val;
+			}
+		} else if (op == 0) {
+			// Short fill: byte count, then byte value.
+			if (src + 2 > srcEnd)
+				break;
+			const byte cnt = src[0];
+			const byte val = src[1];
+			src += 2;
+			for (byte i = 0; i < cnt && dst < dstEnd; i++)
+				*dst++ = val;
+		} else if ((op & 0x80) == 0) {
+			// Short literal copy: `op` bytes (1..0x7F) from src to dst.
+			byte cnt = op;
+			while (cnt-- && dst < dstEnd && src < srcEnd)
+				*dst++ = *src++;
+		} else {
+			// Short skip: (op & 0x7F) bytes — preserves previous-frame pixels.
+			dst += op & 0x7F;
+		}
+	}
+}
+
+bool ANMDecoder::open(const Common::Path &path) {
+	close();
+
+	if (!_file.open(path)) {
+		warning("ANMDecoder: cannot open %s", path.toString().c_str());
+		return false;
+	}
+
+	if (_file.read(_palette, sizeof(_palette)) != sizeof(_palette)) {
+		warning("ANMDecoder: short palette read on %s", path.toString().c_str());
+		close();
+		return false;
+	}
+
+	_frameCount = _file.readUint16LE();
+	if (_frameCount == 0 || _frameCount > 1024) {
+		warning("ANMDecoder: implausible frame count %u in %s",
+				_frameCount, path.toString().c_str());
+		close();
+		return false;
+	}
+
+	(void)_file.readUint16LE();        // header[+0]: ignored
+	_height = _file.readUint16LE();    // header[+2]
+	_width  = _file.readUint16LE();    // header[+4]
+	(void)_file.readUint16LE();        // header[+6]
+	(void)_file.readUint16LE();        // header[+8]
+	(void)_file.readUint16LE();        // header[+10]
+
+	if (_width == 0 || _height == 0) {
+		warning("ANMDecoder: zero dimensions in %s", path.toString().c_str());
+		close();
+		return false;
+	}
+
+	_lengths.resize(_frameCount);
+	for (uint16 i = 0; i < _frameCount; i++)
+		_lengths[i] = _file.readUint16LE();
+
+	_buffer.resize((uint32)_width * _height);
+	memset(_buffer.data(), 0, _buffer.size());
+	_nextFrameIdx = 0;
+
+	debugC(1, kDebugGfx, "ANMDecoder: %s opened, %u frames, %ux%u",
+		   path.toString().c_str(), _frameCount, _width, _height);
+	return true;
+}
+
+void ANMDecoder::close() {
+	if (_file.isOpen())
+		_file.close();
+	_lengths.clear();
+	_buffer.clear();
+	_packed.clear();
+	_frameCount = _width = _height = _nextFrameIdx = 0;
+}
+
+void ANMDecoder::getPalette8(byte *out) const {
+	for (uint i = 0; i < sizeof(_palette); i++)
+		out[i] = (byte)(_palette[i] << 2);
+}
+
+const byte *ANMDecoder::nextFrame() {
+	if (_nextFrameIdx >= _frameCount)
+		return nullptr;
+
+	const uint16 packedSize = _lengths[_nextFrameIdx];
+	if (_packed.size() < packedSize)
+		_packed.resize(packedSize);
+
+	if (_file.read(_packed.data(), packedSize) != packedSize) {
+		warning("ANMDecoder: short read on frame %u", _nextFrameIdx);
+		return nullptr;
+	}
+
+	asmDecompress(_packed.data(), packedSize, _buffer.data(), _buffer.size());
+	_nextFrameIdx++;
+	return _buffer.data();
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/animation.h b/engines/eem/animation.h
new file mode 100644
index 00000000000..9397872e2fd
--- /dev/null
+++ b/engines/eem/animation.h
@@ -0,0 +1,98 @@
+/* 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 EEM_ANIMATION_H
+#define EEM_ANIMATION_H
+
+#include "common/array.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+
+namespace EEM {
+
+/**
+ * Decoder for the engine's full-screen difference animations.
+ *
+ * Used for `BOLT.ANM`, `TITLE.ANM`, `ANIM01.A` .. `ANIM20.A`. The format is
+ * documented by Load_Sequence @ 2503:0006 / OpenDifferenceAnimation @ 2520:0337:
+ *
+ *   - 0x300 bytes : 6-bit VGA palette
+ *   - u16        : frame count
+ *   - 12 bytes   : header (height @ +2, width @ +4, rest unused)
+ *   - frames*u16 : packed length per frame
+ *   - per frame  : `lengths[i]` bytes of RLE-packed delta data
+ *
+ * Each packed frame is unpacked by the custom `_ASM_Decompress` RLE
+ * (1000:0953) into the persistent `_buffer`; skip opcodes preserve pixels
+ * from the previous frame, which is how the difference encoding works.
+ */
+class ANMDecoder {
+public:
+	ANMDecoder() = default;
+	~ANMDecoder() { close(); }
+
+	/// Open @p path and parse the file header. Returns false on error.
+	bool open(const Common::Path &path);
+
+	/// Release the open file and the persistent frame buffer.
+	void close();
+
+	/// Number of frames in the animation.
+	uint16 frameCount() const { return _frameCount; }
+	uint16 width()      const { return _width; }
+	uint16 height()     const { return _height; }
+
+	/// 768 bytes of 6-bit VGA palette (already shifted into 8-bit on getPalette8).
+	const byte *palette6() const { return _palette; }
+
+	/// Fill @p out (768 bytes) with the 8-bit-shifted palette. Convenience.
+	void getPalette8(byte *out) const;
+
+	/**
+	 * Decode the next frame in place into the internal buffer. Returns a
+	 * pointer to the @c width()*@c height() byte image, or nullptr at EOF.
+	 */
+	const byte *nextFrame();
+
+private:
+	Common::File _file;
+	Common::Array<uint16> _lengths;
+	Common::Array<byte> _buffer;   ///< Persistent unpacked frame, width*height bytes.
+	Common::Array<byte> _packed;   ///< Per-frame scratch packed buffer.
+
+	byte _palette[768] = {};
+	uint16 _frameCount = 0;
+	uint16 _width = 0;
+	uint16 _height = 0;
+	uint16 _nextFrameIdx = 0;
+};
+
+/**
+ * Decompress a single frame's RLE payload in place. Mirrors _ASM_Decompress
+ * @ 1000:0953 byte-for-byte. @p dst already holds the previous frame; skip
+ * opcodes leave those pixels untouched.
+ */
+void asmDecompress(const byte *src, uint srcSize, byte *dst, uint dstSize);
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/console.cpp b/engines/eem/console.cpp
deleted file mode 100644
index bddd6e98fac..00000000000
--- a/engines/eem/console.cpp
+++ /dev/null
@@ -1,53 +0,0 @@
-/* ScummVM - Graphic Adventure Engine
- *
- * ScummVM is the legal property of its developers, whose names
- * are too numerous to list here. Please refer to the COPYRIGHT
- * file distributed with this source distribution.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-#include "eem/console.h"
-#include "eem/eem.h"
-#include "eem/resource.h"
-
-namespace EEM {
-
-Console::Console(EEMEngine *vm) : GUI::Debugger(), _vm(vm) {
-	registerCmd("pic", WRAP_METHOD(Console, cmdPic));
-}
-
-bool Console::cmdPic(int argc, const char **argv) {
-	if (argc < 2) {
-		debugPrintf("Usage: pic <number>\n");
-		debugPrintf("Loads picture <number> from PICS.DBD and reports its dimensions.\n");
-		debugPrintf("PICS.DBX has %u entries.\n", _vm->getPics().size());
-		return true;
-	}
-
-	const uint num = (uint)atoi(argv[1]);
-	Picture pic;
-	if (!_vm->getPics().getPicture(num, pic)) {
-		debugPrintf("pic %u: load failed\n", num);
-		return true;
-	}
-
-	debugPrintf("pic %u (idx %u): %dx%d, compsize=%u, flags=0x%04x, miscflags=0x%04x, rowoff=%u\n",
-				num, num - 1, pic.surface.w, pic.surface.h,
-				pic.compsize, pic.flags, pic.miscflags, pic.rowoff);
-	return true;
-}
-
-} // End of namespace EEM
diff --git a/engines/eem/console.h b/engines/eem/console.h
deleted file mode 100644
index e3c89cdd54c..00000000000
--- a/engines/eem/console.h
+++ /dev/null
@@ -1,44 +0,0 @@
-/* ScummVM - Graphic Adventure Engine
- *
- * ScummVM is the legal property of its developers, whose names
- * are too numerous to list here. Please refer to the COPYRIGHT
- * file distributed with this source distribution.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-#ifndef EEM_CONSOLE_H
-#define EEM_CONSOLE_H
-
-#include "gui/debugger.h"
-
-namespace EEM {
-
-class EEMEngine;
-
-class Console : public GUI::Debugger {
-public:
-	explicit Console(EEMEngine *vm);
-	~Console() override {}
-
-private:
-	EEMEngine *_vm;
-
-	bool cmdPic(int argc, const char **argv);
-};
-
-} // End of namespace EEM
-
-#endif
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index a58748773e3..4bd0da9430a 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -32,159 +32,1821 @@
 
 #include "graphics/paletteman.h"
 
-#include "eem/console.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/site.h"
+
+#include "common/config-manager.h"
+#include "common/savefile.h"
+#include "common/serializer.h"
+
+#include "graphics/managed_surface.h"
+
+namespace EEM {
 
 namespace {
 const uint kPalSize = 768;     ///< 256 colors * 3 bytes
 const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720)
-} // anonymous namespace
 
-namespace EEM {
+// Picture / palette IDs from the original code (1-based picture IDs).
+const uint kPicEAKidsLogo      = 0x54;  ///< _ShowEAKids: GetPicture(0x54)
+const uint kPicHighScoreLogo   = 0x20c; ///< _ShowHScoreLogo: GetPicture(0x20c)
+const uint kPicChooseBackground = 0x8c; ///< _DoChoosePartner: GetBackground(0x8c)
+const uint kPalEAKids          = 0x25;
+const uint kPalHighScore       = 0x27;
+
+// Animation IDs (0-based per ANI.DBX). _DoChoosePartner uses GetAnimation(8/9).
+const uint kAniBoy  = 8;
+const uint kAniGirl = 9;
+
+// On-screen positions for the boy and girl partner sprites, from
+// _DoChoosePartner: NewAnimation(0xe2, 0x62, ...) and (0x42, 0x60, ...).
+const int kBoyX  = 0xe2; // 226
+const int kBoyY  = 0x62; // 98
+const int kGirlX = 0x42; // 66
+const int kGirlY = 0x60; // 96
+} // anonymous namespace
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
-	: Engine(syst), _gameDescription(gameDesc), _console(nullptr),
-	  _rng("eem"), _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle) {
+	: Engine(syst), _gameDescription(gameDesc), _rng("eem"),
+	  _playerName("Detective"),
+	  _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle), _partner(0) {
 }
 
 EEMEngine::~EEMEngine() {
-	// _console is owned by the Engine base class.
 }
 
 Common::Error EEMEngine::run() {
-	// Original _main @ 1a35:0f59 enters mode 13h via _SetMode13X (320x200x256).
+	// _SetMode13X @ 1000:0358 enters VGA mode 13h (320x200x256).
 	initGraphics(320, 200);
 
-	_console = new Console(this);
-	setDebugger(_console);
+	if (!openArchives())
+		return Common::Error(Common::kReadingFailed, "EEM archive open failed");
 
-	// _main's startup paints the screen black via _AllBlack @ 172b:0d4b before
-	// the first screen handler runs; we do the same here.
-	byte palette[3 * 256] = { 0 };
-	g_system->getPaletteManager()->setPalette(palette, 0, 256);
+	if (!loadSitePalettes())
+		return Common::Error(Common::kReadingFailed, "SITEPALS load failed");
+
+	// _LoadFont @ 1b66:023c — main 8 px bitmap font.
+	if (!_font.load(Common::Path("FONT.FNT")))
+		warning("FONT.FNT failed to load; text will not render");
 
-	// Mirrors _main's `_picsFile = _fopen("PICS.DBD", ...)` plus
-	// _InitGraphicsSystem's PICS.DBX index parse (172b:0145).
+	// _AllBlack @ 172b:0d4b paints the screen black before the first handler.
+	byte black[3 * 256] = { 0 };
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
+
+	debugC(1, kDebugGeneral, "EEM engine starting");
+
+	// If the user chose "Load" before pressing Play, the framework
+	// invokes `loadGameState` which sets up `_mystery` and `_partner`.
+	// Honour that by skipping the intros and going straight to the
+	// loaded mystery's site loop.
+	const int wantedSave = ConfMan.hasKey("save_slot")
+		? ConfMan.getInt("save_slot") : -1;
+	if (wantedSave >= 0) {
+		const Common::Error err = loadGameState(wantedSave);
+		if (err.getCode() == Common::kNoError && _mystery.isLoaded()) {
+			debugC(1, kDebugGeneral, "Resuming from slot %d at mystery %u",
+				   wantedSave, _mystery.number());
+			doInitClues();
+			doSiteLoop();
+			while (!shouldQuit()) {
+				doCaseSelection();
+				if (!_mystery.isLoaded()) break;
+				doInitClues();
+				doSiteLoop();
+			}
+			return Common::kNoError;
+		}
+	}
+
+	// Reproduces _DoOpeningAnims @ 2520:082a (sans audio):
+	//   EA Kids logo (PIC) -> HighScore Productions logo (PIC) ->
+	//   Storm Software logo (BOLT.ANM) -> 20 character-intro animations
+	//   (ANIM01.A .. ANIM20.A) -> TITLE.ANM. Each can be skipped with a
+	//   click or any key.
+	showEAKidsLogo();
+	if (!shouldQuit())
+		showHighScoreLogo();
+	if (!shouldQuit())
+		playAnm(Common::Path("BOLT.ANM"));
+	for (int i = 1; i <= 20 && !shouldQuit(); i++) {
+		Common::String name = Common::String::format("ANIM%02d.A", i);
+		playAnm(Common::Path(name));
+		// Between anims the original plays a voice clip via _SpoolSound;
+		// without audio we still want a beat so each scene reads.
+		if (!shouldQuit() && i != 20)
+			waitForInput(2000);
+	}
+	if (!shouldQuit())
+		playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
+
+	// After the title chain, the original goes Title (B) -> screen 8
+	// (NewPlayer / saved-record selection) -> screen 9 (ChoosePartner) ->
+	// screen A (CaseSelection) -> site loop. We mirror the same order.
+	if (!shouldQuit())
+		doNewPlayer();
+	if (!shouldQuit())
+		doChoosePartner();
+	if (!shouldQuit())
+		doCaseSelection();
+	if (!shouldQuit() && _mystery.isLoaded()) {
+		// Mark the starting site as active and display the case briefing.
+		// `_DoInitClues` @ 1a35:0411 — case briefing.
+		doInitClues();
+		doSiteLoop();
+
+		// After a case, loop back to CaseSelection.
+		while (!shouldQuit()) {
+			doCaseSelection();
+			if (!_mystery.isLoaded())
+				break;
+			doInitClues();
+			doSiteLoop();
+		}
+	}
+
+	debugC(1, kDebugGeneral, "EEM engine exiting");
+	return Common::kNoError;
+}
+
+bool EEMEngine::openArchives() {
+	// _InitGraphicsSystem @ 172b:0145 opens these five .DBD/.DBX pairs.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
-		return Common::Error(Common::kReadingFailed, "PICS archive missing");
+		warning("PICS archive missing");
+		return false;
+	}
+	if (!_aniArchive.open(Common::Path("ANI.DBD"), Common::Path("ANI.DBX"))) {
+		warning("ANI archive missing");
+		return false;
 	}
+	// SITES + BALLOON are optional for the boot path but needed for site
+	// rendering and clue display.
+	if (!_sitesArchive.open(Common::Path("SITES.DBD"), Common::Path("SITES.DBX")))
+		warning("SITES archive missing — site backgrounds disabled");
+	if (!_balloonArchive.open(Common::Path("BALLOON.DBD"), Common::Path("BALLOON.DBX")))
+		warning("BALLOON archive missing — clue text will lack balloons");
+	return true;
+}
 
-	// Mirrors _ReadPalettes @ 172b:0d89 — slurp SITEPALS in one read.
-	Common::File palFile;
-	if (!palFile.open(Common::Path("SITEPALS"))) {
-		return Common::Error(Common::kReadingFailed, "SITEPALS missing");
+bool EEMEngine::loadSitePalettes() {
+	Common::File f;
+	if (!f.open(Common::Path("SITEPALS"))) {
+		warning("SITEPALS missing");
+		return false;
 	}
-	_sitePals.resize(palFile.size());
-	if (palFile.read(_sitePals.data(), _sitePals.size()) != _sitePals.size()) {
-		return Common::Error(Common::kReadingFailed, "SITEPALS short read");
+	_sitePals.resize(f.size());
+	if (f.read(_sitePals.data(), _sitePals.size()) != _sitePals.size()) {
+		warning("SITEPALS short read");
+		return false;
 	}
-	palFile.close();
-	debugC(1, kDebugGfx, "Loaded %u SITEPALS palettes", (uint)(_sitePals.size() / kPalSize));
+	debugC(1, kDebugGfx, "Loaded %u SITEPALS palettes",
+		   (uint)(_sitePals.size() / kPalSize));
+	return true;
+}
 
-	debugC(1, kDebugGeneral, "EEM engine starting; first screen = 0x%02X", _nextScreen);
+void EEMEngine::setSitePalette(uint num) {
+	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize) {
+		warning("setSitePalette: index %u out of range", num);
+		return;
+	}
+	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects 8-bit
+	// (0..255), so left-shift by 2 like the original VGA hardware did.
+	const byte *src = _sitePals.data() + num * kPalSize;
+	byte expanded[kPalSize];
+	for (uint i = 0; i < kPalSize; i++)
+		expanded[i] = (byte)(src[i] << 2);
+	g_system->getPaletteManager()->setPalette(expanded, 0, 256);
+}
 
-	// Show the first intro image (EA Kids logo) — mirrors the opening of
-	// _ShowEAKids @ 2520:05f0: GetPicture(0x54), MemoryCopy to 0xa000:0,
-	// GetPalette(0x25), setmany(_fpal, 0). Skipped: color-cycle loop.
-	{
-		Picture eakids;
-		if (!_picsArchive.getPicture(0x54, eakids)) {
-			return Common::Error(Common::kReadingFailed, "EA Kids logo (picture #0x54) load failed");
-		}
-		debugC(1, kDebugGfx, "EA Kids logo: %dx%d", eakids.surface.w, eakids.surface.h);
-		blitFullScreen(eakids);
-		setSitePalette(0x25);
+bool EEMEngine::setAnmPalette(const Common::Path &anmPath) {
+	Common::File f;
+	if (!f.open(anmPath)) {
+		warning("setAnmPalette: cannot open %s", anmPath.toString().c_str());
+		return false;
+	}
+	byte raw[kPalSize];
+	if (f.read(raw, kPalSize) != kPalSize) {
+		warning("setAnmPalette: short read on %s", anmPath.toString().c_str());
+		return false;
+	}
+	byte expanded[kPalSize];
+	for (uint i = 0; i < kPalSize; i++)
+		expanded[i] = (byte)(raw[i] << 2);
+	g_system->getPaletteManager()->setPalette(expanded, 0, 256);
+	return true;
+}
+
+void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLastFrame) {
+	ANMDecoder anm;
+	if (!anm.open(path)) {
+		warning("playAnm: %s missing", path.toString().c_str());
+		return;
+	}
+
+	byte palette[768];
+	anm.getPalette8(palette);
+	g_system->getPaletteManager()->setPalette(palette, 0, 256);
+
+	const uint16 w = anm.width();
+	const uint16 h = anm.height();
+
+	while (!shouldQuit()) {
+		const byte *frame = anm.nextFrame();
+		if (!frame)
+			break;
+
+		g_system->copyRectToScreen(frame, w, 0, 0, w, h);
 		g_system->updateScreen();
 
-		// Hold the image for up to 3 s or until the user clicks/keys/quits.
-		const uint32 startMs = g_system->getMillis();
-		while (g_system->getMillis() - startMs < 3000) {
+		// Drain events and let the user skip with click/key. The original
+		// uses _CheckFrameRate / _kbhit; we use a simple fixed delay until
+		// the frame-rate calibration logic from _GetSpeedRating is wired up.
+		const uint32 frameStart = g_system->getMillis();
+		bool aborted = false;
+		while (g_system->getMillis() - frameStart < frameDelayMs && !aborted) {
 			Common::Event event;
-			bool stop = false;
 			while (g_system->getEventManager()->pollEvent(event)) {
 				if (event.type == Common::EVENT_QUIT ||
 					event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
 					event.type == Common::EVENT_LBUTTONDOWN ||
 					event.type == Common::EVENT_KEYDOWN) {
-					stop = true;
+					aborted = true;
+					break;
+				}
+			}
+			g_system->delayMillis(5);
+		}
+		if (aborted)
+			break;
+	}
+
+	if (holdLastFrame && !shouldQuit()) {
+		// Mirror the wait-loop at the end of `_DoOpeningAnims`:
+		//   while (!keyDataAvailable) ;
+		// We accept either a click or a key.
+		while (!shouldQuit()) {
+			Common::Event ev;
+			bool clicked = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN) {
+					clicked = true;
 					break;
 				}
 			}
-			if (stop)
+			if (clicked)
 				break;
 			g_system->updateScreen();
-			g_system->delayMillis(10);
+			g_system->delayMillis(20);
 		}
 	}
+}
 
-	screenDriver();
+void EEMEngine::blitAt(const Picture &pic, int x, int y) {
+	// Clip against the 320x200 frame buffer.
+	const int w = MIN<int>(pic.surface.w, 320 - x);
+	const int h = MIN<int>(pic.surface.h, 200 - y);
+	if (w <= 0 || h <= 0)
+		return;
+	g_system->copyRectToScreen(pic.surface.getPixels(), pic.surface.pitch,
+							   x, y, w, h);
+}
 
-	debugC(1, kDebugGeneral, "EEM engine exiting");
-	return Common::kNoError;
+void EEMEngine::waitForInput(uint32 maxMs) {
+	const uint32 startMs = g_system->getMillis();
+	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT ||
+				event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				event.type == Common::EVENT_LBUTTONDOWN ||
+				event.type == Common::EVENT_KEYDOWN) {
+				return;
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
 }
 
-void EEMEngine::screenDriver() {
-	// Mirrors _ScreenDriver @ 1a35:0dc1. The original walks a 14-entry table at
-	// 1a35:0e5e of (id, handler) pairs; we use a switch as we port handlers in.
-	while (_nextScreen != kScreenInvalid && !shouldQuit()) {
-		ScreenId next = static_cast<ScreenId>(_nextScreen);
-		switch (next) {
-		case kScreenTitle:
-			// TODO(M3): port _ShowTitlePage @ 1a35:06b7
-			warning("Screen 0x%02X (title) not implemented yet", next);
-			_lastScreen = _nextScreen;
-			_nextScreen = kScreenInvalid;
+void EEMEngine::showEAKidsLogo() {
+	// Mirrors _ShowEAKids @ 2520:05f0 (without the color-cycle loop):
+	// GetPicture(0x54), MemoryCopy to VGA, GetPalette(0x25), setmany.
+	Picture pic;
+	if (!_picsArchive.getPicture(kPicEAKidsLogo, pic)) {
+		warning("EA Kids logo (%u) load failed", kPicEAKidsLogo);
+		return;
+	}
+	blitAt(pic, 0, 0);
+	setSitePalette(kPalEAKids);
+	g_system->updateScreen();
+	waitForInput(2500);
+}
+
+void EEMEngine::showHighScoreLogo() {
+	// Mirrors _ShowHScoreLogo @ 2520:0799 (without the wait-loop):
+	// GetPicture(0x20c), MemoryCopy to VGA, GetPalette(0x27), FadeIn.
+	Picture pic;
+	if (!_picsArchive.getPicture(kPicHighScoreLogo, pic)) {
+		warning("HighScore logo (%u) load failed", kPicHighScoreLogo);
+		return;
+	}
+	blitAt(pic, 0, 0);
+	setSitePalette(kPalHighScore);
+	g_system->updateScreen();
+	waitForInput(2500);
+}
+
+void EEMEngine::doNewPlayer() {
+	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
+	// 0x104 + character peek pic 0x107, then shows "Please type your
+	// name" and accepts up to 12 characters until Enter. We render a
+	// minimal version: black screen + prompt.
+	if (!_font.isLoaded()) {
+		_playerName = "Detective";
+		return;
+	}
+
+	Common::String name;
+	const int maxChars = 12;
+
+	// Mirror the original: load PIC 0x104 as the name-entry backdrop.
+	// The original also slides in PIC 0x107 (a peeking character).
+	Picture bg;
+	const bool haveBG = _picsArchive.getPicture(0x104, bg);
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveBG) {
+			const int w = MIN<int>(bg.surface.w, 320);
+			const int h = MIN<int>(bg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.surface.getBasePtr(0, row), w);
+		}
+		_font.drawString(&scratch, 40, 24,
+			"Welcome to Eagle Eye Mysteries!", 0xF);
+		_font.drawString(&scratch, 40, 40, "Please type your name:", 0xF);
+		_font.drawString(&scratch, 40, 60,
+			"(Backspace to delete, Enter to confirm)", 0xF);
+		Common::String shown = name + "_";
+		_font.drawString(&scratch, 40, 90, shown, 0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_RETURN) {
+				if (name.empty())
+					name = "Detective";
+				_playerName = name;
+				return;
+			}
+			if (k == Common::KEYCODE_ESCAPE) {
+				_playerName = "Detective";
+				return;
+			}
+			if (k == Common::KEYCODE_BACKSPACE) {
+				if (!name.empty()) {
+					name.deleteLastChar();
+					dirty = true;
+				}
+				continue;
+			}
+			if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
+				(int)name.size() < maxChars) {
+				name += (char)ev.kbd.ascii;
+				dirty = true;
+			}
+		}
+		if (dirty)
+			draw();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doChoosePartner() {
+	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
+	// animations on a backdrop and polls four click rectangles (two per
+	// character) for the player's choice. We approximate by splitting the
+	// screen at x=160: left half = girl (Jenny), right half = boy (Jake).
+	Picture background;
+	if (!_picsArchive.getPicture(kPicChooseBackground, background)) {
+		warning("ChoosePartner background (%u) load failed", kPicChooseBackground);
+		return;
+	}
+
+	Animation boyAnim;
+	if (!_aniArchive.loadAnimation(kAniBoy, boyAnim) || boyAnim.empty()) {
+		warning("Boy animation (%u) load failed", kAniBoy);
+		return;
+	}
+	Animation girlAnim;
+	if (!_aniArchive.loadAnimation(kAniGirl, girlAnim) || girlAnim.empty()) {
+		warning("Girl animation (%u) load failed", kAniGirl);
+		return;
+	}
+
+	setAnmPalette(Common::Path("TITLE.ANM"));
+	blitAt(background, 0, 0);
+	blitAt(girlAnim[0], kGirlX, kGirlY);
+	blitAt(boyAnim[0], kBoyX, kBoyY);
+	g_system->updateScreen();
+
+	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
+		   "%u girl frames at (%d,%d)",
+		   (uint)boyAnim.size(), kBoyX, kBoyY,
+		   (uint)girlAnim.size(), kGirlX, kGirlY);
+
+	uint frame = 0;
+	uint32 lastTick = g_system->getMillis();
+	while (!shouldQuit()) {
+		// Advance frame at ~5 Hz so the animations cycle gently.
+		if (g_system->getMillis() - lastTick > 200) {
+			lastTick = g_system->getMillis();
+			frame++;
+			blitAt(background, 0, 0);
+			blitAt(girlAnim[frame % girlAnim.size()], kGirlX, kGirlY);
+			blitAt(boyAnim[frame % boyAnim.size()], kBoyX, kBoyY);
+			g_system->updateScreen();
+		}
+
+		Common::Event ev;
+		bool done = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				done = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				_partner = (ev.mouse.x >= 160) ? 0 : 1;
+				debugC(1, kDebugGeneral, "Partner picked: %s",
+					   _partner == 0 ? "Jake" : "Jennifer");
+				done = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					_partner = 1; done = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					_partner = 0; done = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_RETURN ||
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					done = true; break;
+				}
+			}
+		}
+		if (done)
 			break;
-		default:
-			warning("Unknown screen id 0x%02X; exiting", next);
-			_nextScreen = kScreenInvalid;
+		g_system->updateScreen();
+		g_system->delayMillis(20);
+	}
+}
+
+void EEMEngine::doCaseSelection() {
+	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
+	// (chooser background) and a paginated list of mystery names rendered
+	// from M<n>.BIN headers, then calls `_DoChoose` to read a selection.
+	// We approximate with a numeric prompt (0..9 for first ten mysteries,
+	// Tab to cycle, Enter to load).
+	const uint kMaxMystery = 54;
+	// Default selection = the next unsolved mystery so post-win the
+	// player doesn't have to scroll to find what's left.
+	uint sel = 0;
+	for (uint i = 0; i <= kMaxMystery; i++) {
+		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
+			sel = i;
 			break;
 		}
+	}
 
-		// Until handlers run their own event loops, pump events here so the
-		// engine remains responsive and the user can quit.
-		if (!pollEvents())
-			break;
+	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
+	Picture caseBg;
+	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveCaseBg) {
+			const int w = MIN<int>(caseBg.surface.w, 320);
+			const int h = MIN<int>(caseBg.surface.h, 200);
+			for (int row = 0; row < h; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
+			}
+		}
+		if (_font.isLoaded()) {
+			_font.drawString(&scratch, 8, 4,
+				Common::String::format("EAGLE EYE - %s", _playerName.c_str()), 0xF);
+
+			// Solved count.
+			uint solved = 0, perfectSolved = 0;
+			for (uint i = 0; i < sizeof(_mysteriesSolved); i++) {
+				if (_mysteriesSolved[i] >= 1) solved++;
+				if (_mysteriesSolved[i] == 2) perfectSolved++;
+			}
+			_font.drawString(&scratch, 200, 4,
+				Common::String::format("solved %u (1st try %u)",
+									   solved, perfectSolved), 0xF);
+			if (perfectSolved >= 55) {
+				_font.drawString(&scratch, 8, 168,
+					"** PERFECT MASTER SLEUTH! **", 0xF);
+			} else if (solved >= 55) {
+				_font.drawString(&scratch, 8, 168,
+					"** ALL MYSTERIES SOLVED! **", 0xF);
+			}
+
+			char marker = ' ';
+			if (sel < sizeof(_mysteriesSolved)) {
+				if (_mysteriesSolved[sel] == 2) marker = '*';
+				else if (_mysteriesSolved[sel] == 1) marker = '+';
+			}
+			// Per the original tiers: 0 (tutorial), 1-24 (Junior),
+			// 25-48 (Senior), 49-54 (Master).
+			const char *tier = "Tutorial";
+			if (sel >= 1 && sel <= 24) tier = "Junior Sleuth";
+			else if (sel >= 25 && sel <= 48) tier = "Senior Sleuth";
+			else if (sel >= 49 && sel <= 54) tier = "Master Sleuth";
+			_font.drawString(&scratch, 8, 24,
+				Common::String::format("Mystery %u  %c  [%s]",
+									   sel, marker, tier), 0xF);
+			_font.drawString(&scratch, 8, 40,
+				"  0..9        quick select", 0xF);
+			_font.drawString(&scratch, 8, 52,
+				"  Tab / +     next mystery", 0xF);
+			_font.drawString(&scratch, 8, 64,
+				"  Shift+Tab   prev mystery", 0xF);
+			_font.drawString(&scratch, 8, 76,
+				"  PgUp/PgDn   jump 10", 0xF);
+			_font.drawString(&scratch, 8, 88,
+				"  Home/End    first/last", 0xF);
+			_font.drawString(&scratch, 8, 100,
+				"  Enter       start mystery", 0xF);
+			_font.drawString(&scratch, 8, 112,
+				"  F5          save / load (ScummVM)", 0xF);
+			_font.drawString(&scratch, 8, 124,
+				"  ESC         quit", 0xF);
+			_font.drawString(&scratch, 8, 144,
+				"  *  solved on first try", 0xF);
+			_font.drawString(&scratch, 8, 156,
+				"  +  solved", 0xF);
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	draw();
+
+	bool confirmed = false;
+	while (!confirmed && !shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_ESCAPE) {
+                _mystery.clear();
+				_nextScreen = kScreenInvalid;
+				return;
+			}
+			if (k == Common::KEYCODE_RETURN) {
+				confirmed = true;
+				break;
+			}
+			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
+				sel = (uint)(k - Common::KEYCODE_0);
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_TAB || k == Common::KEYCODE_PLUS ||
+				k == Common::KEYCODE_RIGHT || k == Common::KEYCODE_DOWN) {
+				const bool back = (ev.kbd.flags & Common::KBD_SHIFT) ||
+								  k == Common::KEYCODE_LEFT;
+				if (back)
+					sel = (sel == 0) ? kMaxMystery : sel - 1;
+				else
+					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_LEFT || k == Common::KEYCODE_UP ||
+				k == Common::KEYCODE_MINUS) {
+				sel = (sel == 0) ? kMaxMystery : sel - 1;
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_PAGEDOWN) {
+				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_PAGEUP) {
+				sel = (sel < 10) ? 0 : sel - 10;
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_HOME) {
+				sel = 0;
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_END) {
+				sel = kMaxMystery;
+				draw();
+				continue;
+			}
+		}
+		g_system->delayMillis(15);
+	}
+
+	if (!_mystery.load(sel, &_rng)) {
+		warning("doCaseSelection: failed to load mystery %u", sel);
+		_mystery.clear();
+		return;
 	}
+	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
+		   sel, _mystery.numSites(), _mystery.numSuspects());
 }
 
-void EEMEngine::setSitePalette(uint num) {
-	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize) {
-		warning("setSitePalette: index %u out of range", num);
+void EEMEngine::doInitClues() {
+	// Mirrors `_DoInitClues` @ 1a35:0411. Sets BG 0x52 + palette 0x22,
+	// blits the Goblindroid game and book first frames, then displays
+	// the case briefing ClueBlock at InitBlock + 4. Marks the starting
+	// site (InitBlock word[1]) on `_OnSites`.
+	if (!_mystery.isLoaded())
 		return;
+
+	const byte *ib = _mystery.initBlock();
+	if (!ib)
+		return;
+
+	const uint16 startSite = READ_LE_UINT16(ib + 2);
+	if (startSite < Mystery::kVisitedSiteCap)
+		_mystery._onSites[startSite] = 1;
+
+	setSitePalette(0x22);
+	Picture bg;
+	if (_picsArchive.getPicture(0x52, bg))
+		blitAt(bg, 0, 0);
+
+	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
+	const uint bookAni = _partner == 0 ? 0x18 : 0x3c;
+	Animation game, book;
+	if (_aniArchive.loadAnimation(gameAni, game) && !game.empty())
+		blitAt(game[0], 0xcd, 0x6c);
+	if (_aniArchive.loadAnimation(bookAni, book) && !book.empty())
+		blitAt(book[0], 0, 99);
+
+	// Case type 1 also places "Nancy" (a third character) at (0x68, 0x8b)
+	// per `_DoInitClues`.
+	const uint16 caseType = READ_LE_UINT16(ib);
+	if (caseType == 1) {
+		Animation nancy;
+		if (_aniArchive.loadAnimation(0x19, nancy) && !nancy.empty())
+			blitAt(nancy[0], 0x68, 0x8b);
 	}
-	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects 8-bit
-	// (0..255), so left-shift by 2 like the original VGA hardware did.
-	const byte *src = _sitePals.data() + num * kPalSize;
-	byte expanded[kPalSize];
-	for (uint i = 0; i < kPalSize; i++) {
-		expanded[i] = (byte)(src[i] << 2);
+
+	displayClue(ib + 4);
+}
+
+void EEMEngine::doSiteLoop() {
+	// Mirrors the per-mystery site loop. SiteScreen::run() handles
+	// hotspot clicks plus M (map), N (notebook), G (gallery), A (accuse),
+	// Tab (next site), ESC (exit).
+	SiteScreen screen(this, &_mystery);
+	screen.run();
+}
+
+/// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that
+/// the original engine uses as placeholders. Only the two we encounter most
+/// often (player name = 0x80, partner first name = 0x82) are substituted;
+/// other 0x8N opcodes are stripped. The original engine also handles
+/// hyphenation marks and a hint placeholder (0x89) we ignore for now.
+static Common::String parseString(const Common::String &raw,
+								  const Common::String &playerName,
+								  const Common::String &partnerName) {
+	Common::String out;
+	for (uint i = 0; i < raw.size(); i++) {
+		const byte c = (byte)raw[i];
+		if (c == 0x80) {
+			out += playerName;
+		} else if (c == 0x82) {
+			out += partnerName;
+		} else if (c >= 0x80 && c < 0x8A) {
+			// Other control opcodes: eat them silently for now.
+		} else if (c == 0 || c == '\r') {
+			// stop on NUL, ignore CR
+			if (c == 0)
+				break;
+		} else {
+			out += (char)c;
+		}
 	}
-	g_system->getPaletteManager()->setPalette(expanded, 0, 256);
+	return out;
 }
 
-void EEMEngine::blitFullScreen(const Picture &pic) {
-	// _MemoryCopy(0, 0xa000, srcOff, srcSeg) in _ShowEAKids dumps the picture
-	// straight into VGA's 320x200 framebuffer.
-	g_system->copyRectToScreen(pic.surface.getPixels(), pic.surface.pitch,
-							   0, 0, pic.surface.w, pic.surface.h);
+void EEMEngine::applyClueSideEffects(const byte *c) {
+	for (uint j = 0; j < 5; j++) {
+		const uint16 note = READ_LE_UINT16(c + 0x30 + j * 2);
+		if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
+			_mystery._cluesFound[note] = 1;
+
+		const uint16 galIdx = READ_LE_UINT16(c + 0x26 + j * 2);
+		if (galIdx != 0xFFFF && galIdx < Mystery::kGalleryCap) {
+			const uint8 phys = _mystery._newOrder[galIdx];
+			if (phys < Mystery::kGalleryCap)
+				_mystery._inGallery[phys] = 1;
+		}
+
+		const uint16 siteIdx = READ_LE_UINT16(c + 0x1c + j * 2);
+		if (siteIdx != 0xFFFF) {
+			const uint16 siteVal = siteIdx & 0x7FFF;
+			if (siteVal < Mystery::kVisitedSiteCap)
+				_mystery._onSites[siteVal] = 1;
+			if (siteIdx & 0x8000)
+				_mystery._sawCONSITEs = true;
+		}
+	}
 }
 
-bool EEMEngine::pollEvents() {
-	Common::Event event;
-	while (g_system->getEventManager()->pollEvent(event)) {
-		switch (event.type) {
-		case Common::EVENT_QUIT:
-		case Common::EVENT_RETURN_TO_LAUNCHER:
-			return false;
-		default:
-			break;
+void EEMEngine::displayClue(const byte *clueBlock) {
+	if (!clueBlock || !_mystery.isLoaded())
+		return;
+
+	// ClueBlock layout (verified against M0.BIN):
+	//   +0..1: number (entry count)
+	//   +2..3: pic ID for entry 0 (entry N>0 uses prev entry's last 2 bytes)
+	//   +4..:  array of 62-byte entries
+	const uint16 number = READ_LE_UINT16(clueBlock);
+	debugC(1, kDebugScript, "displayClue: %u entries", number);
+	if (number == 0 || number > 32) {
+		// number==0 = no briefing (e.g. mystery 0 case-type 4); >32 is a
+		// guard against bad pointers.
+		return;
+	}
+
+	// Snapshot the current screen as the BG so character pics from
+	// earlier entries don't stack on top of each other.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	bg.clear();
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)bg.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+		}
+	}
+
+	for (uint i = 0; i < number && !shouldQuit(); i++) {
+		// Restore BG before drawing this entry's portrait + balloon.
+		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+		const byte *c = clueBlock + 4 + i * 62;
+		// Per-partner fields:
+		//   +0..1, +2..3: tx, ty (partner 0)
+		//   +4..5, +6..7: tx, ty (partner 1)
+		//   +8..9, +10..11: bubText offset for partner 0/1 (rel. TextBlock)
+		//   +12..13, +14..15: balloon picture ID for partner 0/1
+		//   +16..17, +18..19: bubX, bubY
+		// Per `_DisplayClue` @ 2404:05e6: partner 1 uses its own field
+		// set ONLY when bubText1 is not -1; otherwise it falls back to
+		// the partner 0 fields entirely. Partner 0 always uses field 0.
+		const bool useP1 = (_partner == 1) &&
+			(READ_LE_UINT16(c + 10) != 0xFFFF);
+		const uint partner = useP1 ? 1 : 0;
+		const uint16 textOff = READ_LE_UINT16(c + 8 + partner * 2);
+		const bool hasText = (textOff != 0xFFFF);
+		// Partner 1 bubX/bubY at +0x14/+0x16; partner 0 at +0x10/+0x12.
+		const uint16 bubX = READ_LE_UINT16(c + (useP1 ? 0x14 : 0x10));
+		const uint16 bubY = READ_LE_UINT16(c + (useP1 ? 0x16 : 0x12));
+		const uint16 bubNum = READ_LE_UINT16(c + (useP1 ? 0x0E : 0x0C));
+		const char *raw   = hasText ? _mystery.textAt(textOff) : "";
+
+		// Speaker portrait. Mirrors `_DisplayClue`'s `pic[clues+i*62-2]`:
+		// for entry 0 the pic ID is in the ClueBlock header at +2; for
+		// later entries it sits in the previous entry's last 2 bytes.
+		// Speaker portrait position uses partner 0 fields (+0..+3) when
+		// _partner==0 or when partner 1 falls back; otherwise partner 1
+		// fields (+4..+7). Same logic as the original.
+		const uint16 charX  = READ_LE_UINT16(c + (useP1 ? 4 : 0));
+		const uint16 charY  = READ_LE_UINT16(c + (useP1 ? 6 : 2));
+		const uint16 charPicId = (i == 0)
+			? READ_LE_UINT16(clueBlock + 2)
+			: READ_LE_UINT16(c - 2);
+		if (charPicId != 0 && charPicId != 0xFFFF) {
+			Picture charPic;
+			if (_picsArchive.getPicture(charPicId, charPic) &&
+				charX < 320 && charY < 200) {
+				const int w = MIN<int>(charPic.surface.w, 320 - charX);
+				const int h = MIN<int>(charPic.surface.h, 200 - charY);
+				if (w > 0 && h > 0)
+					g_system->copyRectToScreen(charPic.surface.getPixels(),
+						charPic.surface.pitch, charX, charY, w, h);
+			}
+		}
+
+		// Substitute placeholder control bytes with the entered player
+		// name and the chosen partner's first name (Jake / Jennifer).
+		const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
+		const Common::String text = parseString(raw ? raw : "",
+												_playerName, partnerName);
+
+		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
+		// `_DisplayClue`. The original looks up per-balloon text-area
+		// metadata in a table at offset 0x875 (within `_DisplayClue`'s
+		// segment); we don't have that table decoded yet, so we use a
+		// fixed inset of 8 px from the balloon's top-left.
+		Picture balloon;
+		const uint16 balloonId = bubNum & 0x7F;
+		const bool haveBalloon = bubNum != 0xFFFF &&
+			_balloonArchive.size() > balloonId &&
+			_balloonArchive.loadEntry(balloonId, balloon);
+
+		if (_font.isLoaded() && !text.empty()) {
+			// Snapshot the current screen, overlay balloon + text, then
+			// copy the changed band back. This preserves the site BG
+			// underneath unchanged regions.
+			Graphics::Surface *screen = g_system->lockScreen();
+			if (!screen) break;
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+
+			int textX = bubX;
+			int textY = bubY;
+			int textW = MIN<int>(320 - bubX, 200);
+			int copyY = bubY;
+			int copyH = _font.height() * 4 + 8;
+
+			if (haveBalloon) {
+				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
+				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
+				if (bw > 0 && bh > 0) {
+					for (int row = 0; row < bh; row++) {
+						memcpy((byte *)scratch.getBasePtr(bubX, bubY + row),
+							   (const byte *)balloon.surface.getBasePtr(0, row),
+							   bw);
+					}
+				}
+				// Per-balloon metadata table at 29be:0875 in the original
+				// uses (textX=6, textY=4) inset across all entries; we
+				// adopt the same constants instead of approximating with 8.
+				textX = bubX + 6;
+				textY = bubY + 4;
+				textW = bw - 12;
+				copyH = bh;
+			} else {
+				// No balloon — clear a band so old pixels don't bleed.
+				const Common::Rect band(0, bubY, 320,
+					MIN<int>(bubY + copyH, 200));
+				scratch.fillRect(band, 0);
+				copyY = bubY;
+			}
+
+			_font.drawWordWrapped(&scratch, textX, textY,
+				MAX<int>(8, textW), text, 0xF);
+
+			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
+				scratch.pitch, 0, copyY, 320,
+				MIN<int>(copyH, 200 - copyY));
+			g_system->updateScreen();
+		}
+
+		// Wait for click/key to advance — only if we drew something.
+		// ESC skips the entire dialogue rather than just one entry.
+		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
+			bool advance = false;
+			bool skipAll = false;
+			while (!advance && !shouldQuit()) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_QUIT ||
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+						advance = true;
+						break;
+					}
+					if (ev.type == Common::EVENT_KEYDOWN &&
+						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						advance = true;
+						skipAll = true;
+						break;
+					}
+					if (ev.type == Common::EVENT_LBUTTONDOWN ||
+						ev.type == Common::EVENT_KEYDOWN) {
+						advance = true;
+						break;
+					}
+				}
+				g_system->delayMillis(10);
+			}
+			if (skipAll) {
+				// Apply remaining side-effects without rendering. The
+				// original silently runs the state updates even when the
+				// player skips ahead.
+				for (uint k = i; k < number; k++)
+					applyClueSideEffects(clueBlock + 4 + k * 62);
+				return;
+			}
 		}
+
+		applyClueSideEffects(c);
 	}
+}
+
+void EEMEngine::doNotebook() {
+	// Mirrors `_DrawNotes` @ 161e:01d0 + `_HandleNoteButton`. We list every
+	// found clue with its NoteIndex point value and let the player toggle
+	// "selected" with number keys 1..9 (paged in groups of 9). The total
+	// points of selected clues feed `_SolvedCheck` during accuse.
+	if (!_font.isLoaded())
+		return;
+
+	int page = 0;
+	const int kPerPage = 9;
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		_font.drawString(&scratch, 8, 4, "NOTEBOOK", 0xF);
+		_font.drawString(&scratch, 200, 4,
+			Common::String::format("pts: %d", _mystery.selectedPoints()), 0xF);
+
+		// Build a list of found-clue indices.
+		Common::Array<uint> found;
+		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
+			if (_mystery._cluesFound[i])
+				found.push_back(i);
+		const int total = (int)found.size();
+		const int pages = MAX<int>(1, (total + kPerPage - 1) / kPerPage);
+		page = MIN<int>(page, pages - 1);
+
+		_font.drawString(&scratch, 200, 16,
+			Common::String::format("page %d/%d", page + 1, pages), 0xF);
+
+		const byte *ni = _mystery.noteIndex();
+		const uint16 niCount = _mystery.noteIndexCount();
+		const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
+		int y = 4 + _font.height() * 2 + 4;
+		for (int slot = 0; slot < kPerPage; slot++) {
+			const int idx = page * kPerPage + slot;
+			if (idx >= total)
+				break;
+			const uint clueId = found[idx];
+			Common::String text;
+			int pts = 0;
+			if (ni && clueId < niCount) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				const uint16 ptsRaw  = READ_LE_UINT16(ni + clueId * 4 + 2);
+				pts = (int)(int16)ptsRaw;
+				const Common::String raw = _mystery.textAt(textOff);
+				text = parseString(raw, _playerName, partnerName);
+			}
+			if (text.empty())
+				text = Common::String::format("clue %u", clueId);
+
+			const char selMark = _mystery._noteSelected[clueId] ? '*' : ' ';
+			Common::String line = Common::String::format(
+				"%d [%c] (%d pts) %s", slot + 1, selMark, pts, text.c_str());
+			const int used = _font.drawWordWrapped(&scratch, 8, y, 304, line, 0xF);
+			y += used + 2;
+			if (y >= 192) break;
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	draw();
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		bool exit  = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) { exit = true; break; }
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) { exit = true; break; }
+				if (ev.kbd.keycode >= Common::KEYCODE_1 && ev.kbd.keycode <= Common::KEYCODE_9) {
+					const int slot = (int)(ev.kbd.keycode - Common::KEYCODE_1);
+					Common::Array<uint> found;
+					for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
+						if (_mystery._cluesFound[i])
+							found.push_back(i);
+					const int idx = page * kPerPage + slot;
+					if (idx < (int)found.size()) {
+						const uint clueId = found[idx];
+						_mystery._noteSelected[clueId] ^= 1;
+						dirty = true;
+					}
+				} else if (ev.kbd.keycode == Common::KEYCODE_TAB ||
+						   ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					page++; dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					if (page > 0) page--;
+					dirty = true;
+				}
+			}
+		}
+		if (exit) break;
+		if (dirty) draw();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doGallery() {
+	// Mirrors `_DrawGallery` @ 158f:0046. The original loops `_NumSuspects`
+	// gallery entries (0x46 = 70 bytes each in `_GalleryData`); the first
+	// u16 of each entry is the PIC picture ID for that suspect. We render
+	// them in a row across the screen.
+	if (!_mystery.isLoaded())
+		return;
+
+	const byte *gd = _mystery.galleryData();
+	if (!gd) {
+		warning("doGallery: no GalleryData in mystery %u", _mystery.number());
+		return;
+	}
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+
+	// Use PIC 0x3f as the gallery backdrop, matching `_DoAccuseGallery`.
+	Picture galBg;
+	if (_picsArchive.getPicture(0x3f, galBg)) {
+		const int w = MIN<int>(galBg.surface.w, 320);
+		const int h = MIN<int>(galBg.surface.h, 200);
+		for (int row = 0; row < h; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)galBg.surface.getBasePtr(0, row), w);
+		}
+	}
+
+	if (_font.isLoaded())
+		_font.drawString(&scratch, 8, 4, "GALLERY", 0xF);
+
+	const uint8 num = _mystery.numSuspects();
+	int slotX = 8;
+	const int slotY = 24;
+	const int slotStep = 320 / MAX<uint8>(1, num);
+	for (uint i = 0; i < num; i++) {
+		const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+		if (picId == 0)
+			continue;
+		Picture portrait;
+		if (!_picsArchive.getPicture(picId, portrait))
+			continue;
+		const int placeX = slotX + (slotStep - portrait.surface.w) / 2;
+		const int placeY = slotY;
+		const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+		const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+		if (w > 0 && h > 0) {
+			for (int row = 0; row < h; row++) {
+				memcpy((byte *)scratch.getBasePtr(placeX, placeY + row),
+					   (const byte *)portrait.surface.getBasePtr(0, row), w);
+			}
+		}
+		// Suspect number + discovered marker under the portrait.
+		if (_font.isLoaded()) {
+			const bool discovered = (i < Mystery::kGalleryCap) &&
+									_mystery._inGallery[i];
+			Common::String label = Common::String::format("%u%s",
+				i + 1, discovered ? " *" : "");
+			_font.drawString(&scratch, placeX + 4, placeY + h + 2, label, 0xF);
+		}
+		slotX += slotStep;
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
 	g_system->updateScreen();
-	g_system->delayMillis(10);
-	return true;
+	waitForInput(60000);
+}
+
+void EEMEngine::doBigMap() {
+	// Mirrors `_DoBigMap` @ 20fe:09e7. The original lays out a 0xe9 x 0xab
+	// map window inside frame PIC 0x42 with per-site clickable markers
+	// scrolling under it. We render BIGMAP.PIC at top-left, draw an
+	// overlay listing the sites that the player can travel to (per
+	// `_OnSites`), and accept either number keys or clicks on the
+	// overlay rows to travel.
+	Common::File f;
+	if (!f.open(Common::Path("BIGMAP.PIC"))) {
+		warning("doBigMap: BIGMAP.PIC missing");
+		return;
+	}
+	const uint16 mapH = f.readUint16LE();
+	const uint16 mapW = f.readUint16LE();
+	if (mapW == 0 || mapH == 0)
+		return;
+
+	Common::Array<byte> mapPixels((uint32)mapW * mapH);
+	if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
+		warning("doBigMap: short read on BIGMAP.PIC");
+		return;
+	}
+
+	// Approximate inner map window from the original `_DoBigMap`:
+	//   if (sx < 0x75) sx = 0; else sx -= 0x74;     // 0x74 = 116
+	//   if (mapW < sx + 0xe9) sx = mapW - 0xe9;     // window width  = 233
+	//   if (sy < 0x56) sy = 0; else sy -= 0x55;
+	//   if (mapH < sy + 0xab) sy = mapH - 0xab;     // window height = 171
+	const int kMapWinW = 0xe9; // 233
+	const int kMapWinH = 0xab; // 171
+	const int kMapWinX = 4;
+	const int kMapWinY = 4;
+
+	int scrollX = 0;
+	int scrollY = 0;
+
+	// Auto-scroll to centre the current site, if known.
+	if (_mystery.isLoaded()) {
+		const byte *entry = _mystery.mapEntry(_mystery._siteNumber);
+		if (entry) {
+			const uint16 mx = READ_LE_UINT16(entry + 4);
+			const uint16 my = READ_LE_UINT16(entry + 6);
+			scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW,
+				(int)mx - kMapWinW / 2));
+			scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH,
+				(int)my - kMapWinH / 2));
+		}
+	}
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		// Frame from PIC 0x42 (`_GetPicture(0x42)` in `_DoBigMap`).
+		Picture frame;
+		if (_picsArchive.getPicture(0x42, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+			}
+		}
+
+		// Map content clipped into the inner window, applying scroll.
+		const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
+		const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
+		for (int row = 0; row < copyH; row++) {
+			memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
+				   mapPixels.data() + (scrollY + row) * mapW + scrollX,
+				   copyW);
+		}
+
+		// Site markers from MapData. Each per-mystery MapData entry is
+		// 14 bytes; bytes +4..+5 / +6..+7 hold an (x, y) pair on the big
+		// map. We draw a small filled square for each accessible site.
+		if (_mystery.isLoaded()) {
+			for (uint i = 0; i < _mystery.numSites(); i++) {
+				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+					continue;
+				const byte *entry = _mystery.mapEntry(i);
+				if (!entry)
+					continue;
+				const uint16 mx = READ_LE_UINT16(entry + 4);
+				const uint16 my = READ_LE_UINT16(entry + 6);
+				const int sx = (int)mx - scrollX + kMapWinX;
+				const int sy = (int)my - scrollY + kMapWinY;
+				if (sx < kMapWinX || sx >= kMapWinX + kMapWinW ||
+					sy < kMapWinY || sy >= kMapWinY + kMapWinH)
+					continue;
+				const byte color = (i == _mystery._siteNumber) ? 0x0E : 0x0F;
+				const Common::Rect mark(sx - 2, sy - 2, sx + 3, sy + 3);
+				scratch.fillRect(mark, color);
+				if (_font.isLoaded()) {
+					Common::String num = Common::String::format("%u", i);
+					_font.drawString(&scratch, sx + 4, sy - 4, num, color);
+				}
+			}
+		}
+
+		// Travel-target overlay (right-side panel).
+		if (_font.isLoaded() && _mystery.isLoaded()) {
+			const int panelX = kMapWinX + kMapWinW + 4;
+			int y = 4;
+			_font.drawString(&scratch, panelX, y, "TRAVEL", 0xF);
+			y += _font.height() + 4;
+			for (uint i = 0; i < _mystery.numSites() && y < 192; i++) {
+				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+					continue;
+				const char marker = (i == _mystery._siteNumber) ? '>' : ' ';
+				Common::String label = Common::String::format(
+					"%c %u", marker, i);
+				_font.drawString(&scratch, panelX, y, label, 0x0F);
+				y += _font.height() + 1;
+			}
+			_font.drawString(&scratch, panelX, 188,
+							 "Esc", 0x0F);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return;
+				const int kStep = 16;
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					scrollX = MAX<int>(0, scrollX - kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW), scrollX + kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
+					scrollY = MAX<int>(0, scrollY - kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
+					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH), scrollY + kStep);
+					dirty = true;
+				}
+				if (ev.kbd.keycode >= Common::KEYCODE_0 &&
+					ev.kbd.keycode <= Common::KEYCODE_9) {
+					const uint target = (uint)(ev.kbd.keycode - Common::KEYCODE_0);
+					if (_mystery.isLoaded() &&
+						target < _mystery.numSites() &&
+						_mystery._onSites[target]) {
+						_mystery._lastSite = _mystery._siteNumber;
+						_mystery._siteNumber = (uint16)target;
+						return;
+					}
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Click on a map marker?
+				if (_mystery.isLoaded() &&
+					ev.mouse.x >= kMapWinX &&
+					ev.mouse.x < kMapWinX + kMapWinW &&
+					ev.mouse.y >= kMapWinY &&
+					ev.mouse.y < kMapWinY + kMapWinH) {
+					for (uint i = 0; i < _mystery.numSites(); i++) {
+						if (!_mystery._onSites[i] &&
+							i != _mystery._siteNumber)
+							continue;
+						const byte *entry = _mystery.mapEntry(i);
+						if (!entry) continue;
+						const uint16 mx = READ_LE_UINT16(entry + 4);
+						const uint16 my = READ_LE_UINT16(entry + 6);
+						const int sx = (int)mx - scrollX + kMapWinX;
+						const int sy = (int)my - scrollY + kMapWinY;
+						if (ABS(ev.mouse.x - sx) <= 5 &&
+							ABS(ev.mouse.y - sy) <= 5) {
+							_mystery._lastSite = _mystery._siteNumber;
+							_mystery._siteNumber = (uint16)i;
+							return;
+						}
+					}
+				}
+
+				// Click in the right panel: travel to that row.
+				const int panelX = kMapWinX + kMapWinW + 4;
+				if (_font.isLoaded() && _mystery.isLoaded() &&
+					ev.mouse.x >= panelX) {
+					const int row = (ev.mouse.y - 4 - _font.height() - 4) /
+									(_font.height() + 1);
+					int seen = 0;
+					for (uint i = 0; i < _mystery.numSites(); i++) {
+						if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+							continue;
+						if (seen == row) {
+							_mystery._lastSite = _mystery._siteNumber;
+							_mystery._siteNumber = (uint16)i;
+							return;
+						}
+						seen++;
+					}
+				}
+			}
+		}
+		if (dirty)
+			draw();
+		g_system->delayMillis(10);
+	}
+}
+
+void EEMEngine::doHelp() {
+	// `_KDHelp` reads two hint TextBlock offsets from `_KDTextIndex`:
+	//   word @ +0xe : first-time hint
+	//   word @ +0x10: second-time hint (cycles back to first if missing)
+	// `_SawHelpHint` toggles between them.
+	if (!_mystery.isLoaded() || !_font.isLoaded())
+		return;
+
+	const byte *kd = _mystery.kdTextIndex();
+	if (!kd)
+		return;
+
+	const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
+	const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
+	uint16 use = _mystery._sawHelpHint && hintSecond != 0xFFFF ? hintSecond : hintFirst;
+	if (use == 0xFFFF) {
+		debugC(1, kDebugScript, "doHelp: no hint configured");
+		return;
+	}
+	if (!_mystery._sawHelpHint && hintFirst != 0xFFFF)
+		_mystery._sawHelpHint = true;
+
+	const Common::String raw = _mystery.textAt(use);
+	const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
+	const Common::String text = parseString(raw, _playerName, partnerName);
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	_font.drawString(&scratch, 8, 4, "HELP", 0xF);
+	_font.drawWordWrapped(&scratch, 8, 24, 304, text, 0xF);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+	waitForInput(60000);
+}
+
+bool EEMEngine::areYouSure() {
+	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
+	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
+	// text dialog that preserves the screen behind it.
+	if (!_font.isLoaded())
+		return true;
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	Graphics::ManagedSurface saved(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	if (screen) {
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)saved.getBasePtr(0, row),
+				   (const byte *)screen->getBasePtr(0, row), 320);
+		}
+		g_system->unlockScreen();
+	}
+
+	const Common::Rect dlg(60, 70, 260, 140);
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	for (int row = 0; row < 200; row++)
+		memcpy((byte *)scratch.getBasePtr(0, row),
+			   (const byte *)saved.getBasePtr(0, row), 320);
+	scratch.fillRect(dlg, 0);
+	scratch.frameRect(dlg, 0xF);
+	_font.drawString(&scratch, dlg.left + 8, dlg.top + 8,
+		"Are you sure you want to quit?", 0xF);
+	_font.drawString(&scratch, dlg.left + 16, dlg.top + 36, "Y - Yes", 0xF);
+	_font.drawString(&scratch, dlg.left + 100, dlg.top + 36, "N - No", 0xF);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+
+	bool result = false;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool decided = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				result = true;
+				decided = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_y ||
+					ev.kbd.keycode == Common::KEYCODE_RETURN) {
+					result = true; decided = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_n ||
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					result = false; decided = true; break;
+				}
+			}
+		}
+		if (decided)
+			break;
+		g_system->delayMillis(15);
+	}
+
+	// Restore the screen so the caller's UI is intact.
+	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+	return result;
+}
+
+void EEMEngine::doAccuse() {
+	if (!_mystery.isLoaded())
+		return;
+
+	// Mirrors `_DoAccuseGallery` @ 1df2:0a31. Render gallery + prompt,
+	// accept either keyboard 1..N or a click on a suspect's portrait.
+	const uint8 num = _mystery.numSuspects();
+	if (num == 0)
+		return;
+
+	const byte *gd = _mystery.galleryData();
+	const int slotStep = 320 / MAX<uint8>(1, num);
+	const int slotY    = 24;
+
+	// Mirrors `_DoAccuseGallery`: load PIC 0x3f as the accuse backdrop.
+	Picture accuseBg;
+	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
+
+	auto drawGallery = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveAccuseBg) {
+			const int w = MIN<int>(accuseBg.surface.w, 320);
+			const int h = MIN<int>(accuseBg.surface.h, 200);
+			for (int row = 0; row < h; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)accuseBg.surface.getBasePtr(0, row), w);
+			}
+		}
+		if (_font.isLoaded())
+			_font.drawString(&scratch, 8, 4, "ACCUSE", 0xF);
+
+		for (uint i = 0; i < num; i++) {
+			if (!gd) continue;
+			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+			if (picId == 0)
+				continue;
+			Picture portrait;
+			if (!_picsArchive.getPicture(picId, portrait))
+				continue;
+			const int placeX = i * slotStep +
+							   (slotStep - portrait.surface.w) / 2;
+			const int placeY = slotY;
+			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+			if (w > 0 && h > 0) {
+				for (int row = 0; row < h; row++)
+					memcpy((byte *)scratch.getBasePtr(placeX, placeY + row),
+						   (const byte *)portrait.surface.getBasePtr(0, row), w);
+			}
+			if (_font.isLoaded()) {
+				Common::String label = Common::String::format("%u", i + 1);
+				_font.drawString(&scratch, placeX + 4,
+					placeY + h + 2, label, 0xF);
+			}
+		}
+		if (_font.isLoaded()) {
+			_font.drawString(&scratch, 8, 180,
+				"Click a suspect or press 1..N - ESC to cancel", 0xF);
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	drawGallery();
+
+	int picked = -1;
+	while (picked < 0 && !shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return;
+				const int k = (int)ev.kbd.keycode;
+				if (k >= Common::KEYCODE_1 && k <= Common::KEYCODE_9) {
+					const int idx = k - Common::KEYCODE_1;
+					if (idx < num)
+						picked = idx;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				const int slot = ev.mouse.x / slotStep;
+				if (slot >= 0 && slot < (int)num &&
+					ev.mouse.y >= slotY && ev.mouse.y < slotY + 120)
+					picked = slot;
+			}
+		}
+		g_system->delayMillis(10);
+	}
+	if (picked < 0)
+		return;
+
+	// Real chain evaluation: sum point values of clues the player marked
+	// "selected" in the notebook. Mirrors `_SolvedCheck` @ 1df2:00ec.
+	const int points = _mystery.selectedPoints();
+	const bool guessedRight = _mystery.solvedCheck();
+	debugC(1, kDebugScript, "doAccuse: picked=%d selectedPts=%d -> %s",
+		   picked, points, guessedRight ? "correct" : "wrong");
+
+	// If the player hasn't marked any evidence yet, give them a hint
+	// rather than an instant fail. Mirrors the original "We're not ready
+	// to solve this mystery yet..." string at 29be:10f0.
+	if (points == 0 && _font.isLoaded()) {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		_font.drawWordWrapped(&scratch, 16, 80, 288,
+			"We're not ready to solve this mystery yet. "
+			"Let's keep investigating until we have some "
+			"more solid evidence to make our case! "
+			"(Press N in the site screen to mark clues.)",
+			0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+		waitForInput(15000);
+		return;
+	}
+
+	// Pick the ending based on the chain. For a correct accusation the
+	// original would call `_DisplayClue(_Mystery + AChain[0])`, play
+	// SCRAPBK.ANI and save progress. We load the matching `E<n>.BIN`
+	// ending text and render its pages with prev/next navigation.
+	const int endingNum = guessedRight ? picked : 0;
+	const Common::String fname = Common::String::format("E%d.BIN", endingNum);
+	Common::File f;
+	if (!f.open(Common::Path(fname))) {
+		warning("doAccuse: %s missing", fname.c_str());
+		return;
+	}
+
+	// E<n>.BIN format (verified against `_DisplayEndingPage` @ 1df2:044c):
+	//   u16 numPages
+	//   per page (10 bytes header + NUL-string):
+	//     u16 picNum
+	//     u16 x1, y1, x2, y2  (story rect)
+	//     bytes[] NUL-terminated text
+	const uint32 fileLen = f.size();
+	Common::Array<byte> blob(fileLen);
+	if (f.read(blob.data(), fileLen) != fileLen)
+		return;
+	const byte *e = blob.data();
+	const uint16 pages = READ_LE_UINT16(e);
+
+	const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
+	uint pageIdx = 0;
+
+	while (!shouldQuit()) {
+		// Walk to pageIdx.
+		uint pos = 2;
+		uint cur = 0;
+		while (cur < pageIdx && pos + 10 < fileLen) {
+			const char *t = (const char *)(e + pos + 10);
+			pos += 10 + strlen(t) + 1;
+			cur++;
+		}
+		if (pos + 10 >= fileLen)
+			break;
+
+		const uint16 picNum = READ_LE_UINT16(e + pos + 0);
+		const uint16 x1     = READ_LE_UINT16(e + pos + 2);
+		const uint16 y1     = READ_LE_UINT16(e + pos + 4);
+		const uint16 x2     = READ_LE_UINT16(e + pos + 6);
+		const uint16 y2     = READ_LE_UINT16(e + pos + 8);
+		const char *raw     = (const char *)(e + pos + 10);
+		const Common::String txt = parseString(raw, _playerName, partnerName);
+
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		// Page background.
+		if (picNum != 0) {
+			Picture bg;
+			if (_picsArchive.getPicture(picNum, bg)) {
+				const int w = MIN<int>(bg.surface.w, 320);
+				const int h = MIN<int>(bg.surface.h, 200);
+				for (int row = 0; row < h; row++) {
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)bg.surface.getBasePtr(0, row), w);
+				}
+			}
+		}
+
+		if (_font.isLoaded()) {
+			Common::String banner = "Not enough evidence";
+			if (guessedRight)
+				banner = _mystery._firstTry ? "CORRECT - FIRST TRY!" : "CORRECT!";
+			_font.drawString(&scratch, 8, 4, banner, 0xF);
+			_font.drawString(&scratch, 8, 16,
+				Common::String::format("Evidence: %d/100  Suspect: %d",
+									   points, picked + 1), 0xF);
+			const int wrapW = MAX<int>(16, x2 - x1);
+			const int wrapY = MAX<int>(28, (int)y1);
+			(void)y2;
+			_font.drawWordWrapped(&scratch, x1, wrapY, wrapW, txt, 0xF);
+			_font.drawString(&scratch, 8, 188,
+				Common::String::format("page %u/%u  (Left/Right or click)",
+									   pageIdx + 1, pages), 0xF);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Page navigation.
+		bool advance = false;
+		bool back    = false;
+		bool exit    = false;
+		while (!advance && !back && !exit && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					exit = true; break;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					advance = true; break;
+				}
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+						exit = true;
+					else if (ev.kbd.keycode == Common::KEYCODE_LEFT)
+						back = true;
+					else
+						advance = true;
+					break;
+				}
+			}
+			g_system->delayMillis(15);
+		}
+		if (exit) break;
+		if (advance) {
+			if (pageIdx + 1 >= pages) break;
+			pageIdx++;
+		} else if (back) {
+			if (pageIdx > 0) pageIdx--;
+		}
+	}
+
+	// Mirror `_DisplayCorrect`'s scrap-book animation + solved tracking +
+	// auto-save (the original calls `_SavePlayerRecord` after a win).
+	if (guessedRight) {
+		const uint mn = _mystery.number();
+		if (mn < sizeof(_mysteriesSolved)) {
+			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
+		}
+		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
+
+		// Auto-save into slot 0 (the engine's quicksave slot).
+		const Common::String desc = Common::String::format(
+			"%s — solved mystery %u", _playerName.c_str(), mn);
+		Common::Error err = saveGameState(0, desc, true);
+		if (err.getCode() != Common::kNoError)
+			warning("auto-save after solve failed: %s",
+					err.getDesc().c_str());
+	} else {
+		_mystery._firstTry = false;
+	}
+}
+
+// -------------------- save / load --------------------
+
+namespace {
+const uint32 kSaveMagic = MKTAG('E', 'E', 'M', '0');
+const byte   kSaveVer   = 3;  ///< v2: _mysteriesSolved tracker; v3: player name
+} // anonymous namespace
+
+bool EEMEngine::hasFeature(EngineFeature f) const {
+	// We support saving any time but loading only at startup (via the
+	// `--save-slot=N` resume path or a slot picked from the launcher).
+	// Runtime loads would replace `_mystery._data` while pointers into
+	// it are alive on the stack inside `displayClue` etc.
+	return f == kSupportsSavingDuringRuntime ||
+		   f == kSupportsReturnToLauncher;
+}
+
+bool EEMEngine::canLoadGameStateCurrently(Common::U32String *) {
+	return false;  // Loading is startup-only.
+}
+
+bool EEMEngine::canSaveGameStateCurrently(Common::U32String *) {
+	return _mystery.isLoaded();
+}
+
+Common::Error EEMEngine::saveGameState(int slot, const Common::String &desc, bool isAutosave) {
+	Common::OutSaveFile *out = getSaveFileManager()->openForSaving(getSaveStateName(slot));
+	if (!out)
+		return Common::kCreatingFileFailed;
+
+	out->writeUint32BE(kSaveMagic);
+	out->writeByte(kSaveVer);
+
+	// Header: description + ScummVM extended save metadata are appended
+	// automatically when `EngineFeature::kSavesUseExtendedFormat` is set;
+	// our save body just carries the engine state.
+	(void)desc;
+	(void)isAutosave;
+
+	uint16 mysteryNum = (uint16)_mystery.number();
+	out->writeUint16LE(mysteryNum);
+	out->writeByte(_partner);
+	out->write(_mysteriesSolved, sizeof(_mysteriesSolved));
+
+	// v3: persist the player name so save-slot resume restores it.
+	out->writeUint16LE((uint16)_playerName.size());
+	out->writeString(_playerName);
+
+	debugC(1, kDebugGeneral,
+		   "Saved slot %d: mystery=%u partner=%u name=%s autosave=%d",
+		   slot, mysteryNum, _partner, _playerName.c_str(), isAutosave ? 1 : 0);
+
+	Common::Serializer s(nullptr, out);
+	s.setVersion(kSaveVer);
+	_mystery.syncState(s);
+
+	out->finalize();
+	delete out;
+	return Common::kNoError;
+}
+
+Common::Error EEMEngine::loadGameState(int slot) {
+	Common::InSaveFile *in = getSaveFileManager()->openForLoading(getSaveStateName(slot));
+	if (!in)
+		return Common::kReadingFailed;
+
+	if (in->readUint32BE() != kSaveMagic) {
+		delete in;
+		return Common::kUnknownError;
+	}
+	const byte ver = in->readByte();
+	if (ver > kSaveVer) {
+		delete in;
+		return Common::kUnknownError;
+	}
+
+	const uint16 mysteryNum = in->readUint16LE();
+	_partner = in->readByte();
+	if (ver >= 2)
+		in->read(_mysteriesSolved, sizeof(_mysteriesSolved));
+	else
+		memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
+
+	if (ver >= 3) {
+		const uint16 nameLen = in->readUint16LE();
+		Common::String name;
+		for (uint16 i = 0; i < nameLen && i < 64; i++)
+			name += (char)in->readByte();
+		_playerName = name.empty() ? Common::String("Detective") : name;
+	}
+
+	if (!_mystery.load(mysteryNum, &_rng)) {
+		_mystery.clear();
+		delete in;
+		return Common::kReadingFailed;
+	}
+
+	Common::Serializer s(in, nullptr);
+	s.setVersion(ver);
+	_mystery.syncState(s);
+
+	delete in;
+	debugC(1, kDebugGeneral,
+		   "Loaded slot %d: mystery=%u partner=%u name=%s",
+		   slot, mysteryNum, _partner, _playerName.c_str());
+	return Common::kNoError;
+}
+
+void EEMEngine::screenDriver() {
+	// Placeholder for the eventual dispatch table (one entry per ScreenId).
+	// run() currently calls handlers directly until the title path lands.
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 67a4b0967db..b73ef2ac2bc 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -24,6 +24,7 @@
 #ifndef EEM_EEM_H
 #define EEM_EEM_H
 
+#include "common/array.h"
 #include "common/platform.h"
 #include "common/random.h"
 #include "common/scummsys.h"
@@ -31,21 +32,22 @@
 #include "engines/advancedDetector.h"
 #include "engines/engine.h"
 
+#include "eem/animation.h"
+#include "eem/font.h"
+#include "eem/mystery.h"
 #include "eem/resource.h"
 
 namespace EEM {
 
-class Console;
-
 /**
  * Screen IDs used by the original ScreenDriver dispatch table at 1a35:0e5e.
  * The table holds 14 (id, handler) entries; the loop iterates until it finds
  * a matching id and calls its handler. ID 0xFFFF is the exit sentinel.
  */
 enum ScreenId {
-	kScreenInvalid    = 0xFFFF,
-	kScreenTitle      = 0x0B,  ///< _ShowTitlePage @ 1a35:06b7
-	kScreenNext       = 0x08   ///< follow-up after title (case selection); to be confirmed
+	kScreenInvalid       = 0xFFFF,
+	kScreenChoosePartner = 0x09,  ///< _DoChoosePartner @ 1a35:0756 (boy/girl picker)
+	kScreenTitle         = 0x0B   ///< _ShowTitlePage @ 1a35:06b7
 };
 
 class EEMEngine : public Engine {
@@ -58,9 +60,69 @@ public:
 	const char *getGameId() const;
 	Common::Platform getPlatform() const;
 
+	bool hasFeature(EngineFeature f) const override;
+	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
+	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
+	Common::Error loadGameState(int slot) override;
+	Common::Error saveGameState(int slot, const Common::String &desc, bool isAutosave) override;
+
 	const ADGameDescription *_gameDescription;
 
-	DBDArchive &getPics() { return _picsArchive; }
+	DBDArchive &getPics()    { return _picsArchive; }
+	DBDArchive &getAni()     { return _aniArchive; }
+	DBDArchive &getSites()   { return _sitesArchive; }
+	DBDArchive &getBalloons(){ return _balloonArchive; }
+	Mystery    &getMystery() { return _mystery; }
+	const EEMFont &getFont() const { return _font; }
+
+	/// Display one ClueBlock. @p clueBlock points at the u16 frame count
+	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
+	void displayClue(const byte *clueBlock);
+
+	/// Apply a single ClueEntry's side effects — notebook adds, gallery
+	/// updates, site flags. Called both by `displayClue` after a normal
+	/// click-through and when the player ESC-skips a multi-entry clue.
+	void applyClueSideEffects(const byte *entry);
+
+	/// Show clue/notebook screen. Mirrors `_DrawNotes` @ 161e:01d0.
+	void doNotebook();
+
+	/// Show suspect gallery. Mirrors `_DrawGallery` @ 158f:0046.
+	void doGallery();
+
+	/// Show big map; click chooses next site. Mirrors `_DoBigMap` @ 20fe:09e7.
+	void doBigMap();
+
+	/// Run the accuse flow (pick suspect, evaluate chains, show ending).
+	/// Mirrors `_DoAccuseGallery` @ 1df2:0a31 + `_DisplayEnding` @ 1df2:0548.
+	void doAccuse();
+
+	/// Show a host hint from `KDTextIndex`. Mirrors `_KDHelp` @ 1560:010a +
+	/// `_DisplayHint` @ 1560:0009. Cycles between the two hint slots that
+	/// the original engine tracks via `_SawHelpHint`.
+	void doHelp();
+
+	/// "Are you sure?" yes/no dialog. Mirrors `_AreYouSure` @ 1a35:0a5c.
+	/// Returns true if the user picked YES.
+	bool areYouSure();
+
+private:
+	/**
+	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
+	 * Each iteration calls the screen handler that matches _nextScreen.
+	 * Handlers update _lastScreen / _nextScreen and return; the loop exits
+	 * when _nextScreen == kScreenInvalid.
+	 */
+	void screenDriver();
+
+	/**
+	 * Open the five .DBD/.DBX archive pairs the way _InitGraphicsSystem
+	 * @ 172b:0145 does at boot.
+	 */
+	bool openArchives();
+
+	/** Slurp SITEPALS into @c _sitePals. Mirrors _ReadPalettes @ 172b:0d89. */
+	bool loadSitePalettes();
 
 	/**
 	 * Upload palette index @p num (one of 40 stored in SITEPALS) to the
@@ -69,28 +131,73 @@ public:
 	 */
 	void setSitePalette(uint num);
 
-	/** Blit @p pic to the screen at (0,0), expecting a 320x200 picture. */
-	void blitFullScreen(const Picture &pic);
+	/**
+	 * Upload a 6-bit VGA palette read from the head of an .ANM file (the
+	 * first 0x300 bytes per Load_Sequence @ 2503:0006). Used until the
+	 * full title-page animation chain is wired in.
+	 */
+	bool setAnmPalette(const Common::Path &anmPath);
 
+public:
+	/// Public so SiteScreen can switch palettes per site.
+	void setSitePaletteForSite(uint siteNum) { setSitePalette(siteNum + 1); }
 private:
+
+	/** Blit @p pic to @p x, @p y on screen. */
+	void blitAt(const Picture &pic, int x, int y);
+
+	/** Hold the current frame for up to @p maxMs or until the user inputs. */
+public:
+	void waitForInput(uint32 maxMs);
+private:
+
 	/**
-	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
-	 * Each iteration restores video mode and calls the screen handler that
-	 * matches _nextScreen. Handlers update _lastScreen / _nextScreen and
-	 * return; the loop exits when _nextScreen == kScreenInvalid.
+	 * Play a difference-encoded animation file (.ANM / .A) on the full
+	 * 320x200 screen. Mirrors the data flow of `OpenDifferenceAnimation`
+	 * @ 2520:0337 → `Load_Sequence` + `Play_Sequence`. Audio cues are
+	 * skipped for now. The default frame delay is 120 ms to match the
+	 * original FRAME_RATE = 0x78 used by `_DoOpeningAnims`.
+	 *
+	 * If @p holdLastFrame is true the call blocks on the final frame
+	 * until the user clicks or hits a key — used for the title screen.
 	 */
-	void screenDriver();
+	void playAnm(const Common::Path &path, uint frameDelayMs = 120,
+				 bool holdLastFrame = false);
 
-	bool pollEvents();
+	// Screen handlers — port targets in screens/ later.
+	void showEAKidsLogo();
+	void showHighScoreLogo();
+	void doNewPlayer();          ///< Mirrors `_NewPlayer` @ 1c33:0dda
+	void doChoosePartner();
+	void doCaseSelection();
+	void doSiteLoop();
+
+	/// Render the case briefing background + game/book decorations and
+	/// display the briefing ClueBlock. Mirrors `_DoInitClues` @ 1a35:0411
+	/// minus the live ANI sequence playback.
+	void doInitClues();
+
+	Common::String _playerName;  ///< Substituted into 0x80 placeholders
+
+	/// Per-mystery solved state. 0 = unsolved, 1 = solved, 2 = solved
+	/// on first try. Mirrors `_PlayerRecord.SolvedMysteries[55]` in the
+	/// original `_DisplayCorrect` flow.
+	uint8 _mysteriesSolved[55] = {};
 
-	Console *_console;
 	Common::RandomSource _rng;
 
-	DBDArchive _picsArchive;  ///< PICS.DBD/.DBX (mouse, buttons, markers, balloons sprites)
-	Common::Array<byte> _sitePals; ///< 40 x 768 bytes of 6-bit VGA palettes from SITEPALS
+	DBDArchive _picsArchive;     ///< PICS.DBD/.DBX (sprites, buttons, frame backgrounds)
+	DBDArchive _aniArchive;      ///< ANI.DBD/.DBX (multi-frame character animations)
+	DBDArchive _sitesArchive;    ///< SITES.DBD/.DBX (one full-screen scene per site)
+	DBDArchive _balloonArchive;  ///< BALLOON.DBD/.DBX (speech-balloon sprites)
+	Mystery    _mystery;         ///< Currently-loaded case file (M<n>.BIN)
+	EEMFont    _font;            ///< FONT.FNT - main 8 px font
+
+	Common::Array<byte> _sitePals; ///< 40 x 768 bytes of 6-bit VGA palettes
 
 	uint16 _lastScreen;  ///< Mirrors _LastScreen @ 2d5d:3f24
 	uint16 _nextScreen;  ///< Mirrors _NextScreen @ 2d5d:3f26
+	uint8  _partner;     ///< Mirrors _Partner: 0 = boy (Jake), 1 = girl (Jenny)
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
new file mode 100644
index 00000000000..f8ca1865c8d
--- /dev/null
+++ b/engines/eem/font.cpp
@@ -0,0 +1,139 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/file.h"
+#include "common/textconsole.h"
+#include "common/tokenizer.h"
+
+#include "graphics/managed_surface.h"
+#include "graphics/surface.h"
+
+#include "eem/detection.h"
+#include "eem/font.h"
+
+namespace EEM {
+
+bool EEMFont::load(const Common::Path &path) {
+	Common::File f;
+	if (!f.open(path)) {
+		warning("EEMFont::load: cannot open %s", path.toString().c_str());
+		return false;
+	}
+
+	const uint16 numChars = f.readUint16LE();
+	if (numChars == 0 || numChars > 256) {
+		warning("EEMFont::load: %s reports %u chars", path.toString().c_str(), numChars);
+		return false;
+	}
+	_glyphs.resize(numChars);
+
+	for (uint i = 0; i < numChars; i++) {
+		FontGlyph &g = _glyphs[i];
+		g.height    = f.readByte();
+		g.widthBits = f.readByte();
+		g.sizeBytes = f.readByte();
+		if (g.sizeBytes > 0) {
+			g.bitmap.resize(g.sizeBytes);
+			if (f.read(g.bitmap.data(), g.sizeBytes) != g.sizeBytes) {
+				warning("EEMFont::load: short bitmap read at glyph %u", i);
+				return false;
+			}
+		}
+		if (g.height > _maxHeight)
+			_maxHeight = g.height;
+	}
+
+	debugC(1, kDebugGfx, "Font %s loaded: %u glyphs, max h=%u",
+		   path.toString().c_str(), numChars, _maxHeight);
+	return true;
+}
+
+int EEMFont::charWidth(byte c) const {
+	if (c >= _glyphs.size())
+		return 0;
+	return _glyphs[c].widthBits;
+}
+
+int EEMFont::stringWidth(const Common::String &s) const {
+	int w = 0;
+	for (uint i = 0; i < s.size(); i++)
+		w += charWidth((byte)s[i]);
+	return w;
+}
+
+int EEMFont::drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte color) const {
+	if (!dst || c >= _glyphs.size())
+		return 0;
+	const FontGlyph &g = _glyphs[c];
+	if (g.bitmap.empty())
+		return g.widthBits;
+
+	const uint bytesPerRow = (g.widthBits + 7) / 8;
+	for (uint row = 0; row < g.height; row++) {
+		const int dstY = y + (int)row;
+		if (dstY < 0 || dstY >= dst->h)
+			continue;
+		const byte *srcRow = g.bitmap.data() + row * bytesPerRow;
+		byte *dstRow = (byte *)dst->getBasePtr(0, dstY);
+		for (uint bit = 0; bit < g.widthBits; bit++) {
+			const int dstX = x + (int)bit;
+			if (dstX < 0 || dstX >= dst->w)
+				continue;
+			const byte mask = (byte)(0x80 >> (bit & 7));
+			if (srcRow[bit / 8] & mask)
+				dstRow[dstX] = color;
+		}
+	}
+	return g.widthBits;
+}
+
+int EEMFont::drawString(Graphics::ManagedSurface *dst, int x, int y,
+						const Common::String &s, byte color) const {
+	int penX = x;
+	for (uint i = 0; i < s.size(); i++)
+		penX += drawChar(dst, penX, y, (byte)s[i], color);
+	return penX - x;
+}
+
+int EEMFont::drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y, int width,
+							 const Common::String &s, byte color) const {
+	Common::StringTokenizer tok(s, " \t");
+	int penY = y;
+	Common::String line;
+
+	while (!tok.empty()) {
+		const Common::String word = tok.nextToken();
+		Common::String trial = line.empty() ? word : line + " " + word;
+		if (stringWidth(trial) <= width) {
+			line = trial;
+		} else {
+			drawString(dst, x, penY, line, color);
+			penY += _maxHeight + 1;
+			line = word;
+		}
+	}
+	if (!line.empty())
+		drawString(dst, x, penY, line, color);
+	return penY + _maxHeight - y;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/font.h b/engines/eem/font.h
new file mode 100644
index 00000000000..f175d5360fa
--- /dev/null
+++ b/engines/eem/font.h
@@ -0,0 +1,90 @@
+/* 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 EEM_FONT_H
+#define EEM_FONT_H
+
+#include "common/array.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+#include "common/str.h"
+
+namespace Graphics {
+class ManagedSurface;
+}
+
+namespace EEM {
+
+/// One bitmap glyph, 1 bit per pixel, MSB first.
+struct FontGlyph {
+	uint8 height    = 0;
+	uint8 widthBits = 0;
+	uint8 sizeBytes = 0;
+	Common::Array<byte> bitmap;
+};
+
+/**
+ * Loader for the engine's `.FNT` files (FONT.FNT, SYSTEM.FNT, TINY.FNT,
+ * 8PNTTHIN.FNT). Mirrors `_LoadFont` @ 1b66:023c.
+ *
+ * File layout:
+ *   - u16 numChars
+ *   - per char: u8 height, u8 widthBits, u8 sizeBytes, bytes[sizeBytes] bitmap
+ *
+ * Drawing mirrors `_ShowChar` @ 1b66:0346: each set bit becomes `fontColor`
+ * on the destination surface; clear bits are transparent.
+ */
+class EEMFont {
+public:
+	EEMFont() = default;
+
+	bool load(const Common::Path &path);
+
+	uint16 height() const { return _maxHeight; }
+
+	int charWidth(byte c) const;
+
+	/// Total pixel width of @p s when rendered (no shadow).
+	int stringWidth(const Common::String &s) const;
+
+	/// Draw @p c at (@p x, @p y) on @p dst with foreground @p color.
+	/// Returns the advance width.
+	int drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte color) const;
+
+	/// Draw @p s at (@p x, @p y) and return total advance width.
+	int drawString(Graphics::ManagedSurface *dst, int x, int y,
+				   const Common::String &s, byte color) const;
+
+	/// Word-wrap @p s into the rect (x..x+width, y..) and draw line by line.
+	/// Mirrors the data flow of `_DoWordWrap` @ 1b66:04a7.
+	int drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y, int width,
+						const Common::String &s, byte color) const;
+
+	bool isLoaded() const { return !_glyphs.empty(); }
+
+private:
+	Common::Array<FontGlyph> _glyphs;
+	uint16 _maxHeight = 0;
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index 0d61cf6a944..21dee682b00 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -24,6 +24,8 @@
 
 #include "eem/eem.h"
 
+#include "common/system.h"
+
 namespace EEM {
 
 const char *EEMEngine::getGameId() const {
@@ -48,7 +50,8 @@ public:
 	}
 
 	bool hasFeature(MetaEngineFeature f) const override {
-		return false;
+		return checkExtendedSaves(f) ||
+			   f == kSupportsLoadingDuringStartup;
 	}
 };
 
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index 34247658998..047409575c9 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -1,10 +1,13 @@
 MODULE := engines/eem
 
 MODULE_OBJS = \
-	console.o \
+	animation.o \
 	eem.o \
+	font.o \
 	metaengine.o \
-	resource.o
+	mystery.o \
+	resource.o \
+	site.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_EEM), DYNAMIC_PLUGIN)
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
new file mode 100644
index 00000000000..e3b4051a14a
--- /dev/null
+++ b/engines/eem/mystery.cpp
@@ -0,0 +1,261 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/file.h"
+#include "common/random.h"
+#include "common/serializer.h"
+#include "common/textconsole.h"
+
+#include "eem/detection.h"
+#include "eem/mystery.h"
+
+namespace EEM {
+
+uint16 Mystery::readU16(uint offset) const {
+	if (offset + 2 > _data.size())
+		return 0;
+	return READ_LE_UINT16(_data.data() + offset);
+}
+
+void Mystery::clear() {
+	_data.clear();
+	_number = 0;
+	_initOffset = _mapOffset = _siteIndexOffset = _textOffset = 0;
+	_noteOffset = _galleryOffset = _kdTextOffset = 0;
+	_solvedOffset = _hintOffset = 0;
+	_numSites = 0;
+	_numSuspects = _numCONSITEs = _numCOFFSITEs = 0;
+	memset(_aChain, 0, sizeof(_aChain));
+	memset(_bChain, 0, sizeof(_bChain));
+	memset(_cChain, 0, sizeof(_cChain));
+	memset(_cluesFound, 0, sizeof(_cluesFound));
+	memset(_noteSelected, 0, sizeof(_noteSelected));
+	memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
+	memset(_inGallery, 0, sizeof(_inGallery));
+	memset(_newOrder, 0, sizeof(_newOrder));
+	memset(_visitedSite, 0, sizeof(_visitedSite));
+	memset(_onSites, 0, sizeof(_onSites));
+	_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+	_firstTry = true;
+	_searchLocationNumber = _siteNumber = 0xFFFF;
+	_lastSite = 0x1B;
+}
+
+bool Mystery::load(uint num, Common::RandomSource *rng) {
+	const Common::String fname = Common::String::format("M%u.BIN", num);
+	Common::File f;
+	if (!f.open(Common::Path(fname))) {
+		warning("Mystery::load: cannot open %s", fname.c_str());
+		return false;
+	}
+
+	const int32 size = f.size();
+	if (size <= 64) {
+		warning("Mystery::load: %s too small (%d bytes)", fname.c_str(), size);
+		return false;
+	}
+
+	// Stage to a temporary buffer so a short read leaves the previous
+	// mystery state intact instead of half-clobbering `_data`.
+	Common::Array<byte> staging(size);
+	if (f.read(staging.data(), size) != (uint32)size) {
+		warning("Mystery::load: short read on %s", fname.c_str());
+		return false;
+	}
+	f.close();
+
+	_data = staging;
+	_number = num;
+
+	// Header is 16-bit-word indexed (matches `int *piVar1 = __Mystery; piVar1[N]`).
+	_initOffset      = readU16(0  * 2);
+	_mapOffset       = readU16(2  * 2);
+	_siteIndexOffset = readU16(3  * 2);
+	_textOffset      = readU16(4  * 2);
+	_noteOffset      = readU16(5  * 2);
+	_galleryOffset   = readU16(6  * 2);
+	_kdTextOffset    = readU16(7  * 2);
+	_solvedOffset    = readU16(8  * 2);
+	_hintOffset      = readU16(9  * 2);
+
+	_numSites    = readU16(10 * 2);
+	_numSuspects = (uint8)readU16(13 * 2);
+	_numCONSITEs = (uint8)readU16(14 * 2);
+	_numCOFFSITEs = (uint8)readU16(15 * 2);
+
+	for (uint i = 0; i < kChainLen; i++) {
+		_aChain[i] = readU16((16 + i) * 2);
+		_bChain[i] = readU16((21 + i) * 2);
+		_cChain[i] = readU16((26 + i) * 2);
+	}
+
+	// Per-mystery runtime state — `_ReadMystery` zeroes these at load.
+	memset(_cluesFound, 0, sizeof(_cluesFound));
+	memset(_noteSelected, 0, sizeof(_noteSelected));
+	memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
+	memset(_inGallery, 0, sizeof(_inGallery));
+	// `_NewOrder` in the original randomly cycles the gallery positions
+	// per playthrough. For consistency between clue side-effects (which
+	// write to `_inGallery[_newOrder[galIdx]]`) and gallery rendering
+	// (which iterates logical indices), we keep the identity mapping.
+	// If the original's randomized positioning is required later, both
+	// the side-effect path AND the rendering path need to use it together.
+	(void)rng;
+	for (uint i = 0; i < kGalleryCap; i++)
+		_newOrder[i] = (uint8)i;
+	memset(_visitedSite, 0, sizeof(_visitedSite));
+	memset(_onSites, 0, sizeof(_onSites));
+	_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+	_firstTry = true;
+	_searchLocationNumber = _siteNumber = 0xFFFF;
+	_lastSite = 0x1B; // Sentinel matching _ReadMystery's `_LastSite = 0x1b`.
+
+	debugC(1, kDebugMystery, "Loaded %s (%d B): %u sites, %u suspects, "
+		   "CON=%u COFF=%u, init=0x%04x site=0x%04x text=0x%04x",
+		   fname.c_str(), size, _numSites, _numSuspects,
+		   _numCONSITEs, _numCOFFSITEs,
+		   _initOffset, _siteIndexOffset, _textOffset);
+	return true;
+}
+
+const byte *Mystery::siteIndexEntry(uint siteNum) const {
+	if (!isLoaded() || siteNum >= _numSites)
+		return nullptr;
+	const uint off = _siteIndexOffset + siteNum * 6;
+	if (off + 6 > _data.size())
+		return nullptr;
+	return _data.data() + off;
+}
+
+const byte *Mystery::siteData(uint siteNum) const {
+	const byte *idx = siteIndexEntry(siteNum);
+	if (!idx)
+		return nullptr;
+	const uint16 dataOff = READ_LE_UINT16(idx);
+	if (dataOff >= _data.size())
+		return nullptr;
+	return _data.data() + dataOff;
+}
+
+const byte *Mystery::hotspots(uint siteNum) const {
+	const byte *idx = siteIndexEntry(siteNum);
+	if (!idx)
+		return nullptr;
+	const uint16 hotspotOff = READ_LE_UINT16(idx + 4);
+	if (hotspotOff >= _data.size())
+		return nullptr;
+	return _data.data() + hotspotOff;
+}
+
+uint16 Mystery::hotspotCount(uint siteNum) const {
+	const byte *site = siteData(siteNum);
+	if (!site || (size_t)(site - _data.data()) + 8 > _data.size())
+		return 0;
+	return READ_LE_UINT16(site + 6);
+}
+
+const char *Mystery::textAt(uint16 offset) const {
+	if (!isLoaded())
+		return "";
+	const uint pos = _textOffset + offset;
+	if (pos >= _data.size())
+		return "";
+	return (const char *)(_data.data() + pos);
+}
+
+const byte *Mystery::initBlock() const {
+	if (!isLoaded() || _initOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _initOffset;
+}
+
+const byte *Mystery::galleryData() const {
+	if (!isLoaded() || _galleryOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _galleryOffset;
+}
+
+const byte *Mystery::noteIndex() const {
+	if (!isLoaded() || _noteOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _noteOffset;
+}
+
+uint16 Mystery::noteIndexCount() const {
+	if (!isLoaded())
+		return 0;
+	// NoteIndex runs from _noteOffset to the start of GalleryData.
+	if (_galleryOffset <= _noteOffset)
+		return 0;
+	return (uint16)((_galleryOffset - _noteOffset) / 4);
+}
+
+const byte *Mystery::kdTextIndex() const {
+	if (!isLoaded() || _kdTextOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _kdTextOffset;
+}
+
+const byte *Mystery::mapEntry(uint siteNum) const {
+	if (!isLoaded() || siteNum >= _numSites)
+		return nullptr;
+	const uint off = _mapOffset + siteNum * 14;
+	if (off + 14 > _data.size())
+		return nullptr;
+	return _data.data() + off;
+}
+
+int Mystery::selectedPoints() const {
+	const byte *ni = noteIndex();
+	const uint16 cnt = noteIndexCount();
+	if (!ni || cnt == 0)
+		return 0;
+	int total = 0;
+	for (uint i = 0; i < cnt && i < kCluesFoundCap; i++) {
+		if (!_noteSelected[i])
+			continue;
+		// Each NoteIndex entry is 4 bytes: u16 textOff + u16 points.
+		const uint16 pts = READ_LE_UINT16(ni + i * 4 + 2);
+		total += (int)(int16)pts;
+	}
+	return total;
+}
+
+void Mystery::syncState(Common::Serializer &s) {
+	s.syncBytes(_cluesFound, kCluesFoundCap);
+	s.syncBytes(_noteSelected, kCluesFoundCap);
+	s.syncArray(_hotSpotsSeen, kHotSpotsCap, Common::Serializer::Uint16LE);
+	s.syncArray(_inGallery,    kGalleryCap,  Common::Serializer::Uint16LE);
+	s.syncBytes(_newOrder, kGalleryCap);
+	s.syncArray(_visitedSite, kVisitedSiteCap, Common::Serializer::Uint16LE);
+	s.syncArray(_onSites,     kVisitedSiteCap, Common::Serializer::Uint16LE);
+	s.syncAsByte(_sawCOFFSITEs);
+	s.syncAsByte(_sawCONSITEs);
+	s.syncAsByte(_sawHelpHint);
+	s.syncAsByte(_solvedPuzzle);
+	s.syncAsByte(_firstTry);
+	s.syncAsUint16LE(_searchLocationNumber);
+	s.syncAsUint16LE(_siteNumber);
+	s.syncAsUint16LE(_lastSite);
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
new file mode 100644
index 00000000000..b0ea78677e6
--- /dev/null
+++ b/engines/eem/mystery.h
@@ -0,0 +1,188 @@
+/* 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 EEM_MYSTERY_H
+#define EEM_MYSTERY_H
+
+#include "common/array.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+#include "common/serializer.h"
+#include "common/str.h"
+
+namespace EEM {
+
+/**
+ * One mystery (case file) loaded from `M<n>.BIN`.
+ *
+ * Mirrors the layout established by `_ReadMystery` @ 2404:008f:
+ *
+ *   word[0]  = InitBlock byte offset
+ *   word[2]  = MapData byte offset
+ *   word[3]  = SiteIndex byte offset
+ *   word[4]  = TextBlock byte offset
+ *   word[5]  = NoteIndex byte offset
+ *   word[6]  = GalleryData byte offset
+ *   word[7]  = KDTextIndex byte offset
+ *   word[8]  = SolvedClues byte offset
+ *   word[9]  = HintBlock byte offset
+ *   word[10] = NumSites + start of MysteryStats
+ *   word[13] = NumSuspects (low byte)
+ *   word[14] = NumCONSITEs
+ *   word[15] = NumCOFFSITEs
+ *   word[16..20] = AChain (5 words)
+ *   word[21..25] = BChain
+ *   word[26..30] = CChain
+ *
+ * Per-mystery state is reset every time a mystery is loaded; chains and
+ * indices are pointers into the in-memory `_data` blob.
+ */
+class Mystery {
+public:
+	static const uint kNumChains       = 3;
+	static const uint kChainLen        = 5;
+	static const uint kCluesFoundCap   = 100;
+	static const uint kHotSpotsCap     = 100;
+	static const uint kGalleryCap      = 5;
+	static const uint kVisitedSiteCap  = 20;
+
+	Mystery() = default;
+	~Mystery() = default;
+
+	/// Load `M<num>.BIN` and reset per-mystery state. Returns false on error.
+	bool load(uint num, class Common::RandomSource *rng = nullptr);
+
+	/// Drop the loaded mystery and zero per-mystery state. Safe to call
+	/// at any time; `isLoaded()` returns false afterward.
+	void clear();
+
+	/// True once `load()` succeeded and offsets are valid.
+	bool isLoaded() const { return !_data.empty(); }
+
+	uint number() const { return _number; }
+	uint16 numSites() const { return _numSites; }
+	uint8  numSuspects() const { return _numSuspects; }
+	uint8  numCONSITEs() const { return _numCONSITEs; }
+	uint8  numCOFFSITEs() const { return _numCOFFSITEs; }
+
+	/// Pointer to the InitBlock (case briefing).
+	/// _InitBlock @ 2d5d:?? = mystery + word[0] in `_ReadMystery`.
+	const byte *initBlock() const;
+
+	/// Pointer to the GalleryData; one 0x46-byte entry per suspect.
+	/// First u16 of each entry is the PIC picture ID for that suspect.
+	const byte *galleryData() const;
+
+	/// Pointer to the NoteIndex array (4 bytes per entry: u16 textOff + u16 pts).
+	const byte *noteIndex() const;
+
+	/// Number of entries in NoteIndex.
+	uint16 noteIndexCount() const;
+
+	/// Pointer to the KDTextIndex; first u16s are TextBlock offsets for
+	/// host hint lines.
+	const byte *kdTextIndex() const;
+
+	/// Pointer to the MapData entry for site @p siteNum (14 bytes per
+	/// entry; first u16 = sitepic, +4..7 = (x, y) on the big map).
+	const byte *mapEntry(uint siteNum) const;
+
+	/// Pointer to the SiteIndex entry for site @p siteNum (6 bytes per site).
+	const byte *siteIndexEntry(uint siteNum) const;
+
+	/// Pointer to the SiteData (sitepic, travel, hotspot count, ...)
+	/// referenced by SiteIndex[@p siteNum].
+	const byte *siteData(uint siteNum) const;
+
+	/// Pointer to the hotspot rectangle array for site @p siteNum.
+	/// Each rect is 14 bytes: x1, y1, x2, y2, then 6 bytes of clue data.
+	const byte *hotspots(uint siteNum) const;
+
+	/// Number of hotspots in site @p siteNum.
+	uint16 hotspotCount(uint siteNum) const;
+
+	/// Pointer to a NUL-terminated string at TextBlock+ at p offset.
+	const char *textAt(uint16 offset) const;
+
+	/// Pointer at byte offset @p offset within the mystery blob, or null
+	/// if out of range. Used to chase ClueBlock pointers stored in
+	/// hotspot data.
+	const byte *blobAt(uint32 offset) const {
+		return offset < _data.size() ? _data.data() + offset : nullptr;
+	}
+
+	/// Synchronize the per-mystery runtime state for save/load. The fixed
+	/// arrays serialize first, then the booleans and counters.
+	void syncState(Common::Serializer &s);
+
+	/// Sum of point values of every selected notebook entry. Mirrors
+	/// `_GetSelectedPoints` @ 1df2:00bd.
+	int selectedPoints() const;
+
+	/// True when `selectedPoints() > 99`. Mirrors `_SolvedCheck`.
+	bool solvedCheck() const { return selectedPoints() > 99; }
+
+	/// Per-mystery runtime state, zeroed at load time.
+	uint8  _cluesFound[kCluesFoundCap]   = {};
+	uint8  _noteSelected[kCluesFoundCap] = {};  ///< Mirror `_NoteSelected`
+	uint16 _hotSpotsSeen[kHotSpotsCap]   = {};
+	uint16 _inGallery[kGalleryCap]       = {};
+	uint8  _newOrder[kGalleryCap]        = {};
+	uint16 _visitedSite[kVisitedSiteCap] = {};
+	uint16 _onSites[kVisitedSiteCap]     = {};
+	bool   _sawCOFFSITEs = false;
+	bool   _sawCONSITEs  = false;
+	bool   _sawHelpHint  = false;
+	bool   _solvedPuzzle = false;
+	bool   _firstTry     = true;
+	uint16 _searchLocationNumber = 0xFFFF;
+	uint16 _siteNumber           = 0xFFFF;
+	uint16 _lastSite             = 0xFFFF;
+
+private:
+	Common::Array<byte> _data;
+	uint   _number = 0;
+
+	uint16 _initOffset      = 0;
+	uint16 _mapOffset       = 0;
+	uint16 _siteIndexOffset = 0;
+	uint16 _textOffset      = 0;
+	uint16 _noteOffset      = 0;
+	uint16 _galleryOffset   = 0;
+	uint16 _kdTextOffset    = 0;
+	uint16 _solvedOffset    = 0;
+	uint16 _hintOffset      = 0;
+
+	uint16 _numSites    = 0;
+	uint8  _numSuspects = 0;
+	uint8  _numCONSITEs = 0;
+	uint8  _numCOFFSITEs = 0;
+
+	uint16 _aChain[kChainLen] = {};
+	uint16 _bChain[kChainLen] = {};
+	uint16 _cChain[kChainLen] = {};
+
+	uint16 readU16(uint offset) const;
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index 67c9ab8a78e..15fc9981d46 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -76,6 +76,43 @@ void DBDArchive::close() {
 	_index.clear();
 }
 
+/**
+ * Read one 12-byte frame header + payload at the current stream position.
+ * Shared between picture and animation loaders since the layout is the same.
+ */
+static bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out) {
+	out.flags             = stream.readUint16LE();
+	const uint16 height   = stream.readUint16LE();
+	const uint16 width    = stream.readUint16LE();
+	out.rowoff            = stream.readUint16LE();
+	out.miscflags         = stream.readUint16LE();
+	out.compsize          = stream.readUint16LE();
+
+	if (width == 0 || height == 0) {
+		warning("readFrame: zero dimensions (%ux%u)", width, height);
+		return false;
+	}
+
+	out.surface.create(width, height, Graphics::PixelFormat::createFormatCLUT8());
+	const uint32 unpacked = (uint32)width * (uint32)height;
+
+	if (!compressed) {
+		if (stream.read(out.surface.getPixels(), unpacked) != unpacked) {
+			warning("readFrame: short raw read (%u bytes)", unpacked);
+			return false;
+		}
+		return true;
+	}
+
+	if (!Common::decompressDCL(&stream, (byte *)out.surface.getPixels(),
+							   out.compsize, unpacked)) {
+		warning("readFrame: DCL decompression failed (%u packed -> %u pixels)",
+				out.compsize, unpacked);
+		return false;
+	}
+	return true;
+}
+
 bool DBDArchive::loadEntry(uint num, Picture &out) {
 	if (num >= _index.size()) {
 		warning("DBDArchive::loadEntry: %u out of range (max %u)", num, _index.size());
@@ -88,44 +125,40 @@ bool DBDArchive::loadEntry(uint num, Picture &out) {
 		return false;
 	}
 
-	// Mirrors _GetFromDB @ 172b:105d:
-	//   _fread(i)            // 2-byte skip word (purpose unclear; always 0x0001)
-	//   _fread(pic, 1, 12)   // 12-byte header
+	// Mirrors _GetFromDB @ 172b:105d. The 2-byte word read first matches
+	// loadAnimation's frame-count read; for picture entries it is always 1.
 	(void)_dbd.readUint16LE();
-	out.flags             = _dbd.readUint16LE();
-	const uint16 height   = _dbd.readUint16LE();
-	const uint16 width    = _dbd.readUint16LE();
-	out.rowoff            = _dbd.readUint16LE();
-	out.miscflags         = _dbd.readUint16LE();
-	out.compsize          = _dbd.readUint16LE();
+	return readFrame(_dbd, entry.compressed != 0, out);
+}
 
-	if (width == 0 || height == 0) {
-		warning("DBDArchive::loadEntry: %u has zero dimensions (%ux%u)",
-				num, width, height);
+bool DBDArchive::loadAnimation(uint num, Animation &out) {
+	if (num >= _index.size()) {
+		warning("DBDArchive::loadAnimation: %u out of range (max %u)", num, _index.size());
 		return false;
 	}
 
-	out.surface.create(width, height, Graphics::PixelFormat::createFormatCLUT8());
-
-	if (entry.compressed == 0) {
-		// Raw pixel data — read width*height bytes verbatim.
-		const uint32 pixelCount = (uint32)width * (uint32)height;
-		if (_dbd.read(out.surface.getPixels(), pixelCount) != pixelCount) {
-			warning("DBDArchive::loadEntry: short raw read on %u", num);
-			return false;
-		}
-		return true;
+	const DBEntry &entry = _index[num];
+	if (!_dbd.seek(entry.offset)) {
+		warning("DBDArchive::loadAnimation: seek to 0x%08x failed", entry.offset);
+		return false;
 	}
 
-	// Compressed payload: feed the .DBD stream straight into the DCL
-	// decoder, matching the pattern used by Neverhood's BLB archive.
-	const uint32 unpacked = (uint32)width * (uint32)height;
-	if (!Common::decompressDCL(&_dbd, (byte *)out.surface.getPixels(),
-							   out.compsize, unpacked)) {
-		warning("DBDArchive::loadEntry: DCL decompression failed on %u "
-				"(%u packed -> %u pixels)", num, out.compsize, unpacked);
+	// Mirrors _GetAnimation @ 172b:163a: u16 frame count, then N frames.
+	const uint16 frameCount = _dbd.readUint16LE();
+	if (frameCount == 0 || frameCount > 256) {
+		warning("DBDArchive::loadAnimation: %u has implausible frame count %u",
+				num, frameCount);
 		return false;
 	}
+
+	out.resize(frameCount);
+	for (uint16 i = 0; i < frameCount; i++) {
+		if (!readFrame(_dbd, entry.compressed != 0, out[i])) {
+			warning("DBDArchive::loadAnimation: frame %u/%u failed in entry %u",
+					i, frameCount, num);
+			return false;
+		}
+	}
 	return true;
 }
 
diff --git a/engines/eem/resource.h b/engines/eem/resource.h
index a48deb85518..846bf0f1135 100644
--- a/engines/eem/resource.h
+++ b/engines/eem/resource.h
@@ -59,6 +59,9 @@ struct Picture {
 	Graphics::ManagedSurface surface;
 };
 
+/// Multi-frame animation as stored in ANI.DBD — a sequence of Pictures.
+typedef Common::Array<Picture> Animation;
+
 /**
  * Reader for a .DBD + .DBX archive pair.
  *
@@ -96,6 +99,13 @@ public:
 	 */
 	bool getPicture(uint num, Picture &out) { return loadEntry(num - 1, out); }
 
+	/**
+	 * Load a multi-frame animation entry. Mirrors _GetAnimation @ 172b:163a:
+	 * read u16 frameCount, then for each frame read a 12-byte header and
+	 * decompress the payload. Used for ANI.DBD entries.
+	 */
+	bool loadAnimation(uint num, Animation &out);
+
 private:
 	Common::File _dbd;
 	Common::Array<DBEntry> _index;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
new file mode 100644
index 00000000000..b20a92a96ea
--- /dev/null
+++ b/engines/eem/site.cpp
@@ -0,0 +1,341 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+
+#include "eem/detection.h"
+#include "eem/eem.h"
+#include "eem/mystery.h"
+#include "eem/site.h"
+
+namespace EEM {
+
+void SiteScreen::enter(uint siteNum) {
+	if (!_mystery || !_mystery->isLoaded()) {
+		warning("SiteScreen::enter: no mystery loaded");
+		return;
+	}
+	if (siteNum >= _mystery->numSites()) {
+		warning("SiteScreen::enter: site %u out of range (max %u)",
+				siteNum, _mystery->numSites());
+		return;
+	}
+
+	_mystery->_siteNumber = siteNum;
+	if (siteNum < Mystery::kVisitedSiteCap)
+		_mystery->_visitedSite[siteNum] = 1;
+	debugC(1, kDebugSite, "Entering site %u (%u hotspots)",
+		   siteNum, _mystery->hotspotCount(siteNum));
+
+	// Palette: original `_BuildBackground` calls `GetPalette(sitenum + 1)`
+	// where sitenum is the global SITES.DBD index (= the per-mystery
+	// `sitepic` field), not the per-mystery site index.
+	const byte *sd = _mystery->siteData(siteNum);
+	const uint16 sitepic = sd ? READ_LE_UINT16(sd) : 0;
+	_vm->setSitePaletteForSite(sitepic);
+
+	renderBackground(siteNum);
+	renderHotspots(siteNum);
+	g_system->updateScreen();
+}
+
+void SiteScreen::run() {
+	if (!_mystery || !_mystery->isLoaded())
+		return;
+
+	uint cur = 0;
+	enter(cur);
+
+	while (!_vm->shouldQuit()) {
+		Common::Event event;
+		bool exitRequested = false;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			switch (event.type) {
+			case Common::EVENT_QUIT:
+			case Common::EVENT_RETURN_TO_LAUNCHER:
+				return;
+
+			case Common::EVENT_LBUTTONDOWN: {
+				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
+				if (idx >= 0) {
+					onHotspotClicked(cur, (uint)idx);
+					// Restore the site BG after the clue overlay.
+					enter(cur);
+				}
+				break;
+			}
+
+			case Common::EVENT_KEYDOWN:
+				switch (event.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
+					if (_vm->areYouSure())
+						return;
+					enter(cur);
+					break;
+				case Common::KEYCODE_m:
+					_vm->doBigMap();
+					// Either way the map covered the site — re-render.
+					if (_mystery->_siteNumber < _mystery->numSites())
+						cur = _mystery->_siteNumber;
+					enter(cur);
+					break;
+				case Common::KEYCODE_n:
+					_vm->doNotebook();
+					enter(cur);
+					break;
+				case Common::KEYCODE_g:
+					_vm->doGallery();
+					enter(cur);
+					break;
+				case Common::KEYCODE_a:
+					_vm->doAccuse();
+					exitRequested = true;
+					break;
+				case Common::KEYCODE_h:
+					_vm->doHelp();
+					enter(cur);
+					break;
+				case Common::KEYCODE_v:
+					_showHotspots = !_showHotspots;
+					enter(cur);
+					break;
+				case Common::KEYCODE_r:
+					// Restart the mystery from scratch (mirrors `_ReloadMystery`).
+					if (_mystery->load(_mystery->number())) {
+						cur = 0;
+						enter(cur);
+					}
+					break;
+				case Common::KEYCODE_QUESTION:
+				case Common::KEYCODE_F1: {
+					if (_vm->getFont().isLoaded()) {
+						Graphics::ManagedSurface help(320, 200,
+							Graphics::PixelFormat::createFormatCLUT8());
+						help.clear();
+						const EEMFont &fnt = _vm->getFont();
+						int y = 8;
+						const char *lines[] = {
+							"EAGLE EYE MYSTERIES — keys",
+							"",
+							"  click   search a hotspot",
+							"  V       toggle hotspot outlines",
+							"  M       map (travel between sites)",
+							"  N       notebook (mark evidence with 1..9)",
+							"  G       gallery (suspect portraits)",
+							"  H       hint from the case host",
+							"  A       accuse a suspect",
+							"  R       restart current mystery",
+							"  Tab     next site (cycle)",
+							"  F5      save / load (ScummVM dialog)",
+							"  ? / F1  this help",
+							"  Esc     quit (with confirm)",
+							"",
+							"Notebook: select evidence with 1..9.",
+							"Selected-points > 99 wins the case."
+						};
+						for (uint i = 0; i < sizeof(lines)/sizeof(lines[0]); i++) {
+							fnt.drawString(&help, 8, y, lines[i], 0xF);
+							y += fnt.height() + 1;
+						}
+						g_system->copyRectToScreen(help.getPixels(),
+							help.pitch, 0, 0, 320, 200);
+						g_system->updateScreen();
+						_vm->waitForInput(60000);
+						enter(cur);
+					}
+					break;
+				}
+				case Common::KEYCODE_TAB:
+					_mystery->_lastSite = cur;
+					cur = (cur + 1) % _mystery->numSites();
+					enter(cur);
+					break;
+				default:
+					break;
+				}
+				break;
+
+			default:
+				break;
+			}
+		}
+		if (exitRequested)
+			return;
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+}
+
+void SiteScreen::renderBackground(uint siteNum) {
+	// Mirrors `_BuildBackground` @ 172b:13e2 (simplified):
+	//   1. Load PIC 0x3d (the site frame) from PICS.DBD.
+	//   2. Load entry @p siteNum from SITES.DBD (the site scene).
+	//   3. Composite scene into the frame at the position carried in the
+	//      SiteData fields.
+	//   4. Set palette to (siteNum + 1) — per-site palettes start at 1.
+	// We render frame + scene at (0,0); the original positions the scene
+	// at (x,y) read from the SiteData but we don't have the offsets fully
+	// decoded yet so a top-left placement will do.
+
+	// Frame.
+	Picture frame;
+	if (_vm->getPics().getPicture(0x3d, frame)) {
+		g_system->copyRectToScreen(frame.surface.getPixels(),
+								   frame.surface.pitch,
+								   0, 0, frame.surface.w, frame.surface.h);
+	}
+
+	// Scene from SITES.DBD: indexed by `sitepic` from SiteData (global
+	// SITES.DBD entry, NOT the per-mystery site index). Falls back to
+	// `_GetPicture(sitepic)` if SITES is unavailable.
+	const byte *site = _mystery->siteData(siteNum);
+	const uint16 sitepic = site ? READ_LE_UINT16(site) : 0;
+	Picture scene;
+	bool haveScene = false;
+	bool fromPics = false;
+	if (sitepic > 0 && _vm->getSites().size() > sitepic - 1)
+		haveScene = _vm->getSites().loadEntry(sitepic - 1, scene);
+	if (!haveScene && sitepic > 0) {
+		haveScene = _vm->getPics().getPicture(sitepic, scene);
+		fromPics = haveScene;
+	}
+	if (haveScene) {
+		const int w = MIN<int>(scene.surface.w, 320);
+		const int h = MIN<int>(scene.surface.h, 200);
+		// Full-screen pictures (sitepic fallback) go at (0, 0); smaller
+		// SITES.DBD scenes are centred horizontally with the top below
+		// the HUD bar so progress info stays visible.
+		const int x = fromPics ? 0 : (320 - w) / 2;
+		const int y = fromPics ? 0 : (h < 180 ? 4 : 0);
+		g_system->copyRectToScreen(scene.surface.getPixels(),
+								   scene.surface.pitch, x, y, w, h);
+	}
+}
+
+void SiteScreen::renderHotspots(uint siteNum) {
+	// HUD overlay: site number, found clues, selected points. Drawn at
+	// the BOTTOM of the screen so the scene's top row stays visible —
+	// 320x200 mode 13h has a small bottom strip that the original engine
+	// uses for tool buttons; we repurpose it for the HUD.
+	if (_vm->getFont().isLoaded()) {
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			uint cluesFound = 0;
+			for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
+				if (_mystery->_cluesFound[i])
+					cluesFound++;
+			Common::String hud = Common::String::format(
+				"Site %u  Clues %u  Pts %d   M N G H A R V Tab ?",
+				siteNum, cluesFound, _mystery->selectedPoints());
+			const int hudY = 192;
+			screen->fillRect(Common::Rect(0, hudY, 320, 200), 0);
+			Graphics::ManagedSurface mgr(320, 9,
+				Graphics::PixelFormat::createFormatCLUT8());
+			mgr.clear();
+			_vm->getFont().drawString(&mgr, 4, 0, hud, 0x0F);
+			for (int row = 0; row < 8; row++) {
+				memcpy((byte *)screen->getBasePtr(0, hudY + row),
+					   (const byte *)mgr.getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+		}
+	}
+
+	// Hotspot outlines (`_DrawSearchButtons`): toggle via V.
+	if (!_showHotspots)
+		return;
+
+	const byte *spots = _mystery->hotspots(siteNum);
+	const uint16 count = _mystery->hotspotCount(siteNum);
+	if (!spots)
+		return;
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+
+	for (uint i = 0; i < count; i++) {
+		const byte *r = spots + i * 14;
+		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
+		const int16 y1 = (int16)READ_LE_UINT16(r + 2);
+		const int16 x2 = (int16)READ_LE_UINT16(r + 4);
+		const int16 y2 = (int16)READ_LE_UINT16(r + 6);
+		const Common::Rect rect(MAX<int>(0, x1), MAX<int>(0, y1),
+								MIN<int>(screen->w, x2),
+								MIN<int>(screen->h, y2));
+		// Hotspots flagged as "seen" get a different colour so the
+		// player knows they've already searched them.
+		const byte color =
+			(i < Mystery::kHotSpotsCap && _mystery->_hotSpotsSeen[i]) ? 0x07 : 0x0F;
+		screen->frameRect(rect, color);
+	}
+
+	g_system->unlockScreen();
+}
+
+int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
+	const byte *spots = _mystery->hotspots(siteNum);
+	const uint16 count = _mystery->hotspotCount(siteNum);
+	if (!spots)
+		return -1;
+
+	for (uint i = 0; i < count; i++) {
+		const byte *r = spots + i * 14;
+		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
+		const int16 y1 = (int16)READ_LE_UINT16(r + 2);
+		const int16 x2 = (int16)READ_LE_UINT16(r + 4);
+		const int16 y2 = (int16)READ_LE_UINT16(r + 6);
+		if (x >= x1 && x < x2 && y >= y1 && y < y2)
+			return (int)i;
+	}
+	return -1;
+}
+
+void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
+	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
+
+	// Mark the hotspot itself as seen.
+	if (hotIdx < Mystery::kHotSpotsCap)
+		_mystery->_hotSpotsSeen[hotIdx] = 1;
+	_mystery->_searchLocationNumber = (uint16)hotIdx;
+
+	// Bytes 8..9 of each 14-byte hotspot rect = byte offset within the
+	// mystery blob pointing at a ClueBlock. Verified against M0.BIN:
+	// site 0 hotspot 0 -> 0x0502 -> "Hi! I think somebody's playing a
+	// trick on us...". `displayClue` runs the entry's side effects
+	// (`_AddNotebook` for ClueEntry +0x30..+0x39, gallery +0x26..+0x2f,
+	// onsite +0x1c..+0x25) so we don't need to touch `_cluesFound` here.
+	const byte *spots = _mystery->hotspots(siteNum);
+	if (spots) {
+		const uint16 clueOff = READ_LE_UINT16(spots + hotIdx * 14 + 8);
+		debugC(2, kDebugSite, "  hotspot %u -> clue offset 0x%04x",
+			   hotIdx, clueOff);
+		const byte *clueBlock = _mystery->blobAt(clueOff);
+		if (clueBlock)
+			_vm->displayClue(clueBlock);
+	}
+	// Caller (`SiteScreen::run`) re-renders the site after this returns.
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/site.h b/engines/eem/site.h
new file mode 100644
index 00000000000..ce450bb1905
--- /dev/null
+++ b/engines/eem/site.h
@@ -0,0 +1,75 @@
+/* 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 EEM_SITE_H
+#define EEM_SITE_H
+
+#include "common/rect.h"
+#include "common/scummsys.h"
+
+namespace EEM {
+
+class EEMEngine;
+class Mystery;
+
+/// One hotspot (search rectangle) within a site, 14 bytes on disk.
+struct Hotspot {
+	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates
+	uint16 clueOffset;          ///< +8: byte offset of ClueBlock in the mystery file
+	uint16 hotspotIndex;        ///< +10: 1-based hotspot ordinal within the site
+	uint16 extra;               ///< +12: unknown (zero in M0..M54)
+
+	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }
+};
+
+/**
+ * Site / scene controller.
+ *
+ * Walks the mystery's SiteIndex to render one site at a time, polls hotspots
+ * for the player's search clicks, and dispatches clue display. Mirrors the
+ * site loop driven by `_DrawSearchButtons` @ 2404:0a8f and `_SearchButtons`
+ * @ 2404:0bfb.
+ */
+class SiteScreen {
+public:
+	SiteScreen(EEMEngine *vm, Mystery *mystery)
+		: _vm(vm), _mystery(mystery) {}
+
+	/** Enter site @p siteNum. Renders BG + hotspots; runs the input loop. */
+	void enter(uint siteNum);
+
+	/// Run the per-mystery loop: site -> map -> next site, ESC exits.
+	void run();
+
+private:
+	void renderBackground(uint siteNum);
+	void renderHotspots(uint siteNum);
+	int  hotspotAtPoint(uint siteNum, int x, int y) const;
+	void onHotspotClicked(uint siteNum, uint hotIdx);
+
+	EEMEngine *_vm;
+	Mystery *_mystery;
+	bool _showHotspots = true;  ///< Toggle outlines with V key.
+};
+
+} // End of namespace EEM
+
+#endif


Commit: 8ccba0b3474d69485c07cd04d14e572b1c319078
    https://github.com/scummvm/scummvm/commit/8ccba0b3474d69485c07cd04d14e572b1c319078
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:33+02:00

Commit Message:
EEM: correct font rendering using char to glyph table

Changed paths:
    engines/eem/eem.cpp
    engines/eem/font.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 4bd0da9430a..3ac46881d01 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -753,7 +753,11 @@ static Common::String parseString(const Common::String &raw,
 		const byte c = (byte)raw[i];
 		if (c == 0x80) {
 			out += playerName;
-		} else if (c == 0x82) {
+		} else if (c == 0x81 || c == 0x82) {
+			// Both forms substitute the partner's name. The original
+			// likely has a casual ("Jake/Jenny") and a formal
+			// ("Jake/Jennifer") variant; we render the same partner
+			// name in both spots — close enough for natural reading.
 			out += partnerName;
 		} else if (c >= 0x80 && c < 0x8A) {
 			// Other control opcodes: eat them silently for now.
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index f8ca1865c8d..45d4a12a894 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -32,6 +32,34 @@
 
 namespace EEM {
 
+// Character -> glyph translation table from CHR2FNT segment (29b6:0000).
+// 128 bytes mapping ASCII 0..127 to a glyph index in FONT.FNT (which only
+// stores 85 glyphs covering ' ', '!'..'9', ':', 'A'..'Z'). Lowercase
+// letters map to uppercase glyphs; chars without a glyph map to 0.
+static const byte kCharToGlyph[128] = {
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // 0x20..0x27 ' '..'\''
+	0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, // 0x28..0x2F '('..'/'
+	0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // 0x30..0x37 '0'..'7'
+	0x18, 0x19, 0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x38..0x3F '8','9',':'..
+	0x00, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, // 0x40..0x47 '@','A'..'G'
+	0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, // 0x48..0x4F 'H'..'O'
+	0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, // 0x50..0x57 'P'..'W'
+	0x52, 0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x58..0x5F 'X','Y','Z'..
+	0x00, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, // 0x60..0x67 '`','a'..'g'
+	0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, // 0x68..0x6F 'h'..'o'
+	0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, // 0x70..0x77 'p'..'w'
+	0x52, 0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00  // 0x78..0x7F 'x','y','z'..
+};
+
+static inline byte mapChar(byte c) {
+	return c < 128 ? kCharToGlyph[c] : 0;
+}
+
+
 bool EEMFont::load(const Common::Path &path) {
 	Common::File f;
 	if (!f.open(path)) {
@@ -68,9 +96,10 @@ bool EEMFont::load(const Common::Path &path) {
 }
 
 int EEMFont::charWidth(byte c) const {
-	if (c >= _glyphs.size())
+	const byte g = mapChar(c);
+	if (g >= _glyphs.size())
 		return 0;
-	return _glyphs[c].widthBits;
+	return _glyphs[g].widthBits;
 }
 
 int EEMFont::stringWidth(const Common::String &s) const {
@@ -81,9 +110,12 @@ int EEMFont::stringWidth(const Common::String &s) const {
 }
 
 int EEMFont::drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte color) const {
-	if (!dst || c >= _glyphs.size())
+	if (!dst)
+		return 0;
+	const byte gi = mapChar(c);
+	if (gi >= _glyphs.size())
 		return 0;
-	const FontGlyph &g = _glyphs[c];
+	const FontGlyph &g = _glyphs[gi];
 	if (g.bitmap.empty())
 		return g.widthBits;
 


Commit: f349111de13c1c9ced40cd28a13ebcf2120f10e6
    https://github.com/scummvm/scummvm/commit/f349111de13c1c9ced40cd28a13ebcf2120f10e6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:33+02:00

Commit Message:
EEM: use Font API to reduce code duplication

Changed paths:
    engines/eem/eem.cpp
    engines/eem/font.cpp
    engines/eem/font.h
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 3ac46881d01..793f1b6505a 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -388,13 +388,11 @@ void EEMEngine::doNewPlayer() {
 				memcpy((byte *)scratch.getBasePtr(0, row),
 					   (const byte *)bg.surface.getBasePtr(0, row), w);
 		}
-		_font.drawString(&scratch, 40, 24,
-			"Welcome to Eagle Eye Mysteries!", 0xF);
-		_font.drawString(&scratch, 40, 40, "Please type your name:", 0xF);
-		_font.drawString(&scratch, 40, 60,
-			"(Backspace to delete, Enter to confirm)", 0xF);
+		_font.drawString(&scratch, "Welcome to Eagle Eye Mysteries!", 40, 24, 320, 0xF);
+		_font.drawString(&scratch, "Please type your name:", 40, 40, 320, 0xF);
+		_font.drawString(&scratch, "(Backspace to delete, Enter to confirm)", 40, 60, 320, 0xF);
 		Common::String shown = name + "_";
-		_font.drawString(&scratch, 40, 90, shown, 0xF);
+		_font.drawString(&scratch, shown, 40, 90, 320, 0xF);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -555,8 +553,7 @@ void EEMEngine::doCaseSelection() {
 			}
 		}
 		if (_font.isLoaded()) {
-			_font.drawString(&scratch, 8, 4,
-				Common::String::format("EAGLE EYE - %s", _playerName.c_str()), 0xF);
+			_font.drawString(&scratch, Common::String::format("EAGLE EYE - %s", _playerName.c_str()), 8, 4, 320, 0xF);
 
 			// Solved count.
 			uint solved = 0, perfectSolved = 0;
@@ -564,15 +561,12 @@ void EEMEngine::doCaseSelection() {
 				if (_mysteriesSolved[i] >= 1) solved++;
 				if (_mysteriesSolved[i] == 2) perfectSolved++;
 			}
-			_font.drawString(&scratch, 200, 4,
-				Common::String::format("solved %u (1st try %u)",
-									   solved, perfectSolved), 0xF);
+			_font.drawString(&scratch, Common::String::format("solved %u (1st try %u)",
+									   solved, perfectSolved), 200, 4, 320, 0xF);
 			if (perfectSolved >= 55) {
-				_font.drawString(&scratch, 8, 168,
-					"** PERFECT MASTER SLEUTH! **", 0xF);
+				_font.drawString(&scratch, "** PERFECT MASTER SLEUTH! **", 8, 168, 320, 0xF);
 			} else if (solved >= 55) {
-				_font.drawString(&scratch, 8, 168,
-					"** ALL MYSTERIES SOLVED! **", 0xF);
+				_font.drawString(&scratch, "** ALL MYSTERIES SOLVED! **", 8, 168, 320, 0xF);
 			}
 
 			char marker = ' ';
@@ -586,29 +580,18 @@ void EEMEngine::doCaseSelection() {
 			if (sel >= 1 && sel <= 24) tier = "Junior Sleuth";
 			else if (sel >= 25 && sel <= 48) tier = "Senior Sleuth";
 			else if (sel >= 49 && sel <= 54) tier = "Master Sleuth";
-			_font.drawString(&scratch, 8, 24,
-				Common::String::format("Mystery %u  %c  [%s]",
-									   sel, marker, tier), 0xF);
-			_font.drawString(&scratch, 8, 40,
-				"  0..9        quick select", 0xF);
-			_font.drawString(&scratch, 8, 52,
-				"  Tab / +     next mystery", 0xF);
-			_font.drawString(&scratch, 8, 64,
-				"  Shift+Tab   prev mystery", 0xF);
-			_font.drawString(&scratch, 8, 76,
-				"  PgUp/PgDn   jump 10", 0xF);
-			_font.drawString(&scratch, 8, 88,
-				"  Home/End    first/last", 0xF);
-			_font.drawString(&scratch, 8, 100,
-				"  Enter       start mystery", 0xF);
-			_font.drawString(&scratch, 8, 112,
-				"  F5          save / load (ScummVM)", 0xF);
-			_font.drawString(&scratch, 8, 124,
-				"  ESC         quit", 0xF);
-			_font.drawString(&scratch, 8, 144,
-				"  *  solved on first try", 0xF);
-			_font.drawString(&scratch, 8, 156,
-				"  +  solved", 0xF);
+			_font.drawString(&scratch, Common::String::format("Mystery %u  %c  [%s]",
+									   sel, marker, tier), 8, 24, 320, 0xF);
+			_font.drawString(&scratch, "  0..9        quick select", 8, 40, 320, 0xF);
+			_font.drawString(&scratch, "  Tab / +     next mystery", 8, 52, 320, 0xF);
+			_font.drawString(&scratch, "  Shift+Tab   prev mystery", 8, 64, 320, 0xF);
+			_font.drawString(&scratch, "  PgUp/PgDn   jump 10", 8, 76, 320, 0xF);
+			_font.drawString(&scratch, "  Home/End    first/last", 8, 88, 320, 0xF);
+			_font.drawString(&scratch, "  Enter       start mystery", 8, 100, 320, 0xF);
+			_font.drawString(&scratch, "  F5          save / load (ScummVM)", 8, 112, 320, 0xF);
+			_font.drawString(&scratch, "  ESC         quit", 8, 124, 320, 0xF);
+			_font.drawString(&scratch, "  *  solved on first try", 8, 144, 320, 0xF);
+			_font.drawString(&scratch, "  +  solved", 8, 156, 320, 0xF);
 		}
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
@@ -910,7 +893,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			int textY = bubY;
 			int textW = MIN<int>(320 - bubX, 200);
 			int copyY = bubY;
-			int copyH = _font.height() * 4 + 8;
+			int copyH = _font.getFontHeight() * 4 + 8;
 
 			if (haveBalloon) {
 				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
@@ -1002,9 +985,8 @@ void EEMEngine::doNotebook() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
-		_font.drawString(&scratch, 8, 4, "NOTEBOOK", 0xF);
-		_font.drawString(&scratch, 200, 4,
-			Common::String::format("pts: %d", _mystery.selectedPoints()), 0xF);
+		_font.drawString(&scratch, "NOTEBOOK", 8, 4, 320, 0xF);
+		_font.drawString(&scratch, Common::String::format("pts: %d", _mystery.selectedPoints()), 200, 4, 320, 0xF);
 
 		// Build a list of found-clue indices.
 		Common::Array<uint> found;
@@ -1015,13 +997,12 @@ void EEMEngine::doNotebook() {
 		const int pages = MAX<int>(1, (total + kPerPage - 1) / kPerPage);
 		page = MIN<int>(page, pages - 1);
 
-		_font.drawString(&scratch, 200, 16,
-			Common::String::format("page %d/%d", page + 1, pages), 0xF);
+		_font.drawString(&scratch, Common::String::format("page %d/%d", page + 1, pages), 200, 16, 320, 0xF);
 
 		const byte *ni = _mystery.noteIndex();
 		const uint16 niCount = _mystery.noteIndexCount();
 		const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
-		int y = 4 + _font.height() * 2 + 4;
+		int y = 4 + _font.getFontHeight() * 2 + 4;
 		for (int slot = 0; slot < kPerPage; slot++) {
 			const int idx = page * kPerPage + slot;
 			if (idx >= total)
@@ -1118,7 +1099,7 @@ void EEMEngine::doGallery() {
 	}
 
 	if (_font.isLoaded())
-		_font.drawString(&scratch, 8, 4, "GALLERY", 0xF);
+		_font.drawString(&scratch, "GALLERY", 8, 4, 320, 0xF);
 
 	const uint8 num = _mystery.numSuspects();
 	int slotX = 8;
@@ -1147,7 +1128,7 @@ void EEMEngine::doGallery() {
 									_mystery._inGallery[i];
 			Common::String label = Common::String::format("%u%s",
 				i + 1, discovered ? " *" : "");
-			_font.drawString(&scratch, placeX + 4, placeY + h + 2, label, 0xF);
+			_font.drawString(&scratch, label, placeX + 4, placeY + h + 2, 320, 0xF);
 		}
 		slotX += slotStep;
 	}
@@ -1254,7 +1235,7 @@ void EEMEngine::doBigMap() {
 				scratch.fillRect(mark, color);
 				if (_font.isLoaded()) {
 					Common::String num = Common::String::format("%u", i);
-					_font.drawString(&scratch, sx + 4, sy - 4, num, color);
+					_font.drawString(&scratch, num, sx + 4, sy - 4, 320, color);
 				}
 			}
 		}
@@ -1263,19 +1244,18 @@ void EEMEngine::doBigMap() {
 		if (_font.isLoaded() && _mystery.isLoaded()) {
 			const int panelX = kMapWinX + kMapWinW + 4;
 			int y = 4;
-			_font.drawString(&scratch, panelX, y, "TRAVEL", 0xF);
-			y += _font.height() + 4;
+			_font.drawString(&scratch, "TRAVEL", panelX, y, 320, 0xF);
+			y += _font.getFontHeight() + 4;
 			for (uint i = 0; i < _mystery.numSites() && y < 192; i++) {
 				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 					continue;
 				const char marker = (i == _mystery._siteNumber) ? '>' : ' ';
 				Common::String label = Common::String::format(
 					"%c %u", marker, i);
-				_font.drawString(&scratch, panelX, y, label, 0x0F);
-				y += _font.height() + 1;
+				_font.drawString(&scratch, label, panelX, y, 320, 0x0F);
+				y += _font.getFontHeight() + 1;
 			}
-			_font.drawString(&scratch, panelX, 188,
-							 "Esc", 0x0F);
+			_font.drawString(&scratch, "Esc", panelX, 188, 320, 0x0F);
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -1350,8 +1330,8 @@ void EEMEngine::doBigMap() {
 				const int panelX = kMapWinX + kMapWinW + 4;
 				if (_font.isLoaded() && _mystery.isLoaded() &&
 					ev.mouse.x >= panelX) {
-					const int row = (ev.mouse.y - 4 - _font.height() - 4) /
-									(_font.height() + 1);
+					const int row = (ev.mouse.y - 4 - _font.getFontHeight() - 4) /
+									(_font.getFontHeight() + 1);
 					int seen = 0;
 					for (uint i = 0; i < _mystery.numSites(); i++) {
 						if (!_mystery._onSites[i] && i != _mystery._siteNumber)
@@ -1401,7 +1381,7 @@ void EEMEngine::doHelp() {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
-	_font.drawString(&scratch, 8, 4, "HELP", 0xF);
+	_font.drawString(&scratch, "HELP", 8, 4, 320, 0xF);
 	_font.drawWordWrapped(&scratch, 8, 24, 304, text, 0xF);
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
@@ -1435,10 +1415,9 @@ bool EEMEngine::areYouSure() {
 			   (const byte *)saved.getBasePtr(0, row), 320);
 	scratch.fillRect(dlg, 0);
 	scratch.frameRect(dlg, 0xF);
-	_font.drawString(&scratch, dlg.left + 8, dlg.top + 8,
-		"Are you sure you want to quit?", 0xF);
-	_font.drawString(&scratch, dlg.left + 16, dlg.top + 36, "Y - Yes", 0xF);
-	_font.drawString(&scratch, dlg.left + 100, dlg.top + 36, "N - No", 0xF);
+	_font.drawString(&scratch, "Are you sure you want to quit?", dlg.left + 8, dlg.top + 8, 320, 0xF);
+	_font.drawString(&scratch, "Y - Yes", dlg.left + 16, dlg.top + 36, 320, 0xF);
+	_font.drawString(&scratch, "N - No", dlg.left + 100, dlg.top + 36, 320, 0xF);
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
 	g_system->updateScreen();
@@ -1507,7 +1486,7 @@ void EEMEngine::doAccuse() {
 			}
 		}
 		if (_font.isLoaded())
-			_font.drawString(&scratch, 8, 4, "ACCUSE", 0xF);
+			_font.drawString(&scratch, "ACCUSE", 8, 4, 320, 0xF);
 
 		for (uint i = 0; i < num; i++) {
 			if (!gd) continue;
@@ -1529,13 +1508,11 @@ void EEMEngine::doAccuse() {
 			}
 			if (_font.isLoaded()) {
 				Common::String label = Common::String::format("%u", i + 1);
-				_font.drawString(&scratch, placeX + 4,
-					placeY + h + 2, label, 0xF);
+				_font.drawString(&scratch, label, placeX + 4, placeY + h + 2, 320, 0xF);
 			}
 		}
 		if (_font.isLoaded()) {
-			_font.drawString(&scratch, 8, 180,
-				"Click a suspect or press 1..N - ESC to cancel", 0xF);
+			_font.drawString(&scratch, "Click a suspect or press 1..N - ESC to cancel", 8, 180, 320, 0xF);
 		}
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
@@ -1668,17 +1645,15 @@ void EEMEngine::doAccuse() {
 			Common::String banner = "Not enough evidence";
 			if (guessedRight)
 				banner = _mystery._firstTry ? "CORRECT - FIRST TRY!" : "CORRECT!";
-			_font.drawString(&scratch, 8, 4, banner, 0xF);
-			_font.drawString(&scratch, 8, 16,
-				Common::String::format("Evidence: %d/100  Suspect: %d",
-									   points, picked + 1), 0xF);
+			_font.drawString(&scratch, banner, 8, 4, 320, 0xF);
+			_font.drawString(&scratch, Common::String::format("Evidence: %d/100  Suspect: %d",
+									   points, picked + 1), 8, 16, 320, 0xF);
 			const int wrapW = MAX<int>(16, x2 - x1);
 			const int wrapY = MAX<int>(28, (int)y1);
 			(void)y2;
 			_font.drawWordWrapped(&scratch, x1, wrapY, wrapW, txt, 0xF);
-			_font.drawString(&scratch, 8, 188,
-				Common::String::format("page %u/%u  (Left/Right or click)",
-									   pageIdx + 1, pages), 0xF);
+			_font.drawString(&scratch, Common::String::format("page %u/%u  (Left/Right or click)",
+									   pageIdx + 1, pages), 8, 188, 320, 0xF);
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index 45d4a12a894..d01e22d3a0e 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -22,9 +22,7 @@
 #include "common/debug.h"
 #include "common/file.h"
 #include "common/textconsole.h"
-#include "common/tokenizer.h"
 
-#include "graphics/managed_surface.h"
 #include "graphics/surface.h"
 
 #include "eem/detection.h"
@@ -32,9 +30,9 @@
 
 namespace EEM {
 
-// Character -> glyph translation table from CHR2FNT segment (29b6:0000).
-// 128 bytes mapping ASCII 0..127 to a glyph index in FONT.FNT (which only
-// stores 85 glyphs covering ' ', '!'..'9', ':', 'A'..'Z'). Lowercase
+// Character → glyph translation table from segment CHR2FNT (29b6:0000).
+// 128 bytes mapping ASCII 0..127 to a glyph index in FONT.FNT (which
+// only stores 85 glyphs covering ' ', '!'..'9', ':', 'A'..'Z'). Lowercase
 // letters map to uppercase glyphs; chars without a glyph map to 0.
 static const byte kCharToGlyph[128] = {
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -55,11 +53,10 @@ static const byte kCharToGlyph[128] = {
 	0x52, 0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00  // 0x78..0x7F 'x','y','z'..
 };
 
-static inline byte mapChar(byte c) {
+static inline byte mapChar(uint32 c) {
 	return c < 128 ? kCharToGlyph[c] : 0;
 }
 
-
 bool EEMFont::load(const Common::Path &path) {
 	Common::File f;
 	if (!f.open(path)) {
@@ -69,10 +66,12 @@ bool EEMFont::load(const Common::Path &path) {
 
 	const uint16 numChars = f.readUint16LE();
 	if (numChars == 0 || numChars > 256) {
-		warning("EEMFont::load: %s reports %u chars", path.toString().c_str(), numChars);
+		warning("EEMFont::load: %s reports %u chars",
+				path.toString().c_str(), numChars);
 		return false;
 	}
 	_glyphs.resize(numChars);
+	_maxHeight = _maxWidth = 0;
 
 	for (uint i = 0; i < numChars; i++) {
 		FontGlyph &g = _glyphs[i];
@@ -88,36 +87,43 @@ bool EEMFont::load(const Common::Path &path) {
 		}
 		if (g.height > _maxHeight)
 			_maxHeight = g.height;
+		if (g.widthBits > _maxWidth)
+			_maxWidth = g.widthBits;
 	}
 
-	debugC(1, kDebugGfx, "Font %s loaded: %u glyphs, max h=%u",
-		   path.toString().c_str(), numChars, _maxHeight);
+	debugC(1, kDebugGfx, "Font %s loaded: %u glyphs, max %ux%u",
+		   path.toString().c_str(), numChars, _maxWidth, _maxHeight);
 	return true;
 }
 
-int EEMFont::charWidth(byte c) const {
-	const byte g = mapChar(c);
-	if (g >= _glyphs.size())
+int EEMFont::getCharWidth(uint32 chr) const {
+	const byte gi = mapChar(chr);
+	if (gi >= _glyphs.size())
 		return 0;
-	return _glyphs[g].widthBits;
+	return _glyphs[gi].widthBits;
 }
 
-int EEMFont::stringWidth(const Common::String &s) const {
-	int w = 0;
-	for (uint i = 0; i < s.size(); i++)
-		w += charWidth((byte)s[i]);
-	return w;
+int EEMFont::drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y,
+							 int width, const Common::String &s,
+							 uint32 color) const {
+	Common::Array<Common::String> lines;
+	wordWrapText(s, width, lines);
+	const int lineH = getFontHeight() + 1;
+	for (uint i = 0; i < lines.size(); i++)
+		drawString(dst, lines[i], x, y + (int)i * lineH, width, color);
+	return (int)lines.size() * lineH;
 }
 
-int EEMFont::drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte color) const {
+void EEMFont::drawChar(Graphics::Surface *dst, uint32 chr, int x, int y,
+					   uint32 color) const {
 	if (!dst)
-		return 0;
-	const byte gi = mapChar(c);
+		return;
+	const byte gi = mapChar(chr);
 	if (gi >= _glyphs.size())
-		return 0;
+		return;
 	const FontGlyph &g = _glyphs[gi];
 	if (g.bitmap.empty())
-		return g.widthBits;
+		return;
 
 	const uint bytesPerRow = (g.widthBits + 7) / 8;
 	for (uint row = 0; row < g.height; row++) {
@@ -132,40 +138,9 @@ int EEMFont::drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte
 				continue;
 			const byte mask = (byte)(0x80 >> (bit & 7));
 			if (srcRow[bit / 8] & mask)
-				dstRow[dstX] = color;
-		}
-	}
-	return g.widthBits;
-}
-
-int EEMFont::drawString(Graphics::ManagedSurface *dst, int x, int y,
-						const Common::String &s, byte color) const {
-	int penX = x;
-	for (uint i = 0; i < s.size(); i++)
-		penX += drawChar(dst, penX, y, (byte)s[i], color);
-	return penX - x;
-}
-
-int EEMFont::drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y, int width,
-							 const Common::String &s, byte color) const {
-	Common::StringTokenizer tok(s, " \t");
-	int penY = y;
-	Common::String line;
-
-	while (!tok.empty()) {
-		const Common::String word = tok.nextToken();
-		Common::String trial = line.empty() ? word : line + " " + word;
-		if (stringWidth(trial) <= width) {
-			line = trial;
-		} else {
-			drawString(dst, x, penY, line, color);
-			penY += _maxHeight + 1;
-			line = word;
+				dstRow[dstX] = (byte)color;
 		}
 	}
-	if (!line.empty())
-		drawString(dst, x, penY, line, color);
-	return penY + _maxHeight - y;
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/font.h b/engines/eem/font.h
index f175d5360fa..6ccfd6d4946 100644
--- a/engines/eem/font.h
+++ b/engines/eem/font.h
@@ -27,9 +27,7 @@
 #include "common/scummsys.h"
 #include "common/str.h"
 
-namespace Graphics {
-class ManagedSurface;
-}
+#include "graphics/font.h"
 
 namespace EEM {
 
@@ -49,40 +47,37 @@ struct FontGlyph {
  *   - u16 numChars
  *   - per char: u8 height, u8 widthBits, u8 sizeBytes, bytes[sizeBytes] bitmap
  *
- * Drawing mirrors `_ShowChar` @ 1b66:0346: each set bit becomes `fontColor`
- * on the destination surface; clear bits are transparent.
+ * Subclasses `Graphics::Font` so callers can use the standard
+ * `drawString` / `drawStringUnboxed` / `wordWrapText` helpers without
+ * us reimplementing them. Lookups go through a 128-byte char→glyph
+ * translation table extracted from CHR2FNT (segment 29b6:0000) — the
+ * font is uppercase-only with lowercase aliased to uppercase glyphs.
  */
-class EEMFont {
+class EEMFont : public Graphics::Font {
 public:
 	EEMFont() = default;
 
 	bool load(const Common::Path &path);
+	bool isLoaded() const { return !_glyphs.empty(); }
 
-	uint16 height() const { return _maxHeight; }
-
-	int charWidth(byte c) const;
-
-	/// Total pixel width of @p s when rendered (no shadow).
-	int stringWidth(const Common::String &s) const;
-
-	/// Draw @p c at (@p x, @p y) on @p dst with foreground @p color.
-	/// Returns the advance width.
-	int drawChar(Graphics::ManagedSurface *dst, int x, int y, byte c, byte color) const;
-
-	/// Draw @p s at (@p x, @p y) and return total advance width.
-	int drawString(Graphics::ManagedSurface *dst, int x, int y,
-				   const Common::String &s, byte color) const;
-
-	/// Word-wrap @p s into the rect (x..x+width, y..) and draw line by line.
-	/// Mirrors the data flow of `_DoWordWrap` @ 1b66:04a7.
-	int drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y, int width,
-						const Common::String &s, byte color) const;
+	// --- Graphics::Font overrides ---
+	int getFontHeight() const override { return _maxHeight; }
+	int getMaxCharWidth() const override { return _maxWidth; }
+	int getCharWidth(uint32 chr) const override;
+	void drawChar(Graphics::Surface *dst, uint32 chr, int x, int y,
+				  uint32 color) const override;
+	using Graphics::Font::drawChar;  // keep ManagedSurface overload
 
-	bool isLoaded() const { return !_glyphs.empty(); }
+	/// Convenience wrap-and-draw helper that uses the inherited
+	/// `wordWrapText` to break @p s into lines and then `drawString`
+	/// to render each. Returns the total height drawn.
+	int drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y,
+						int width, const Common::String &s, uint32 color) const;
 
 private:
 	Common::Array<FontGlyph> _glyphs;
 	uint16 _maxHeight = 0;
+	uint16 _maxWidth  = 0;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index b20a92a96ea..319f31b3f68 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -155,8 +155,8 @@ void SiteScreen::run() {
 							"Selected-points > 99 wins the case."
 						};
 						for (uint i = 0; i < sizeof(lines)/sizeof(lines[0]); i++) {
-							fnt.drawString(&help, 8, y, lines[i], 0xF);
-							y += fnt.height() + 1;
+							fnt.drawString(&help, lines[i], 8, y, 320, 0xF);
+							y += fnt.getFontHeight() + 1;
 						}
 						g_system->copyRectToScreen(help.getPixels(),
 							help.pitch, 0, 0, 320, 200);
@@ -253,7 +253,7 @@ void SiteScreen::renderHotspots(uint siteNum) {
 			Graphics::ManagedSurface mgr(320, 9,
 				Graphics::PixelFormat::createFormatCLUT8());
 			mgr.clear();
-			_vm->getFont().drawString(&mgr, 4, 0, hud, 0x0F);
+			_vm->getFont().drawString(&mgr, hud, 4, 0, 320, 0x0F);
 			for (int row = 0; row < 8; row++) {
 				memcpy((byte *)screen->getBasePtr(0, hudY + row),
 					   (const byte *)mgr.getBasePtr(0, row), 320);


Commit: 17d95660b896e97b384b14d0101af3945b8c5f70
    https://github.com/scummvm/scummvm/commit/17d95660b896e97b384b14d0101af3945b8c5f70
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:33+02:00

Commit Message:
EEM: completed kCharToGlyph

Changed paths:
    engines/eem/eem.cpp
    engines/eem/font.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 793f1b6505a..981329193e0 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -388,11 +388,12 @@ void EEMEngine::doNewPlayer() {
 				memcpy((byte *)scratch.getBasePtr(0, row),
 					   (const byte *)bg.surface.getBasePtr(0, row), w);
 		}
-		_font.drawString(&scratch, "Welcome to Eagle Eye Mysteries!", 40, 24, 320, 0xF);
-		_font.drawString(&scratch, "Please type your name:", 40, 40, 320, 0xF);
-		_font.drawString(&scratch, "(Backspace to delete, Enter to confirm)", 40, 60, 320, 0xF);
+		// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
+		// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
+		// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
+		_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
 		Common::String shown = name + "_";
-		_font.drawString(&scratch, shown, 40, 90, 320, 0xF);
+		_font.drawString(&scratch, shown, 80, 80, 240, 0xF);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index d01e22d3a0e..19f40c1b77e 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -30,10 +30,18 @@
 
 namespace EEM {
 
-// Character → glyph translation table from segment CHR2FNT (29b6:0000).
-// 128 bytes mapping ASCII 0..127 to a glyph index in FONT.FNT (which
-// only stores 85 glyphs covering ' ', '!'..'9', ':', 'A'..'Z'). Lowercase
-// letters map to uppercase glyphs; chars without a glyph map to 0.
+// Character → glyph translation table.
+//
+// FONT.FNT layout (verified by dumping glyph bitmaps):
+//   index 0..26  : ' ' .. ':' (punctuation, digits)
+//   index 27..32 : ';' '<' '=' '>' '?' '@'
+//   index 33..58 : UPPERCASE A..Z
+//   index 59..84 : lowercase a..z
+//
+// The CHR2FNT segment table at 29b6:0000 in the original maps both 'A'
+// and 'a' to the lowercase glyph (so the original engine renders all
+// text in lowercase). We route uppercase ASCII letters to the uppercase
+// glyph slots (33..58) for proper mixed-case rendering.
 static const byte kCharToGlyph[128] = {
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -42,11 +50,11 @@ static const byte kCharToGlyph[128] = {
 	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // 0x20..0x27 ' '..'\''
 	0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, // 0x28..0x2F '('..'/'
 	0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // 0x30..0x37 '0'..'7'
-	0x18, 0x19, 0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x38..0x3F '8','9',':'..
-	0x00, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, // 0x40..0x47 '@','A'..'G'
-	0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, // 0x48..0x4F 'H'..'O'
-	0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, // 0x50..0x57 'P'..'W'
-	0x52, 0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x58..0x5F 'X','Y','Z'..
+	0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, // 0x38..0x3F '8','9',':',';','<','=','>','?'
+	0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, // 0x40..0x47 '@','A'..'G'
+	0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, // 0x48..0x4F 'H'..'O'
+	0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, // 0x50..0x57 'P'..'W'
+	0x38, 0x39, 0x3A, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x58..0x5F 'X','Y','Z'..
 	0x00, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, // 0x60..0x67 '`','a'..'g'
 	0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, // 0x68..0x6F 'h'..'o'
 	0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, // 0x70..0x77 'p'..'w'


Commit: 57dc88dafe89fdec344074a193af45a011293ecb
    https://github.com/scummvm/scummvm/commit/57dc88dafe89fdec344074a193af45a011293ecb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:34+02:00

Commit Message:
EEM: show mouse when selecting character

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


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 981329193e0..add9de7d1ef 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -30,6 +30,7 @@
 
 #include "engines/util.h"
 
+#include "graphics/cursorman.h"
 #include "graphics/paletteman.h"
 
 #include "eem/detection.h"
@@ -65,6 +66,35 @@ const int kBoyX  = 0xe2; // 226
 const int kBoyY  = 0x62; // 98
 const int kGirlX = 0x42; // 66
 const int kGirlY = 0x60; // 96
+
+// 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
+// _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
+// visible/hidden via _MouseCursor; we leave it on once the screens
+// that need it (ChoosePartner, CaseSelection, sites) are reached.
+//   0 = transparent, 1 = black outline, 2 = white fill
+const byte kCursorBitmap[11 * 16] = {
+	1,1,0,0,0,0,0,0,0,0,0,
+	1,2,1,0,0,0,0,0,0,0,0,
+	1,2,2,1,0,0,0,0,0,0,0,
+	1,2,2,2,1,0,0,0,0,0,0,
+	1,2,2,2,2,1,0,0,0,0,0,
+	1,2,2,2,2,2,1,0,0,0,0,
+	1,2,2,2,2,2,2,1,0,0,0,
+	1,2,2,2,2,2,2,2,1,0,0,
+	1,2,2,2,2,2,2,2,2,1,0,
+	1,2,2,2,2,2,2,2,2,2,1,
+	1,2,2,2,2,2,1,0,0,0,0,
+	1,2,1,0,1,2,2,1,0,0,0,
+	1,1,0,0,1,2,2,1,0,0,0,
+	0,0,0,0,0,1,2,2,1,0,0,
+	0,0,0,0,0,1,2,2,1,0,0,
+	0,0,0,0,0,0,1,2,2,1,0
+};
+const byte kCursorPalette[] = {
+	0x00, 0x00, 0x00, // 0 — transparent (key)
+	0x00, 0x00, 0x00, // 1 — outline
+	0xFF, 0xFF, 0xFF  // 2 — fill
+};
 } // anonymous namespace
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
@@ -90,6 +120,14 @@ Common::Error EEMEngine::run() {
 	if (!_font.load(Common::Path("FONT.FNT")))
 		warning("FONT.FNT failed to load; text will not render");
 
+	// _InitMouse @ 152d:018b in the original — install our 11x16 arrow,
+	// using palette index 0 as the transparency key. The cursor is left
+	// hidden through the opening anims and switched on at NewPlayer /
+	// ChoosePartner where the player actually clicks.
+	CursorMan.replaceCursor(kCursorBitmap, 11, 16, 0, 0, 0);
+	CursorMan.replaceCursorPalette(kCursorPalette, 0, 3);
+	CursorMan.showMouse(false);
+
 	// _AllBlack @ 172b:0d4b paints the screen black before the first handler.
 	byte black[3 * 256] = { 0 };
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
@@ -107,6 +145,7 @@ Common::Error EEMEngine::run() {
 		if (err.getCode() == Common::kNoError && _mystery.isLoaded()) {
 			debugC(1, kDebugGeneral, "Resuming from slot %d at mystery %u",
 				   wantedSave, _mystery.number());
+			CursorMan.showMouse(true);
 			doInitClues();
 			doSiteLoop();
 			while (!shouldQuit()) {
@@ -122,27 +161,34 @@ Common::Error EEMEngine::run() {
 	// Reproduces _DoOpeningAnims @ 2520:082a (sans audio):
 	//   EA Kids logo (PIC) -> HighScore Productions logo (PIC) ->
 	//   Storm Software logo (BOLT.ANM) -> 20 character-intro animations
-	//   (ANIM01.A .. ANIM20.A) -> TITLE.ANM. Each can be skipped with a
-	//   click or any key.
+	//   (ANIM01.A .. ANIM20.A) -> TITLE.ANM. Click / any key skips a
+	//   single clip; ESC skips the rest of the chain (waitForInput /
+	//   playAnm raise `_skipIntro` so each subsequent step bails out).
+	_skipIntro = false;
 	showEAKidsLogo();
-	if (!shouldQuit())
+	if (!shouldQuit() && !_skipIntro)
 		showHighScoreLogo();
-	if (!shouldQuit())
+	if (!shouldQuit() && !_skipIntro)
 		playAnm(Common::Path("BOLT.ANM"));
-	for (int i = 1; i <= 20 && !shouldQuit(); i++) {
+	for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
 		Common::String name = Common::String::format("ANIM%02d.A", i);
 		playAnm(Common::Path(name));
 		// Between anims the original plays a voice clip via _SpoolSound;
 		// without audio we still want a beat so each scene reads.
-		if (!shouldQuit() && i != 20)
+		if (!shouldQuit() && !_skipIntro && i != 20)
 			waitForInput(2000);
 	}
-	if (!shouldQuit())
+	if (!shouldQuit() && !_skipIntro)
 		playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
+	_skipIntro = false;
 
 	// After the title chain, the original goes Title (B) -> screen 8
 	// (NewPlayer / saved-record selection) -> screen 9 (ChoosePartner) ->
 	// screen A (CaseSelection) -> site loop. We mirror the same order.
+	// Mouse stays hidden through the opening anims; show it now for
+	// the interactive screens (matches `_MouseCursor = 1` at the tail
+	// of `_NewPlayer`).
+	CursorMan.showMouse(true);
 	if (!shouldQuit())
 		doNewPlayer();
 	if (!shouldQuit())
@@ -261,6 +307,9 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 		// Drain events and let the user skip with click/key. The original
 		// uses _CheckFrameRate / _kbhit; we use a simple fixed delay until
 		// the frame-rate calibration logic from _GetSpeedRating is wired up.
+		// ESC additionally sets `_skipIntro` so the opening-anim chain in
+		// run() bails out of the whole sequence instead of advancing to
+		// the next clip.
 		const uint32 frameStart = g_system->getMillis();
 		bool aborted = false;
 		while (g_system->getMillis() - frameStart < frameDelayMs && !aborted) {
@@ -268,8 +317,13 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 			while (g_system->getEventManager()->pollEvent(event)) {
 				if (event.type == Common::EVENT_QUIT ||
 					event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
-					event.type == Common::EVENT_LBUTTONDOWN ||
-					event.type == Common::EVENT_KEYDOWN) {
+					event.type == Common::EVENT_LBUTTONDOWN) {
+					aborted = true;
+					break;
+				}
+				if (event.type == Common::EVENT_KEYDOWN) {
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+						_skipIntro = true;
 					aborted = true;
 					break;
 				}
@@ -280,7 +334,7 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 			break;
 	}
 
-	if (holdLastFrame && !shouldQuit()) {
+	if (holdLastFrame && !shouldQuit() && !_skipIntro) {
 		// Mirror the wait-loop at the end of `_DoOpeningAnims`:
 		//   while (!keyDataAvailable) ;
 		// We accept either a click or a key.
@@ -290,8 +344,13 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_QUIT ||
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
-					ev.type == Common::EVENT_LBUTTONDOWN ||
-					ev.type == Common::EVENT_KEYDOWN) {
+					ev.type == Common::EVENT_LBUTTONDOWN) {
+					clicked = true;
+					break;
+				}
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+						_skipIntro = true;
 					clicked = true;
 					break;
 				}
@@ -315,14 +374,20 @@ void EEMEngine::blitAt(const Picture &pic, int x, int y) {
 }
 
 void EEMEngine::waitForInput(uint32 maxMs) {
+	// ESC additionally raises `_skipIntro` so the opening-anim chain
+	// can fast-forward past the rest of the sequence.
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
 		Common::Event event;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			if (event.type == Common::EVENT_QUIT ||
 				event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
-				event.type == Common::EVENT_LBUTTONDOWN ||
-				event.type == Common::EVENT_KEYDOWN) {
+				event.type == Common::EVENT_LBUTTONDOWN) {
+				return;
+			}
+			if (event.type == Common::EVENT_KEYDOWN) {
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					_skipIntro = true;
 				return;
 			}
 		}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index b73ef2ac2bc..ed75d78e5ec 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -198,6 +198,11 @@ private:
 	uint16 _lastScreen;  ///< Mirrors _LastScreen @ 2d5d:3f24
 	uint16 _nextScreen;  ///< Mirrors _NextScreen @ 2d5d:3f26
 	uint8  _partner;     ///< Mirrors _Partner: 0 = boy (Jake), 1 = girl (Jenny)
+
+	/// Set when ESC is pressed during an intro animation or logo. Tells
+	/// the opening-anim loop in run() to skip the rest of the chain
+	/// instead of asking the user to click through every screen.
+	bool _skipIntro = false;
 };
 
 } // End of namespace EEM


Commit: 84a82d5580235109bfdbab0d47a27159e7c6ed6c
    https://github.com/scummvm/scummvm/commit/84a82d5580235109bfdbab0d47a27159e7c6ed6c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:34+02:00

Commit Message:
EEM: improved initial menu following the original implementation

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index add9de7d1ef..c14c56b4f10 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -587,20 +587,57 @@ void EEMEngine::doChoosePartner() {
 
 void EEMEngine::doCaseSelection() {
 	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
-	// (chooser background) and a paginated list of mystery names rendered
-	// from M<n>.BIN headers, then calls `_DoChoose` to read a selection.
-	// We approximate with a numeric prompt (0..9 for first ten mysteries,
-	// Tab to cycle, Enter to load).
+	// (chooser background) plus a centred "Book %d" / "Challenge Book"
+	// header at (y=12) and then calls `_DoChoose(list)` to render the
+	// menu via `DrawList` @ 1c33:040d at (_TextBox+3, DAT_29be_0d02) =
+	// (61, 35), 12 rows × 10 px line height. The menu list itself is
+	// the static array at 29be:0d6a (verified via `push 0x0d6a` at
+	// 1c33:1ab4). Strings are at 29be:0ef4 onwards. Layout:
+	//   list[0]  = "----------------------------------"
+	//   list[1]  = "         Choose A Mystery"
+	//   list[2..10] = alternating menu items + separators
+	// Five selectable items: Choose A Mystery / Practice Mystery /
+	// See ScrapBook 1/2/3.
 	const uint kMaxMystery = 54;
-	// Default selection = the next unsolved mystery so post-win the
-	// player doesn't have to scroll to find what's left.
-	uint sel = 0;
-	for (uint i = 0; i <= kMaxMystery; i++) {
-		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
-			sel = i;
-			break;
-		}
-	}
+
+	enum MenuPick {
+		kPickChoose = 0,
+		kPickPractice,
+		kPickScrap1,
+		kPickScrap2,
+		kPickScrap3,
+		kNumPicks
+	};
+	const char *kPickLabel[kNumPicks] = {
+		"         Choose A Mystery",
+		"         Practice Mystery",
+		"         See ScrapBook 1",
+		"         See ScrapBook 2",
+		"         See ScrapBook 3"
+	};
+	// ScrapBooks aren't implemented yet — grey them so the player can't
+	// stop on them, mirroring the original `_Greys` mask.
+	const bool kPickEnabled[kNumPicks] = { true, true, false, false, false };
+	uint pick = kPickChoose;
+
+	const char *kSeparator = "----------------------------------";
+
+	// Click rectangles from the original `_DoChoose` @ 1c33:0514 — each
+	// `_InRect(_MouseX, _MouseY, addr, 0x29be)` reads one 4×u16 rect at
+	// the listed offset in segment 29be ({x1, y1, x2, y2}). We use
+	// `Common::Rect` (left/top/right/bottom) which also gives us
+	// `contains(x, y)` for hit testing.
+	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
+	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
+	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
+	const Common::Rect kUpArrowRect (240,  31, 250,  43); // 29be:0cf0 scroll up
+	const Common::Rect kDnArrowRect (240, 148, 250, 159); // 29be:0cf8 scroll dn
+	const Common::Rect kListRect    ( 58,  35, 238, 158); // 29be:0d00 list panel
+
+	// The original `_NewPlayer` set `_MouseCursor = 1` on exit; the
+	// chain of screens after it expects the cursor to stay visible.
+	// Reassert here in case anything between hid it.
+	CursorMan.showMouse(true);
 
 	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
 	Picture caseBg;
@@ -619,53 +656,215 @@ void EEMEngine::doCaseSelection() {
 			}
 		}
 		if (_font.isLoaded()) {
-			_font.drawString(&scratch, Common::String::format("EAGLE EYE - %s", _playerName.c_str()), 8, 4, 320, 0xF);
+			// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
+			// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
+			// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
+			const int kListX  = 58 + 3;
+			const int kListW  = 238 - kListX;
+			const int kListY0 = 35;
+			const int kLineH  = 10;
+
+			// Top centred "Book %d" / "Challenge Book" title — sprintf
+			// format strings at 29be:0deb / 29be:0dfa shown via
+			// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
+			// original. We don't track challenge tier yet so always
+			// show "Book 1".
+			const Common::String book = "Book 1";
+			const int titleW = _font.getStringWidth(book);
+			const int titleX = (0xba - titleW) / 2 + 0x3c;
+			_font.drawString(&scratch, book, titleX, 12, 320, 0xF);
+
+			// Render 11 list rows: separator + menu item pairs.
+			//   row 0  separator
+			//   row 1  Choose A Mystery
+			//   row 2  separator
+			//   row 3  Practice Mystery
+			//   ...
+			//   row 9  See ScrapBook 3
+			//   row 10 separator
+			for (int r = 0; r < 11; r++) {
+				const int y = kListY0 + r * kLineH;
+				if ((r & 1) == 0) {
+					_font.drawString(&scratch, kSeparator, kListX, y, kListW, 0x7);
+					continue;
+				}
+				const uint mp = (uint)(r >> 1);
+				const bool isSel  = (mp == pick);
+				const byte color  = isSel        ? 0xF :
+									kPickEnabled[mp] ? 0x7 : 0x8;
+				_font.drawString(&scratch, kPickLabel[mp], kListX, y, kListW, color);
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	auto pickPrev = [&]() {
+		for (int i = 0; i < (int)kNumPicks; i++) {
+			pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
+			if (kPickEnabled[pick])
+				break;
+		}
+	};
+	auto pickNext = [&]() {
+		for (int i = 0; i < (int)kNumPicks; i++) {
+			pick = (pick + 1) % kNumPicks;
+			if (kPickEnabled[pick])
+				break;
+		}
+	};
+
+	draw();
 
-			// Solved count.
-			uint solved = 0, perfectSolved = 0;
-			for (uint i = 0; i < sizeof(_mysteriesSolved); i++) {
-				if (_mysteriesSolved[i] >= 1) solved++;
-				if (_mysteriesSolved[i] == 2) perfectSolved++;
+	bool exitChosen = false;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool confirmed = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// OK / EXIT / HELP buttons (rectangles from `_DoChoose`).
+				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
+					confirmed = true;
+					break;
+				}
+				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
+					exitChosen = true;
+					confirmed = true;
+					break;
+				}
+				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// HELP placeholder — original calls `_DisplayHint`;
+					// our help screen is wired to `H` later in the flow.
+					continue;
+				}
+				// List panel: click on a non-separator row selects the
+				// menu entry under the cursor.
+				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
+					const int kLineH = 10;
+					const int row = (ev.mouse.y - kListRect.top) / kLineH;
+					if ((row & 1) == 1) {
+						const uint mp = (uint)(row >> 1);
+						if (mp < kNumPicks && kPickEnabled[mp]) {
+							pick = mp;
+							draw();
+							continue;
+						}
+					}
+				}
 			}
-			_font.drawString(&scratch, Common::String::format("solved %u (1st try %u)",
-									   solved, perfectSolved), 200, 4, 320, 0xF);
-			if (perfectSolved >= 55) {
-				_font.drawString(&scratch, "** PERFECT MASTER SLEUTH! **", 8, 168, 320, 0xF);
-			} else if (solved >= 55) {
-				_font.drawString(&scratch, "** ALL MYSTERIES SOLVED! **", 8, 168, 320, 0xF);
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_ESCAPE) {
+				exitChosen = true;
+				confirmed = true;
+				break;
+			}
+			if (k == Common::KEYCODE_RETURN) {
+				confirmed = true;
+				break;
+			}
+			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
+				pickPrev();
+				draw();
+				continue;
 			}
+			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
+				k == Common::KEYCODE_TAB) {
+				pickNext();
+				draw();
+				continue;
+			}
+		}
+		if (confirmed) {
+			draw();
+			break;
+		}
+		g_system->delayMillis(15);
+	}
+
+	if (shouldQuit())
+		return;
 
-			char marker = ' ';
-			if (sel < sizeof(_mysteriesSolved)) {
-				if (_mysteriesSolved[sel] == 2) marker = '*';
-				else if (_mysteriesSolved[sel] == 1) marker = '+';
+	if (exitChosen) {
+		_mystery.clear();
+		_nextScreen = kScreenInvalid;
+		return;
+	}
+
+	// "Practice Mystery" is the tutorial → mystery 0.
+	if (pick == kPickPractice) {
+		if (!_mystery.load(0, &_rng)) {
+			warning("doCaseSelection: failed to load practice mystery");
+			_mystery.clear();
+		}
+		return;
+	}
+
+	if (pick != kPickChoose) {
+		// ScrapBooks aren't implemented; bail back to the menu loop.
+		_mystery.clear();
+		return;
+	}
+
+	// "Choose A Mystery" sub-screen: pick a specific case from the
+	// 55-mystery roster. The original opens a different list here;
+	// we approximate with the tier-aware numeric chooser we used
+	// before. Default to the first unsolved mystery.
+	uint sel = 0;
+	for (uint i = 0; i <= kMaxMystery; i++) {
+		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
+			sel = i;
+			break;
+		}
+	}
+
+	auto drawSubmenu = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveCaseBg) {
+			const int w = MIN<int>(caseBg.surface.w, 320);
+			const int h = MIN<int>(caseBg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
+		}
+		if (_font.isLoaded()) {
+			const int kListX  = 61;
+			const int kListW  = 238 - kListX;
+			const int kListY0 = 35;
+			const int kLineH  = 10;
+			const int kVisible = 12;
+			int top = (int)sel - kVisible / 2;
+			if (top < 0) top = 0;
+			if (top + kVisible > (int)kMaxMystery + 1)
+				top = (int)kMaxMystery + 1 - kVisible;
+			for (int r = 0; r < kVisible; r++) {
+				const int idx = top + r;
+				if (idx > (int)kMaxMystery)
+					break;
+				char marker = ' ';
+				if ((uint)idx < sizeof(_mysteriesSolved)) {
+					if (_mysteriesSolved[idx] == 2) marker = '*';
+					else if (_mysteriesSolved[idx] == 1) marker = '+';
+				}
+				const char arrow = ((uint)idx == sel) ? '>' : ' ';
+				_font.drawString(&scratch,
+								 Common::String::format("%c %c Mystery %d", arrow, marker, idx),
+								 kListX, kListY0 + r * kLineH, kListW, 0xF);
 			}
-			// Per the original tiers: 0 (tutorial), 1-24 (Junior),
-			// 25-48 (Senior), 49-54 (Master).
-			const char *tier = "Tutorial";
-			if (sel >= 1 && sel <= 24) tier = "Junior Sleuth";
-			else if (sel >= 25 && sel <= 48) tier = "Senior Sleuth";
-			else if (sel >= 49 && sel <= 54) tier = "Master Sleuth";
-			_font.drawString(&scratch, Common::String::format("Mystery %u  %c  [%s]",
-									   sel, marker, tier), 8, 24, 320, 0xF);
-			_font.drawString(&scratch, "  0..9        quick select", 8, 40, 320, 0xF);
-			_font.drawString(&scratch, "  Tab / +     next mystery", 8, 52, 320, 0xF);
-			_font.drawString(&scratch, "  Shift+Tab   prev mystery", 8, 64, 320, 0xF);
-			_font.drawString(&scratch, "  PgUp/PgDn   jump 10", 8, 76, 320, 0xF);
-			_font.drawString(&scratch, "  Home/End    first/last", 8, 88, 320, 0xF);
-			_font.drawString(&scratch, "  Enter       start mystery", 8, 100, 320, 0xF);
-			_font.drawString(&scratch, "  F5          save / load (ScummVM)", 8, 112, 320, 0xF);
-			_font.drawString(&scratch, "  ESC         quit", 8, 124, 320, 0xF);
-			_font.drawString(&scratch, "  *  solved on first try", 8, 144, 320, 0xF);
-			_font.drawString(&scratch, "  +  solved", 8, 156, 320, 0xF);
 		}
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
 
-	draw();
-
+	drawSubmenu();
 	bool confirmed = false;
 	while (!confirmed && !shouldQuit()) {
 		Common::Event ev;
@@ -673,12 +872,48 @@ void EEMEngine::doCaseSelection() {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Same `_DoChoose` rectangles as the top-level menu.
+				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
+					confirmed = true;
+					break;
+				}
+				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
+					_mystery.clear();
+					return;
+				}
+				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					sel = (sel == 0) ? kMaxMystery : sel - 1;
+					drawSubmenu();
+					continue;
+				}
+				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+					drawSubmenu();
+					continue;
+				}
+				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// Pick the row under the cursor.
+					const int kLineH = 10;
+					const int kVisible = 12;
+					int top = (int)sel - kVisible / 2;
+					if (top < 0) top = 0;
+					if (top + kVisible > (int)kMaxMystery + 1)
+						top = (int)kMaxMystery + 1 - kVisible;
+					const int row = (ev.mouse.y - kListRect.top) / kLineH;
+					const int idx = top + row;
+					if (idx >= 0 && idx <= (int)kMaxMystery) {
+						sel = (uint)idx;
+						drawSubmenu();
+					}
+					continue;
+				}
+			}
 			if (ev.type != Common::EVENT_KEYDOWN)
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
 			if (k == Common::KEYCODE_ESCAPE) {
-                _mystery.clear();
-				_nextScreen = kScreenInvalid;
+				_mystery.clear();
 				return;
 			}
 			if (k == Common::KEYCODE_RETURN) {
@@ -687,46 +922,31 @@ void EEMEngine::doCaseSelection() {
 			}
 			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
 				sel = (uint)(k - Common::KEYCODE_0);
-				draw();
+				drawSubmenu();
 				continue;
 			}
-			if (k == Common::KEYCODE_TAB || k == Common::KEYCODE_PLUS ||
-				k == Common::KEYCODE_RIGHT || k == Common::KEYCODE_DOWN) {
-				const bool back = (ev.kbd.flags & Common::KBD_SHIFT) ||
-								  k == Common::KEYCODE_LEFT;
-				if (back)
-					sel = (sel == 0) ? kMaxMystery : sel - 1;
-				else
-					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
-				draw();
+			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
+				sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+				drawSubmenu();
 				continue;
 			}
-			if (k == Common::KEYCODE_LEFT || k == Common::KEYCODE_UP ||
-				k == Common::KEYCODE_MINUS) {
+			if (k == Common::KEYCODE_UP) {
 				sel = (sel == 0) ? kMaxMystery : sel - 1;
-				draw();
+				drawSubmenu();
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEDOWN) {
 				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
-				draw();
+				drawSubmenu();
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEUP) {
 				sel = (sel < 10) ? 0 : sel - 10;
-				draw();
-				continue;
-			}
-			if (k == Common::KEYCODE_HOME) {
-				sel = 0;
-				draw();
-				continue;
-			}
-			if (k == Common::KEYCODE_END) {
-				sel = kMaxMystery;
-				draw();
+				drawSubmenu();
 				continue;
 			}
+			if (k == Common::KEYCODE_HOME) { sel = 0; drawSubmenu(); continue; }
+			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; drawSubmenu(); continue; }
 		}
 		g_system->delayMillis(15);
 	}


Commit: 17288a601407fe4b999357d3659adfdbd701af00
    https://github.com/scummvm/scummvm/commit/17288a601407fe4b999357d3659adfdbd701af00
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:35+02:00

Commit Message:
EEM: update pointer during main menu

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index c14c56b4f10..c6e96ccd8ca 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -500,6 +500,7 @@ void EEMEngine::doNewPlayer() {
 		}
 		if (dirty)
 			draw();
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 }
@@ -784,6 +785,7 @@ void EEMEngine::doCaseSelection() {
 			draw();
 			break;
 		}
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 
@@ -948,6 +950,7 @@ void EEMEngine::doCaseSelection() {
 			if (k == Common::KEYCODE_HOME) { sel = 0; drawSubmenu(); continue; }
 			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; drawSubmenu(); continue; }
 		}
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 
@@ -1184,11 +1187,21 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (haveBalloon) {
 				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
 				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
+				// `_AddPicBackground` passes `pic->miscflags >> 8` as
+				// the transparent colour to `_Rect_Move_Mask`. Without
+				// that mask, the balloon's tail/corner padding bleeds
+				// into the surrounding scene. The on-disk u16 at file
+				// offset 0 maps to our `Picture::flags` field.
+				const byte transp = (byte)(balloon.flags >> 8);
 				if (bw > 0 && bh > 0) {
 					for (int row = 0; row < bh; row++) {
-						memcpy((byte *)scratch.getBasePtr(bubX, bubY + row),
-							   (const byte *)balloon.surface.getBasePtr(0, row),
-							   bw);
+						const byte *src =
+							(const byte *)balloon.surface.getBasePtr(0, row);
+						byte *dst = (byte *)scratch.getBasePtr(bubX, bubY + row);
+						for (int col = 0; col < bw; col++) {
+							if (src[col] != transp)
+								dst[col] = src[col];
+						}
 					}
 				}
 				// Per-balloon metadata table at 29be:0875 in the original
@@ -1240,6 +1253,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						break;
 					}
 				}
+				// Tick the screen so the OSystem cursor follows the
+				// mouse — ScummVM redraws the cursor overlay only on
+				// updateScreen.
+				g_system->updateScreen();
 				g_system->delayMillis(10);
 			}
 			if (skipAll) {
@@ -1351,6 +1368,7 @@ void EEMEngine::doNotebook() {
 		}
 		if (exit) break;
 		if (dirty) draw();
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 }
@@ -1634,6 +1652,7 @@ void EEMEngine::doBigMap() {
 		}
 		if (dirty)
 			draw();
+		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
 }
@@ -1732,6 +1751,7 @@ bool EEMEngine::areYouSure() {
 		}
 		if (decided)
 			break;
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 
@@ -1830,6 +1850,7 @@ void EEMEngine::doAccuse() {
 					picked = slot;
 			}
 		}
+		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
 	if (picked < 0)
@@ -1970,6 +1991,7 @@ void EEMEngine::doAccuse() {
 					break;
 				}
 			}
+			g_system->updateScreen();
 			g_system->delayMillis(15);
 		}
 		if (exit) break;


Commit: d3e517a57579c9b310cfd8b62eb09c3e85d4a8d8
    https://github.com/scummvm/scummvm/commit/d3e517a57579c9b310cfd8b62eb09c3e85d4a8d8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:35+02:00

Commit Message:
EEM: correct ballon dialog placement

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index c6e96ccd8ca..b46d1a1842c 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1019,26 +1019,56 @@ void EEMEngine::doSiteLoop() {
 /// hyphenation marks and a hint placeholder (0x89) we ignore for now.
 static Common::String parseString(const Common::String &raw,
 								  const Common::String &playerName,
-								  const Common::String &partnerName) {
+								  uint partner) {
+	// Substitution opcodes from `_ParseString` @ 1b66:07c3, jump-table
+	// at 1b66:0cbe. Each handler reads `_Partner` (16-bit at 0x7918)
+	// and indexes the name table at 29be:0c28 ({Jake, Jennifer, he,
+	// she, him, her, his} as far pointers).
+	//   0x80 — player's typed name (auto-cap word starts) — uses _PlayerRecord
+	//   0x81 — _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
+	//   0x82 — _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
+	//   0x83 — _Partner == 0 ? "he"       : "she"
+	//   0x84 — _Partner == 0 ? "him"      : "her"
+	//   0x85 — _Partner == 0 ? "his"      : "her"
+	//   0x86..0x88 read a different gender flag at 0x7985 — left alone
+	//     until that flag's source is traced.
+	//   0x89 — KD hint placeholder (handled by caller).
+	const bool isJake = (partner == 0);
 	Common::String out;
 	for (uint i = 0; i < raw.size(); i++) {
 		const byte c = (byte)raw[i];
-		if (c == 0x80) {
+		switch (c) {
+		case 0x80:
 			out += playerName;
-		} else if (c == 0x81 || c == 0x82) {
-			// Both forms substitute the partner's name. The original
-			// likely has a casual ("Jake/Jenny") and a formal
-			// ("Jake/Jennifer") variant; we render the same partner
-			// name in both spots — close enough for natural reading.
-			out += partnerName;
-		} else if (c >= 0x80 && c < 0x8A) {
-			// Other control opcodes: eat them silently for now.
-		} else if (c == 0 || c == '\r') {
-			// stop on NUL, ignore CR
-			if (c == 0)
-				break;
-		} else {
+			break;
+		case 0x81:
+			out += isJake ? "Jake" : "Jennifer";
+			break;
+		case 0x82:
+			out += isJake ? "Jennifer" : "Jake";
+			break;
+		case 0x83:
+			out += isJake ? "he" : "she";
+			break;
+		case 0x84:
+			out += isJake ? "him" : "her";
+			break;
+		case 0x85:
+			out += isJake ? "his" : "her";
+			break;
+		case 0x86:
+		case 0x87:
+		case 0x88:
+		case 0x89:
+			// Eaten silently — see comment above.
+			break;
+		case 0:
+			return out;
+		case '\r':
+			break;
+		default:
 			out += (char)c;
+			break;
 		}
 	}
 	return out;
@@ -1147,11 +1177,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			}
 		}
 
-		// Substitute placeholder control bytes with the entered player
-		// name and the chosen partner's first name (Jake / Jennifer).
-		const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
+		// Substitute control bytes (0x80..0x89) — see `parseString` for
+		// the table. 0x81 = chosen detective, 0x82 = the other one.
 		const Common::String text = parseString(raw ? raw : "",
-												_playerName, partnerName);
+												_playerName, _partner);
 
 		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
 		// `_DisplayClue`. The original looks up per-balloon text-area
@@ -1188,25 +1217,33 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
 				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
 				// `_AddPicBackground` passes `pic->miscflags >> 8` as
-				// the transparent colour to `_Rect_Move_Mask`. Without
-				// that mask, the balloon's tail/corner padding bleeds
-				// into the surrounding scene. The on-disk u16 at file
-				// offset 0 maps to our `Picture::flags` field.
+				// the transparent colour to `_Rect_Move_Mask`. The
+				// on-disk u16 at file offset 0 maps to `Picture::flags`.
 				const byte transp = (byte)(balloon.flags >> 8);
+				// `_GetBalloon @ 172b:1d7d` mirrors the picture horizontally
+				// when `(bubNum & 0x80)` is set — used for right-side
+				// speakers so the tail points the other way.
+				const bool flipBalloon = (bubNum & 0x80) != 0;
 				if (bw > 0 && bh > 0) {
 					for (int row = 0; row < bh; row++) {
 						const byte *src =
 							(const byte *)balloon.surface.getBasePtr(0, row);
 						byte *dst = (byte *)scratch.getBasePtr(bubX, bubY + row);
 						for (int col = 0; col < bw; col++) {
-							if (src[col] != transp)
-								dst[col] = src[col];
+							const int srcCol = flipBalloon
+								? (balloon.surface.w - 1 - col)
+								: col;
+							const byte px = src[srcCol];
+							if (px != transp)
+								dst[col] = px;
 						}
 					}
 				}
-				// Per-balloon metadata table at 29be:0875 in the original
-				// uses (textX=6, textY=4) inset across all entries; we
-				// adopt the same constants instead of approximating with 8.
+				// Per-balloon metadata table at 29be:0875 — 10-byte
+				// entries indexed by `(bubNum & 0x7f)`. Layout:
+				//   +0..1 textX inset, +2..3 textY inset, +4..5 textWidth.
+				// All entries use textX=6, textY=4 so we hard-code those
+				// constants; textWidth is read live from the table.
 				textX = bubX + 6;
 				textY = bubY + 4;
 				textW = bw - 12;
@@ -1219,8 +1256,11 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				copyY = bubY;
 			}
 
+			// `_DisplayClue` @ 2404:07fe passes fontColor=0 (palette
+			// index 0 of the case-briefing palette 0x22) to `_WordWrap`.
+			// Hard-coding 0xF here gave the wrong colour.
 			_font.drawWordWrapped(&scratch, textX, textY,
-				MAX<int>(8, textW), text, 0xF);
+				MAX<int>(8, textW), text, 0);
 
 			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
 				scratch.pitch, 0, copyY, 320,
@@ -1304,7 +1344,6 @@ void EEMEngine::doNotebook() {
 
 		const byte *ni = _mystery.noteIndex();
 		const uint16 niCount = _mystery.noteIndexCount();
-		const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
 		int y = 4 + _font.getFontHeight() * 2 + 4;
 		for (int slot = 0; slot < kPerPage; slot++) {
 			const int idx = page * kPerPage + slot;
@@ -1318,7 +1357,7 @@ void EEMEngine::doNotebook() {
 				const uint16 ptsRaw  = READ_LE_UINT16(ni + clueId * 4 + 2);
 				pts = (int)(int16)ptsRaw;
 				const Common::String raw = _mystery.textAt(textOff);
-				text = parseString(raw, _playerName, partnerName);
+				text = parseString(raw, _playerName, _partner);
 			}
 			if (text.empty())
 				text = Common::String::format("clue %u", clueId);
@@ -1680,8 +1719,7 @@ void EEMEngine::doHelp() {
 		_mystery._sawHelpHint = true;
 
 	const Common::String raw = _mystery.textAt(use);
-	const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
-	const Common::String text = parseString(raw, _playerName, partnerName);
+	const Common::String text = parseString(raw, _playerName, _partner);
 
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -1908,7 +1946,6 @@ void EEMEngine::doAccuse() {
 	const byte *e = blob.data();
 	const uint16 pages = READ_LE_UINT16(e);
 
-	const Common::String partnerName = (_partner == 0) ? "Jake" : "Jennifer";
 	uint pageIdx = 0;
 
 	while (!shouldQuit()) {
@@ -1929,7 +1966,7 @@ void EEMEngine::doAccuse() {
 		const uint16 x2     = READ_LE_UINT16(e + pos + 6);
 		const uint16 y2     = READ_LE_UINT16(e + pos + 8);
 		const char *raw     = (const char *)(e + pos + 10);
-		const Common::String txt = parseString(raw, _playerName, partnerName);
+		const Common::String txt = parseString(raw, _playerName, _partner);
 
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());


Commit: b60f6c5570c0ab4ccb94ef18b806617db515d7fa
    https://github.com/scummvm/scummvm/commit/b60f6c5570c0ab4ccb94ef18b806617db515d7fa
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:35+02:00

Commit Message:
EEM: draw the big map and allow player to move between locations

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index b46d1a1842c..f906aec7d69 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -147,12 +147,19 @@ Common::Error EEMEngine::run() {
 				   wantedSave, _mystery.number());
 			CursorMan.showMouse(true);
 			doInitClues();
-			doSiteLoop();
+			// Original screen 0 → screen 1: after the briefing the
+			// game opens the map (function at 20fe:120b → _DoBigMap)
+			// and only enters a site once the player clicks on one.
+			doBigMap();
+			if (_mystery.isLoaded())
+				doSiteLoop();
 			while (!shouldQuit()) {
 				doCaseSelection();
 				if (!_mystery.isLoaded()) break;
 				doInitClues();
-				doSiteLoop();
+				doBigMap();
+				if (_mystery.isLoaded())
+					doSiteLoop();
 			}
 			return Common::kNoError;
 		}
@@ -199,7 +206,12 @@ Common::Error EEMEngine::run() {
 		// Mark the starting site as active and display the case briefing.
 		// `_DoInitClues` @ 1a35:0411 — case briefing.
 		doInitClues();
-		doSiteLoop();
+		// Original screen 0 → screen 1: after the briefing the game
+		// opens the map (function at 20fe:120b → `_DoBigMap`) and only
+		// enters a site once the player clicks on one.
+		doBigMap();
+		if (_mystery.isLoaded())
+			doSiteLoop();
 
 		// After a case, loop back to CaseSelection.
 		while (!shouldQuit()) {
@@ -207,7 +219,12 @@ Common::Error EEMEngine::run() {
 			if (!_mystery.isLoaded())
 				break;
 			doInitClues();
-			doSiteLoop();
+			// Original screen 0 → screen 1: after the briefing the
+			// game opens the map (function at 20fe:120b → _DoBigMap)
+			// and only enters a site once the player clicks on one.
+			doBigMap();
+			if (_mystery.isLoaded())
+				doSiteLoop();
 		}
 	}
 
@@ -978,6 +995,13 @@ void EEMEngine::doInitClues() {
 	const uint16 startSite = READ_LE_UINT16(ib + 2);
 	if (startSite < Mystery::kVisitedSiteCap)
 		_mystery._onSites[startSite] = 1;
+	// Mirror the original: at briefing time the player isn't actually
+	// at any site yet — they pick from the map next. Set _siteNumber
+	// to the start site so the map opens centred on the only initially
+	// accessible location and the post-map site loop has a sensible
+	// resume point.
+	_mystery._siteNumber = startSite;
+	_mystery._lastSite = startSite;
 
 	setSitePalette(0x22);
 	Picture bg;
@@ -1483,12 +1507,21 @@ void EEMEngine::doGallery() {
 }
 
 void EEMEngine::doBigMap() {
-	// Mirrors `_DoBigMap` @ 20fe:09e7. The original lays out a 0xe9 x 0xab
-	// map window inside frame PIC 0x42 with per-site clickable markers
-	// scrolling under it. We render BIGMAP.PIC at top-left, draw an
-	// overlay listing the sites that the player can travel to (per
-	// `_OnSites`), and accept either number keys or clicks on the
-	// overlay rows to travel.
+	// Mirrors the small/scrollable map view used inside the site loop —
+	// the original game draws a 0xe9 × 0xab viewport at (2, 2) into
+	// `_MapBitMap` (= BIGMAP.PIC) via `DrawMap @ 20fe:1058`; on top of
+	// that, `_StampButtons @ 20fe:0d2f` bakes site icons into the
+	// bitmap at MapData offsets (+4, +6). We render BIGMAP.PIC into a
+	// 233×171 viewport and overlay markers ourselves.
+	//
+	// MapData entry layout (14 bytes), confirmed from
+	// `_DrawBigMapButtons @ 20fe:0877` and `_StampButtons @ 20fe:0d2f`:
+	//   +0..1  BigMap X (overview frame PIC 0x42)
+	//   +2..3  BigMap Y
+	//   +4..5  SmallMap X (stamped into _MapBitMap at this position)
+	//   +6..7  SmallMap Y
+	//   +8..9  crime-flag (DAT_2d5d_5436): non-zero → crime-scene marker
+	//   +10..13 unused
 	Common::File f;
 	if (!f.open(Common::Path("BIGMAP.PIC"))) {
 		warning("doBigMap: BIGMAP.PIC missing");
@@ -1505,11 +1538,8 @@ void EEMEngine::doBigMap() {
 		return;
 	}
 
-	// Approximate inner map window from the original `_DoBigMap`:
-	//   if (sx < 0x75) sx = 0; else sx -= 0x74;     // 0x74 = 116
-	//   if (mapW < sx + 0xe9) sx = mapW - 0xe9;     // window width  = 233
-	//   if (sy < 0x56) sy = 0; else sy -= 0x55;
-	//   if (mapH < sy + 0xab) sy = mapH - 0xab;     // window height = 171
+	// Viewport size from `DrawMap`: 0xe9 × 0xab at screen (2, 2).
+	// We use (4, 4) so a 1-pixel inset preserves the frame border.
 	const int kMapWinW = 0xe9; // 233
 	const int kMapWinH = 0xab; // 171
 	const int kMapWinX = 4;
@@ -1518,7 +1548,7 @@ void EEMEngine::doBigMap() {
 	int scrollX = 0;
 	int scrollY = 0;
 
-	// Auto-scroll to centre the current site, if known.
+	// Auto-scroll to centre the current site (using SmallMap coords).
 	if (_mystery.isLoaded()) {
 		const byte *entry = _mystery.mapEntry(_mystery._siteNumber);
 		if (entry) {
@@ -1531,6 +1561,14 @@ void EEMEngine::doBigMap() {
 		}
 	}
 
+	CursorMan.showMouse(true);
+
+	// `_DoBigMap @ 20fe:09e7` calls `_GetPalette(0x24)` after loading
+	// the frame. Without this, the map would render under whatever
+	// palette the prior site set, which makes BIGMAP.PIC look like
+	// noise / incomplete colours.
+	setSitePalette(0x24);
+
 	auto draw = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -1556,9 +1594,12 @@ void EEMEngine::doBigMap() {
 				   copyW);
 		}
 
-		// Site markers from MapData. Each per-mystery MapData entry is
-		// 14 bytes; bytes +4..+5 / +6..+7 hold an (x, y) pair on the big
-		// map. We draw a small filled square for each accessible site.
+		// Site markers — three states from `_DrawBigMapButtons`:
+		//   _DoneMarker if `SaveSiteComplete[i]` (already searched)
+		//   _CrimeMarker if MapData[+8] != 0 (crime scene)
+		//   _SiteMarker otherwise
+		// We don't have the marker PICs traced yet, so draw small filled
+		// squares with three colours that match the original semantics.
 		if (_mystery.isLoaded()) {
 			for (uint i = 0; i < _mystery.numSites(); i++) {
 				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
@@ -1568,37 +1609,26 @@ void EEMEngine::doBigMap() {
 					continue;
 				const uint16 mx = READ_LE_UINT16(entry + 4);
 				const uint16 my = READ_LE_UINT16(entry + 6);
+				const uint16 crime = READ_LE_UINT16(entry + 8);
 				const int sx = (int)mx - scrollX + kMapWinX;
 				const int sy = (int)my - scrollY + kMapWinY;
 				if (sx < kMapWinX || sx >= kMapWinX + kMapWinW ||
 					sy < kMapWinY || sy >= kMapWinY + kMapWinH)
 					continue;
-				const byte color = (i == _mystery._siteNumber) ? 0x0E : 0x0F;
-				const Common::Rect mark(sx - 2, sy - 2, sx + 3, sy + 3);
-				scratch.fillRect(mark, color);
-				if (_font.isLoaded()) {
-					Common::String num = Common::String::format("%u", i);
-					_font.drawString(&scratch, num, sx + 4, sy - 4, 320, color);
-				}
-			}
-		}
 
-		// Travel-target overlay (right-side panel).
-		if (_font.isLoaded() && _mystery.isLoaded()) {
-			const int panelX = kMapWinX + kMapWinW + 4;
-			int y = 4;
-			_font.drawString(&scratch, "TRAVEL", panelX, y, 320, 0xF);
-			y += _font.getFontHeight() + 4;
-			for (uint i = 0; i < _mystery.numSites() && y < 192; i++) {
-				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-					continue;
-				const char marker = (i == _mystery._siteNumber) ? '>' : ' ';
-				Common::String label = Common::String::format(
-					"%c %u", marker, i);
-				_font.drawString(&scratch, label, panelX, y, 320, 0x0F);
-				y += _font.getFontHeight() + 1;
+				byte color;
+				if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
+					color = 0x07;       // searched (DoneMarker analogue)
+				else if (crime != 0)
+					color = 0x0C;       // crime scene (CrimeMarker analogue)
+				else
+					color = 0x0F;       // available (SiteMarker analogue)
+				if (i == _mystery._siteNumber)
+					color = 0x0E;       // current site — bright yellow
+
+				const Common::Rect mark(sx - 3, sy - 3, sx + 4, sy + 4);
+				scratch.fillRect(mark, color);
 			}
-			_font.drawString(&scratch, "Esc", panelX, 188, 320, 0x0F);
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -1669,24 +1699,6 @@ void EEMEngine::doBigMap() {
 					}
 				}
 
-				// Click in the right panel: travel to that row.
-				const int panelX = kMapWinX + kMapWinW + 4;
-				if (_font.isLoaded() && _mystery.isLoaded() &&
-					ev.mouse.x >= panelX) {
-					const int row = (ev.mouse.y - 4 - _font.getFontHeight() - 4) /
-									(_font.getFontHeight() + 1);
-					int seen = 0;
-					for (uint i = 0; i < _mystery.numSites(); i++) {
-						if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-							continue;
-						if (seen == row) {
-							_mystery._lastSite = _mystery._siteNumber;
-							_mystery._siteNumber = (uint16)i;
-							return;
-						}
-						seen++;
-					}
-				}
 			}
 		}
 		if (dirty)
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 319f31b3f68..8fa94de5ae5 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -64,7 +64,14 @@ void SiteScreen::run() {
 	if (!_mystery || !_mystery->isLoaded())
 		return;
 
-	uint cur = 0;
+	// The caller (run() in eem.cpp) is responsible for bringing the
+	// player into a site via the map first, so `_siteNumber` is
+	// already set to the destination they picked. Resuming a save
+	// also restores `_siteNumber`. Start there instead of forcing
+	// site 0 each time.
+	uint cur = _mystery->_siteNumber;
+	if (cur >= _mystery->numSites())
+		cur = 0;
 	enter(cur);
 
 	while (!_vm->shouldQuit()) {
@@ -188,19 +195,26 @@ void SiteScreen::run() {
 }
 
 void SiteScreen::renderBackground(uint siteNum) {
-	// Mirrors `_BuildBackground` @ 172b:13e2 (simplified):
-	//   1. Load PIC 0x3d (the site frame) from PICS.DBD.
-	//   2. Load entry @p siteNum from SITES.DBD (the site scene).
-	//   3. Composite scene into the frame at the position carried in the
-	//      SiteData fields.
-	//   4. Set palette to (siteNum + 1) — per-site palettes start at 1.
-	// We render frame + scene at (0,0); the original positions the scene
-	// at (x,y) read from the SiteData but we don't have the offsets fully
-	// decoded yet so a top-left placement will do.
+	// Site loop entry `screen 1` calls into a function at 20fe:120b in
+	// the original; that function opens `_GetPicture(0x43)` (PIC 0x43,
+	// 1-based) as the screen frame and `_GetPalette(0x23)` as the base
+	// palette. The case-briefing's `_BuildBackground @ 172b:13e2`
+	// instead uses entry 0x3d directly and palette `sitenum + 1` — but
+	// that path is only used by `_DisplayCorrect` (winner scene), not
+	// by the regular per-site renderer.
+	//
+	// We follow the regular site loop here:
+	//   1. PIC 0x43 frame at (0, 0).
+	//   2. SITES.DBD entry indexed by SiteData[+0] (1-based sitepic) at
+	//      (x, y) from the original `_Rect_Move` composition.
+	//   3. Per-site palette (`sitenum + 1`) — set by the caller in
+	//      SiteScreen::enter via `setSitePaletteForSite`.
 
-	// Frame.
 	Picture frame;
-	if (_vm->getPics().getPicture(0x3d, frame)) {
+	bool haveFrame = _vm->getPics().getPicture(0x43, frame);
+	if (!haveFrame)
+		haveFrame = _vm->getPics().getPicture(0x3d, frame);
+	if (haveFrame) {
 		g_system->copyRectToScreen(frame.surface.getPixels(),
 								   frame.surface.pitch,
 								   0, 0, frame.surface.w, frame.surface.h);


Commit: 3fe1b2e115c9d504281bb706e8b6fc2daecf3261
    https://github.com/scummvm/scummvm/commit/3fe1b2e115c9d504281bb706e8b6fc2daecf3261
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:36+02:00

Commit Message:
EEM: improved character animations when arriving to locations

Changed paths:
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index ed75d78e5ec..24bddf6f644 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -74,6 +74,7 @@ public:
 	DBDArchive &getBalloons(){ return _balloonArchive; }
 	Mystery    &getMystery() { return _mystery; }
 	const EEMFont &getFont() const { return _font; }
+	uint8       getPartnerIndex() const { return _partner; }
 
 	/// Display one ClueBlock. @p clueBlock points at the u16 frame count
 	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 8fa94de5ae5..2ee29ea63ab 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -56,6 +56,23 @@ void SiteScreen::enter(uint siteNum) {
 	_vm->setSitePaletteForSite(sitepic);
 
 	renderBackground(siteNum);
+
+	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` whenever
+	// `_LastSite != _SiteNumber`. We track the last site we *played*
+	// the arrival on so re-entries (after notebook/map/etc.) don't
+	// repeat the animation.
+	if ((int)siteNum != _lastSiteAnim) {
+		enterSiteAnim();
+		_lastSiteAnim = (int)siteNum;
+		// Re-paint the BG; the arrival animation drew on top of it.
+		renderBackground(siteNum);
+	}
+
+	// Persistent partner sprite (`_NewAnimation` at the tail of
+	// `_DoSiteLoop`). Drawn after the BG so the hotspot outlines and
+	// HUD that follow stay on top of it.
+	renderPartner(siteNum);
+
 	renderHotspots(siteNum);
 	g_system->updateScreen();
 }
@@ -194,56 +211,245 @@ void SiteScreen::run() {
 	}
 }
 
-void SiteScreen::renderBackground(uint siteNum) {
-	// Site loop entry `screen 1` calls into a function at 20fe:120b in
-	// the original; that function opens `_GetPicture(0x43)` (PIC 0x43,
-	// 1-based) as the screen frame and `_GetPalette(0x23)` as the base
-	// palette. The case-briefing's `_BuildBackground @ 172b:13e2`
-	// instead uses entry 0x3d directly and palette `sitenum + 1` — but
-	// that path is only used by `_DisplayCorrect` (winner scene), not
-	// by the regular per-site renderer.
-	//
-	// We follow the regular site loop here:
-	//   1. PIC 0x43 frame at (0, 0).
-	//   2. SITES.DBD entry indexed by SiteData[+0] (1-based sitepic) at
-	//      (x, y) from the original `_Rect_Move` composition.
-	//   3. Per-site palette (`sitenum + 1`) — set by the caller in
-	//      SiteScreen::enter via `setSitePaletteForSite`.
+void SiteScreen::enterSiteAnim() {
+	// Mirrors `_EnterSiteAnim @ 1000:9b21`. Two phases, both partner
+	// dependent:
+	//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny). Sprite
+	//             starts at (320 - sprite_w, 199 - sprite_h) and slides
+	//             left until off-screen.
+	//   Phase 2 — KD slide-in: anim 7 (Jake) / 0xf (Jenny). Sprite enters
+	//             from x = -sprite_w at y = 0x8b (Jake) / 0x8e (Jenny)
+	//             and slides until x = 0.
+	// Original cycles frames every `_MoveSkateBoardPixels` worth of
+	// motion (a runtime-calibrated speed value); we use a fixed 4 px
+	// per tick which feels close to the DOS pacing.
+	if (!_vm || !_mystery)
+		return;
+	const uint8 partner = _vm->getPartnerIndex();
+	const uint kSkateAni = (partner == 0) ? 6  : 0xe;
+	const uint kKDAni    = (partner == 0) ? 7  : 0xf;
+	const int  kKDY      = (partner == 0) ? 0x8b : 0x8e;
+
+	// Snapshot the current screen so we can restore between frames.
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	for (int row = 0; row < 200; row++) {
+		memcpy((byte *)bg.getBasePtr(0, row),
+			   (const byte *)screen->getBasePtr(0, row), 320);
+	}
+	g_system->unlockScreen();
+
+	auto blitFrame = [](Graphics::ManagedSurface &dst, const Picture &p,
+						int x, int y, byte transp) {
+		const int w = p.surface.w, h = p.surface.h;
+		for (int row = 0; row < h; row++) {
+			const int dstY = y + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+			byte *out = (byte *)dst.getBasePtr(0, dstY);
+			for (int col = 0; col < w; col++) {
+				const int dstX = x + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					out[dstX] = src[col];
+			}
+		}
+	};
+
+	// Phase 1 — skateboard scroll. `_GetAnimation(6 | 0xe)`.
+	Animation skate;
+	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
+		// `iVar4 = 199 - sprite_h`, `uVar5 = 320 - sprite_w` from the
+		// original; sprite_h/w come from the FIRST frame.
+		const int spriteH = skate[0].surface.h;
+		const int spriteW = skate[0].surface.w;
+		int x = (320 - spriteW) & ~3;            // 4-px aligned (mode-X)
+		const int y = 199 - spriteH;
+		const byte transp = (byte)(skate[0].flags >> 8);
+		uint frameIdx = 0;
+		int distSinceTick = 0;
+		const int kStep = 4;            // _MoveSkateBoardPixels analogue
+		const int kFrameTicks = 0xc;    // original switches frame at 12 px
+
+		while (x + spriteW > 0 && !_vm->shouldQuit()) {
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.getBasePtr(0, row), 320);
+			}
+			blitFrame(scratch, skate[frameIdx], x, y, transp);
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_KEYDOWN ||
+					ev.type == Common::EVENT_LBUTTONDOWN) {
+					return; // user-skip — bail out of the animation
+				}
+			}
+
+			x -= kStep;
+			distSinceTick += kStep;
+			if (distSinceTick >= kFrameTicks) {
+				frameIdx = (frameIdx + 1) % skate.size();
+				distSinceTick = 0;
+			}
+			g_system->delayMillis(40);
+		}
+	}
+
+	// Phase 2 — KD slide-in. From `_EnterSiteAnim` each frame is blitted
+	// at its OWN anchor offsets (the sprite "walks in" because the
+	// frame-by-frame anchors decrease as the animation progresses):
+	//   destX = -frame.miscflags    (on-disk byte 8 = anchor X)
+	//   destY = kKDY - frame.rowoff (on-disk byte 6 = anchor Y)
+	// Each frame waits one `_CheckFrameRate` tick — we use 80 ms which
+	// matches the original's ~12 FPS pacing.
+	Animation kd;
+	if (_vm->getAni().loadAnimation(kKDAni, kd) && !kd.empty()) {
+		for (uint frameIdx = 0;
+			 frameIdx < kd.size() && !_vm->shouldQuit();
+			 frameIdx++) {
+			const Picture &fr = kd[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			const int destX = -(int)fr.miscflags;
+			const int destY = kKDY - (int)fr.rowoff;
 
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.getBasePtr(0, row), 320);
+			}
+			blitFrame(scratch, fr, destX, destY, transp);
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_KEYDOWN ||
+					ev.type == Common::EVENT_LBUTTONDOWN) {
+					return;
+				}
+			}
+			g_system->delayMillis(80);
+		}
+	}
+}
+
+void SiteScreen::renderPartner(uint siteNum) {
+	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
+	// table index, then for each (speaker × partner) loads
+	//   anim  = WaitAnims[speakerIdx].anim[partner]
+	//   x     = WaitAnims[speakerIdx].x[partner]
+	//   y     = WaitAnims[speakerIdx].y[partner]
+	// from the table at `_WaitAnims @ 29be:021c`. Each entry is
+	// 12 bytes / 6 u16:
+	//   +0..1 anim Jake, +2..3 anim Jenny,
+	//   +4..5 x    Jake, +6..7 x    Jenny,
+	//   +8..9 y    Jake, +10..11 y    Jenny.
+	// Verbatim copy of the bytes Ghidra dumped at 29be:021c so we
+	// don't need to ship the original data segment.
+	static const uint16 kWaitAnims[][6] = {
+		{ 0x00, 0x0a, 0x06, 0x06, 0x50, 0x50 }, // 0
+		{ 0x03, 0x0c, 0x06, 0x06, 0x50, 0x50 }, // 1
+		{ 0x01, 0x0b, 0x06, 0x06, 0x50, 0x50 }, // 2
+		{ 0x04, 0x0d, 0x06, 0x06, 0x50, 0x50 }, // 3
+		{ 0x02, 0x10, 0x06, 0x06, 0x50, 0x50 }, // 4
+		{ 0x05, 0x05, 0x06, 0x06, 0x50, 0x50 }, // 5
+		{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
+		{ 0x00, 0x00, 0x23, 0x6f, 0x38, 0x88 }, // 7 — special pos
+		{ 0x07, 0xb1, 0x39, 0xc8, 0x88, 0xae }, // 8 — likely junk; 0xb1 anim id is suspect
+		{ 0x9d, 0xbe, 0xa3, 0xae, 0xb8, 0xbe }  // 9 — likely junk
+	};
+
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 speaker = READ_LE_UINT16(site + 8);
+	if (speaker >= ARRAYSIZE(kWaitAnims))
+		return;
+
+	const uint8 partner = _vm->getPartnerIndex();
+	const uint  animId  = kWaitAnims[speaker][0 + partner];
+	const int   x       = (int)(int16)kWaitAnims[speaker][2 + partner];
+	const int   y       = (int)(int16)kWaitAnims[speaker][4 + partner];
+
+	Animation anim;
+	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
+		return;
+
+	// Show the first frame as a static sprite. The original updates it
+	// each `_CheckFrameRate` tick; we don't have a frame pump in the
+	// site loop yet so a static pose is enough for now.
+	const Picture &fr = anim[0];
+	const byte transp = (byte)(fr.flags >> 8);
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+	for (int row = 0; row < fr.surface.h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= 200)
+			continue;
+		const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+		byte *dst = (byte *)screen->getBasePtr(0, dstY);
+		for (int col = 0; col < fr.surface.w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= 320)
+				continue;
+			if (src[col] != transp)
+				dst[dstX] = src[col];
+		}
+	}
+	g_system->unlockScreen();
+}
+
+void SiteScreen::renderBackground(uint siteNum) {
+	// Mirrors `_BuildBackground(sitepic, 0x42, 0x14)` as called from
+	// `_DoSiteLoop @ 168d:03f4` and `_DisplayCorrect`. The original:
+	//   1. Loads frame via `_GetFromDB(_PicIndex, 0x3d)` — that's
+	//      DBI entry 0x3d (0-based). Note this is NOT the same as
+	//      `_GetPicture(0x3d)` which would index entry 0x3c. Our
+	//      `loadEntry(0x3d)` matches the original directly.
+	//   2. Loads SITES.DBD entry by `sitepic` (0-based, from
+	//      SiteData[+0..+1]).
+	//   3. `_Rect_Move(... x, y, 48000, ...)` composes the scene at
+	//      (x, y) = (0x42, 0x14) = (66, 20) on top of the frame.
+	//   4. `_GetPalette(sitepic + 1)` — per-site palette.
 	Picture frame;
-	bool haveFrame = _vm->getPics().getPicture(0x43, frame);
-	if (!haveFrame)
-		haveFrame = _vm->getPics().getPicture(0x3d, frame);
-	if (haveFrame) {
+	if (_vm->getPics().loadEntry(0x3d, frame)) {
 		g_system->copyRectToScreen(frame.surface.getPixels(),
 								   frame.surface.pitch,
 								   0, 0, frame.surface.w, frame.surface.h);
 	}
 
-	// Scene from SITES.DBD: indexed by `sitepic` from SiteData (global
-	// SITES.DBD entry, NOT the per-mystery site index). Falls back to
-	// `_GetPicture(sitepic)` if SITES is unavailable.
 	const byte *site = _mystery->siteData(siteNum);
 	const uint16 sitepic = site ? READ_LE_UINT16(site) : 0;
 	Picture scene;
 	bool haveScene = false;
-	bool fromPics = false;
 	if (sitepic > 0 && _vm->getSites().size() > sitepic - 1)
 		haveScene = _vm->getSites().loadEntry(sitepic - 1, scene);
-	if (!haveScene && sitepic > 0) {
+	if (!haveScene && sitepic > 0)
 		haveScene = _vm->getPics().getPicture(sitepic, scene);
-		fromPics = haveScene;
-	}
 	if (haveScene) {
-		const int w = MIN<int>(scene.surface.w, 320);
-		const int h = MIN<int>(scene.surface.h, 200);
-		// Full-screen pictures (sitepic fallback) go at (0, 0); smaller
-		// SITES.DBD scenes are centred horizontally with the top below
-		// the HUD bar so progress info stays visible.
-		const int x = fromPics ? 0 : (320 - w) / 2;
-		const int y = fromPics ? 0 : (h < 180 ? 4 : 0);
-		g_system->copyRectToScreen(scene.surface.getPixels(),
-								   scene.surface.pitch, x, y, w, h);
+		// Hard-coded composition position from `_BuildBackground`:
+		//   `_Rect_Move(0, 0, h, ..., 0x42, 0x14, 48000, h, w)`.
+		const int x = 0x42;
+		const int y = 0x14;
+		const int w = MIN<int>(scene.surface.w, 320 - x);
+		const int h = MIN<int>(scene.surface.h, 200 - y);
+		if (w > 0 && h > 0)
+			g_system->copyRectToScreen(scene.surface.getPixels(),
+									   scene.surface.pitch, x, y, w, h);
 	}
 }
 
diff --git a/engines/eem/site.h b/engines/eem/site.h
index ce450bb1905..2346f5e5a77 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -65,9 +65,22 @@ private:
 	int  hotspotAtPoint(uint siteNum, int x, int y) const;
 	void onHotspotClicked(uint siteNum, uint hotIdx);
 
+	/// Play the partner's site-arrival sequence once `_LastSite !=
+	/// _SiteNumber`. Mirrors `_EnterSiteAnim @ 1000:9b21` — animation
+	/// 6 (Jake) / 14 (Jenny) skateboards in from the right edge along
+	/// the bottom, then animation 7 / 15 slides KD in from the left.
+	void enterSiteAnim();
+
+	/// Draw the persistent in-site partner sprite (Jake or Jenny
+	/// standing/idling) at the position from `_WaitAnims` @ 29be:021c.
+	/// Mirrors the `_GetAnimation` + `_NewAnimation` block at the tail
+	/// of `_DoSiteLoop @ 168d:03f4`.
+	void renderPartner(uint siteNum);
+
 	EEMEngine *_vm;
 	Mystery *_mystery;
 	bool _showHotspots = true;  ///< Toggle outlines with V key.
+	int _lastSiteAnim = -1;     ///< Last site we played the arrival on.
 };
 
 } // End of namespace EEM


Commit: 30367c4690af026d7a3889ebbf7530ba2bfbe2d7
    https://github.com/scummvm/scummvm/commit/30367c4690af026d7a3889ebbf7530ba2bfbe2d7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:36+02:00

Commit Message:
EEM: zoom/overview map initial implementation

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index f906aec7d69..98539e94427 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1507,85 +1507,197 @@ void EEMEngine::doGallery() {
 }
 
 void EEMEngine::doBigMap() {
-	// Mirrors the small/scrollable map view used inside the site loop —
-	// the original game draws a 0xe9 × 0xab viewport at (2, 2) into
-	// `_MapBitMap` (= BIGMAP.PIC) via `DrawMap @ 20fe:1058`; on top of
-	// that, `_StampButtons @ 20fe:0d2f` bakes site icons into the
-	// bitmap at MapData offsets (+4, +6). We render BIGMAP.PIC into a
-	// 233×171 viewport and overlay markers ourselves.
+	// Two-stage flow that mirrors the original screen-1 wrapper at
+	// 20fe:120b and `_DoBigMap @ 20fe:09e7`:
 	//
-	// MapData entry layout (14 bytes), confirmed from
-	// `_DrawBigMapButtons @ 20fe:0877` and `_StampButtons @ 20fe:0d2f`:
-	//   +0..1  BigMap X (overview frame PIC 0x42)
-	//   +2..3  BigMap Y
-	//   +4..5  SmallMap X (stamped into _MapBitMap at this position)
-	//   +6..7  SmallMap Y
-	//   +8..9  crime-flag (DAT_2d5d_5436): non-zero → crime-scene marker
-	//   +10..13 unused
+	//   STAGE 1 — Overview. PIC 0x42 + site icons drawn via the
+	//   `_DrawBigMapButtons` algorithm at BigMap coords MapData[+4/+6].
+	//   The original `_DoBigMap` returns sx/sy = (mouseX*2 - 0x74,
+	//   mouseY*2 - 0x55) when the player clicks inside `BigMapWindow`,
+	//   which is the scroll position into the SmallMap.
+	//
+	//   STAGE 2 — Detail zoom. PIC 0x43 frame + a 0xe9 × 0xab viewport
+	//   into BIGMAP.PIC at (2, 2), drawn by `DrawMap @ 20fe:1058` with
+	//   the (sx, sy) returned from stage 1. Site icons are stamped at
+	//   SmallMap coords MapData[+8/+0xa] via `_StampButtons`. Click on
+	//   a site icon → travel.
+	//
+	// MapData entry layout (14 bytes), verified directly from the
+	// disassembly of `_DrawBigMapButtons @ 20fe:0877` (`PUSH ES:[BX+4]`
+	// for X, `PUSH ES:[BX+6]` for Y, `CMP ES:[BX+0xc], 0` for crime)
+	// and `_StampButtons @ 20fe:0d2f` (`MOV AX, ES:[BX+8]`,
+	// `MOV AX, ES:[BX+0xa]`):
+	//   +0..3   ??? (not yet decoded)
+	//   +4..5   BigMap X
+	//   +6..7   BigMap Y
+	//   +8..9   SmallMap X
+	//   +0xa..b SmallMap Y
+	//   +0xc..d crime-flag
+
+	if (!_mystery.isLoaded())
+		return;
+
+	CursorMan.showMouse(true);
+
+	// `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
+	setSitePalette(0x24);
+
+	const Common::Rect kSetupRect(0xc7, 0x12, 0xc7 + 0x32, 0x12 + 0xa); // approx; original from globals
+	(void)kSetupRect; // not yet wired into our overlay
+
+	// ------------------------------------------------------------------
+	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
+	// ------------------------------------------------------------------
+
+	auto drawOverview = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		Picture frame;
+		if (_picsArchive.getPicture(0x42, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+		}
+
+		// Site icons at BigMap coords (+4, +6). Three colours by state:
+		//   visited → DoneMarker analogue, crime flag → CrimeMarker,
+		//   else → SiteMarker. Current site gets a bright highlight.
+		for (uint i = 0; i < _mystery.numSites(); i++) {
+			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+				continue;
+			const byte *entry = _mystery.mapEntry(i);
+			if (!entry)
+				continue;
+			const uint16 mx    = READ_LE_UINT16(entry + 0x4);
+			const uint16 my    = READ_LE_UINT16(entry + 0x6);
+			const uint16 crime = READ_LE_UINT16(entry + 0xc);
+
+			byte color;
+			if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
+				color = 0x07;
+			else if (crime != 0)
+				color = 0x0C;
+			else
+				color = 0x0F;
+			if (i == _mystery._siteNumber)
+				color = 0x0E;
+
+			const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
+			scratch.fillRect(mark, color);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	auto findSiteAt = [&](int x, int y) -> int {
+		// Hit-test the icons drawn in stage 1. Generous radius matches
+		// the marker square plus a couple of pixels of slop.
+		for (uint i = 0; i < _mystery.numSites(); i++) {
+			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+				continue;
+			const byte *entry = _mystery.mapEntry(i);
+			if (!entry) continue;
+			const int mx = (int)READ_LE_UINT16(entry + 0x4);
+			const int my = (int)READ_LE_UINT16(entry + 0x6);
+			if (ABS(x - mx) <= 6 && ABS(y - my) <= 6)
+				return (int)i;
+		}
+		return -1;
+	};
+
+	drawOverview();
+
+	bool wantZoom = false;
+	int  zoomX = 0, zoomY = 0;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN &&
+				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				const int hit = findSiteAt(ev.mouse.x, ev.mouse.y);
+				if (hit >= 0) {
+					// Direct click on a site marker → travel.
+					_mystery._lastSite = _mystery._siteNumber;
+					_mystery._siteNumber = (uint16)hit;
+					return;
+				}
+				// `_DoBigMap` rule for clicks elsewhere: convert mouse
+				// coords to a SmallMap scroll position
+				//   sx = mouseX * 2 - 0x74
+				//   sy = mouseY * 2 - 0x55
+				// then transition to the detail zoom.
+				int sx = ev.mouse.x * 2;
+				int sy = ev.mouse.y * 2;
+				sx = (sx < 0x75) ? 0 : sx - 0x74;
+				sy = (sy < 0x56) ? 0 : sy - 0x55;
+				zoomX = sx;
+				zoomY = sy;
+				wantZoom = true;
+				break;
+			}
+		}
+		if (wantZoom)
+			break;
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+
+	if (!wantZoom)
+		return;
+
+	// ------------------------------------------------------------------
+	// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
+	// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
+	// ------------------------------------------------------------------
+
 	Common::File f;
 	if (!f.open(Common::Path("BIGMAP.PIC"))) {
-		warning("doBigMap: BIGMAP.PIC missing");
+		warning("doBigMap: BIGMAP.PIC missing for detail view");
 		return;
 	}
 	const uint16 mapH = f.readUint16LE();
 	const uint16 mapW = f.readUint16LE();
 	if (mapW == 0 || mapH == 0)
 		return;
-
 	Common::Array<byte> mapPixels((uint32)mapW * mapH);
 	if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
-		warning("doBigMap: short read on BIGMAP.PIC");
+		warning("doBigMap: short read on BIGMAP.PIC for detail view");
 		return;
 	}
 
-	// Viewport size from `DrawMap`: 0xe9 × 0xab at screen (2, 2).
-	// We use (4, 4) so a 1-pixel inset preserves the frame border.
 	const int kMapWinW = 0xe9; // 233
 	const int kMapWinH = 0xab; // 171
-	const int kMapWinX = 4;
-	const int kMapWinY = 4;
-
-	int scrollX = 0;
-	int scrollY = 0;
-
-	// Auto-scroll to centre the current site (using SmallMap coords).
-	if (_mystery.isLoaded()) {
-		const byte *entry = _mystery.mapEntry(_mystery._siteNumber);
-		if (entry) {
-			const uint16 mx = READ_LE_UINT16(entry + 4);
-			const uint16 my = READ_LE_UINT16(entry + 6);
-			scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW,
-				(int)mx - kMapWinW / 2));
-			scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH,
-				(int)my - kMapWinH / 2));
-		}
-	}
+	const int kMapWinX = 2;
+	const int kMapWinY = 2;
 
-	CursorMan.showMouse(true);
-
-	// `_DoBigMap @ 20fe:09e7` calls `_GetPalette(0x24)` after loading
-	// the frame. Without this, the map would render under whatever
-	// palette the prior site set, which makes BIGMAP.PIC look like
-	// noise / incomplete colours.
-	setSitePalette(0x24);
+	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
+	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
-	auto draw = [&]() {
+	auto drawDetail = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 
-		// Frame from PIC 0x42 (`_GetPicture(0x42)` in `_DoBigMap`).
 		Picture frame;
-		if (_picsArchive.getPicture(0x42, frame)) {
+		if (_picsArchive.getPicture(0x43, frame)) {
 			const int w = MIN<int>(frame.surface.w, 320);
 			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++) {
+			for (int row = 0; row < h; row++)
 				memcpy((byte *)scratch.getBasePtr(0, row),
 					   (const byte *)frame.surface.getBasePtr(0, row), w);
-			}
 		}
 
-		// Map content clipped into the inner window, applying scroll.
 		const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
 		const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
 		for (int row = 0; row < copyH; row++) {
@@ -1594,48 +1706,42 @@ void EEMEngine::doBigMap() {
 				   copyW);
 		}
 
-		// Site markers — three states from `_DrawBigMapButtons`:
-		//   _DoneMarker if `SaveSiteComplete[i]` (already searched)
-		//   _CrimeMarker if MapData[+8] != 0 (crime scene)
-		//   _SiteMarker otherwise
-		// We don't have the marker PICs traced yet, so draw small filled
-		// squares with three colours that match the original semantics.
-		if (_mystery.isLoaded()) {
-			for (uint i = 0; i < _mystery.numSites(); i++) {
-				if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-					continue;
-				const byte *entry = _mystery.mapEntry(i);
-				if (!entry)
-					continue;
-				const uint16 mx = READ_LE_UINT16(entry + 4);
-				const uint16 my = READ_LE_UINT16(entry + 6);
-				const uint16 crime = READ_LE_UINT16(entry + 8);
-				const int sx = (int)mx - scrollX + kMapWinX;
-				const int sy = (int)my - scrollY + kMapWinY;
-				if (sx < kMapWinX || sx >= kMapWinX + kMapWinW ||
-					sy < kMapWinY || sy >= kMapWinY + kMapWinH)
-					continue;
+		// Stamped site icons at SmallMap coords (+8, +0xa). Same colour
+		// scheme as the overview so the player can tell them apart.
+		for (uint i = 0; i < _mystery.numSites(); i++) {
+			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+				continue;
+			const byte *entry = _mystery.mapEntry(i);
+			if (!entry) continue;
+			const uint16 mx    = READ_LE_UINT16(entry + 0x8);
+			const uint16 my    = READ_LE_UINT16(entry + 0xa);
+			const uint16 crime = READ_LE_UINT16(entry + 0xc);
+			const int sx = (int)mx - scrollX + kMapWinX;
+			const int sy = (int)my - scrollY + kMapWinY;
+			if (sx < kMapWinX || sx >= kMapWinX + kMapWinW ||
+				sy < kMapWinY || sy >= kMapWinY + kMapWinH)
+				continue;
 
-				byte color;
-				if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
-					color = 0x07;       // searched (DoneMarker analogue)
-				else if (crime != 0)
-					color = 0x0C;       // crime scene (CrimeMarker analogue)
-				else
-					color = 0x0F;       // available (SiteMarker analogue)
-				if (i == _mystery._siteNumber)
-					color = 0x0E;       // current site — bright yellow
-
-				const Common::Rect mark(sx - 3, sy - 3, sx + 4, sy + 4);
-				scratch.fillRect(mark, color);
-			}
+			byte color;
+			if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
+				color = 0x07;
+			else if (crime != 0)
+				color = 0x0C;
+			else
+				color = 0x0F;
+			if (i == _mystery._siteNumber)
+				color = 0x0E;
+
+			const Common::Rect mark(sx - 3, sy - 3, sx + 4, sy + 4);
+			scratch.fillRect(mark, color);
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
-	draw();
+
+	drawDetail();
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -1646,37 +1752,26 @@ void EEMEngine::doBigMap() {
 				return;
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return;
+					return;  // exit detail back to caller (site loop / engine)
 				const int kStep = 16;
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
 					scrollX = MAX<int>(0, scrollX - kStep);
 					dirty = true;
 				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW), scrollX + kStep);
+					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW),
+						scrollX + kStep);
 					dirty = true;
 				} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
 					scrollY = MAX<int>(0, scrollY - kStep);
 					dirty = true;
 				} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
-					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH), scrollY + kStep);
+					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH),
+						scrollY + kStep);
 					dirty = true;
 				}
-				if (ev.kbd.keycode >= Common::KEYCODE_0 &&
-					ev.kbd.keycode <= Common::KEYCODE_9) {
-					const uint target = (uint)(ev.kbd.keycode - Common::KEYCODE_0);
-					if (_mystery.isLoaded() &&
-						target < _mystery.numSites() &&
-						_mystery._onSites[target]) {
-						_mystery._lastSite = _mystery._siteNumber;
-						_mystery._siteNumber = (uint16)target;
-						return;
-					}
-				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Click on a map marker?
-				if (_mystery.isLoaded() &&
-					ev.mouse.x >= kMapWinX &&
+				if (ev.mouse.x >= kMapWinX &&
 					ev.mouse.x < kMapWinX + kMapWinW &&
 					ev.mouse.y >= kMapWinY &&
 					ev.mouse.y < kMapWinY + kMapWinH) {
@@ -1686,23 +1781,22 @@ void EEMEngine::doBigMap() {
 							continue;
 						const byte *entry = _mystery.mapEntry(i);
 						if (!entry) continue;
-						const uint16 mx = READ_LE_UINT16(entry + 4);
-						const uint16 my = READ_LE_UINT16(entry + 6);
+						const uint16 mx = READ_LE_UINT16(entry + 0x8);
+						const uint16 my = READ_LE_UINT16(entry + 0xa);
 						const int sx = (int)mx - scrollX + kMapWinX;
 						const int sy = (int)my - scrollY + kMapWinY;
-						if (ABS(ev.mouse.x - sx) <= 5 &&
-							ABS(ev.mouse.y - sy) <= 5) {
+						if (ABS(ev.mouse.x - sx) <= 6 &&
+							ABS(ev.mouse.y - sy) <= 6) {
 							_mystery._lastSite = _mystery._siteNumber;
 							_mystery._siteNumber = (uint16)i;
 							return;
 						}
 					}
 				}
-
 			}
 		}
 		if (dirty)
-			draw();
+			drawDetail();
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}


Commit: a4c4170605ca1557ef49e108465c07e0e62d2fa8
    https://github.com/scummvm/scummvm/commit/a4c4170605ca1557ef49e108465c07e0e62d2fa8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:36+02:00

Commit Message:
EEM: load the correct sites and make sure they are labeled

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 98539e94427..e79f9723286 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -248,6 +248,12 @@ bool EEMEngine::openArchives() {
 		warning("SITES archive missing — site backgrounds disabled");
 	if (!_balloonArchive.open(Common::Path("BALLOON.DBD"), Common::Path("BALLOON.DBX")))
 		warning("BALLOON archive missing — clue text will lack balloons");
+	// `_GetButton @ 172b:199d` reads from this archive (see strings
+	// 'button.dbd' / 'Button.DBX' at 29be:06bf / 29be:04bb). Each
+	// per-site map marker (used by `_StampButtons @ 20fe:0d2f` and
+	// looked up via MapData[+0]) lives here.
+	if (!_buttonArchive.open(Common::Path("BUTTON.DBD"), Common::Path("BUTTON.DBX")))
+		warning("BUTTON archive missing — map markers will be unlabelled");
 	return true;
 }
 
@@ -1595,22 +1601,6 @@ void EEMEngine::doBigMap() {
 		g_system->updateScreen();
 	};
 
-	auto findSiteAt = [&](int x, int y) -> int {
-		// Hit-test the icons drawn in stage 1. Generous radius matches
-		// the marker square plus a couple of pixels of slop.
-		for (uint i = 0; i < _mystery.numSites(); i++) {
-			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-				continue;
-			const byte *entry = _mystery.mapEntry(i);
-			if (!entry) continue;
-			const int mx = (int)READ_LE_UINT16(entry + 0x4);
-			const int my = (int)READ_LE_UINT16(entry + 0x6);
-			if (ABS(x - mx) <= 6 && ABS(y - my) <= 6)
-				return (int)i;
-		}
-		return -1;
-	};
-
 	drawOverview();
 
 	bool wantZoom = false;
@@ -1625,18 +1615,11 @@ void EEMEngine::doBigMap() {
 				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				const int hit = findSiteAt(ev.mouse.x, ev.mouse.y);
-				if (hit >= 0) {
-					// Direct click on a site marker → travel.
-					_mystery._lastSite = _mystery._siteNumber;
-					_mystery._siteNumber = (uint16)hit;
-					return;
-				}
-				// `_DoBigMap` rule for clicks elsewhere: convert mouse
-				// coords to a SmallMap scroll position
-				//   sx = mouseX * 2 - 0x74
-				//   sy = mouseY * 2 - 0x55
-				// then transition to the detail zoom.
+				// `_DoBigMap @ 20fe:09e7` does NOT travel directly when
+				// the player clicks an overview icon — it always returns
+				// `(sx, sy) = (mouseX*2 - 0x74, mouseY*2 - 0x55)` so the
+				// caller can switch to the detail zoom centred there.
+				// Travel happens after a SECOND click in the detail view.
 				int sx = ev.mouse.x * 2;
 				int sy = ev.mouse.y * 2;
 				sx = (sx < 0x75) ? 0 : sx - 0x74;
@@ -1706,34 +1689,42 @@ void EEMEngine::doBigMap() {
 				   copyW);
 		}
 
-		// Stamped site icons at SmallMap coords (+8, +0xa). Same colour
-		// scheme as the overview so the player can tell them apart.
+		// Stamped site buttons. `_StampButtons @ 20fe:0d2f` does:
+		//   button = _GetButton(MapData[+0])      // BUTTON.DBD entry
+		//   destX  = MapData[+8],  destY = MapData[+0xa]
+		// then bakes the button PIC into the map bitmap. Each button
+		// sprite carries the site name baked in. We blit them on top
+		// of the BIGMAP.PIC viewport at the same SmallMap coords.
 		for (uint i = 0; i < _mystery.numSites(); i++) {
 			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 				continue;
 			const byte *entry = _mystery.mapEntry(i);
 			if (!entry) continue;
-			const uint16 mx    = READ_LE_UINT16(entry + 0x8);
-			const uint16 my    = READ_LE_UINT16(entry + 0xa);
-			const uint16 crime = READ_LE_UINT16(entry + 0xc);
+			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+			const uint16 mx       = READ_LE_UINT16(entry + 0x8);
+			const uint16 my       = READ_LE_UINT16(entry + 0xa);
+
+			Picture button;
+			if (!_buttonArchive.loadEntry(buttonId, button))
+				continue;
 			const int sx = (int)mx - scrollX + kMapWinX;
 			const int sy = (int)my - scrollY + kMapWinY;
-			if (sx < kMapWinX || sx >= kMapWinX + kMapWinW ||
-				sy < kMapWinY || sy >= kMapWinY + kMapWinH)
-				continue;
-
-			byte color;
-			if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
-				color = 0x07;
-			else if (crime != 0)
-				color = 0x0C;
-			else
-				color = 0x0F;
-			if (i == _mystery._siteNumber)
-				color = 0x0E;
-
-			const Common::Rect mark(sx - 3, sy - 3, sx + 4, sy + 4);
-			scratch.fillRect(mark, color);
+			const byte transp = (byte)(button.flags >> 8);
+
+			// Crop blit against the viewport.
+			const int x0 = MAX<int>(sx, kMapWinX);
+			const int y0 = MAX<int>(sy, kMapWinY);
+			const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
+			const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
+			for (int row = y0; row < y1; row++) {
+				const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
+				byte *dst = (byte *)scratch.getBasePtr(0, row);
+				for (int col = x0; col < x1; col++) {
+					const byte px = src[col - sx];
+					if (px != transp)
+						dst[col] = px;
+				}
+			}
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -1775,18 +1766,28 @@ void EEMEngine::doBigMap() {
 					ev.mouse.x < kMapWinX + kMapWinW &&
 					ev.mouse.y >= kMapWinY &&
 					ev.mouse.y < kMapWinY + kMapWinH) {
+					// Hit-test the per-site button at its actual bbox
+					// (`_StampButtons` records the rect at SmallMap +8/+0xa
+					// with the button PIC's width/height).
 					for (uint i = 0; i < _mystery.numSites(); i++) {
 						if (!_mystery._onSites[i] &&
 							i != _mystery._siteNumber)
 							continue;
 						const byte *entry = _mystery.mapEntry(i);
 						if (!entry) continue;
-						const uint16 mx = READ_LE_UINT16(entry + 0x8);
-						const uint16 my = READ_LE_UINT16(entry + 0xa);
+						const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+						const uint16 mx       = READ_LE_UINT16(entry + 0x8);
+						const uint16 my       = READ_LE_UINT16(entry + 0xa);
+						Picture button;
+						int bw = 16, bh = 16;
+						if (_buttonArchive.loadEntry(buttonId, button)) {
+							bw = button.surface.w;
+							bh = button.surface.h;
+						}
 						const int sx = (int)mx - scrollX + kMapWinX;
 						const int sy = (int)my - scrollY + kMapWinY;
-						if (ABS(ev.mouse.x - sx) <= 6 &&
-							ABS(ev.mouse.y - sy) <= 6) {
+						if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
+							ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
 							_mystery._lastSite = _mystery._siteNumber;
 							_mystery._siteNumber = (uint16)i;
 							return;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 24bddf6f644..ec8e9d27b35 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -72,6 +72,7 @@ public:
 	DBDArchive &getAni()     { return _aniArchive; }
 	DBDArchive &getSites()   { return _sitesArchive; }
 	DBDArchive &getBalloons(){ return _balloonArchive; }
+	DBDArchive &getButtons() { return _buttonArchive; }
 	Mystery    &getMystery() { return _mystery; }
 	const EEMFont &getFont() const { return _font; }
 	uint8       getPartnerIndex() const { return _partner; }
@@ -191,6 +192,7 @@ private:
 	DBDArchive _aniArchive;      ///< ANI.DBD/.DBX (multi-frame character animations)
 	DBDArchive _sitesArchive;    ///< SITES.DBD/.DBX (one full-screen scene per site)
 	DBDArchive _balloonArchive;  ///< BALLOON.DBD/.DBX (speech-balloon sprites)
+	DBDArchive _buttonArchive;   ///< BUTTON.DBD/.DBX (per-site labeled map buttons; `_GetButton`)
 	Mystery    _mystery;         ///< Currently-loaded case file (M<n>.BIN)
 	EEMFont    _font;            ///< FONT.FNT - main 8 px font
 
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 2ee29ea63ab..0a65bf95b68 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -432,14 +432,22 @@ void SiteScreen::renderBackground(uint siteNum) {
 								   0, 0, frame.surface.w, frame.surface.h);
 	}
 
+	// `_BuildBackground @ 172b:13e2` calls
+	//   `_GetFromDB(_siteFile, &_SiteDBIndex, sitenum)`
+	// with the value at SiteData[+0] passed straight through. The
+	// `_GetFromDB` callee uses that as a **0-based** index into
+	// SITES.DBD (verified at 172b:14c8: `MOV BX, [BP+0x6]; IMUL BX, BX, 0xa`
+	// — the dbi entry stride is 10 bytes, no -1 adjustment). Our
+	// previous `loadEntry(sitepic - 1)` was off by one, which is why
+	// the tutorial mystery rendered scenes from neighbouring cases.
 	const byte *site = _mystery->siteData(siteNum);
 	const uint16 sitepic = site ? READ_LE_UINT16(site) : 0;
 	Picture scene;
 	bool haveScene = false;
-	if (sitepic > 0 && _vm->getSites().size() > sitepic - 1)
-		haveScene = _vm->getSites().loadEntry(sitepic - 1, scene);
-	if (!haveScene && sitepic > 0)
-		haveScene = _vm->getPics().getPicture(sitepic, scene);
+	if (sitepic < _vm->getSites().size())
+		haveScene = _vm->getSites().loadEntry(sitepic, scene);
+	if (!haveScene)
+		haveScene = _vm->getPics().getPicture(sitepic + 1, scene);
 	if (haveScene) {
 		// Hard-coded composition position from `_BuildBackground`:
 		//   `_Rect_Move(0, 0, h, ..., 0x42, 0x14, 48000, h, w)`.


Commit: 83ab9371832178fbc586a3cfef5918cc31f00f7a
    https://github.com/scummvm/scummvm/commit/83ab9371832178fbc586a3cfef5918cc31f00f7a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:37+02:00

Commit Message:
EEM: show NPC on sites

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index e79f9723286..414180c0e75 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1569,9 +1569,36 @@ void EEMEngine::doBigMap() {
 					   (const byte *)frame.surface.getBasePtr(0, row), w);
 		}
 
-		// Site icons at BigMap coords (+4, +6). Three colours by state:
-		//   visited → DoneMarker analogue, crime flag → CrimeMarker,
-		//   else → SiteMarker. Current site gets a bright highlight.
+		// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
+		// once at boot via `_GetPicture` (1-based IDs → entries N-1):
+		//   _DoneMarker  = PIC 0x20d  (already-searched site)
+		//   _SiteMarker  = PIC 0xc5   (default available site)
+		//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
+		// Picked per-site by `_DrawBigMapButtons @ 20fe:0877`:
+		//   1. SaveSiteComplete[i] → DoneMarker
+		//   2. else MapData[+0xc] != 0 → CrimeMarker
+		//   3. else SiteMarker
+		Picture done, normal, crimeM;
+		const bool haveDone   = _picsArchive.getPicture(0x20d, done);
+		const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
+		const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
+
+		auto blitMarker = [&](const Picture &m, int x, int y) {
+			const byte transp = (byte)(m.flags >> 8);
+			for (int row = 0; row < m.surface.h; row++) {
+				const int dstY = y + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)m.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < m.surface.w; col++) {
+					const int dstX = x + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		};
+
 		for (uint i = 0; i < _mystery.numSites(); i++) {
 			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 				continue;
@@ -1581,19 +1608,21 @@ void EEMEngine::doBigMap() {
 			const uint16 mx    = READ_LE_UINT16(entry + 0x4);
 			const uint16 my    = READ_LE_UINT16(entry + 0x6);
 			const uint16 crime = READ_LE_UINT16(entry + 0xc);
-
-			byte color;
-			if (i < Mystery::kVisitedSiteCap && _mystery._visitedSite[i])
-				color = 0x07;
-			else if (crime != 0)
-				color = 0x0C;
-			else
-				color = 0x0F;
-			if (i == _mystery._siteNumber)
-				color = 0x0E;
-
-			const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
-			scratch.fillRect(mark, color);
+			const bool   done_ = (i < Mystery::kVisitedSiteCap)
+								  && _mystery._visitedSite[i];
+
+			const Picture *m = nullptr;
+			if (done_ && haveDone)            m = &done;
+			else if (crime != 0 && haveCrime) m = &crimeM;
+			else if (haveNormal)              m = &normal;
+
+			if (m)
+				blitMarker(*m, (int)mx, (int)my);
+			else {
+				// Fallback if the markers couldn't be loaded.
+				const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
+				scratch.fillRect(mark, 0x0F);
+			}
 		}
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0a65bf95b68..2250b7523ce 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -42,9 +42,14 @@ void SiteScreen::enter(uint siteNum) {
 		return;
 	}
 
+	// Capture whether this is the first time the player enters this
+	// site BEFORE we mark it visited — `_DoSiteLoop @ 168d:03f4`
+	// uses the same check to decide whether to play the arrival
+	// dialog: `if (_VisitedSite[_SiteNumber] == 0) _DisplayClue(...)`.
+	const bool firstVisit = (siteNum < Mystery::kVisitedSiteCap)
+							 && (_mystery->_visitedSite[siteNum] == 0);
+
 	_mystery->_siteNumber = siteNum;
-	if (siteNum < Mystery::kVisitedSiteCap)
-		_mystery->_visitedSite[siteNum] = 1;
 	debugC(1, kDebugSite, "Entering site %u (%u hotspots)",
 		   siteNum, _mystery->hotspotCount(siteNum));
 
@@ -68,6 +73,12 @@ void SiteScreen::enter(uint siteNum) {
 		renderBackground(siteNum);
 	}
 
+	// Per-site NPCs / decorative animations (`_DoSiteLoop` reads
+	// `siteData[+0xa]` as the drop count and iterates 6-byte entries
+	// from `siteData[+0x48]`). Drawn before the partner sprite so
+	// the partner appears on top of any background NPCs.
+	renderDrops(siteNum);
+
 	// Persistent partner sprite (`_NewAnimation` at the tail of
 	// `_DoSiteLoop`). Drawn after the BG so the hotspot outlines and
 	// HUD that follow stay on top of it.
@@ -75,6 +86,39 @@ void SiteScreen::enter(uint siteNum) {
 
 	renderHotspots(siteNum);
 	g_system->updateScreen();
+
+	// First-visit dialog. `_DoSiteLoop @ 168d:03f4` does:
+	//   if (_VisitedSite[_SiteNumber] == 0) {
+	//       _DisplayClue(_Mystery + SiteIndex[siteNum*6 + 2], 1);
+	//       _VisitedSite[_SiteNumber] = 1;
+	//   }
+	// `SiteIndex[+2..+3]` is the byte offset (within the mystery
+	// buffer) of a ClueBlock that holds the partner's arrival
+	// dialogue. We've kept the +2 field undocumented up to now —
+	// this confirms it's the entry-clue offset.
+	if (firstVisit) {
+		const byte *idx = _mystery->siteIndexEntry(siteNum);
+		if (idx) {
+			const uint16 clueOff = READ_LE_UINT16(idx + 2);
+			if (clueOff != 0xFFFF) {
+				const byte *clueBlock = _mystery->blobAt(clueOff);
+				if (clueBlock)
+					_vm->displayClue(clueBlock);
+			}
+		}
+		if (siteNum < Mystery::kVisitedSiteCap)
+			_mystery->_visitedSite[siteNum] = 1;
+		// The dialog overlay will have left the screen with portrait /
+		// balloon residues; refresh the site so the player returns to
+		// a clean state.
+		renderBackground(siteNum);
+		renderDrops(siteNum);
+		renderPartner(siteNum);
+		renderHotspots(siteNum);
+		g_system->updateScreen();
+	} else if (siteNum < Mystery::kVisitedSiteCap) {
+		_mystery->_visitedSite[siteNum] = 1;
+	}
 }
 
 void SiteScreen::run() {
@@ -346,6 +390,92 @@ void SiteScreen::enterSiteAnim() {
 	}
 }
 
+void SiteScreen::renderDrops(uint siteNum) {
+	// `_DoSiteLoop @ 168d:03f4` runs TWO per-site loops, both feeding
+	// the visible NPCs / decorations on the site BG:
+	//
+	//   Loop 1 (animations / animated NPCs)
+	//     bound: siteData[+0xa]
+	//     per entry at siteData[+0x48 + i*6]:  {animId, x, y}
+	//     if animId == -1: a `_ColorCycle(x, y)` palette range each tick
+	//     else: `_GetAnimation(animId)` + `_NewAnimation(x, y, ...)` —
+	//           added inactive (arg5=0), updated by `_UpdateAnimations`.
+	//
+	//   Loop 2 (static drops)
+	//     bound: siteData[+0x4]  (verified at 168d:05c0:
+	//            `MOV ES:[BX+0x4], DI; CMP ES:[BX+0x4], DI`)
+	//     per entry at siteData[+0xc + i*6]:    {picId, x, y}
+	//     each → `_AddDrop(picId, x, y)` which loads PIC `picId-1`
+	//     from PICS.DBD and blits it at (x, y) onto offscreen 32000.
+	//
+	// Both contribute to the visible scene. We render Loop 2 statics
+	// directly and Loop 1's first frame as a static sprite (per-tick
+	// animation cycling is still TODO).
+	if (!_mystery)
+		return;
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 numStatic = READ_LE_UINT16(site + 0x4);
+	const uint16 numAnims  = READ_LE_UINT16(site + 0xa);
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+
+	auto blitMasked = [&](const Picture &p, int x, int y) {
+		const byte transp = (byte)(p.flags >> 8);
+		for (int row = 0; row < p.surface.h; row++) {
+			const int dstY = y + row;
+			if (dstY < 0 || dstY >= screen->h) continue;
+			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+			byte *dst = (byte *)screen->getBasePtr(0, dstY);
+			for (int col = 0; col < p.surface.w; col++) {
+				const int dstX = x + col;
+				if (dstX < 0 || dstX >= screen->w) continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	};
+
+	// Loop 2 — `_AddDrop(picId, x, y)`. PIC IDs are 1-based per
+	// `_AddDrop @ 172b:1a77` (it does `_GetFromDB(.., number - 1)`).
+	if (numStatic > 0 && numStatic <= 16) {
+		for (uint i = 0; i < numStatic; i++) {
+			const uint dropOff = 0xc + i * 6;
+			const uint16 picId = READ_LE_UINT16(site + dropOff + 0);
+			const int16  x     = (int16)READ_LE_UINT16(site + dropOff + 2);
+			const int16  y     = (int16)READ_LE_UINT16(site + dropOff + 4);
+			if (picId == 0)
+				continue;
+			Picture pic;
+			if (!_vm->getPics().getPicture(picId, pic))
+				continue;
+			blitMasked(pic, x, y);
+		}
+	}
+
+	// Loop 1 — animation drops. Use frame 0 as a static placeholder
+	// for non-`-1` entries.
+	if (numAnims > 0 && numAnims <= 16) {
+		for (uint i = 0; i < numAnims; i++) {
+			const uint dropOff = 0x48 + i * 6;
+			const int16 animId = (int16)READ_LE_UINT16(site + dropOff + 0);
+			if (animId < 0)
+				continue; // -1 → ColorCycle entry, handled per tick
+			const int16 x = (int16)READ_LE_UINT16(site + dropOff + 2);
+			const int16 y = (int16)READ_LE_UINT16(site + dropOff + 4);
+			Animation anim;
+			if (!_vm->getAni().loadAnimation((uint)animId, anim) || anim.empty())
+				continue;
+			blitMasked(anim[0], x, y);
+		}
+	}
+
+	g_system->unlockScreen();
+}
+
 void SiteScreen::renderPartner(uint siteNum) {
 	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
 	// table index, then for each (speaker × partner) loads
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 2346f5e5a77..125a6cd853a 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -77,6 +77,15 @@ private:
 	/// of `_DoSiteLoop @ 168d:03f4`.
 	void renderPartner(uint siteNum);
 
+	/// Draw the per-site NPC "drops" (the locals you click on to
+	/// trigger a clue). `_DoSiteLoop` reads `siteData[+0xa]` as the
+	/// drop count, then iterates 6-byte entries at `siteData[+0x48]`:
+	///   {anim_id (-1 = ColorCycle), x, y}.
+	/// Each non-(-1) entry is a `_NewAnimation(x, y, animId)`. We blit
+	/// the first frame as a static sprite; the original cycles via
+	/// `_UpdateAnimations`.
+	void renderDrops(uint siteNum);
+
 	EEMEngine *_vm;
 	Mystery *_mystery;
 	bool _showHotspots = true;  ///< Toggle outlines with V key.


Commit: a5cefd687110f156ae779c6983b1bbf7c6b17010
    https://github.com/scummvm/scummvm/commit/a5cefd687110f156ae779c6983b1bbf7c6b17010
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:37+02:00

Commit Message:
EEM: implement clickable areas on maps

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 414180c0e75..6143f54daf9 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1632,6 +1632,11 @@ void EEMEngine::doBigMap() {
 
 	drawOverview();
 
+	// Static rectangles read directly from the binary at the labelled
+	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
+	const Common::Rect kBigMapWindow   (  0,   0, 247, 192); // 29be:1596
+	const Common::Rect kSetupBtnRect   (252,   4, 315,  42); // 29be:15ce
+
 	bool wantZoom = false;
 	int  zoomX = 0, zoomY = 0;
 	while (!shouldQuit()) {
@@ -1644,19 +1649,27 @@ void EEMEngine::doBigMap() {
 				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// `_DoBigMap @ 20fe:09e7` does NOT travel directly when
-				// the player clicks an overview icon — it always returns
-				// `(sx, sy) = (mouseX*2 - 0x74, mouseY*2 - 0x55)` so the
-				// caller can switch to the detail zoom centred there.
-				// Travel happens after a SECOND click in the detail view.
-				int sx = ev.mouse.x * 2;
-				int sy = ev.mouse.y * 2;
-				sx = (sx < 0x75) ? 0 : sx - 0x74;
-				sy = (sy < 0x56) ? 0 : sy - 0x55;
-				zoomX = sx;
-				zoomY = sy;
-				wantZoom = true;
-				break;
+				// SetupButtonRect → `_NextScreen = 6` (the original's
+				// settings screen). We use it as "back to menu":
+				// abandon the current mystery and return to case
+				// selection.
+				if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
+					_mystery.clear();
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				// Click in the BigMapWindow → zoom. Original formula:
+				//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
+				if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
+					int sx = ev.mouse.x * 2;
+					int sy = ev.mouse.y * 2;
+					sx = (sx < 0x75) ? 0 : sx - 0x74;
+					sy = (sy < 0x56) ? 0 : sy - 0x55;
+					zoomX = sx;
+					zoomY = sy;
+					wantZoom = true;
+					break;
+				}
 			}
 		}
 		if (wantZoom)
@@ -1791,10 +1804,67 @@ void EEMEngine::doBigMap() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				if (ev.mouse.x >= kMapWinX &&
-					ev.mouse.x < kMapWinX + kMapWinW &&
-					ev.mouse.y >= kMapWinY &&
-					ev.mouse.y < kMapWinY + kMapWinH) {
+				// Scroll arrows + slider rects live in `SmallMapButtons`
+				// at 29be:0x159e (six 8-byte rects in order: Y-up, Y-down,
+				// X-left, X-right, right-panel, top-right) plus the
+				// dedicated `XSliderRect @ 29be:15d6` and
+				// `YSliderRect @ 29be:15de`. Format {x1,y1,x2,y2}.
+				const Common::Rect kArrowYUp   (237,   2, 247,  11);
+				const Common::Rect kArrowYDown (237, 163, 247, 172);
+				const Common::Rect kArrowXLeft (  2, 175,  12, 185);
+				const Common::Rect kArrowXRight(224, 175, 234, 185);
+				const Common::Rect kXSlider    ( 15, 175, 221, 185);
+				const Common::Rect kYSlider    (237,  14, 247, 160);
+				const Common::Rect kSetupBtn   (252,   4, 315,  42);
+
+				const int kArrowStep = 16;
+				const int kSliderRange = mapW - kMapWinW;
+				const int kSliderRangeY = mapH - kMapWinH;
+
+				if (kSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
+					// Setup button on detail too — `_NextScreen = 6` in
+					// the original. We treat it the same way: bail back
+					// to case selection.
+					_mystery.clear();
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollY = MAX<int>(0, scrollY - kArrowStep);
+					dirty = true;
+				} else if (kArrowYDown.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollY = MIN<int>(MAX<int>(0, kSliderRangeY),
+						scrollY + kArrowStep);
+					dirty = true;
+				} else if (kArrowXLeft.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollX = MAX<int>(0, scrollX - kArrowStep);
+					dirty = true;
+				} else if (kArrowXRight.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollX = MIN<int>(MAX<int>(0, kSliderRange),
+						scrollX + kArrowStep);
+					dirty = true;
+				} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
+					// Click on X slider track → jump scrollX so the
+					// click position maps proportionally into the map.
+					if (kSliderRange > 0) {
+						const int t = ev.mouse.x - kXSlider.left;
+						const int tw = kXSlider.width();
+						scrollX = MAX<int>(0, MIN<int>(kSliderRange,
+							t * kSliderRange / MAX<int>(1, tw)));
+						dirty = true;
+					}
+				} else if (kYSlider.contains(ev.mouse.x, ev.mouse.y)) {
+					if (kSliderRangeY > 0) {
+						const int t = ev.mouse.y - kYSlider.top;
+						const int th = kYSlider.height();
+						scrollY = MAX<int>(0, MIN<int>(kSliderRangeY,
+							t * kSliderRangeY / MAX<int>(1, th)));
+						dirty = true;
+					}
+				} else if (ev.mouse.x >= kMapWinX &&
+						   ev.mouse.x < kMapWinX + kMapWinW &&
+						   ev.mouse.y >= kMapWinY &&
+						   ev.mouse.y < kMapWinY + kMapWinH) {
 					// Hit-test the per-site button at its actual bbox
 					// (`_StampButtons` records the rect at SmallMap +8/+0xa
 					// with the button PIC's width/height).
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 2250b7523ce..49f7340ac2c 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -145,6 +145,31 @@ void SiteScreen::run() {
 				return;
 
 			case Common::EVENT_LBUTTONDOWN: {
+				// On-screen UI buttons. `_DoSiteLoop @ 168d:03f4` calls
+				//   _FindButton(&SiteButtons, 2, MouseX, MouseY)
+				// where `SiteButtons` is two 8-byte rectangles at
+				// 29be:0x274 (verified via 168d:0729-0848):
+				//   Button 0: (35, 111) - (56, 136)  → notebook
+				//                                       (`_NextScreen = 4`)
+				//   Button 1: (7, 177)  - (57, 200)  → map
+				//                                       (`_NextScreen = 1`)
+				// Test the buttons before falling through to hotspots so
+				// a click on the PDA / map icon doesn't accidentally
+				// trigger a hotspot underneath.
+				const Common::Rect kBtnNotebook(35, 111, 56, 136);
+				const Common::Rect kBtnMap     ( 7, 177, 57, 200);
+				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
+					_vm->doNotebook();
+					enter(cur);
+					break;
+				}
+				if (kBtnMap.contains(event.mouse.x, event.mouse.y)) {
+					_vm->doBigMap();
+					if (_mystery->_siteNumber < _mystery->numSites())
+						cur = _mystery->_siteNumber;
+					enter(cur);
+					break;
+				}
 				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
 				if (idx >= 0) {
 					onHotspotClicked(cur, (uint)idx);


Commit: 0fe0c95dfdbdb01b029c42871173348e6b5ccf9f
    https://github.com/scummvm/scummvm/commit/0fe0c95dfdbdb01b029c42871173348e6b5ccf9f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:37+02:00

Commit Message:
EEM: improved NPC handling

Changed paths:
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 49f7340ac2c..30f97bc29c0 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -73,16 +73,21 @@ void SiteScreen::enter(uint siteNum) {
 		renderBackground(siteNum);
 	}
 
-	// Per-site NPCs / decorative animations (`_DoSiteLoop` reads
-	// `siteData[+0xa]` as the drop count and iterates 6-byte entries
-	// from `siteData[+0x48]`). Drawn before the partner sprite so
-	// the partner appears on top of any background NPCs.
-	renderDrops(siteNum);
-
-	// Persistent partner sprite (`_NewAnimation` at the tail of
-	// `_DoSiteLoop`). Drawn after the BG so the hotspot outlines and
-	// HUD that follow stay on top of it.
-	renderPartner(siteNum);
+	// Static drops (Loop 2 from `_DoSiteLoop`) — no animation, baked
+	// into the BG snapshot the run() pump uses to restore.
+	renderStaticDrops(siteNum);
+
+	// Snapshot the static layers so per-tick animation re-blits don't
+	// have to re-load PIC 0x43, the SITES.DBD scene, or each
+	// `_AddDrop` PIC every frame.
+	captureBgSnapshot();
+	_snapshotSite = (int)siteNum;
+
+	// Animated NPCs (Loop 1) and the persistent partner sit on top of
+	// the snapshot. Initial frame goes at tickMs=now.
+	const uint32 now = g_system->getMillis();
+	renderAnimatedDrops(siteNum, now);
+	renderPartner(siteNum, now);
 
 	renderHotspots(siteNum);
 	g_system->updateScreen();
@@ -110,10 +115,14 @@ void SiteScreen::enter(uint siteNum) {
 			_mystery->_visitedSite[siteNum] = 1;
 		// The dialog overlay will have left the screen with portrait /
 		// balloon residues; refresh the site so the player returns to
-		// a clean state.
+		// a clean state. Re-build the snapshot too.
 		renderBackground(siteNum);
-		renderDrops(siteNum);
-		renderPartner(siteNum);
+		renderStaticDrops(siteNum);
+		captureBgSnapshot();
+		_snapshotSite = (int)siteNum;
+		const uint32 nowAfter = g_system->getMillis();
+		renderAnimatedDrops(siteNum, nowAfter);
+		renderPartner(siteNum, nowAfter);
 		renderHotspots(siteNum);
 		g_system->updateScreen();
 	} else if (siteNum < Mystery::kVisitedSiteCap) {
@@ -275,6 +284,21 @@ void SiteScreen::run() {
 		}
 		if (exitRequested)
 			return;
+
+		// Per-tick frame pump (mirrors `_CheckFrameRate` +
+		// `_UpdateAnimations` at the top of `_DoSiteLoop`'s main loop).
+		// Restore the static BG snapshot, redraw animated NPCs +
+		// partner at the current frame, then re-render hotspots on
+		// top. We tick at 100 ms (~10 FPS) which is in the same ball
+		// park as the original.
+		const uint32 now = g_system->getMillis();
+		if (_snapshotSite == (int)cur && now - _lastTickMs >= 100) {
+			restoreBgSnapshot();
+			renderAnimatedDrops(cur, now);
+			renderPartner(cur, now);
+			renderHotspots(cur);
+			_lastTickMs = now;
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
@@ -415,93 +439,131 @@ void SiteScreen::enterSiteAnim() {
 	}
 }
 
-void SiteScreen::renderDrops(uint siteNum) {
-	// `_DoSiteLoop @ 168d:03f4` runs TWO per-site loops, both feeding
-	// the visible NPCs / decorations on the site BG:
-	//
-	//   Loop 1 (animations / animated NPCs)
-	//     bound: siteData[+0xa]
-	//     per entry at siteData[+0x48 + i*6]:  {animId, x, y}
-	//     if animId == -1: a `_ColorCycle(x, y)` palette range each tick
-	//     else: `_GetAnimation(animId)` + `_NewAnimation(x, y, ...)` —
-	//           added inactive (arg5=0), updated by `_UpdateAnimations`.
-	//
-	//   Loop 2 (static drops)
-	//     bound: siteData[+0x4]  (verified at 168d:05c0:
-	//            `MOV ES:[BX+0x4], DI; CMP ES:[BX+0x4], DI`)
-	//     per entry at siteData[+0xc + i*6]:    {picId, x, y}
-	//     each → `_AddDrop(picId, x, y)` which loads PIC `picId-1`
-	//     from PICS.DBD and blits it at (x, y) onto offscreen 32000.
-	//
-	// Both contribute to the visible scene. We render Loop 2 statics
-	// directly and Loop 1's first frame as a static sprite (per-tick
-	// animation cycling is still TODO).
+// Mask-aware blit from a Picture into a Graphics::Surface.
+static void blitMaskedSurface(Graphics::Surface *screen,
+							  const Picture &p, int x, int y) {
+	if (!screen)
+		return;
+	const byte transp = (byte)(p.flags >> 8);
+	for (int row = 0; row < p.surface.h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= screen->h) continue;
+		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+		byte *dst = (byte *)screen->getBasePtr(0, dstY);
+		for (int col = 0; col < p.surface.w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= screen->w) continue;
+			if (src[col] != transp)
+				dst[dstX] = src[col];
+		}
+	}
+}
+
+void SiteScreen::renderStaticDrops(uint siteNum) {
+	// Loop 2 from `_DoSiteLoop @ 168d:03f4`:
+	//   bound: siteData[+0x4]   (verified at 168d:05c0:
+	//          `MOV ES:[BX+0x4], DI; CMP ES:[BX+0x4], DI`)
+	//   per entry at siteData[+0xc + i*6]: {picId, x, y}
+	//   each → `_AddDrop(picId, x, y)` (`_AddDrop @ 172b:1a77`):
+	//   loads PIC `picId-1` from PICS.DBD and blits with miscflags
+	//   high-byte as the transparent colour. These NEVER cycle, so
+	//   they belong to the BG snapshot.
 	if (!_mystery)
 		return;
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
 	const uint16 numStatic = READ_LE_UINT16(site + 0x4);
-	const uint16 numAnims  = READ_LE_UINT16(site + 0xa);
+	if (numStatic == 0 || numStatic > 16)
+		return;
 
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
 
-	auto blitMasked = [&](const Picture &p, int x, int y) {
-		const byte transp = (byte)(p.flags >> 8);
-		for (int row = 0; row < p.surface.h; row++) {
-			const int dstY = y + row;
-			if (dstY < 0 || dstY >= screen->h) continue;
-			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-			byte *dst = (byte *)screen->getBasePtr(0, dstY);
-			for (int col = 0; col < p.surface.w; col++) {
-				const int dstX = x + col;
-				if (dstX < 0 || dstX >= screen->w) continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
-	};
-
-	// Loop 2 — `_AddDrop(picId, x, y)`. PIC IDs are 1-based per
-	// `_AddDrop @ 172b:1a77` (it does `_GetFromDB(.., number - 1)`).
-	if (numStatic > 0 && numStatic <= 16) {
-		for (uint i = 0; i < numStatic; i++) {
-			const uint dropOff = 0xc + i * 6;
-			const uint16 picId = READ_LE_UINT16(site + dropOff + 0);
-			const int16  x     = (int16)READ_LE_UINT16(site + dropOff + 2);
-			const int16  y     = (int16)READ_LE_UINT16(site + dropOff + 4);
-			if (picId == 0)
-				continue;
-			Picture pic;
-			if (!_vm->getPics().getPicture(picId, pic))
-				continue;
-			blitMasked(pic, x, y);
-		}
+	for (uint i = 0; i < numStatic; i++) {
+		const uint dropOff = 0xc + i * 6;
+		const uint16 picId = READ_LE_UINT16(site + dropOff + 0);
+		const int16  x     = (int16)READ_LE_UINT16(site + dropOff + 2);
+		const int16  y     = (int16)READ_LE_UINT16(site + dropOff + 4);
+		if (picId == 0)
+			continue;
+		Picture pic;
+		if (!_vm->getPics().getPicture(picId, pic))
+			continue;
+		blitMaskedSurface(screen, pic, x, y);
 	}
 
-	// Loop 1 — animation drops. Use frame 0 as a static placeholder
-	// for non-`-1` entries.
-	if (numAnims > 0 && numAnims <= 16) {
-		for (uint i = 0; i < numAnims; i++) {
-			const uint dropOff = 0x48 + i * 6;
-			const int16 animId = (int16)READ_LE_UINT16(site + dropOff + 0);
-			if (animId < 0)
-				continue; // -1 → ColorCycle entry, handled per tick
-			const int16 x = (int16)READ_LE_UINT16(site + dropOff + 2);
-			const int16 y = (int16)READ_LE_UINT16(site + dropOff + 4);
-			Animation anim;
-			if (!_vm->getAni().loadAnimation((uint)animId, anim) || anim.empty())
-				continue;
-			blitMasked(anim[0], x, y);
-		}
+	g_system->unlockScreen();
+}
+
+void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
+	// Loop 1 from `_DoSiteLoop @ 168d:03f4`:
+	//   bound: siteData[+0xa]
+	//   per entry at siteData[+0x48 + i*6]: {animId, x, y}
+	//   animId == -1 → `_ColorCycle(x, y)` palette range (handled
+	//                  in the run() loop's frame pump as palette
+	//                  rotation; not yet implemented).
+	//   else → `_GetAnimation(animId)` + `_NewAnimation` then
+	//          `_UpdateAnimations @ 172b:09c1` walks a sequence
+	//          script (entries are frame indices; 0x80 = end-of-loop,
+	//          0x81 = jump command). We don't have the sequence-script
+	//          structure decoded yet, so for now we cycle through the
+	//          raw animation frames in order using a global tick.
+	if (!_mystery)
+		return;
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 numAnims = READ_LE_UINT16(site + 0xa);
+	if (numAnims == 0 || numAnims > 16)
+		return;
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+
+	const uint32 kFramePeriodMs = 100; // ~10 FPS, in line with `_CheckFrameRate`.
+
+	for (uint i = 0; i < numAnims; i++) {
+		const uint dropOff = 0x48 + i * 6;
+		const int16 animId = (int16)READ_LE_UINT16(site + dropOff + 0);
+		if (animId < 0)
+			continue;
+		const int16 x = (int16)READ_LE_UINT16(site + dropOff + 2);
+		const int16 y = (int16)READ_LE_UINT16(site + dropOff + 4);
+		Animation anim;
+		if (!_vm->getAni().loadAnimation((uint)animId, anim) || anim.empty())
+			continue;
+		const uint frameIdx = (uint)((tickMs / kFramePeriodMs) % anim.size());
+		blitMaskedSurface(screen, anim[frameIdx], x, y);
 	}
 
 	g_system->unlockScreen();
 }
 
-void SiteScreen::renderPartner(uint siteNum) {
+void SiteScreen::captureBgSnapshot() {
+	_bgSnapshot.create(320, 200, Graphics::PixelFormat::createFormatCLUT8());
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen) {
+		_snapshotSite = -1;
+		return;
+	}
+	for (int row = 0; row < 200; row++) {
+		memcpy((byte *)_bgSnapshot.getBasePtr(0, row),
+			   (const byte *)screen->getBasePtr(0, row), 320);
+	}
+	g_system->unlockScreen();
+}
+
+void SiteScreen::restoreBgSnapshot() {
+	if (_bgSnapshot.w != 320 || _bgSnapshot.h != 200)
+		return;
+	g_system->copyRectToScreen(_bgSnapshot.getPixels(), _bgSnapshot.pitch,
+							   0, 0, 320, 200);
+}
+
+void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
 	// table index, then for each (speaker × partner) loads
 	//   anim  = WaitAnims[speakerIdx].anim[partner]
@@ -543,28 +605,16 @@ void SiteScreen::renderPartner(uint siteNum) {
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
 		return;
 
-	// Show the first frame as a static sprite. The original updates it
-	// each `_CheckFrameRate` tick; we don't have a frame pump in the
-	// site loop yet so a static pose is enough for now.
-	const Picture &fr = anim[0];
-	const byte transp = (byte)(fr.flags >> 8);
+	// `_UpdateAnimations @ 172b:09c1` advances the partner's frame
+	// every `_CheckFrameRate` tick. We pick the frame from a global
+	// 100 ms clock so the partner cycles in sync with the animated
+	// drops.
+	const uint32 kFramePeriodMs = 100;
+	const uint frameIdx = (uint)((tickMs / kFramePeriodMs) % anim.size());
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
-	for (int row = 0; row < fr.surface.h; row++) {
-		const int dstY = y + row;
-		if (dstY < 0 || dstY >= 200)
-			continue;
-		const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-		byte *dst = (byte *)screen->getBasePtr(0, dstY);
-		for (int col = 0; col < fr.surface.w; col++) {
-			const int dstX = x + col;
-			if (dstX < 0 || dstX >= 320)
-				continue;
-			if (src[col] != transp)
-				dst[dstX] = src[col];
-		}
-	}
+	blitMaskedSurface(screen, anim[frameIdx], x, y);
 	g_system->unlockScreen();
 }
 
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 125a6cd853a..bae8dc53cc6 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -25,6 +25,8 @@
 #include "common/rect.h"
 #include "common/scummsys.h"
 
+#include "graphics/managed_surface.h"
+
 namespace EEM {
 
 class EEMEngine;
@@ -74,22 +76,39 @@ private:
 	/// Draw the persistent in-site partner sprite (Jake or Jenny
 	/// standing/idling) at the position from `_WaitAnims` @ 29be:021c.
 	/// Mirrors the `_GetAnimation` + `_NewAnimation` block at the tail
-	/// of `_DoSiteLoop @ 168d:03f4`.
-	void renderPartner(uint siteNum);
-
-	/// Draw the per-site NPC "drops" (the locals you click on to
-	/// trigger a clue). `_DoSiteLoop` reads `siteData[+0xa]` as the
-	/// drop count, then iterates 6-byte entries at `siteData[+0x48]`:
-	///   {anim_id (-1 = ColorCycle), x, y}.
-	/// Each non-(-1) entry is a `_NewAnimation(x, y, animId)`. We blit
-	/// the first frame as a static sprite; the original cycles via
-	/// `_UpdateAnimations`.
-	void renderDrops(uint siteNum);
+	/// of `_DoSiteLoop @ 168d:03f4`. `tickMs` selects which frame of
+	/// the partner's animation to render; in the original, frames
+	/// advance per `_CheckFrameRate` tick via `_UpdateAnimations`.
+	void renderPartner(uint siteNum, uint32 tickMs);
+
+	/// Draw the per-site `_AddDrop` static decorations (Loop 2).
+	/// `_DoSiteLoop` runs this loop with bound siteData[+0x4] and
+	/// 6-byte entries at siteData[+0xc]: {picId, x, y}. These never
+	/// animate so they go in the BG snapshot.
+	void renderStaticDrops(uint siteNum);
+
+	/// Draw the per-site animated NPCs (Loop 1) at the current tick.
+	/// `_DoSiteLoop` registers each via `_NewAnimation` (siteData[+0xa]
+	/// entries at siteData[+0x48]: {animId (-1 = ColorCycle), x, y})
+	/// and `_UpdateAnimations @ 172b:09c1` advances frame indices each
+	/// tick. We use a millis-based frame index so all NPCs cycle in
+	/// step with the global clock.
+	void renderAnimatedDrops(uint siteNum, uint32 tickMs);
+
+	/// Snapshot the post-BG, post-static-drops screen so the per-tick
+	/// frame pump can restore it without reloading PICS.DBD entries.
+	void captureBgSnapshot();
+
+	/// Restore the snapshot taken at `captureBgSnapshot` time.
+	void restoreBgSnapshot();
 
 	EEMEngine *_vm;
 	Mystery *_mystery;
-	bool _showHotspots = true;  ///< Toggle outlines with V key.
-	int _lastSiteAnim = -1;     ///< Last site we played the arrival on.
+	bool _showHotspots = true;     ///< Toggle outlines with V key.
+	int _lastSiteAnim = -1;        ///< Last site we played the arrival on.
+	int _snapshotSite = -1;        ///< Site number the snapshot belongs to.
+	Graphics::ManagedSurface _bgSnapshot;
+	uint32 _lastTickMs = 0;        ///< Last frame-pump tick in ms.
 };
 
 } // End of namespace EEM


Commit: ca825ec5b1b8e203da3defda1275ba22bd0e07c3
    https://github.com/scummvm/scummvm/commit/ca825ec5b1b8e203da3defda1275ba22bd0e07c3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:38+02:00

Commit Message:
EEM: added more features to the PDA

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 6143f54daf9..578f3290a6f 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -987,10 +987,23 @@ void EEMEngine::doCaseSelection() {
 }
 
 void EEMEngine::doInitClues() {
-	// Mirrors `_DoInitClues` @ 1a35:0411. Sets BG 0x52 + palette 0x22,
-	// blits the Goblindroid game and book first frames, then displays
-	// the case briefing ClueBlock at InitBlock + 4. Marks the starting
-	// site (InitBlock word[1]) on `_OnSites`.
+	// Mirrors `_DoInitClues` @ 1a35:0411. The original does:
+	//   1. _AllBlack(); _GetBackground(0x52); _GetPalette(0x22);
+	//   2. _GetAnimation(gameAni); _NewAnimation(0xcd, 0x6c, ...)
+	//      _GetAnimation(bookAni); _NewAnimation(0,    99,   ...)
+	//      (case type 1 also: _NewAnimation(0x68, 0x8b, nancyAni))
+	//   3. _UpdateAnimations(); _FadeIn();
+	//   4. while (frame != gameNum) { _CheckFrameRate(); _UpdateAnimations(); }
+	//        — cycles through the entire game animation once. Click skips.
+	//   5. _PlayInSequence(seqId, ...) — plays a follow-up sequence based
+	//      on partner + case type.
+	//   6. _DisplayClue(InitBlock + 2, 1) — the briefing dialogue.
+	//   7. _OnSites[startSite] = 1.
+	//
+	// gameAni / bookAni / nancyAni values verified directly from Ghidra:
+	//   gameAni  = 0x17 (Jake) / 0x3b (Jenny)
+	//   bookAni  = 0x18 (Jake) / 0x3c (Jenny)
+	//   nancyAni = 0x19 (case type 1 only)
 	if (!_mystery.isLoaded())
 		return;
 
@@ -1001,11 +1014,6 @@ void EEMEngine::doInitClues() {
 	const uint16 startSite = READ_LE_UINT16(ib + 2);
 	if (startSite < Mystery::kVisitedSiteCap)
 		_mystery._onSites[startSite] = 1;
-	// Mirror the original: at briefing time the player isn't actually
-	// at any site yet — they pick from the map next. Set _siteNumber
-	// to the start site so the map opens centred on the only initially
-	// accessible location and the post-map site loop has a sensible
-	// resume point.
 	_mystery._siteNumber = startSite;
 	_mystery._lastSite = startSite;
 
@@ -1016,21 +1024,82 @@ void EEMEngine::doInitClues() {
 
 	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
 	const uint bookAni = _partner == 0 ? 0x18 : 0x3c;
-	Animation game, book;
-	if (_aniArchive.loadAnimation(gameAni, game) && !game.empty())
-		blitAt(game[0], 0xcd, 0x6c);
-	if (_aniArchive.loadAnimation(bookAni, book) && !book.empty())
-		blitAt(book[0], 0, 99);
-
-	// Case type 1 also places "Nancy" (a third character) at (0x68, 0x8b)
-	// per `_DoInitClues`.
+	Animation game, book, nancy;
+	const bool haveGame  = _aniArchive.loadAnimation(gameAni, game) && !game.empty();
+	const bool haveBook  = _aniArchive.loadAnimation(bookAni, book) && !book.empty();
+
 	const uint16 caseType = READ_LE_UINT16(ib);
-	if (caseType == 1) {
-		Animation nancy;
-		if (_aniArchive.loadAnimation(0x19, nancy) && !nancy.empty())
-			blitAt(nancy[0], 0x68, 0x8b);
+	const bool haveNancy = (caseType == 1)
+						  && _aniArchive.loadAnimation(0x19, nancy)
+						  && !nancy.empty();
+
+	auto blitMaskedAt = [&](const Picture &p, int x, int y) {
+		const byte transp = (byte)(p.flags >> 8);
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (!screen) return;
+		for (int row = 0; row < p.surface.h; row++) {
+			const int dstY = y + row;
+			if (dstY < 0 || dstY >= screen->h) continue;
+			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+			byte *dst = (byte *)screen->getBasePtr(0, dstY);
+			for (int col = 0; col < p.surface.w; col++) {
+				const int dstX = x + col;
+				if (dstX < 0 || dstX >= screen->w) continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+		g_system->unlockScreen();
+	};
+
+	// Step 4 — cycle through the game animation once before the briefing.
+	// Mirrors the `while (uVar9 != gameNum)` loop. The original calls
+	// `_UpdateAnimations` per `_CheckFrameRate` tick (~10 fps). We use
+	// 100 ms ticks for the same cadence. Click / key skips.
+	if (haveGame || haveBook || haveNancy) {
+		const uint frameCount = haveGame ? game.size() : 8;
+		bool skip = false;
+		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
+			// Restore BG + advance frame.
+			if (_picsArchive.getPicture(0x52, bg))
+				blitAt(bg, 0, 0);
+			if (haveGame)
+				blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+			if (haveBook)
+				blitMaskedAt(book[frame % book.size()], 0, 99);
+			if (haveNancy)
+				blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+			g_system->updateScreen();
+
+			// Wait 100 ms or until input.
+			const uint32 wakeup = g_system->getMillis() + 100;
+			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_LBUTTONDOWN ||
+						ev.type == Common::EVENT_KEYDOWN) {
+						skip = true;
+						break;
+					}
+				}
+				g_system->delayMillis(10);
+			}
+		}
 	}
 
+	// Composite the final frames (or first frames if skipped) so the BG
+	// is in a sensible state when displayClue overlays the speaker.
+	if (_picsArchive.getPicture(0x52, bg))
+		blitAt(bg, 0, 0);
+	if (haveGame)
+		blitMaskedAt(game[0], 0xcd, 0x6c);
+	if (haveBook)
+		blitMaskedAt(book[0], 0, 99);
+	if (haveNancy)
+		blitMaskedAt(nancy[0], 0x68, 0x8b);
+	g_system->updateScreen();
+
+	// Step 6 — case briefing dialogue.
 	displayClue(ib + 4);
 }
 
@@ -1269,14 +1338,39 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						}
 					}
 				}
-				// Per-balloon metadata table at 29be:0875 — 10-byte
-				// entries indexed by `(bubNum & 0x7f)`. Layout:
-				//   +0..1 textX inset, +2..3 textY inset, +4..5 textWidth.
-				// All entries use textX=6, textY=4 so we hard-code those
-				// constants; textWidth is read live from the table.
-				textX = bubX + 6;
-				textY = bubY + 4;
-				textW = bw - 12;
+				// Per-balloon metadata table verified from 29be:0875 —
+				// 10-byte entries indexed by `(bubNum & 0x7f)`. Layout:
+				//   +0..1 textX inset, +2..3 textY inset, +4..5 width,
+				//   +6..7 height, +8..9 tail offset.
+				// 52 entries total; insets vary (3, 5, 6, or 8 px).
+				// The original `_DisplayClue` does:
+				//   _WordWrap(bubX + table[bubNum].x, bubY + table[bubNum].y,
+				//             table[bubNum].w, ...);
+				static const struct { uint16 x, y, w; } kBalloonTable[] = {
+					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+					{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+				};
+				const uint kBalloonTableSize = sizeof(kBalloonTable) /
+											   sizeof(kBalloonTable[0]);
+				const uint balloonIdx = balloonId < kBalloonTableSize
+										? balloonId : 0;
+				const auto &bm = kBalloonTable[balloonIdx];
+				textX = bubX + bm.x;
+				textY = bubY + bm.y;
+				textW = bm.w;
 				copyH = bh;
 			} else {
 				// No balloon — clear a band so old pixels don't bleed.
@@ -1344,109 +1438,340 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 }
 
 void EEMEngine::doNotebook() {
-	// Mirrors `_DrawNotes` @ 161e:01d0 + `_HandleNoteButton`. We list every
-	// found clue with its NoteIndex point value and let the player toggle
-	// "selected" with number keys 1..9 (paged in groups of 9). The total
-	// points of selected clues feed `_SolvedCheck` during accuse.
-	if (!_font.isLoaded())
+	// Mirrors `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
+	// `_HandleNoteButton @ 161e:03cb`.
+	//
+	// Layout (verified from Ghidra labels in 29be:013f / 29be:0147):
+	//   _NotebookRect = (78, 12, 288, 152)   — note display rectangle.
+	//   _NoteButtons (11 entries, 8 bytes each, at 29be:0147):
+	//     [0]  (134, 174, 155, 190)  decorative — `_HandleNoteButton(0)`
+	//                                returns immediately (i-1 unsigned > 9).
+	//     [1]  (93,  174, 115, 190)  → `_InterfaceHelp(0)` (handler 0x3f9)
+	//     [2]  (157, 174, 178, 190)  → handler 0x477   (page nav)
+	//     [3]  (5,   80,  44, 110)   → `_KDHelp` (host hint, 0x403)
+	//     [4]  (180, 174, 201, 190)  → solve / accuse  (0x436)
+	//     [5]  (204, 174, 224, 190)  → `_NextScreen = 5` (gallery, 0x489)
+	//     [6]  (226, 174, 247, 190)  → handler 0x4ab
+	//     [7]  (7,   177,  57, 200)  → handler 0x480   (back to map)
+	//     [8]  (35,  111,  56, 136)  → `_NextScreen = 3` (site)
+	//     [9]  (0, 0, 0, 0)          → same exit as [8]
+	//     [10] (66,  79, 267, 174)   → `_InterfaceHelp(0)` (note area)
+	//   Background: PIC 0x3f.
+	//   Partner anim: anim 1 (Jake) / 0xb (Jenny) at (5, 80).
+	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
+	const Common::Rect kNotebookRect(78, 12, 288, 152);
+	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1]
+	const Common::Rect kBtnPagePrev(157, 174, 178, 190);  // [2]
+	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3]
+	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4]
+	const Common::Rect kBtnGallery (204, 174, 224, 190);  // [5]
+	const Common::Rect kBtnPageNext(226, 174, 247, 190);  // [6]
+	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7]
+	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8]
+	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10]
+	(void)kBtnHelp1; (void)kBtnPagePrev; (void)kBtnPageNext;
+
+	CursorMan.showMouse(true);
+
 	int page = 0;
-	const int kPerPage = 9;
+	int hoveredNoteSlot = -1;
+
+	// Build a list of found-clue indices, identical ordering to the
+	// original's iteration through `_CluesFound[]`.
+	auto buildFound = [&]() {
+		Common::Array<uint> found;
+		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
+			if (_mystery._cluesFound[i])
+				found.push_back(i);
+		return found;
+	};
 
 	auto draw = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
-		_font.drawString(&scratch, "NOTEBOOK", 8, 4, 320, 0xF);
-		_font.drawString(&scratch, Common::String::format("pts: %d", _mystery.selectedPoints()), 200, 4, 320, 0xF);
 
-		// Build a list of found-clue indices.
-		Common::Array<uint> found;
-		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
-			if (_mystery._cluesFound[i])
-				found.push_back(i);
-		const int total = (int)found.size();
-		const int pages = MAX<int>(1, (total + kPerPage - 1) / kPerPage);
-		page = MIN<int>(page, pages - 1);
+		// PIC 0x3f frame.
+		Picture frame;
+		if (_picsArchive.getPicture(0x3f, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+		}
 
-		_font.drawString(&scratch, Common::String::format("page %d/%d", page + 1, pages), 200, 16, 320, 0xF);
+		// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
+		const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
+		Animation partnerAni;
+		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+			const Picture &fr = partnerAni[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = 80 + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = 5 + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
 
+		// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
+		// rendering each found clue's text inside `_NotebookRect` with
+		// word-wrap. Selected clues are highlighted (color 0x3c in the
+		// original's case-briefing palette).
+		const Common::Array<uint> found = buildFound();
 		const byte *ni = _mystery.noteIndex();
 		const uint16 niCount = _mystery.noteIndexCount();
-		int y = 4 + _font.getFontHeight() * 2 + 4;
-		for (int slot = 0; slot < kPerPage; slot++) {
-			const int idx = page * kPerPage + slot;
-			if (idx >= total)
-				break;
-			const uint clueId = found[idx];
-			Common::String text;
-			int pts = 0;
+
+		const int kRectX = kNotebookRect.left;
+		const int kRectY = kNotebookRect.top;
+		const int kRectW = kNotebookRect.width();
+		const int kRectH = kNotebookRect.height();
+
+		// Walk forward to the start clue of the current page.
+		// Each page renders as many clues as fit in `kRectH`.
+		int clueCursor = 0;
+		Common::Array<int> pageStarts;
+		pageStarts.push_back(0);
+		{
+			const int lineH = _font.getFontHeight() + 1;
+			int y = kRectY;
+			while (clueCursor < (int)found.size()) {
+				const uint clueId = found[clueCursor];
+				Common::String txt;
+				if (ni && clueId < niCount) {
+					const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+					txt = parseString(_mystery.textAt(textOff),
+									  _playerName, _partner);
+				}
+				// Measure height by wrapping the text without drawing.
+				Common::Array<Common::String> wrapped;
+				_font.wordWrapText(txt, kRectW, wrapped);
+				const int h = (int)wrapped.size() * lineH;
+				if (y + h + 7 > kRectY + kRectH) {
+					// Page break before this clue.
+					y = kRectY;
+					pageStarts.push_back(clueCursor);
+				}
+				y += h + 7;
+				clueCursor++;
+			}
+			if (page >= (int)pageStarts.size())
+				page = (int)pageStarts.size() - 1;
+			if (page < 0)
+				page = 0;
+		}
+
+		// Track per-slot rectangles so the click handler can map a
+		// click in `kNoteArea` back to a clue index.
+		Common::Array<Common::Rect> slotRects;
+		Common::Array<uint> slotClues;
+
+		const int startClue = (page < (int)pageStarts.size())
+								? pageStarts[page] : 0;
+		const int endClue   = (page + 1 < (int)pageStarts.size())
+								? pageStarts[page + 1] : (int)found.size();
+
+		int y = kRectY;
+		for (int i = startClue; i < endClue; i++) {
+			const uint clueId = found[i];
+			Common::String txt;
 			if (ni && clueId < niCount) {
 				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				const uint16 ptsRaw  = READ_LE_UINT16(ni + clueId * 4 + 2);
-				pts = (int)(int16)ptsRaw;
-				const Common::String raw = _mystery.textAt(textOff);
-				text = parseString(raw, _playerName, _partner);
-			}
-			if (text.empty())
-				text = Common::String::format("clue %u", clueId);
-
-			const char selMark = _mystery._noteSelected[clueId] ? '*' : ' ';
-			Common::String line = Common::String::format(
-				"%d [%c] (%d pts) %s", slot + 1, selMark, pts, text.c_str());
-			const int used = _font.drawWordWrapped(&scratch, 8, y, 304, line, 0xF);
-			y += used + 2;
-			if (y >= 192) break;
+				txt = parseString(_mystery.textAt(textOff),
+								  _playerName, _partner);
+			}
+			if (txt.empty())
+				txt = Common::String::format("clue %u", clueId);
+			// Compute wrapped lines first to know the rect.
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, kRectW, wrapped);
+			const int lineH = _font.getFontHeight() + 1;
+			const int h = (int)wrapped.size() * lineH;
+
+			// Per `_DrawNotes @ 161e:01d0`: text uses
+			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
+			// (light yellow-white) for selected. Paint a dark "paper"
+			// rectangle behind the text first — without this, the
+			// notebook BG (PIC 0x3f) bleeds through and makes the cyan
+			// text hard to read on lighter pixel runs. The fill colour
+			// 0x20 maps to a dark navy across all site palettes.
+			scratch.fillRect(Common::Rect(kRectX - 2, y - 1,
+				kRectX + kRectW + 2, y + h + 1), 0x20);
+
+			const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
+			for (uint li = 0; li < wrapped.size(); li++) {
+				_font.drawString(&scratch, wrapped[li], kRectX,
+								 y + (int)li * lineH, kRectW, color);
+			}
+			slotRects.push_back(Common::Rect(kRectX, y,
+											  kRectX + kRectW, y + h));
+			slotClues.push_back(clueId);
+			y += h + 7;
 		}
+
+		// Page indicator + selected-points counter. Paint a small dark
+		// strip behind them too so the text reads on the PDA frame's
+		// upper-right corner.
+		scratch.fillRect(Common::Rect(266, 0, 320, 24), 0x20);
+		_font.drawString(&scratch, Common::String::format("p%d/%d",
+								   page + 1, (int)pageStarts.size()),
+						 270, 4, 320, 0x5C);
+		_font.drawString(&scratch, Common::String::format("%d pts",
+								   _mystery.selectedPoints()),
+						 270, 14, 320, 0x5C);
+		(void)hoveredNoteSlot;
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
+
+		// Stash slot info on the captures so the click handler below
+		// can use it via the closure.
+		_notebookSlotRects = slotRects;
+		_notebookSlotClues = slotClues;
 	};
 
 	draw();
+
+	uint32 lastDraw = g_system->getMillis();
+
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
-		bool exit  = false;
+		bool exitFlag = false;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) { exit = true; break; }
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				exitFlag = true;
+				break;
+			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) { exit = true; break; }
-				if (ev.kbd.keycode >= Common::KEYCODE_1 && ev.kbd.keycode <= Common::KEYCODE_9) {
-					const int slot = (int)(ev.kbd.keycode - Common::KEYCODE_1);
-					Common::Array<uint> found;
-					for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
-						if (_mystery._cluesFound[i])
-							found.push_back(i);
-					const int idx = page * kPerPage + slot;
-					if (idx < (int)found.size()) {
-						const uint clueId = found[idx];
-						_mystery._noteSelected[clueId] ^= 1;
-						dirty = true;
-					}
-				} else if (ev.kbd.keycode == Common::KEYCODE_TAB ||
-						   ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					page++; dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					exitFlag = true;
+					break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
+					ev.kbd.keycode == Common::KEYCODE_PAGEUP) {
 					if (page > 0) page--;
 					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT ||
+						   ev.kbd.keycode == Common::KEYCODE_PAGEDOWN ||
+						   ev.kbd.keycode == Common::KEYCODE_TAB) {
+					page++;
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_h) {
+					doHelp();
+					dirty = true;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Test buttons in the order the original would —
+				// button 0 / 9 are dead zones, so check the actionable
+				// rects directly. Earlier rects "win" when overlapping
+				// (matches `_FindButton`).
+				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					exitFlag = true;
+					break;  // back to site
+				}
+				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+					doBigMap();
+					exitFlag = true;
+					break;
+				}
+				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+					doHelp();              // _KDHelp = host hint
+					dirty = true;
+					continue;
+				}
+				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+					doAccuse();
+					exitFlag = true;
+					break;
+				}
+				if (kBtnGallery.contains(ev.mouse.x, ev.mouse.y)) {
+					doGallery();
+					dirty = true;
+					continue;
+				}
+				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
+					if (page > 0) page--;
+					dirty = true;
+					continue;
+				}
+				if (kBtnPageNext.contains(ev.mouse.x, ev.mouse.y)) {
+					page++;
+					dirty = true;
+					continue;
+				}
+				if (kNoteArea.contains(ev.mouse.x, ev.mouse.y)) {
+					// Toggle the selection on whichever clue's text
+					// the click landed in. The original calls
+					// `_InterfaceHelp` here; that's the help screen,
+					// not selection — selection is in the Accuse
+					// screen. We use the area for selection because
+					// keyboard 1..9 toggling is awkward, and the
+					// resulting `_NoteSelected` state is what
+					// `_SolvedCheck` reads.
+					for (uint i = 0; i < _notebookSlotRects.size(); i++) {
+						if (_notebookSlotRects[i].contains(ev.mouse.x,
+														   ev.mouse.y)) {
+							const uint clueId = _notebookSlotClues[i];
+							_mystery._noteSelected[clueId] ^= 1;
+							dirty = true;
+							break;
+						}
+					}
+					continue;
 				}
 			}
 		}
-		if (exit) break;
-		if (dirty) draw();
+		if (exitFlag)
+			break;
+
+		const uint32 now = g_system->getMillis();
+		// Re-render every 100 ms so the partner sprite cycles frames.
+		if (dirty || now - lastDraw >= 100) {
+			draw();
+			lastDraw = now;
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 }
 
 void EEMEngine::doGallery() {
-	// Mirrors `_DrawGallery` @ 158f:0046. The original loops `_NumSuspects`
-	// gallery entries (0x46 = 70 bytes each in `_GalleryData`); the first
-	// u16 of each entry is the PIC picture ID for that suspect. We render
-	// them in a row across the screen.
+	// Mirrors `_DoGallery @ 158f:065b` and `_DrawGallery @ 158f:0046`.
+	// Verified directly from the disassembly:
+	//   * Background: PIC 0x3f (same as PDA).
+	//   * Partner sprite at (5, 0x50): anim 2 (Jake) / 0x10 (Jenny).
+	//     `_NewAnimation(5, 0x50, ...)`. NOTE: gallery uses anim 2/0x10,
+	//     PDA uses 1/0xb — different sprites.
+	//   * Five fixed slot positions at `29be:0x116` (4 bytes per slot,
+	//     `{u16 x, u16 y}`):
+	//         slot 0 = ( 83,  14)   slot 3 = (119,  90)
+	//         slot 1 = (155,  14)   slot 4 = (191,  90)
+	//         slot 2 = (227,  14)
+	//   * For each logical suspect i in 0..NumSuspects-1:
+	//         picId   = `*(u16 *)(_GalleryData + i * 0x46)` (entry +0).
+	//         visible = `_InGallery[_NewOrder[i]] != 0`.
+	//         drawX   = positions[_NewOrder[i]].x
+	//         drawY   = positions[_NewOrder[i]].y + (0x48 - pic.height)
+	//     So portraits are BOTTOM-aligned to baselines 0x48 + pos.y.
+	//   * Click on portrait via `_SearchSuspects` → `MoreInfo(i)` shows
+	//     the suspect detail page. ESC returns to PDA.
+	//   * Frame-cycled @ 100ms via `_CheckFrameRate` + `_UpdateAnimations`
+	//     + `_GizmoColorCycle`.
 	if (!_mystery.isLoaded())
 		return;
 
@@ -1456,60 +1781,323 @@ void EEMEngine::doGallery() {
 		return;
 	}
 
-	Graphics::ManagedSurface scratch(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
+	CursorMan.showMouse(true);
+
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, // 0
+		{ 155,  14 }, // 1
+		{ 227,  14 }, // 2
+		{ 119,  90 }, // 3
+		{ 191,  90 }  // 4
+	};
 
-	// Use PIC 0x3f as the gallery backdrop, matching `_DoAccuseGallery`.
+	// Pre-load static elements once.
 	Picture galBg;
-	if (_picsArchive.getPicture(0x3f, galBg)) {
-		const int w = MIN<int>(galBg.surface.w, 320);
-		const int h = MIN<int>(galBg.surface.h, 200);
-		for (int row = 0; row < h; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)galBg.surface.getBasePtr(0, row), w);
-		}
-	}
+	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
-	if (_font.isLoaded())
-		_font.drawString(&scratch, "GALLERY", 8, 4, 320, 0xF);
+	// Gallery partner anim — `_DoGallery` calls `_GetAnimation(uVar6)` with
+	// uVar6 = 2 (Jake) / 0x10 (Jenny). Different from PDA (1 / 0xb).
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
+							  && !partnerAni.empty();
 
 	const uint8 num = _mystery.numSuspects();
-	int slotX = 8;
-	const int slotY = 24;
-	const int slotStep = 320 / MAX<uint8>(1, num);
+
+	// Cache slot rects for click hit-testing.
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<int> slotSuspect; // logical suspect index in [0, num)
+	slotRects.resize(num);
+	slotSuspect.resize(num);
 	for (uint i = 0; i < num; i++) {
-		const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-		if (picId == 0)
-			continue;
-		Picture portrait;
-		if (!_picsArchive.getPicture(picId, portrait))
-			continue;
-		const int placeX = slotX + (slotStep - portrait.surface.w) / 2;
-		const int placeY = slotY;
-		const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-		const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-		if (w > 0 && h > 0) {
+		slotSuspect[i] = -1;
+	}
+
+	auto drawFrame = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		if (haveBg) {
+			const int bw = MIN<int>(galBg.surface.w, 320);
+			const int bh = MIN<int>(galBg.surface.h, 200);
+			for (int row = 0; row < bh; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+			}
+		}
+
+		// Partner sprite frame @ (5, 0x50).
+		if (havePartner) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+			const Picture &fr = partnerAni[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			const int px = 5, py = 0x50;
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = py + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = px + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
+		// Portraits.
+		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
+			slotRects[i] = Common::Rect();   // empty
+			slotSuspect[i] = -1;
+
+			const uint8 phys = _mystery._newOrder[i];
+			if (phys >= 5)
+				continue;
+			const bool discovered = _mystery._inGallery[phys] != 0;
+			if (!discovered)
+				continue;
+
+			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+			if (picId == 0)
+				continue;
+			Picture portrait;
+			if (!_picsArchive.getPicture(picId, portrait))
+				continue;
+
+			const Slot &s = kGallerySlots[phys];
+			const int placeX = s.x;
+			const int placeY = s.y + (0x48 - portrait.surface.h);
+			const byte transp = (byte)(portrait.flags >> 8);
+
+			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+			if (w <= 0 || h <= 0)
+				continue;
 			for (int row = 0; row < h; row++) {
-				memcpy((byte *)scratch.getBasePtr(placeX, placeY + row),
-					   (const byte *)portrait.surface.getBasePtr(0, row), w);
+				const int dstY = placeY + row;
+				if (dstY < 0) continue;
+				const byte *src =
+					(const byte *)portrait.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < w; col++) {
+					const int dstX = placeX + col;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+
+			// Cache rect for hit-test.
+			slotRects[i] = Common::Rect(placeX, placeY,
+										 placeX + w, placeY + h);
+			slotSuspect[i] = (int)i;
+
+			// Index label below portrait — original doesn't draw labels
+			// (the detail page does), but useful while MoreInfo isn't
+			// implemented.
+			if (_font.isLoaded()) {
+				Common::String label = Common::String::format("%u", i + 1);
+				_font.drawString(&scratch, label,
+								 placeX + portrait.surface.w / 2 - 3,
+								 placeY + portrait.surface.h + 2,
+								 320, 0x5C);
 			}
 		}
-		// Suspect number + discovered marker under the portrait.
+
+		// Header / hint line — KD's hint balloon would normally show here
+		// (`_DoAccuseGallery` plays a balloon at top), but the standalone
+		// `_DoGallery` doesn't. We add a small header for clarity.
 		if (_font.isLoaded()) {
-			const bool discovered = (i < Mystery::kGalleryCap) &&
-									_mystery._inGallery[i];
-			Common::String label = Common::String::format("%u%s",
-				i + 1, discovered ? " *" : "");
-			_font.drawString(&scratch, label, placeX + 4, placeY + h + 2, 320, 0xF);
+			_font.drawString(&scratch, "GALLERY", 60, 4, 256, 0x5C);
+			_font.drawString(&scratch, "Click suspect | ESC", 60, 188, 256, 0x5C);
 		}
-		slotX += slotStep;
-	}
 
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
-	waitForInput(60000);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	drawFrame();
+	uint32 lastDraw = g_system->getMillis();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool exitFlag = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					exitFlag = true;
+					break;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// `_SearchSuspects` walks the per-slot rects and returns
+				// the suspect index. We mirror that with cached rects.
+				bool clicked = false;
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotSuspect[i] < 0) continue;
+					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
+						// `MoreInfo(i)` — show the suspect detail page.
+						// Mirrors `MoreInfo @ 158f:0419`:
+						//   _RefreshGalleryBackground();
+						//   _GetPicture(*(u16*)(gd + i*0x46));
+						//   _AddPicBackground(pic, 0x94, 0xf);
+						//   _DrawGalleryNotes(gd + i*0x46);
+						//   loop until ESC or button click.
+						// Suspect data layout (verified against M1):
+						//   +0..1: picId (used here AND for gallery slot)
+						//   +8..9: number of clues for this suspect
+						//   +0xa..??: array of u16 clue IDs (terminated
+						//             by 0xFFFF if shorter than count).
+						const uint suspectIdx = (uint)slotSuspect[i];
+						const byte *suspect = gd + suspectIdx * 0x46;
+						const uint16 detailPic =
+							READ_LE_UINT16(suspect + 0);
+						const uint16 clueCount =
+							READ_LE_UINT16(suspect + 8);
+
+						Graphics::ManagedSurface ms(320, 200,
+							Graphics::PixelFormat::createFormatCLUT8());
+						ms.clear();
+						if (haveBg) {
+							const int bw = MIN<int>(galBg.surface.w, 320);
+							const int bh = MIN<int>(galBg.surface.h, 200);
+							for (int row = 0; row < bh; row++) {
+								memcpy((byte *)ms.getBasePtr(0, row),
+									   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+							}
+						}
+						// Full suspect picture at (0x94, 0xf).
+						Picture detail;
+						if (_picsArchive.getPicture(detailPic, detail)) {
+							const byte transp =
+								(byte)(detail.flags >> 8);
+							const int dx = 0x94, dy = 0x0f;
+							const int dw = MIN<int>(detail.surface.w, 320 - dx);
+							const int dh = MIN<int>(detail.surface.h, 200 - dy);
+							for (int row = 0; row < dh; row++) {
+								const byte *src =
+									(const byte *)detail.surface.getBasePtr(0, row);
+								byte *dst =
+									(byte *)ms.getBasePtr(0, dy + row);
+								for (int col = 0; col < dw; col++) {
+									if (src[col] != transp)
+										dst[dx + col] = src[col];
+								}
+							}
+						}
+						// Suspect's clue notes inside _GalleryNoteRect
+						// = (78, 93, 288, 152), per 29be:0100.
+						const int rx = 78, ry = 93;
+						const int rw = 288 - 78, rh = 152 - 93;
+						// Paint a dark fill behind the entire notes area
+						// so cyan text on a possibly-cyan PIC background
+						// stays readable. Color 0x20 = dark navy in all
+						// site palettes.
+						ms.fillRect(Common::Rect(rx - 2, ry - 12,
+							rx + rw + 2, ry + rh + 12), 0x20);
+
+						const byte *ni = _mystery.noteIndex();
+						const uint16 niCount = _mystery.noteIndexCount();
+						int yPos = ry;
+						const int lineH = _font.getFontHeight() + 1;
+						bool drewAny = false;
+						for (uint k = 0; k < clueCount && k < 30; k++) {
+							const uint16 clueId =
+								READ_LE_UINT16(suspect + 0xa + k * 2);
+							if (clueId == 0xFFFF) break;
+							if (clueId >= Mystery::kCluesFoundCap ||
+								!_mystery._cluesFound[clueId])
+								continue;
+							if (!ni || clueId >= niCount) continue;
+							const uint16 textOff =
+								READ_LE_UINT16(ni + clueId * 4);
+							Common::String txt =
+								parseString(_mystery.textAt(textOff),
+											_playerName, _partner);
+							if (txt.empty()) continue;
+							const byte color =
+								_mystery._noteSelected[clueId] ? 0x3C : 0x5C;
+							const int hLine = _font.drawWordWrapped(
+								&ms, rx, yPos, rw, txt, color);
+							yPos += hLine + 7;
+							drewAny = true;
+							if (yPos + lineH > ry + rh) break;
+						}
+						if (!drewAny && _font.isLoaded()) {
+							_font.drawString(&ms,
+								"No clues yet for this suspect.",
+								rx, ry, rw, 0x5C);
+						}
+						// Header / footer text.
+						if (_font.isLoaded()) {
+							_font.drawString(&ms, "SUSPECT FILE",
+											  rx, ry - 11, rw, 0x3C);
+							_font.drawString(&ms, "(click / ESC: back)",
+											  rx, ry + rh + 2, rw, 0x3C);
+						}
+						g_system->copyRectToScreen(ms.getPixels(),
+							ms.pitch, 0, 0, 320, 200);
+						g_system->updateScreen();
+
+						// Wait for click or ESC. Drain the queued
+						// LBUTTONDOWN that triggered this MoreInfo first
+						// so we don't immediately accept it as the
+						// dismiss event.
+						g_system->delayMillis(150);
+						{
+							Common::Event drain;
+							while (g_system->getEventManager()->pollEvent(drain)) {
+								if (drain.type == Common::EVENT_QUIT ||
+									drain.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									return;
+							}
+						}
+						bool back = false;
+						while (!back && !shouldQuit()) {
+							Common::Event e2;
+							while (g_system->getEventManager()->pollEvent(e2)) {
+								if (e2.type == Common::EVENT_LBUTTONDOWN ||
+									(e2.type == Common::EVENT_KEYDOWN &&
+									 (e2.kbd.keycode == Common::KEYCODE_ESCAPE ||
+									  e2.kbd.keycode == Common::KEYCODE_RETURN))) {
+									back = true;
+									break;
+								}
+								if (e2.type == Common::EVENT_QUIT ||
+									e2.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									return;
+							}
+							g_system->delayMillis(20);
+						}
+						// Force gallery redraw immediately so the
+						// player isn't left looking at the dismissed
+						// MoreInfo screen until the next 100 ms tick.
+						drawFrame();
+						lastDraw = g_system->getMillis();
+						clicked = true;
+						break;
+					}
+				}
+				(void)clicked;
+			}
+		}
+		if (exitFlag) break;
+
+		const uint32 now = g_system->getMillis();
+		if (now - lastDraw >= 100) {
+			drawFrame();
+			lastDraw = now;
+		}
+		g_system->delayMillis(15);
+	}
 }
 
 void EEMEngine::doBigMap() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index ec8e9d27b35..85c25b26c22 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -206,6 +206,13 @@ private:
 	/// the opening-anim loop in run() to skip the rest of the chain
 	/// instead of asking the user to click through every screen.
 	bool _skipIntro = false;
+
+	/// Per-slot rectangles + clue IDs from the most recent notebook
+	/// render, populated by the `draw` lambda inside `doNotebook` and
+	/// consumed by the click handler. The original walks the notes
+	/// inline; we cache the layout to keep click hit-testing simple.
+	Common::Array<Common::Rect> _notebookSlotRects;
+	Common::Array<uint>         _notebookSlotClues;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 30f97bc29c0..964f7707eaa 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -24,6 +24,8 @@
 #include "common/system.h"
 #include "common/textconsole.h"
 
+#include "graphics/paletteman.h"
+
 #include "eem/detection.h"
 #include "eem/eem.h"
 #include "eem/mystery.h"
@@ -83,6 +85,11 @@ void SiteScreen::enter(uint siteNum) {
 	captureBgSnapshot();
 	_snapshotSite = (int)siteNum;
 
+	// Cache ColorCycle palette ranges for this site so the per-tick
+	// frame pump can rotate them. Mirrors the init scan at the top of
+	// `_DoSiteLoop @ 168d:03f4`.
+	scanColorCycles(siteNum);
+
 	// Animated NPCs (Loop 1) and the persistent partner sit on top of
 	// the snapshot. Initial frame goes at tickMs=now.
 	const uint32 now = g_system->getMillis();
@@ -297,6 +304,10 @@ void SiteScreen::run() {
 			renderAnimatedDrops(cur, now);
 			renderPartner(cur, now);
 			renderHotspots(cur);
+			// Per-tick palette rotation for ColorCycle entries +
+			// hotspot marching ants. Matches `_ColorCycle(start, end)`
+			// calls inside `_DoSiteLoop @ 168d:03f4`'s main loop.
+			applyColorCycles();
 			_lastTickMs = now;
 		}
 		g_system->updateScreen();
@@ -542,6 +553,67 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	g_system->unlockScreen();
 }
 
+void SiteScreen::scanColorCycles(uint siteNum) {
+	// `_DoSiteLoop @ 168d:03f4` walks Loop 1 entries (siteData[+0xa]
+	// count, 6-byte entries at siteData[+0x48]) and stores each entry
+	// with `animId == -1` into a 5-slot table:
+	//   start palette idx = entry +2
+	//   end   palette idx = entry +4
+	// We mirror the layout exactly. Up to 5 entries are tracked (the
+	// original's `[unaff_BP + -0x12]` and `[unaff_BP + -0x1c]` arrays
+	// are 5 × u16 each).
+	_colorCycles.clear();
+	if (!_mystery)
+		return;
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 numAnims = READ_LE_UINT16(site + 0xa);
+	for (uint i = 0; i < numAnims && _colorCycles.size() < 5; i++) {
+		const int16 animId = (int16)READ_LE_UINT16(site + 0x48 + i * 6);
+		if (animId != -1)
+			continue;
+		const uint16 startPal = READ_LE_UINT16(site + 0x48 + i * 6 + 2);
+		const uint16 endPal   = READ_LE_UINT16(site + 0x48 + i * 6 + 4);
+		ColorCycleRange r;
+		r.start = (uint8)startPal;
+		r.end   = (uint8)endPal;
+		if (r.end > r.start)
+			_colorCycles.push_back(r);
+	}
+}
+
+void SiteScreen::applyColorCycles() {
+	// `_ColorCycle @ 172b:2015` rotates `_fpal[start..end]` by one
+	// palette slot — saves [start], shifts [start..end-1] = [start+1..
+	// end], restores saved at [end] — then re-uploads via `_Set_Palette`.
+	// We do the same against ScummVM's palette manager. Always rotate
+	// 0xf9..0xfe for hotspot marching ants (the `_ColorCycle(0xf9,
+	// 0xfe)` call at the bottom of `_DoSiteLoop`'s main loop).
+	auto rotate = [&](uint8 start, uint8 end) {
+		if (end <= start) return;
+		const uint count = (uint)end - (uint)start + 1;
+		byte buf[256 * 3];
+		g_system->getPaletteManager()->grabPalette(buf, start, count);
+		// Save first triplet, shift, restore at end.
+		byte savedR = buf[0], savedG = buf[1], savedB = buf[2];
+		for (uint i = 0; i + 1 < count; i++) {
+			buf[i * 3 + 0] = buf[(i + 1) * 3 + 0];
+			buf[i * 3 + 1] = buf[(i + 1) * 3 + 1];
+			buf[i * 3 + 2] = buf[(i + 1) * 3 + 2];
+		}
+		buf[(count - 1) * 3 + 0] = savedR;
+		buf[(count - 1) * 3 + 1] = savedG;
+		buf[(count - 1) * 3 + 2] = savedB;
+		g_system->getPaletteManager()->setPalette(buf, start, count);
+	};
+	for (uint i = 0; i < _colorCycles.size(); i++) {
+		rotate(_colorCycles[i].start, _colorCycles[i].end);
+	}
+	// Hotspot marching ants — always cycled.
+	rotate(0xF9, 0xFE);
+}
+
 void SiteScreen::captureBgSnapshot() {
 	_bgSnapshot.create(320, 200, Graphics::PixelFormat::createFormatCLUT8());
 	Graphics::Surface *screen = g_system->lockScreen();
@@ -708,6 +780,19 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	if (!screen)
 		return;
 
+	// Mirrors `_DrawSearchButtons @ 2404:0a8f`:
+	//   for each hotspot:
+	//     if `_Sawit(theSite, loc)` (= _SaveBuffer[hotspot[+0xa]] != 0)
+	//       _DrawRect(rect)        // outline in cycling colors 0xF9..0xFE
+	//     else
+	//       _DrawSolidRect(rect)   // outline in solid white 0xFF
+	// `_DrawRect`'s cycling colors produce a "marching ants" effect that
+	// makes already-searched hotspots visually distinct without hiding
+	// them. We approximate the cycling by rotating the start color via
+	// the global tick.
+	const uint32 tickMs = g_system->getMillis();
+	const byte cyclePhase = (byte)((tickMs / 80) & 0x07);  // 0..7
+
 	for (uint i = 0; i < count; i++) {
 		const byte *r = spots + i * 14;
 		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
@@ -717,11 +802,43 @@ void SiteScreen::renderHotspots(uint siteNum) {
 		const Common::Rect rect(MAX<int>(0, x1), MAX<int>(0, y1),
 								MIN<int>(screen->w, x2),
 								MIN<int>(screen->h, y2));
-		// Hotspots flagged as "seen" get a different colour so the
-		// player knows they've already searched them.
-		const byte color =
-			(i < Mystery::kHotSpotsCap && _mystery->_hotSpotsSeen[i]) ? 0x07 : 0x0F;
-		screen->frameRect(rect, color);
+		const bool seen = (i < Mystery::kHotSpotsCap)
+						   && _mystery->_hotSpotsSeen[i];
+		if (!seen) {
+			// `_DrawSolidRect` — solid white outline (color 0xFF).
+			screen->frameRect(rect, 0xFF);
+		} else {
+			// `_DrawRect` — cycling colors 0xF9..0xFE on each pixel of
+			// the outline. We approximate per-pixel cycling with a
+			// per-rect phase shift so the rects look animated. Start
+			// color is rotated via the global clock.
+			const byte palette[6] = { 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE };
+			byte color = palette[cyclePhase % 6];
+			// Top edge
+			for (int x = rect.left; x < rect.right; x++) {
+				if (x >= 0 && x < screen->w && rect.top >= 0 && rect.top < screen->h)
+					*(byte *)screen->getBasePtr(x, rect.top) = color;
+				color = palette[(color - 0xF9 + 1) % 6];
+			}
+			// Right edge
+			for (int y = rect.top; y < rect.bottom; y++) {
+				if (rect.right - 1 >= 0 && rect.right - 1 < screen->w && y >= 0 && y < screen->h)
+					*(byte *)screen->getBasePtr(rect.right - 1, y) = color;
+				color = palette[(color - 0xF9 + 1) % 6];
+			}
+			// Bottom edge
+			for (int x = rect.right - 1; x >= rect.left; x--) {
+				if (x >= 0 && x < screen->w && rect.bottom - 1 >= 0 && rect.bottom - 1 < screen->h)
+					*(byte *)screen->getBasePtr(x, rect.bottom - 1) = color;
+				color = palette[(color - 0xF9 + 1) % 6];
+			}
+			// Left edge
+			for (int y = rect.bottom - 1; y >= rect.top; y--) {
+				if (rect.left >= 0 && rect.left < screen->w && y >= 0 && y < screen->h)
+					*(byte *)screen->getBasePtr(rect.left, y) = color;
+				color = palette[(color - 0xF9 + 1) % 6];
+			}
+		}
 	}
 
 	g_system->unlockScreen();
@@ -748,9 +865,21 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
-	// Mark the hotspot itself as seen.
+	// `_DoSiteLoop @ 168d:03f4` (after _DisplayClue):
+	//   _HotSpotsSeen[hotspot[+0xa] * 2] = _HotSpotComplete;
+	// The "seen" key is the hotspotIndex field (+0xa) — the 1-based
+	// ordinal — NOT the array index. Two hotspots can share an ordinal
+	// across sites (e.g., a partner's clue you can re-read), so this
+	// matters for cross-site state.
+	const byte *spots = _mystery->hotspots(siteNum);
+	uint hotOrdinal = hotIdx; // fallback to array index
+	if (spots) {
+		hotOrdinal = READ_LE_UINT16(spots + hotIdx * 14 + 0xa);
+	}
+	if (hotOrdinal < Mystery::kHotSpotsCap)
+		_mystery->_hotSpotsSeen[hotOrdinal] = 1;
 	if (hotIdx < Mystery::kHotSpotsCap)
-		_mystery->_hotSpotsSeen[hotIdx] = 1;
+		_mystery->_hotSpotsSeen[hotIdx] = 1;  // also mark by array idx for our render
 	_mystery->_searchLocationNumber = (uint16)hotIdx;
 
 	// Bytes 8..9 of each 14-byte hotspot rect = byte offset within the
@@ -759,7 +888,6 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	// trick on us...". `displayClue` runs the entry's side effects
 	// (`_AddNotebook` for ClueEntry +0x30..+0x39, gallery +0x26..+0x2f,
 	// onsite +0x1c..+0x25) so we don't need to touch `_cluesFound` here.
-	const byte *spots = _mystery->hotspots(siteNum);
 	if (spots) {
 		const uint16 clueOff = READ_LE_UINT16(spots + hotIdx * 14 + 8);
 		debugC(2, kDebugSite, "  hotspot %u -> clue offset 0x%04x",
diff --git a/engines/eem/site.h b/engines/eem/site.h
index bae8dc53cc6..00c1e647ea0 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -102,6 +102,18 @@ private:
 	/// Restore the snapshot taken at `captureBgSnapshot` time.
 	void restoreBgSnapshot();
 
+	/// Scan the site's Loop 1 entries for ColorCycle entries (animId
+	/// == -1) and cache their (start, end) palette ranges. Called from
+	/// `enter()`. Mirrors `_DoSiteLoop @ 168d:03f4`'s init scan.
+	void scanColorCycles(uint siteNum);
+
+	/// Rotate cached ColorCycle palette ranges (and 0xf9..0xfe for
+	/// hotspot marching ants) one step. Mirrors the original's per-tick
+	/// `_ColorCycle(start, end)` calls inside `_DoSiteLoop`'s main
+	/// loop. ScummVM's palette manager grabs current 8-bit RGB and
+	/// writes back the rotated values.
+	void applyColorCycles();
+
 	EEMEngine *_vm;
 	Mystery *_mystery;
 	bool _showHotspots = true;     ///< Toggle outlines with V key.
@@ -109,6 +121,11 @@ private:
 	int _snapshotSite = -1;        ///< Site number the snapshot belongs to.
 	Graphics::ManagedSurface _bgSnapshot;
 	uint32 _lastTickMs = 0;        ///< Last frame-pump tick in ms.
+
+	/// Per-site cached ColorCycle ranges. Up to 5 (matching the
+	/// original's 5-slot animation table).
+	struct ColorCycleRange { uint8 start, end; };
+	Common::Array<ColorCycleRange> _colorCycles;
 };
 
 } // End of namespace EEM


Commit: b36297d2a5918762f6f4a4e19efd9ed5f587f130
    https://github.com/scummvm/scummvm/commit/b36297d2a5918762f6f4a4e19efd9ed5f587f130
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:38+02:00

Commit Message:
EEM: polish PDA features: buttons now works correctly

Changed paths:
    engines/eem/eem.cpp
    engines/eem/mystery.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 578f3290a6f..554c588ab32 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1099,6 +1099,74 @@ void EEMEngine::doInitClues() {
 		blitMaskedAt(nancy[0], 0x68, 0x8b);
 	g_system->updateScreen();
 
+	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
+	//   Jake (partner=0):
+	//     caseType=1 → anim 0x38 at (0xcd, 0x6d)
+	//     caseType=2 → anim 0x37 at (0xcd, 0x6c)
+	//     caseType=3 → anim 0x39 at (0xcd, 0x6c)
+	//   Jenny (partner=1):
+	//     caseType=2 → anim 0x3a at (0xcd, 0x6c)
+	//     caseType=3 → anim 0x3d at (0xcd, 0x6c)
+	// `_PlayInSequence @ 172b:2d03` plays each frame at (sx-w, sy-rowoff)
+	// with mask blit, advancing one frame per `_CheckFrameRate` tick.
+	uint16 seqAni = 0xFFFF;
+	uint16 seqY   = 0x6c;
+	if (_partner == 0) {
+		switch (caseType) {
+		case 1: seqAni = 0x38; seqY = 0x6d; break;
+		case 2: seqAni = 0x37; seqY = 0x6c; break;
+		case 3: seqAni = 0x39; seqY = 0x6c; break;
+		default: break;
+		}
+	} else {
+		switch (caseType) {
+		case 2: seqAni = 0x3a; seqY = 0x6c; break;
+		case 3: seqAni = 0x3d; seqY = 0x6c; break;
+		default: break;
+		}
+	}
+	if (seqAni != 0xFFFF) {
+		Animation seq;
+		if (_aniArchive.loadAnimation(seqAni, seq) && !seq.empty()) {
+			bool skip = false;
+			for (uint frame = 0; frame < seq.size() && !shouldQuit() && !skip;
+				 frame++) {
+				const Picture &fr = seq[frame];
+				// Restore BG + base anim frames so each new frame
+				// composites cleanly.
+				if (_picsArchive.getPicture(0x52, bg))
+					blitAt(bg, 0, 0);
+				if (haveGame)
+					blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+				if (haveBook)
+					blitMaskedAt(book[frame % book.size()], 0, 99);
+				if (haveNancy)
+					blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+				// Anchor: original blits at `(sx - frame.width,
+				// sy - frame.rowoff)`. `frame.rowoff` is the y-anchor
+				// in our PicData. We use width/height directly since
+				// loadAnimation places anchor at (0, 0).
+				const int dstX = (int)0xcd - (int)fr.surface.w;
+				const int dstY = (int)seqY - (int)fr.rowoff;
+				blitMaskedAt(fr, dstX, dstY);
+				g_system->updateScreen();
+				const uint32 wakeup = g_system->getMillis() + 100;
+				while (g_system->getMillis() < wakeup &&
+					   !shouldQuit() && !skip) {
+					Common::Event ev;
+					while (g_system->getEventManager()->pollEvent(ev)) {
+						if (ev.type == Common::EVENT_LBUTTONDOWN ||
+							ev.type == Common::EVENT_KEYDOWN) {
+							skip = true;
+							break;
+						}
+					}
+					g_system->delayMillis(10);
+				}
+			}
+		}
+	}
+
 	// Step 6 — case briefing dialogue.
 	displayClue(ib + 4);
 }
@@ -1461,17 +1529,31 @@ void EEMEngine::doNotebook() {
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
+	// Button rects from `_NoteButtons @ 29be:0147` matched to handler
+	// addresses via the jump table at `_HandleNoteButton + 0xec` (i.e.
+	// 161e:04ec). Decoded handlers (i = rect_index, dispatch = handler[i-1]):
+	//   rect 0 (134,155) → no handler (i-1 underflows; original treats
+	//                      this as a decorative/no-op slot)
+	//   rect 1 (93,115)  → 0x03f9 = `_InterfaceHelp(0)`           (HELP)
+	//   rect 2 (157,178) → 0x0477 = `_NextScreen = 5`             (GALLERY)
+	//   rect 3 (5,80)    → 0x0403 = `_KDHelp`                     (host hint)
+	//   rect 4 (180,201) → 0x0436 = `_SolvedCheck` -> NextScreen=7 (SOLVE)
+	//   rect 5 (204,224) → 0x0489 = `_EraseNotes` + `_DrawNotes`  (PAGE NEXT)
+	//   rect 6 (226,247) → 0x04ab = decrement CurrentPage + redraw (PAGE PREV)
+	//   rect 7 (7,177)   → 0x0480 = `_NextScreen = 2`             (MAP)
+	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
+	//   rect 9 (0,0)     → 0x03ed = same as rect 8
+	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
 	const Common::Rect kNotebookRect(78, 12, 288, 152);
-	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1]
-	const Common::Rect kBtnPagePrev(157, 174, 178, 190);  // [2]
-	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3]
-	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4]
-	const Common::Rect kBtnGallery (204, 174, 224, 190);  // [5]
-	const Common::Rect kBtnPageNext(226, 174, 247, 190);  // [6]
-	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7]
-	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8]
-	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10]
-	(void)kBtnHelp1; (void)kBtnPagePrev; (void)kBtnPageNext;
+	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1] HELP
+	const Common::Rect kBtnGallery (157, 174, 178, 190);  // [2] GALLERY
+	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3] KD HELP
+	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4] SOLVE
+	const Common::Rect kBtnPageNext(204, 174, 224, 190);  // [5] PAGE NEXT
+	const Common::Rect kBtnPagePrev(226, 174, 247, 190);  // [6] PAGE PREV
+	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7] MAP
+	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8] SITE
+	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10] note area
 
 	CursorMan.showMouse(true);
 
@@ -1593,22 +1675,16 @@ void EEMEngine::doNotebook() {
 			}
 			if (txt.empty())
 				txt = Common::String::format("clue %u", clueId);
-			// Compute wrapped lines first to know the rect.
+			// Per `_DrawNotes @ 161e:01d0`: text uses
+			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
+			// (light yellow-white) for selected. Both contrast cleanly
+			// with the PDA screen's natural blue, so we draw text
+			// directly on PIC 0x3f without an extra fill rectangle —
+			// matches the original design.
 			Common::Array<Common::String> wrapped;
 			_font.wordWrapText(txt, kRectW, wrapped);
 			const int lineH = _font.getFontHeight() + 1;
 			const int h = (int)wrapped.size() * lineH;
-
-			// Per `_DrawNotes @ 161e:01d0`: text uses
-			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
-			// (light yellow-white) for selected. Paint a dark "paper"
-			// rectangle behind the text first — without this, the
-			// notebook BG (PIC 0x3f) bleeds through and makes the cyan
-			// text hard to read on lighter pixel runs. The fill colour
-			// 0x20 maps to a dark navy across all site palettes.
-			scratch.fillRect(Common::Rect(kRectX - 2, y - 1,
-				kRectX + kRectW + 2, y + h + 1), 0x20);
-
 			const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
 			for (uint li = 0; li < wrapped.size(); li++) {
 				_font.drawString(&scratch, wrapped[li], kRectX,
@@ -1620,10 +1696,7 @@ void EEMEngine::doNotebook() {
 			y += h + 7;
 		}
 
-		// Page indicator + selected-points counter. Paint a small dark
-		// strip behind them too so the text reads on the PDA frame's
-		// upper-right corner.
-		scratch.fillRect(Common::Rect(266, 0, 320, 24), 0x20);
+		// Page indicator + selected-points counter directly on PIC.
 		_font.drawString(&scratch, Common::String::format("p%d/%d",
 								   page + 1, (int)pageStarts.size()),
 						 270, 4, 320, 0x5C);
@@ -1704,6 +1777,13 @@ void EEMEngine::doNotebook() {
 					dirty = true;
 					continue;
 				}
+				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
+					// rect 1 → `_InterfaceHelp(0)`. We use doHelp() to
+					// surface the same kind of hint pane.
+					doHelp();
+					dirty = true;
+					continue;
+				}
 				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
 					if (page > 0) page--;
 					dirty = true;
@@ -1849,71 +1929,71 @@ void EEMEngine::doGallery() {
 			}
 		}
 
-		// Portraits.
+		// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1
+		// and only renders those flagged in `_InGallery[NewOrder[i]]`.
+		// Undiscovered slots are left empty in the original. We render
+		// a darkened placeholder + "?" so the player has visual feedback
+		// that suspects exist but are still unknown.
+		uint discoveredCount = 0;
 		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
-			slotRects[i] = Common::Rect();   // empty
+			slotRects[i] = Common::Rect();
 			slotSuspect[i] = -1;
 
 			const uint8 phys = _mystery._newOrder[i];
 			if (phys >= 5)
 				continue;
-			const bool discovered = _mystery._inGallery[phys] != 0;
-			if (!discovered)
-				continue;
-
-			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-			if (picId == 0)
-				continue;
-			Picture portrait;
-			if (!_picsArchive.getPicture(picId, portrait))
-				continue;
-
 			const Slot &s = kGallerySlots[phys];
-			const int placeX = s.x;
-			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const byte transp = (byte)(portrait.flags >> 8);
 
-			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-			if (w <= 0 || h <= 0)
-				continue;
-			for (int row = 0; row < h; row++) {
-				const int dstY = placeY + row;
-				if (dstY < 0) continue;
-				const byte *src =
-					(const byte *)portrait.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < w; col++) {
-					const int dstX = placeX + col;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-
-			// Cache rect for hit-test.
-			slotRects[i] = Common::Rect(placeX, placeY,
-										 placeX + w, placeY + h);
-			slotSuspect[i] = (int)i;
+			const bool discovered = _mystery._inGallery[phys] != 0;
+			if (discovered) {
+				const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+				Picture portrait;
+				if (picId == 0 ||
+					!_picsArchive.getPicture(picId, portrait))
+					continue;
 
-			// Index label below portrait — original doesn't draw labels
-			// (the detail page does), but useful while MoreInfo isn't
-			// implemented.
-			if (_font.isLoaded()) {
-				Common::String label = Common::String::format("%u", i + 1);
-				_font.drawString(&scratch, label,
-								 placeX + portrait.surface.w / 2 - 3,
-								 placeY + portrait.surface.h + 2,
-								 320, 0x5C);
+				const int placeX = s.x;
+				const int placeY = s.y + (0x48 - portrait.surface.h);
+				const byte transp = (byte)(portrait.flags >> 8);
+				const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+				const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+				if (w <= 0 || h <= 0)
+					continue;
+				for (int row = 0; row < h; row++) {
+					const int dstY = placeY + row;
+					if (dstY < 0) continue;
+					const byte *src =
+						(const byte *)portrait.surface.getBasePtr(0, row);
+					byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+					for (int col = 0; col < w; col++) {
+						const int dstX = placeX + col;
+						if (src[col] != transp)
+							dst[dstX] = src[col];
+					}
+				}
+				slotRects[i] = Common::Rect(placeX, placeY,
+											 placeX + w, placeY + h);
+				slotSuspect[i] = (int)i;
+				discoveredCount++;
+			} else {
+				// Undiscovered placeholder — small framed "?" box at
+				// (s.x, s.y) sized 0x40 × 0x48 (typical portrait size).
+				const int phW = 0x40, phH = 0x48;
+				const int phX = s.x, phY = s.y;
+				if (phX + phW <= 320 && phY + phH <= 200) {
+					scratch.fillRect(Common::Rect(phX, phY,
+						phX + phW, phY + phH), 0x20);
+					scratch.frameRect(Common::Rect(phX, phY,
+						phX + phW, phY + phH), 0x5C);
+					if (_font.isLoaded()) {
+						_font.drawString(&scratch, "?",
+							phX + phW / 2 - 3,
+							phY + phH / 2 - 4, phW, 0x5C);
+					}
+				}
 			}
 		}
-
-		// Header / hint line — KD's hint balloon would normally show here
-		// (`_DoAccuseGallery` plays a balloon at top), but the standalone
-		// `_DoGallery` doesn't. We add a small header for clarity.
-		if (_font.isLoaded()) {
-			_font.drawString(&scratch, "GALLERY", 60, 4, 256, 0x5C);
-			_font.drawString(&scratch, "Click suspect | ESC", 60, 188, 256, 0x5C);
-		}
+		(void)discoveredCount;
 
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
@@ -1938,6 +2018,52 @@ void EEMEngine::doGallery() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// PDA bottom-bar buttons mirror `_NoteButtons @ 29be:0147`.
+				// `_DoGallery @ 158f:065b` shares the SAME button table
+				// with `_DoNotebook` (both call `_FindButton` against the
+				// 11-entry table at 0x147). `_HandleGalleryButton @
+				// 158f:05c0` dispatches via a different jump table
+				// (158f:0645). Verified gallery button mapping:
+				//   rect 0 (134,155) → 0x05ef = `_NextScreen = 4` (NOTEBOOK)
+				//   rect 1 (93,115)  → 0x0625 = `_InterfaceHelp` (HELP)
+				//   rect 2 (157,178) → 0x0638 = generic exit (no-op)
+				//   rect 3 (5,80)    → 0x061e = `_KDHelp` (host hint)
+				//   rect 4 (180,201) → 0x05ff = `_SolvedCheck` -> SOLVE
+				//   rect 5 (204,224) → 0x0638 = generic exit
+				//   rect 6 (226,247) → 0x0638 = generic exit
+				//   rect 7 (7,177)   → 0x05f7 = `_NextScreen = 2` (MAP)
+				//   rect 8 (35,111)  → 0x05e4 = `_NextScreen = 3` (SITE)
+				const Common::Rect kBtnSite    ( 35, 111,  56, 136); // [8] SITE
+				const Common::Rect kBtnMap     (  7, 177,  57, 200); // [7] MAP
+				const Common::Rect kBtnAccuse  (180, 174, 201, 190); // [4] SOLVE
+				const Common::Rect kBtnNotebook(134, 174, 155, 190); // [0] NOTEBOOK (back to PDA notes)
+				const Common::Rect kBtnHelp    ( 93, 174, 115, 190); // [1] HELP
+				const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
+				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					exitFlag = true; break;
+				}
+				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+					doBigMap();
+					exitFlag = true; break;
+				}
+				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+					doAccuse();
+					exitFlag = true; break;
+				}
+				if (kBtnNotebook.contains(ev.mouse.x, ev.mouse.y)) {
+					// Already came from notebook; exiting returns to it.
+					exitFlag = true; break;
+				}
+				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {
+					doHelp();
+					lastDraw = 0;
+					continue;
+				}
+				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+					doHelp();
+					lastDraw = 0;
+					continue;
+				}
 				// `_SearchSuspects` walks the per-slot rects and returns
 				// the suspect index. We mirror that with cached rects.
 				bool clicked = false;
@@ -1994,15 +2120,11 @@ void EEMEngine::doGallery() {
 							}
 						}
 						// Suspect's clue notes inside _GalleryNoteRect
-						// = (78, 93, 288, 152), per 29be:0100.
+						// = (78, 93, 288, 152), per 29be:0100. Cyan text
+						// renders directly on the PDA's natural blue
+						// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
 						const int rx = 78, ry = 93;
 						const int rw = 288 - 78, rh = 152 - 93;
-						// Paint a dark fill behind the entire notes area
-						// so cyan text on a possibly-cyan PIC background
-						// stays readable. Color 0x20 = dark navy in all
-						// site palettes.
-						ms.fillRect(Common::Rect(rx - 2, ry - 12,
-							rx + rw + 2, ry + rh + 12), 0x20);
 
 						const byte *ni = _mystery.noteIndex();
 						const uint16 niCount = _mystery.noteIndexCount();
@@ -2597,65 +2719,127 @@ void EEMEngine::doAccuse() {
 	if (!_mystery.isLoaded())
 		return;
 
-	// Mirrors `_DoAccuseGallery` @ 1df2:0a31. Render gallery + prompt,
-	// accept either keyboard 1..N or a click on a suspect's portrait.
+	// Mirrors `_DoAccuseGallery @ 1df2:0a31`:
+	//   1. Show KD's hint balloon (KDTextIndex[+8] text).
+	//   2. `_GetBackground(0x3f)` — same backdrop as PDA / gallery.
+	//   3. `_DrawGallery()` — renders portraits at the standard 5 slots
+	//      (positions verified at 29be:0x116, bottom-aligned baseline 0x48).
+	//   4. Click loop dispatching on `_NoteButtons` (same table as PDA)
+	//      with a separate `_HandleAccuseNoteButton` jump table.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0)
 		return;
 
 	const byte *gd = _mystery.galleryData();
-	const int slotStep = 320 / MAX<uint8>(1, num);
-	const int slotY    = 24;
 
-	// Mirrors `_DoAccuseGallery`: load PIC 0x3f as the accuse backdrop.
+	// Verbatim from 29be:0x116 — same five suspect slot positions as
+	// `_DrawGallery @ 158f:0046`.
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, { 155,  14 }, { 227,  14 },
+		{ 119,  90 }, { 191,  90 }
+	};
+
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<int> slotSuspect;
+	slotRects.resize(num);
+	slotSuspect.resize(num);
+	for (uint i = 0; i < num; i++)
+		slotSuspect[i] = -1;
+
 	auto drawGallery = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		if (haveAccuseBg) {
-			const int w = MIN<int>(accuseBg.surface.w, 320);
-			const int h = MIN<int>(accuseBg.surface.h, 200);
-			for (int row = 0; row < h; row++) {
+			const int bw = MIN<int>(accuseBg.surface.w, 320);
+			const int bh = MIN<int>(accuseBg.surface.h, 200);
+			for (int row = 0; row < bh; row++) {
 				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)accuseBg.surface.getBasePtr(0, row), w);
+					   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
 			}
 		}
-		if (_font.isLoaded())
-			_font.drawString(&scratch, "ACCUSE", 8, 4, 320, 0xF);
 
-		for (uint i = 0; i < num; i++) {
+		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
+			slotRects[i] = Common::Rect();
+			slotSuspect[i] = -1;
 			if (!gd) continue;
+			const uint8 phys = _mystery._newOrder[i];
+			if (phys >= 5) continue;
+			const Slot &s = kGallerySlots[phys];
+
 			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-			if (picId == 0)
-				continue;
+			if (picId == 0) continue;
 			Picture portrait;
 			if (!_picsArchive.getPicture(picId, portrait))
 				continue;
-			const int placeX = i * slotStep +
-							   (slotStep - portrait.surface.w) / 2;
-			const int placeY = slotY;
+
+			const int placeX = s.x;
+			const int placeY = s.y + (0x48 - portrait.surface.h);
+			const byte transp = (byte)(portrait.flags >> 8);
 			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
 			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-			if (w > 0 && h > 0) {
-				for (int row = 0; row < h; row++)
-					memcpy((byte *)scratch.getBasePtr(placeX, placeY + row),
-						   (const byte *)portrait.surface.getBasePtr(0, row), w);
-			}
-			if (_font.isLoaded()) {
-				Common::String label = Common::String::format("%u", i + 1);
-				_font.drawString(&scratch, label, placeX + 4, placeY + h + 2, 320, 0xF);
+			if (w <= 0 || h <= 0) continue;
+			for (int row = 0; row < h; row++) {
+				const int dstY = placeY + row;
+				if (dstY < 0) continue;
+				const byte *src =
+					(const byte *)portrait.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < w; col++) {
+					const int dstX = placeX + col;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
 			}
+			slotRects[i] = Common::Rect(placeX, placeY,
+										 placeX + w, placeY + h);
+			slotSuspect[i] = (int)i;
 		}
-		if (_font.isLoaded()) {
-			_font.drawString(&scratch, "Click a suspect or press 1..N - ESC to cancel", 8, 180, 320, 0xF);
-		}
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
+
+	// Step 1 — KD hint balloon. `KDTextIndex[+8]` = third hint slot
+	// (offset 8 from the index array).
+	const byte *kdIdx = _mystery.kdTextIndex();
+	if (kdIdx) {
+		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
+		if (textOff != -1) {
+			const char *raw = _mystery.textAt((uint16)textOff);
+			Common::String hint =
+				parseString(raw ? raw : "", _playerName, _partner);
+			if (!hint.empty()) {
+				// Mini ClueBlock: 1 entry, partner-0 fields. Compose
+				// just the balloon at (0x21, 0x10) (default position
+				// from `_DoAccuseGallery`).
+				Graphics::ManagedSurface ms(320, 200,
+					Graphics::PixelFormat::createFormatCLUT8());
+				ms.clear();
+				if (haveAccuseBg) {
+					const int bw = MIN<int>(accuseBg.surface.w, 320);
+					const int bh = MIN<int>(accuseBg.surface.h, 200);
+					for (int row = 0; row < bh; row++) {
+						memcpy((byte *)ms.getBasePtr(0, row),
+							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+					}
+				}
+				if (_font.isLoaded()) {
+					_font.drawWordWrapped(&ms, 0x52, 0x14, 218, hint, 0x5C);
+				}
+				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
+					0, 0, 320, 200);
+				g_system->updateScreen();
+				waitForInput(8000);
+			}
+		}
+	}
+
 	drawGallery();
 
 	int picked = -1;
@@ -2676,10 +2860,13 @@ void EEMEngine::doAccuse() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				const int slot = ev.mouse.x / slotStep;
-				if (slot >= 0 && slot < (int)num &&
-					ev.mouse.y >= slotY && ev.mouse.y < slotY + 120)
-					picked = slot;
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotSuspect[i] < 0) continue;
+					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
+						picked = (int)i;
+						break;
+					}
+				}
 			}
 		}
 		g_system->updateScreen();
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index b0ea78677e6..96da8666f1b 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -101,6 +101,18 @@ public:
 	/// host hint lines.
 	const byte *kdTextIndex() const;
 
+	/// Pointer to the HintBlock; per-clue hint TextBlock offsets indexed
+	/// by `_aChain[i]` (the Nth required clue). Mirrors the
+	/// `_HintBlock` global read in `_KDHelp @ 1560:010a`.
+	const byte *hintBlock() const;
+
+	/// Read entry @p i from `_aChain` (the required-clue chain). Returns
+	/// 0xFFFF when no entry exists. Used by `_KDHelp` to walk unfound
+	/// clues for hints.
+	uint16 aChain(uint i) const {
+		return i < kChainLen ? _aChain[i] : 0xFFFF;
+	}
+
 	/// Pointer to the MapData entry for site @p siteNum (14 bytes per
 	/// entry; first u16 = sitepic, +4..7 = (x, y) on the big map).
 	const byte *mapEntry(uint siteNum) const;


Commit: 7be086ee1b8206cebbfe0ec0a9a065431c0c100b
    https://github.com/scummvm/scummvm/commit/7be086ee1b8206cebbfe0ec0a9a065431c0c100b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:38+02:00

Commit Message:
EEM: polish PDA features: help interface added

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


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 554c588ab32..618c43862a3 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1778,9 +1778,9 @@ void EEMEngine::doNotebook() {
 					continue;
 				}
 				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
-					// rect 1 → `_InterfaceHelp(0)`. We use doHelp() to
-					// surface the same kind of hint pane.
-					doHelp();
+					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
+					// blits PICs 0x63 / 0x1ae fullscreen for click-through.
+					doInterfaceHelp(0);
 					dirty = true;
 					continue;
 				}
@@ -2055,7 +2055,10 @@ void EEMEngine::doGallery() {
 					exitFlag = true; break;
 				}
 				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {
-					doHelp();
+					// Gallery rect 1 → `_InterfaceHelp(0)` per jmp table at
+					// 158f:0625 (HandleGalleryButton). Same picture sequence
+					// as the notebook HELP button.
+					doInterfaceHelp(0);
 					lastDraw = 0;
 					continue;
 				}
@@ -2648,6 +2651,91 @@ void EEMEngine::doHelp() {
 	waitForInput(60000);
 }
 
+void EEMEngine::doInterfaceHelp(uint num) {
+	// Mirrors `_InterfaceHelp(num)` @ 1560:0205. The original walks
+	// `HelpData @ 29be:00c8` (5-byte entries: u8 count, then up to 2
+	// u16 picIds), `_GetPicture`s each one, blits it fullscreen via
+	// `_Rect_Move_Mask(0, 0, ...)`, and waits for click / key. ESC ends
+	// the cycle; any other input advances to the next pic.
+	//
+	// Verified from Ghidra HelpData bytes:
+	//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
+	//   entry 1: count=2, picIds = 0x0192, 0x01b1
+	// Only entry 0 is reachable from the PDA notebook (rect 1) and the
+	// gallery (rect 1) — both call `_InterfaceHelp(0)`.
+	static const uint16 kHelpPics[][2] = {
+		{ 0x0063, 0x01ae },
+		{ 0x0192, 0x01b1 },
+	};
+	if (num >= ARRAYSIZE(kHelpPics))
+		return;
+
+	debugC(1, kDebugScript, "doInterfaceHelp(%u): showing pics 0x%x, 0x%x",
+		   num, kHelpPics[num][0], kHelpPics[num][1]);
+
+	for (uint i = 0; i < 2; i++) {
+		const uint16 picId = kHelpPics[num][i];
+		Picture pic;
+		if (!_picsArchive.getPicture(picId, pic)) {
+			warning("doInterfaceHelp: getPicture(0x%x) failed", picId);
+			continue;
+		}
+		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
+			   picId, pic.surface.w, pic.surface.h, pic.flags);
+
+		// Compose a 320x200 frame (cleared) and blit the help pic at (0,0)
+		// with the original's masked-blit semantics: pixels equal to the
+		// pic's sub-mode (high byte of `pic[0]`, see `_Rect_Move_Mask`
+		// param_10 at 1000:03fc) are treated as transparent and skipped.
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		const byte transp = (byte)(pic.flags >> 8);
+		const int w = MIN<int>(pic.surface.w, 320);
+		const int h = MIN<int>(pic.surface.h, 200);
+		for (int row = 0; row < h; row++) {
+			const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, row);
+			for (int col = 0; col < w; col++) {
+				if (src[col] != transp)
+					dst[col] = src[col];
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		bool escape = false;
+		while (!shouldQuit() && !escape) {
+			Common::Event ev;
+			bool advance = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					return;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					advance = true;
+					break;
+				}
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+						escape = true;
+					else
+						advance = true;
+					break;
+				}
+			}
+			if (advance || escape)
+				break;
+			g_system->updateScreen();
+			g_system->delayMillis(15);
+		}
+		if (escape)
+			break;
+	}
+}
+
 bool EEMEngine::areYouSure() {
 	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
 	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
@@ -2805,8 +2893,24 @@ void EEMEngine::doAccuse() {
 		g_system->updateScreen();
 	};
 
-	// Step 1 — KD hint balloon. `KDTextIndex[+8]` = third hint slot
-	// (offset 8 from the index array).
+	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
+	// (1df2:0a4c-1df2:0afe):
+	//   text       = TextBlock + KDTextIndex[+8]
+	//   bub        = _GetKDTextBalloon(text)              — 1df2:0105
+	//   GetBalloon(bub)                                   → pic in [BP-6:-8]
+	//   if (pic.h < 0x4e)  y = (0x50 - pic.h) >> 1   else y = 1   (1df2:0a8b)
+	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
+	//   WordWrap(0x21 + tbl[bub].x, y + tbl[bub].y, tbl[bub].w, text, color=0, -1)
+	//   tbl base = 29be:0875, 10-byte entries: x at +0, y at +2, w at +4
+	//
+	// `_GetKDTextBalloon` (1df2:0105): when (ctype[firstChar] & 2) == 0 →
+	//   bub = *(u16*)29be:1068 = 0x0017                   (verified)
+	// else                                                 → table lookup
+	//   bub = *(u16*)(29be:0fe6 + 0x1e + firstChar*2)
+	// Bit 1 in Borland's ctype = punctuation, so non-punctuation (most
+	// letters / control bytes / digits) takes the constant-balloon path.
+	// We use 0x17 as the default; the table-lookup path covers a small
+	// minority of texts (those starting with `,` `.` `:` etc.).
 	const byte *kdIdx = _mystery.kdTextIndex();
 	if (kdIdx) {
 		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
@@ -2815,9 +2919,19 @@ void EEMEngine::doAccuse() {
 			Common::String hint =
 				parseString(raw ? raw : "", _playerName, _partner);
 			if (!hint.empty()) {
-				// Mini ClueBlock: 1 entry, partner-0 fields. Compose
-				// just the balloon at (0x21, 0x10) (default position
-				// from `_DoAccuseGallery`).
+				// 29be:1068 = 0x0017, the non-punctuation default.
+				const uint16 bubNum = 0x17;
+				Picture balloon;
+				const bool haveBalloon =
+					_balloonArchive.size() > (bubNum & 0x7F) &&
+					_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+
+				// 1df2:0a8b-1df2:0aa5: y = (h < 0x4e) ? (0x50-h)>>1 : 1
+				const int balloonX = 0x21;
+				int balloonY = 1;
+				if (haveBalloon && balloon.surface.h < 0x4e)
+					balloonY = (0x50 - balloon.surface.h) / 2;
+
 				Graphics::ManagedSurface ms(320, 200,
 					Graphics::PixelFormat::createFormatCLUT8());
 				ms.clear();
@@ -2829,8 +2943,29 @@ void EEMEngine::doAccuse() {
 							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
 					}
 				}
-				if (_font.isLoaded()) {
-					_font.drawWordWrapped(&ms, 0x52, 0x14, 218, hint, 0x5C);
+				// `_Rect_Move_Mask` (1000:03fc) — pixels == pic[0]>>8 are
+				// transparent (verified at displayClue site).
+				if (haveBalloon) {
+					const byte transp = (byte)(balloon.flags >> 8);
+					const int bw = MIN<int>(balloon.surface.w, 320 - balloonX);
+					const int bh = MIN<int>(balloon.surface.h, 200 - balloonY);
+					for (int row = 0; row < bh; row++) {
+						const byte *src = (const byte *)balloon.surface.getBasePtr(0, row);
+						byte *dst = (byte *)ms.getBasePtr(balloonX, balloonY + row);
+						for (int col = 0; col < bw; col++) {
+							if (src[col] != transp)
+								dst[col] = src[col];
+						}
+					}
+				}
+				// 29be:0875 entry 23 (= 0x17): bytes `05 00 04 00 9b 00 96 00 23 00`
+				// → x=5, y=4, w=155 (verified). 1df2:0acb pushes color=0.
+				if (haveBalloon && _font.isLoaded()) {
+					_font.drawWordWrapped(&ms, balloonX + 5, balloonY + 4, 155,
+										  hint, 0);
+				} else if (_font.isLoaded()) {
+					_font.drawWordWrapped(&ms, balloonX + 5, balloonY + 4, 155,
+										  hint, 0xF);
 				}
 				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
 					0, 0, 320, 200);
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 85c25b26c22..2e69fd2347c 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -104,6 +104,11 @@ public:
 	/// the original engine tracks via `_SawHelpHint`.
 	void doHelp();
 
+	/// Display the interface-help picture sequence. Mirrors `_InterfaceHelp`
+	/// @ 1560:0205 — walks `HelpData @ 29be:00c8`, blits each pic fullscreen,
+	/// and waits for click / key (ESC ends the cycle).
+	void doInterfaceHelp(uint num = 0);
+
 	/// "Are you sure?" yes/no dialog. Mirrors `_AreYouSure` @ 1a35:0a5c.
 	/// Returns true if the user picked YES.
 	bool areYouSure();


Commit: d075832f54e402f46ab2e01b0905cfe9f0777914
    https://github.com/scummvm/scummvm/commit/d075832f54e402f46ab2e01b0905cfe9f0777914
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:39+02:00

Commit Message:
EEM: polish PDA features: acusations

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


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 618c43862a3..2a4778f0d76 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -2736,6 +2736,60 @@ void EEMEngine::doInterfaceHelp(uint num) {
 	}
 }
 
+uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
+	// Mirrors `_GetKDTextBalloon @ 1df2:0105`:
+	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
+	//   else                              bub = *(u16*)(29be:0fe6+0x1e+c*2)
+	// `ctype` is Borland's `_ctype_` array at `29be:2be1`. Bit 1 (0x02) is
+	// set only for digits '0'..'9' (verified by reading the table — '0'..'9'
+	// each map to byte 0x02; everything else has bit 1 clear).
+	// Lookup table at 29be:1064 (= 29be:0fe6 + 0x1e + '0'*2):
+	//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
+	//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
+	// Note `*(u16*)29be:1068` (= entry for '2') is the same byte the
+	// non-digit fallback returns — the original encodes the constant by
+	// reusing the digit-2 slot.
+	if (firstChar < '0' || firstChar > '9')
+		return 0x17;
+	static const uint16 kDigitBalloons[10] = {
+		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
+	};
+	return kDigitBalloons[firstChar - '0'];
+}
+
+bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
+								  uint16 &yInset, uint16 &textW) const {
+	// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
+	// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
+	// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read here:
+	//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
+	// (+6/+8 = balloon h / tail offset, both unused for text layout).
+	static const struct { uint16 x, y, w; } kTable[] = {
+		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+		{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+	};
+	const uint idx = bubNum & 0x7F;
+	if (idx >= ARRAYSIZE(kTable))
+		return false;
+	xInset = kTable[idx].x;
+	yInset = kTable[idx].y;
+	textW  = kTable[idx].w;
+	return true;
+}
+
 bool EEMEngine::areYouSure() {
 	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
 	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
@@ -2838,6 +2892,7 @@ void EEMEngine::doAccuse() {
 	for (uint i = 0; i < num; i++)
 		slotSuspect[i] = -1;
 
+	int highlighted = 0;
 	auto drawGallery = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -2857,6 +2912,11 @@ void EEMEngine::doAccuse() {
 			if (!gd) continue;
 			const uint8 phys = _mystery._newOrder[i];
 			if (phys >= 5) continue;
+			// `_DrawGallery @ 158f:00b9` skips suspects whose
+			// `_InGallery[phys]` flag is 0 — that's the original gate
+			// (some suspects only become visible after being met or
+			// stay hidden after a wrong accusation removes them).
+			if (_mystery._inGallery[phys] == 0) continue;
 			const Slot &s = kGallerySlots[phys];
 
 			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
@@ -2888,6 +2948,19 @@ void EEMEngine::doAccuse() {
 			slotSuspect[i] = (int)i;
 		}
 
+		// Highlight indicator. The original moves the mouse cursor
+		// to the centre of the highlighted suspect via `_PutMouseInRect`
+		// (1df2:0b8e) — we draw a 1px outline in palette index 0xFE
+		// (within the marching-ants cycle range 0xF9..0xFE) which is
+		// unambiguously visible under any palette without warping the
+		// player's cursor.
+		if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
+			!slotRects[highlighted].isEmpty()) {
+			Common::Rect r = slotRects[highlighted];
+			r.grow(1);
+			scratch.frameRect(r, 0xFE);
+		}
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -2895,22 +2968,13 @@ void EEMEngine::doAccuse() {
 
 	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
 	// (1df2:0a4c-1df2:0afe):
-	//   text       = TextBlock + KDTextIndex[+8]
-	//   bub        = _GetKDTextBalloon(text)              — 1df2:0105
-	//   GetBalloon(bub)                                   → pic in [BP-6:-8]
-	//   if (pic.h < 0x4e)  y = (0x50 - pic.h) >> 1   else y = 1   (1df2:0a8b)
+	//   text  = TextBlock + KDTextIndex[+8]               (1df2:0a4c-0a57)
+	//   bub   = _GetKDTextBalloon(text[0])                (1df2:0a6d)
+	//   GetBalloon(bub)                                   (1df2:0a7c)
+	//   y     = (h < 0x4e) ? (0x50 - h) >> 1 : 1          (1df2:0a8b-0aa5)
 	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
-	//   WordWrap(0x21 + tbl[bub].x, y + tbl[bub].y, tbl[bub].w, text, color=0, -1)
-	//   tbl base = 29be:0875, 10-byte entries: x at +0, y at +2, w at +4
-	//
-	// `_GetKDTextBalloon` (1df2:0105): when (ctype[firstChar] & 2) == 0 →
-	//   bub = *(u16*)29be:1068 = 0x0017                   (verified)
-	// else                                                 → table lookup
-	//   bub = *(u16*)(29be:0fe6 + 0x1e + firstChar*2)
-	// Bit 1 in Borland's ctype = punctuation, so non-punctuation (most
-	// letters / control bytes / digits) takes the constant-balloon path.
-	// We use 0x17 as the default; the table-lookup path covers a small
-	// minority of texts (those starting with `,` `.` `:` etc.).
+	//   WordWrap(0x21+tbl[bub].x, y+tbl[bub].y, tbl[bub].w, text, color=0)
+	//     tbl @ 29be:0875, 10-byte entries (1df2:0ad6-0af1)
 	const byte *kdIdx = _mystery.kdTextIndex();
 	if (kdIdx) {
 		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
@@ -2919,8 +2983,14 @@ void EEMEngine::doAccuse() {
 			Common::String hint =
 				parseString(raw ? raw : "", _playerName, _partner);
 			if (!hint.empty()) {
-				// 29be:1068 = 0x0017, the non-punctuation default.
-				const uint16 bubNum = 0x17;
+				// First-char dispatch via getKDTextBalloon (1df2:0105).
+				// Note: we pass the *parsed* first char; the original
+				// reads it BEFORE `_ParseString`, but the player-name /
+				// partner-name substitutions never start with digits, so
+				// the dispatch result is the same either way.
+				const byte firstChar =
+					hint.empty() ? (byte)0 : (byte)hint[0];
+				const uint16 bubNum = getKDTextBalloon(firstChar);
 				Picture balloon;
 				const bool haveBalloon =
 					_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -2943,8 +3013,8 @@ void EEMEngine::doAccuse() {
 							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
 					}
 				}
-				// `_Rect_Move_Mask` (1000:03fc) — pixels == pic[0]>>8 are
-				// transparent (verified at displayClue site).
+				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)
+				// skips pixels equal to `pic[0] >> 8`.
 				if (haveBalloon) {
 					const byte transp = (byte)(balloon.flags >> 8);
 					const int bw = MIN<int>(balloon.surface.w, 320 - balloonX);
@@ -2958,14 +3028,13 @@ void EEMEngine::doAccuse() {
 						}
 					}
 				}
-				// 29be:0875 entry 23 (= 0x17): bytes `05 00 04 00 9b 00 96 00 23 00`
-				// → x=5, y=4, w=155 (verified). 1df2:0acb pushes color=0.
-				if (haveBalloon && _font.isLoaded()) {
-					_font.drawWordWrapped(&ms, balloonX + 5, balloonY + 4, 155,
-										  hint, 0);
-				} else if (_font.isLoaded()) {
-					_font.drawWordWrapped(&ms, balloonX + 5, balloonY + 4, 155,
-										  hint, 0xF);
+				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
+				uint16 tx = 5, ty = 4, tw = 155;
+				getBalloonInsets(bubNum, tx, ty, tw);
+				if (_font.isLoaded()) {
+					_font.drawWordWrapped(&ms, balloonX + tx,
+										  balloonY + ty, tw, hint,
+										  haveBalloon ? 0 : 0xF);
 				}
 				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
 					0, 0, 320, 200);
@@ -2975,9 +3044,42 @@ void EEMEngine::doAccuse() {
 		}
 	}
 
+	// Helper to find the next "alive" slot (one whose `_inGallery[phys]`
+	// flag is still set so a portrait was actually drawn). Mirrors the
+	// way the original wraps DI past empty slots.
+	auto nextLiveSlot = [&](int from, int dir) -> int {
+		const int n = (int)slotRects.size();
+		if (n <= 0) return 0;
+		for (int step = 1; step <= n; step++) {
+			int idx = (from + dir * step) % n;
+			if (idx < 0) idx += n;
+			if (!slotRects[idx].isEmpty())
+				return idx;
+		}
+		return from;
+	};
+	if (slotRects[highlighted].isEmpty())
+		highlighted = nextLiveSlot(highlighted, +1);
+
 	drawGallery();
 
+	// Wait-for-pick loop. Mirrors `_DoAccuseGallery` 1df2:0b26-1df2:0bc8:
+	//   * `_CheckFrameRate` + `_UpdateAnimations` per tick (1df2:0b2a-0b33)
+	//   * 5-entry input dispatch table @ 1df2:0bc9:
+	//       0x09 (TAB)   → handler 0x0b94 (cycle highlight)
+	//       0x0d (Enter) → handler 0x0b72 (pick = _SearchSuspects)
+	//       0x4b (LEFT)  → handler 0x0b94
+	//       0x4d (RIGHT) → handler 0x0b94
+	//       0xFFFF (mb)  → handler 0x0b72
+	//   * 0x0b94: `INC DI` + wraparound + `_PutMouseInRect(&Guys[DI])`,
+	//     i.e. advance highlight and warp cursor (1df2:0b94-0bb1).
+	//   * 0x0b72: `_SearchSuspects` (158f:0584) — mouse-rect hit-test;
+	//     if non-0xFFFF, pick that suspect.
+	// We don't warp the cursor (unfriendly under SDL); instead the
+	// highlight is drawn as a 1px outline and Enter picks it.
 	int picked = -1;
+	uint32 lastTick = g_system->getMillis();
+	bool dirty = false;
 	while (picked < 0 && !shouldQuit()) {
 		Common::Event ev;
 		while (g_system->getEventManager()->pollEvent(ev)) {
@@ -2985,13 +3087,39 @@ void EEMEngine::doAccuse() {
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
 			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+				switch (ev.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
 					return;
-				const int k = (int)ev.kbd.keycode;
-				if (k >= Common::KEYCODE_1 && k <= Common::KEYCODE_9) {
-					const int idx = k - Common::KEYCODE_1;
-					if (idx < num)
-						picked = idx;
+				case Common::KEYCODE_TAB:
+				case Common::KEYCODE_RIGHT:
+					highlighted = nextLiveSlot(highlighted, +1);
+					dirty = true;
+					break;
+				case Common::KEYCODE_LEFT:
+					// 1df2:0b94 increments DI for LEFT too — but a
+					// keyboard-driven UX is friendlier with separate
+					// directions, so we mirror Right=+1 / Left=-1.
+					highlighted = nextLiveSlot(highlighted, -1);
+					dirty = true;
+					break;
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER:
+					if (highlighted >= 0 &&
+						highlighted < (int)slotRects.size() &&
+						!slotRects[highlighted].isEmpty()) {
+						picked = highlighted;
+					}
+					break;
+				default: {
+					const int k = (int)ev.kbd.keycode;
+					if (k >= Common::KEYCODE_1 && k <= Common::KEYCODE_9) {
+						const int idx = k - Common::KEYCODE_1;
+						if (idx < num &&
+							!slotRects[idx].isEmpty())
+							picked = idx;
+					}
+					break;
+				}
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -3004,6 +3132,16 @@ void EEMEngine::doAccuse() {
 				}
 			}
 		}
+		// 100 ms tick — the original calls `_UpdateAnimations` per
+		// `_CheckFrameRate` (1df2:0b33). The accuse screen has no
+		// animations registered, so the tick is just a redraw cadence.
+		// We still re-render whenever the highlight moves (`dirty`).
+		const uint32 now = g_system->getMillis();
+		if (dirty || now - lastTick >= 100) {
+			drawGallery();
+			lastTick = now;
+			dirty = false;
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 2e69fd2347c..2bc741352ff 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -109,6 +109,20 @@ public:
 	/// and waits for click / key (ESC ends the cycle).
 	void doInterfaceHelp(uint num = 0);
 
+	/// First-char-dispatch balloon picker. Mirrors `_GetKDTextBalloon @
+	/// 1df2:0105`. For digit first chars (0..9) returns balloon from the
+	/// table at `29be:1064`; for any other char returns the constant
+	/// `*(u16*)29be:1068 = 0x17`. The original branch is on Borland's
+	/// ctype-bit-1 (= digit) at `29be:2be1 + char`.
+	uint16 getKDTextBalloon(byte firstChar) const;
+
+	/// Look up balloon-text-inset metadata. Mirrors the 52-entry table at
+	/// `29be:0875`, indexed by `(bubNum & 0x7F)`. 10 bytes per entry; only
+	/// the first 3 fields (x inset, y inset, text width) are used for
+	/// rendering. Returns false if `bubNum` is outside the table.
+	bool getBalloonInsets(uint16 bubNum, uint16 &xInset, uint16 &yInset,
+						  uint16 &textW) const;
+
 	/// "Are you sure?" yes/no dialog. Mirrors `_AreYouSure` @ 1a35:0a5c.
 	/// Returns true if the user picked YES.
 	bool areYouSure();


Commit: 2ae70bdb92a852327c9ecca085393194145902f0
    https://github.com/scummvm/scummvm/commit/2ae70bdb92a852327c9ecca085393194145902f0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:39+02:00

Commit Message:
EEM: basic parners animations

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 2a4778f0d76..cedef8690b7 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1307,9 +1307,20 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		//   +8..9, +10..11: bubText offset for partner 0/1 (rel. TextBlock)
 		//   +12..13, +14..15: balloon picture ID for partner 0/1
 		//   +16..17, +18..19: bubX, bubY
+		//   +0x3a..+0x3b:    KD-anim number (-1 = none)
 		// Per `_DisplayClue` @ 2404:05e6: partner 1 uses its own field
 		// set ONLY when bubText1 is not -1; otherwise it falls back to
 		// the partner 0 fields entirely. Partner 0 always uses field 0.
+
+		// Per-clue partner reaction animation. `_DisplayClue` @
+		// 2404:0635-064b checks `clueEntry[+0x3a]` and, when not -1,
+		// calls `_DoKDAnim(num)` BEFORE drawing the speaker portrait.
+		// This is what surfaces "Jenny takes a picture with a camera"
+		// (and the matching Jake gestures) during NPC searches.
+		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
+		if (kdAnimNum != -1)
+			playKdAnim((uint16)kdAnimNum);
+
 		const bool useP1 = (_partner == 1) &&
 			(READ_LE_UINT16(c + 10) != 0xFFFF);
 		const uint partner = useP1 ? 1 : 0;
@@ -2757,6 +2768,190 @@ uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
 	return kDigitBalloons[firstChar - '0'];
 }
 
+void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
+	if (bg && bg->w == 320 && bg->h == 200) {
+		_partnerEraseBg.create(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)_partnerEraseBg.getBasePtr(0, row),
+				   (const byte *)bg->getBasePtr(0, row), 320);
+		}
+	} else {
+		_partnerEraseBg.free();
+	}
+}
+
+void EEMEngine::playKdAnim(uint16 num) {
+	// Mirrors `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
+	//   _SuspendAnimation(WaitHandle);
+	//   anim   = WaitAnims[1+num].anim[partner]   (table @ 29be:0228)
+	//   x      = WaitAnims[1+num].x[partner]
+	//   y      = WaitAnims[1+num].y[partner]
+	//   _PlayAnimation(anim, x, y, WaitHandle)
+	//     → registers a state-4 (one-shot) animation slot and lets
+	//       `_UpdateAnimations` walk the sequence script until 0x80,
+	//       then frees this slot and re-activates `WaitHandle`.
+	// Our port renders the partner's idle inline in each redraw rather
+	// than via a slot system, so we play the one-shot synchronously here
+	// (blocking) and resume normal idle rendering when the caller
+	// returns. That matches the user-visible effect: the partner's
+	// gesture (Jenny taking a picture, etc.) finishes before the
+	// speaker portrait + speech balloon appear.
+	//
+	// Six valid kdAnimNum entries (0..5). Verified bytes from
+	// `29be:0228`. Layout per entry: { animJake, animJenny, xJake,
+	// xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
+	static const uint16 kKdAnimTable[6][6] = {
+		{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
+		{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
+		{ 0x04, 0x0d, 6, 6, 80, 80 }, // 2
+		{ 0x02, 0x10, 6, 6, 80, 80 }, // 3 — same as gallery
+		{ 0x05, 0x05, 6, 6, 80, 80 }, // 4 — same anim both partners
+		{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
+	};
+	if (num >= ARRAYSIZE(kKdAnimTable))
+		return;
+
+	const uint partner = (_partner == 0) ? 0 : 1;
+	const uint16 animId = kKdAnimTable[num][partner];
+	const int    px     = (int)kKdAnimTable[num][2 + partner];
+	const int    py     = (int)kKdAnimTable[num][4 + partner];
+
+	Animation anim;
+	if (!_aniArchive.loadAnimation(animId, anim) || anim.empty()) {
+		warning("playKdAnim(%u): anim %u failed to load", num, animId);
+		return;
+	}
+
+	// Sequence-script lookup. Entries copied verbatim from
+	// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
+	// Each script is a u16[] of frame indices terminated by 0x80; we
+	// don't yet handle 0x81 jumps (none of the kdAnim sequences use
+	// them — verified). seqnum == animId for these calls (per
+	// `_PlayAnimation` 172b:1f5d push order).
+	struct Script {
+		uint16 seqnum;
+		uint8 len;
+		uint8 frames[20];  // long enough for any kdAnim script
+	};
+	static const Script kScripts[] = {
+		// seqnum 1 (29be:188a) — head bob
+		{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+		// seqnum 2 (29be:18aa) — short blip then long pause
+		{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
+		// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
+		{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
+		// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
+		{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+		// seqnum 5 (29be:1910) — held idle with a single peak
+		{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
+		// seqnum 6 (29be:192c) — empty (immediate END)
+		{ 0x06,  0, { 0 } },
+		// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
+		{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+		// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
+		{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
+		// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
+		{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+		// seqnum 0x10 (29be:1956) — Jenny short anim
+		{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
+	};
+	const uint8 *frames = nullptr;
+	uint frameCount = 0;
+	for (uint i = 0; i < ARRAYSIZE(kScripts); i++) {
+		if (kScripts[i].seqnum == animId) {
+			frames = kScripts[i].frames;
+			frameCount = kScripts[i].len;
+			break;
+		}
+	}
+	if (frameCount == 0) {
+		// Fallback: linear playback through anim cells (better than
+		// nothing if a future kdAnim references an unscripted anim).
+		frameCount = (uint)anim.size();
+	}
+
+	// Erase-source for between-frame redraw. Prefer the partner-less
+	// backdrop the caller stashed via `setPartnerEraseBg` (e.g. the
+	// site's `_bgSnapshot`, which has the static drops + frame but no
+	// partner sprite). Without that, fall back to whatever's currently
+	// on screen — which works for full-screen contexts (PDA / accuse /
+	// briefing) where there is no separate idle partner overlay to
+	// erase, but produces visible "ghosting" against the site's idle
+	// partner cell at (6, 80) because it has the resting pose baked in.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)bg.getBasePtr(0, row),
+				   (const byte *)_partnerEraseBg.getBasePtr(0, row), 320);
+		}
+	} else {
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (!screen)
+			return;
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)bg.getBasePtr(0, row),
+				   (const byte *)screen->getBasePtr(0, row), 320);
+		}
+		g_system->unlockScreen();
+	}
+
+	for (uint i = 0; i < frameCount && !shouldQuit(); i++) {
+		const uint frameIdx = frames ? (uint)frames[i] : i;
+		if (frameIdx >= anim.size())
+			continue;
+		const Picture &fr = anim[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+
+		// Restore BG, then masked-blit the next frame.
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)bg.getBasePtr(0, row), 320);
+		}
+		const int w = MIN<int>(fr.surface.w, 320 - px);
+		const int h = MIN<int>(fr.surface.h, 200 - py);
+		for (int row = 0; row < h; row++) {
+			const int dstY = py + row;
+			if (dstY < 0) continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < w; col++) {
+				const int dstX = px + col;
+				if (dstX < 0) continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// One frame per `_CheckFrameRate` tick. The original calibrates
+		// this to ~10 fps; 100 ms matches what the rest of the engine
+		// uses for partner / NPC frame cycling.
+		const uint32 wakeup = g_system->getMillis() + 100;
+		while (g_system->getMillis() < wakeup && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				// Drain events but don't allow skipping mid-animation —
+				// the speaker portrait + balloon haven't been drawn yet
+				// and a click would otherwise eat the upcoming clue.
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+					return;
+			}
+			g_system->delayMillis(10);
+		}
+	}
+
+	// Restore BG so the next caller (speaker portrait blit) starts clean.
+	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
 								  uint16 &yInset, uint16 &textW) const {
 	// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 2bc741352ff..7b5c26a4be7 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -116,6 +116,26 @@ public:
 	/// ctype-bit-1 (= digit) at `29be:2be1 + char`.
 	uint16 getKDTextBalloon(byte firstChar) const;
 
+	/// Play the partner's one-shot reaction animation slot @num. Mirrors
+	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`. The
+	/// per-partner (animId, x, y) come from `_WaitAnims[1+num] @ 29be:0228`,
+	/// and the per-frame timing follows the sequence script that the
+	/// original would index at `_AnimationSequences[seqnum=animId]`. Used
+	/// by `displayClue` when a ClueEntry's KD-anim field (+0x3a) is set —
+	/// e.g. Jenny's "take a picture" gesture when the player searches an
+	/// NPC. Blocks until the script's first 0x80 marker.
+	void playKdAnim(uint16 num);
+
+	/// Provide a "clean" 320x200 backdrop for the next `playKdAnim` (and
+	/// any future blocking partner-anim playback) to use as the
+	/// background-erase source. Without this, the camera animation would
+	/// composite on top of the static partner sprite from the screen and
+	/// the previous resting frame would bleed through transparent pixels.
+	/// `SiteScreen` calls this with its `_bgSnapshot` (site BG + static
+	/// drops, no NPCs / partner) before invoking `displayClue` from a
+	/// hotspot click. Pass `nullptr` to clear.
+	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
+
 	/// Look up balloon-text-inset metadata. Mirrors the 52-entry table at
 	/// `29be:0875`, indexed by `(bubNum & 0x7F)`. 10 bytes per entry; only
 	/// the first 3 fields (x inset, y inset, text width) are used for
@@ -232,6 +252,13 @@ private:
 	/// inline; we cache the layout to keep click hit-testing simple.
 	Common::Array<Common::Rect> _notebookSlotRects;
 	Common::Array<uint>         _notebookSlotClues;
+
+	/// Optional clean BG (no partner / NPC sprites) used by `playKdAnim`
+	/// to erase the partner's resting frame between camera-anim cells.
+	/// Empty when no caller has provided one (PDA / case briefing /
+	/// accuse contexts use their own composed backdrops). See
+	/// `setPartnerEraseBg`.
+	Graphics::ManagedSurface _partnerEraseBg;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 964f7707eaa..6cd2dd402c8 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -114,8 +114,15 @@ void SiteScreen::enter(uint siteNum) {
 			const uint16 clueOff = READ_LE_UINT16(idx + 2);
 			if (clueOff != 0xFFFF) {
 				const byte *clueBlock = _mystery->blobAt(clueOff);
-				if (clueBlock)
+				if (clueBlock) {
+					// See onHotspotClicked — supply a partner-less BG
+					// so KD-anim playback (e.g. the partner's arrival
+					// camera gesture) doesn't ghost over the resting
+					// idle frame.
+					_vm->setPartnerEraseBg(&_bgSnapshot);
 					_vm->displayClue(clueBlock);
+					_vm->setPartnerEraseBg(nullptr);
+				}
 			}
 		}
 		if (siteNum < Mystery::kVisitedSiteCap)
@@ -893,8 +900,18 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 		debugC(2, kDebugSite, "  hotspot %u -> clue offset 0x%04x",
 			   hotIdx, clueOff);
 		const byte *clueBlock = _mystery->blobAt(clueOff);
-		if (clueBlock)
+		if (clueBlock) {
+			// Hand the engine our partner-less backdrop so that
+			// `_DoKDAnim` / `playKdAnim` (the camera-style reaction
+			// animation that fires when a ClueEntry has +0x3a != -1)
+			// can erase the partner's resting frame between cells
+			// instead of compositing over it. Cleared after the call
+			// so accuse / briefing contexts fall back to their own
+			// snapshots.
+			_vm->setPartnerEraseBg(&_bgSnapshot);
 			_vm->displayClue(clueBlock);
+			_vm->setPartnerEraseBg(nullptr);
+		}
 	}
 	// Caller (`SiteScreen::run`) re-renders the site after this returns.
 }


Commit: 30a356bc07e43935d404112d7a87a002c13ab407
    https://github.com/scummvm/scummvm/commit/30a356bc07e43935d404112d7a87a002c13ab407
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:39+02:00

Commit Message:
EEM: more parners animations, for intro and overview map

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index cedef8690b7..594fba894ef 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -551,27 +551,74 @@ void EEMEngine::doChoosePartner() {
 	}
 
 	setAnmPalette(Common::Path("TITLE.ANM"));
-	blitAt(background, 0, 0);
-	blitAt(girlAnim[0], kGirlX, kGirlY);
-	blitAt(boyAnim[0], kBoyX, kBoyY);
-	g_system->updateScreen();
+
+	// `_DoHappiness @ 172b:27b5`: the cursor's X column picks one of 4
+	// rects (29be:030f, all full-height); past rect 3 → "level 4".
+	// Each level swaps the partner's sequence script to a more / less
+	// "happy" cycle. Boy seqs at 29be:0337 (5 × 0x14 bytes), girl seqs
+	// at 29be:039b. Both cycle through 9 frames (the boy/girl anim
+	// cells contain 10 cells = pairs of "neutral, smile" at increasing
+	// intensity). Lifted verbatim from the binary so the gestures
+	// match the original beat-for-beat.
+	static const Common::Rect kHappyZones[4] = {
+		Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
+		Common::Rect( 70, 0, 126, 200), // girl's column
+		Common::Rect(126, 0, 182, 200), // middle
+		Common::Rect(182, 0, 235, 200), // boy's column
+	};
+	static const uint8 kBoySeqs[5][9] = {
+		{ 0,0,0,0,0,0,0,1,0 }, // level 0
+		{ 2,2,2,2,2,2,2,3,2 }, // level 1
+		{ 4,4,4,4,4,4,4,5,4 }, // level 2
+		{ 6,6,6,6,6,6,7,6,6 }, // level 3
+		{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
+	};
+	static const uint8 kGirlSeqs[5][9] = {
+		{ 8,9,8,8,8,8,8,8,8 },
+		{ 6,6,6,7,6,6,6,6,6 },
+		{ 4,4,5,4,4,4,4,4,4 },
+		{ 2,2,2,2,2,2,3,2,2 },
+		{ 0,0,0,0,0,1,0,0,0 },
+	};
+	auto happinessLevel = [](int x) {
+		for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
+			if (kHappyZones[i].contains(x, 100))
+				return (uint)i;
+		}
+		return 4u; // past zone 3 → max level
+	};
+
+	int curMouseX = 0xa0;  // _DoChoosePartner sets `_SetMousePos(0xa0, 0x96)` on entry
+	int curMouseY = 0x96;
+	uint level = happinessLevel(curMouseX);
+	uint seqIdx = 0;       // step within the 9-frame seq
+
+	auto draw = [&]() {
+		blitAt(background, 0, 0);
+		const uint girlFrame = kGirlSeqs[level][seqIdx % 9];
+		const uint boyFrame  = kBoySeqs [level][seqIdx % 9];
+		blitAt(girlAnim[girlFrame % girlAnim.size()], kGirlX, kGirlY);
+		blitAt(boyAnim[boyFrame  % boyAnim.size()],  kBoyX,  kBoyY);
+		g_system->updateScreen();
+	};
+	draw();
 
 	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
 		   "%u girl frames at (%d,%d)",
 		   (uint)boyAnim.size(), kBoyX, kBoyY,
 		   (uint)girlAnim.size(), kGirlX, kGirlY);
 
-	uint frame = 0;
 	uint32 lastTick = g_system->getMillis();
 	while (!shouldQuit()) {
-		// Advance frame at ~5 Hz so the animations cycle gently.
-		if (g_system->getMillis() - lastTick > 200) {
+		// Advance through the 9-frame seq at 100 ms — `_CheckFrameRate`
+		// cadence. The seq is short and loops; matches the original
+		// `_UpdateAnimations` which restarts at curIdx=0 on the 0x80
+		// marker. Mirrors `_DoHappiness`'s rewriting of `curIdx = 0xFFFF`
+		// when the cursor crosses zones (we restart `seqIdx` instead).
+		if (g_system->getMillis() - lastTick > 100) {
 			lastTick = g_system->getMillis();
-			frame++;
-			blitAt(background, 0, 0);
-			blitAt(girlAnim[frame % girlAnim.size()], kGirlX, kGirlY);
-			blitAt(boyAnim[frame % boyAnim.size()], kBoyX, kBoyY);
-			g_system->updateScreen();
+			seqIdx = (seqIdx + 1) % 9;
+			draw();
 		}
 
 		Common::Event ev;
@@ -582,6 +629,16 @@ void EEMEngine::doChoosePartner() {
 				done = true;
 				break;
 			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				curMouseX = ev.mouse.x;
+				curMouseY = ev.mouse.y;
+				const uint newLevel = happinessLevel(curMouseX);
+				if (newLevel != level) {
+					level = newLevel;
+					seqIdx = 0; // restart cycle so the gesture pops
+					draw();
+				}
+			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				_partner = (ev.mouse.x >= 160) ? 0 : 1;
 				debugC(1, kDebugGeneral, "Partner picked: %s",
@@ -667,6 +724,19 @@ void EEMEngine::doCaseSelection() {
 	Picture caseBg;
 	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
 
+	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` (1c33:0b7e-0ba1)
+	// loads anim 0x15 (Jake-paired) or 0x16 (Jenny-paired) and registers
+	// `_NewAnimation(0x112, 0x50, ..., seqnum=0x15, prior=1)` — partner-
+	// dependent because the host KD changes who's "with him" on the
+	// briefing intro frame. Runs continuously through the menu loop via
+	// `_UpdateAnimations`. We approximate with millis-based frame cycling.
+	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
+	Animation kdAnim;
+	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
+							 && !kdAnim.empty();
+	const int kKdAnimX = 0x112;
+	const int kKdAnimY = 0x50;
+
 	auto draw = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -679,6 +749,27 @@ void EEMEngine::doCaseSelection() {
 					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
 			}
 		}
+
+		// KD greeter frame — masked-blit current animation cell at
+		// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
+		if (haveKdAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % kdAnim.size());
+			const Picture &fr = kdAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kKdAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kKdAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
 		if (_font.isLoaded()) {
 			// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
 			// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
@@ -740,11 +831,19 @@ void EEMEngine::doCaseSelection() {
 	};
 
 	draw();
+	uint32 lastTick = g_system->getMillis();
 
 	bool exitChosen = false;
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool confirmed = false;
+		// Redraw every 100 ms so the KD greeter cycles. Mirrors the
+		// `_CheckFrameRate` cadence in `_CaseSelection`'s main loop.
+		const uint32 now = g_system->getMillis();
+		if (haveKdAnim && now - lastTick >= 100) {
+			lastTick = now;
+			draw();
+		}
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
@@ -2279,6 +2378,22 @@ void EEMEngine::doBigMap() {
 	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
 	// ------------------------------------------------------------------
 
+	// `_DoBigMap @ 20fe:09e7` (20fe:0a44-0a99) registers a partner sprite
+	// on the overview frame. The animation depends on `_LastScreen`:
+	//   * When LastScreen == 2 (came from the site loop) the original
+	//     plays an entrance anim (`anum-1` for Jake / Jenny) at
+	//     (0x102, 0x50), then on END swaps to the idle anim at (0xfd,
+	//     0x50). We don't track LastScreen finely enough to distinguish,
+	//     so we render the IDLE pose at (0xfd, 0x50) which is what the
+	//     player sees the rest of the time anyway.
+	//   * Idle anim ID: Jake = 0x14 (20), Jenny = 0x12 (18).
+	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
+	Animation mapAnim;
+	const bool haveMapAnim = _aniArchive.loadAnimation(kMapAniId, mapAnim)
+							   && !mapAnim.empty();
+	const int kMapAnimX = 0xfd;
+	const int kMapAnimY = 0x50;
+
 	auto drawOverview = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -2349,12 +2464,36 @@ void EEMEngine::doBigMap() {
 			}
 		}
 
+		// Partner sprite — masked-blit at (0xfd, 0x50). Same per-tick
+		// idle the original would run via `_UpdateAnimations` once the
+		// entrance one-shot transitions out (see `_DoBigMap` 0xae3-0xae7
+		// where it swaps animId on the 0x80 marker).
+		if (haveMapAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % mapAnim.size());
+			const Picture &fr = mapAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kMapAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kMapAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
 
 	drawOverview();
+	uint32 mapLastTick = g_system->getMillis();
 
 	// Static rectangles read directly from the binary at the labelled
 	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
@@ -2398,6 +2537,13 @@ void EEMEngine::doBigMap() {
 		}
 		if (wantZoom)
 			break;
+		// Cycle the partner-sprite frame every 100 ms (matching the
+		// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
+		const uint32 now = g_system->getMillis();
+		if (haveMapAnim && now - mapLastTick >= 100) {
+			mapLastTick = now;
+			drawOverview();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
@@ -2433,6 +2579,19 @@ void EEMEngine::doBigMap() {
 	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
+	// `_DoMapScreen @ 20fe:120b` (20fe:12cd-12f0): partner sprite on
+	// the detail-zoom screen. Jake = anim 0x13 (19), Jenny = anim 0x11
+	// (17). Position (0x101, 0x50) = (257, 80), seqnum 0x13. The cells
+	// here have a "looking at the map" pose, distinct from the BigMap
+	// overview entrance/idle.
+	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
+	Animation detailAnim;
+	const bool haveDetailAnim = _aniArchive.loadAnimation(kDetailAniId,
+														   detailAnim)
+								  && !detailAnim.empty();
+	const int kDetailAnimX = 0x101;
+	const int kDetailAnimY = 0x50;
+
 	auto drawDetail = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -2493,12 +2652,35 @@ void EEMEngine::doBigMap() {
 			}
 		}
 
+		// Partner sprite on the detail map. Drawn last so it sits over
+		// the frame and the BIGMAP.PIC viewport.
+		if (haveDetailAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx =
+				(uint)((now / 100) % detailAnim.size());
+			const Picture &fr = detailAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kDetailAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kDetailAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
 
 	drawDetail();
+	uint32 detailLastTick = g_system->getMillis();
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -2619,6 +2801,13 @@ void EEMEngine::doBigMap() {
 				}
 			}
 		}
+		// Cycle the partner sprite at 100 ms ticks (same cadence as
+		// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
+		const uint32 now = g_system->getMillis();
+		if (haveDetailAnim && now - detailLastTick >= 100) {
+			detailLastTick = now;
+			dirty = true;
+		}
 		if (dirty)
 			drawDetail();
 		g_system->updateScreen();
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 6cd2dd402c8..8e119ab07f3 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -653,9 +653,12 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	//   +0..1 anim Jake, +2..3 anim Jenny,
 	//   +4..5 x    Jake, +6..7 x    Jenny,
 	//   +8..9 y    Jake, +10..11 y    Jenny.
-	// Verbatim copy of the bytes Ghidra dumped at 29be:021c so we
-	// don't need to ship the original data segment.
-	static const uint16 kWaitAnims[][6] = {
+	// Seven valid entries verified against the bytes at 29be:021c.
+	// Anything past entry 6 in the binary is `_SiteButtons` rect data
+	// that follows the table in memory — NOT continuation entries —
+	// so we cap the table here and skip rendering for siteData[+8] >= 7
+	// (which would indicate corrupt mystery data anyway).
+	static const uint16 kWaitAnims[7][6] = {
 		{ 0x00, 0x0a, 0x06, 0x06, 0x50, 0x50 }, // 0
 		{ 0x03, 0x0c, 0x06, 0x06, 0x50, 0x50 }, // 1
 		{ 0x01, 0x0b, 0x06, 0x06, 0x50, 0x50 }, // 2
@@ -663,17 +666,17 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 		{ 0x02, 0x10, 0x06, 0x06, 0x50, 0x50 }, // 4
 		{ 0x05, 0x05, 0x06, 0x06, 0x50, 0x50 }, // 5
 		{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
-		{ 0x00, 0x00, 0x23, 0x6f, 0x38, 0x88 }, // 7 — special pos
-		{ 0x07, 0xb1, 0x39, 0xc8, 0x88, 0xae }, // 8 — likely junk; 0xb1 anim id is suspect
-		{ 0x9d, 0xbe, 0xa3, 0xae, 0xb8, 0xbe }  // 9 — likely junk
 	};
 
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
 	const uint16 speaker = READ_LE_UINT16(site + 8);
-	if (speaker >= ARRAYSIZE(kWaitAnims))
+	if (speaker >= ARRAYSIZE(kWaitAnims)) {
+		warning("renderPartner: site %u has speakerIdx=%u out of range",
+				siteNum, speaker);
 		return;
+	}
 
 	const uint8 partner = _vm->getPartnerIndex();
 	const uint  animId  = kWaitAnims[speaker][0 + partner];


Commit: 0f7bf385b7d57e6e4e98df854fbfb483c84640ef
    https://github.com/scummvm/scummvm/commit/0f7bf385b7d57e6e4e98df854fbfb483c84640ef
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:40+02:00

Commit Message:
EEM: refactored the code following the original symbols

Changed paths:
  A engines/eem/clues.cpp
  A engines/eem/graphics.cpp
  A engines/eem/ui.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/module.mk
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
new file mode 100644
index 00000000000..a12136c304f
--- /dev/null
+++ b/engines/eem/clues.cpp
@@ -0,0 +1,784 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/savefile.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+
+#include "graphics/cursorman.h"
+#include "graphics/managed_surface.h"
+
+#include "eem/detection.h"
+#include "eem/eem.h"
+
+// EEM — clue / briefing pipeline (SCRIPT.C clue parts + KD.C briefing parts).
+// Everything that drives a `ClueBlock` through the displayClue / portrait /
+// balloon / notebook-side-effect flow plus the case-briefing intro that
+// preambles into the same flow.
+
+namespace EEM {
+
+namespace {
+// Picture / animation IDs verified against `_DoChoosePartner @ 1a35:0756`.
+const uint kPicChooseBackground = 0x8c; ///< `_GetBackground(0x8c)`
+const uint kAniBoy  = 8;                 ///< `_GetAnimation(8)` (Jake)
+const uint kAniGirl = 9;                 ///< `_GetAnimation(9)` (Jenny)
+
+// On-screen positions verified from `_NewAnimation` calls @ 1a35:07b9 / 07d5.
+const int kBoyX  = 0xe2; // 226
+const int kBoyY  = 0x62; // 98
+const int kGirlX = 0x42; // 66
+const int kGirlY = 0x60; // 96
+} // anonymous namespace
+
+void EEMEngine::doChoosePartner() {
+	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
+	// animations on a backdrop and polls four click rectangles (two per
+	// character) for the player's choice. We approximate by splitting the
+	// screen at x=160: left half = girl (Jenny), right half = boy (Jake).
+	Picture background;
+	if (!_picsArchive.getPicture(kPicChooseBackground, background)) {
+		warning("ChoosePartner background (%u) load failed", kPicChooseBackground);
+		return;
+	}
+
+	Animation boyAnim;
+	if (!_aniArchive.loadAnimation(kAniBoy, boyAnim) || boyAnim.empty()) {
+		warning("Boy animation (%u) load failed", kAniBoy);
+		return;
+	}
+	Animation girlAnim;
+	if (!_aniArchive.loadAnimation(kAniGirl, girlAnim) || girlAnim.empty()) {
+		warning("Girl animation (%u) load failed", kAniGirl);
+		return;
+	}
+
+	setAnmPalette(Common::Path("TITLE.ANM"));
+
+	// `_DoHappiness @ 172b:27b5`: the cursor's X column picks one of 4
+	// rects (29be:030f, all full-height); past rect 3 → "level 4".
+	// Each level swaps the partner's sequence script to a more / less
+	// "happy" cycle. Boy seqs at 29be:0337 (5 × 0x14 bytes), girl seqs
+	// at 29be:039b. Both cycle through 9 frames (the boy/girl anim
+	// cells contain 10 cells = pairs of "neutral, smile" at increasing
+	// intensity). Lifted verbatim from the binary so the gestures
+	// match the original beat-for-beat.
+	static const Common::Rect kHappyZones[4] = {
+		Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
+		Common::Rect( 70, 0, 126, 200), // girl's column
+		Common::Rect(126, 0, 182, 200), // middle
+		Common::Rect(182, 0, 235, 200), // boy's column
+	};
+	static const uint8 kBoySeqs[5][9] = {
+		{ 0,0,0,0,0,0,0,1,0 }, // level 0
+		{ 2,2,2,2,2,2,2,3,2 }, // level 1
+		{ 4,4,4,4,4,4,4,5,4 }, // level 2
+		{ 6,6,6,6,6,6,7,6,6 }, // level 3
+		{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
+	};
+	static const uint8 kGirlSeqs[5][9] = {
+		{ 8,9,8,8,8,8,8,8,8 },
+		{ 6,6,6,7,6,6,6,6,6 },
+		{ 4,4,5,4,4,4,4,4,4 },
+		{ 2,2,2,2,2,2,3,2,2 },
+		{ 0,0,0,0,0,1,0,0,0 },
+	};
+	auto happinessLevel = [](int x) {
+		for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
+			if (kHappyZones[i].contains(x, 100))
+				return (uint)i;
+		}
+		return 4u; // past zone 3 → max level
+	};
+
+	// `_DoChoosePartner` opens with `_SetMousePos(0xa0, 0x96)` so the
+	// cursor lands centred between the two partners — start the
+	// happiness level from that initial X.
+	int curMouseX = 0xa0;
+	uint level = happinessLevel(curMouseX);
+	uint seqIdx = 0;       // step within the 9-frame seq
+
+	auto draw = [&]() {
+		blitAt(background, 0, 0);
+		const uint girlFrame = kGirlSeqs[level][seqIdx % 9];
+		const uint boyFrame  = kBoySeqs [level][seqIdx % 9];
+		blitAt(girlAnim[girlFrame % girlAnim.size()], kGirlX, kGirlY);
+		blitAt(boyAnim[boyFrame  % boyAnim.size()],  kBoyX,  kBoyY);
+		g_system->updateScreen();
+	};
+	draw();
+
+	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
+		   "%u girl frames at (%d,%d)",
+		   (uint)boyAnim.size(), kBoyX, kBoyY,
+		   (uint)girlAnim.size(), kGirlX, kGirlY);
+
+	uint32 lastTick = g_system->getMillis();
+	while (!shouldQuit()) {
+		// Advance through the 9-frame seq at 100 ms — `_CheckFrameRate`
+		// cadence. The seq is short and loops; matches the original
+		// `_UpdateAnimations` which restarts at curIdx=0 on the 0x80
+		// marker. Mirrors `_DoHappiness`'s rewriting of `curIdx = 0xFFFF`
+		// when the cursor crosses zones (we restart `seqIdx` instead).
+		if (g_system->getMillis() - lastTick > 100) {
+			lastTick = g_system->getMillis();
+			seqIdx = (seqIdx + 1) % 9;
+			draw();
+		}
+
+		Common::Event ev;
+		bool done = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				done = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				curMouseX = ev.mouse.x;
+				const uint newLevel = happinessLevel(curMouseX);
+				if (newLevel != level) {
+					level = newLevel;
+					seqIdx = 0; // restart cycle so the gesture pops
+					draw();
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				_partner = (ev.mouse.x >= 160) ? 0 : 1;
+				debugC(1, kDebugGeneral, "Partner picked: %s",
+					   _partner == 0 ? "Jake" : "Jennifer");
+				done = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					_partner = 1; done = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					_partner = 0; done = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_RETURN ||
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					done = true; break;
+				}
+			}
+		}
+		if (done)
+			break;
+		g_system->updateScreen();
+		g_system->delayMillis(20);
+	}
+}
+
+void EEMEngine::doInitClues() {
+	// Mirrors `_DoInitClues` @ 1a35:0411. The original does:
+	//   1. _AllBlack(); _GetBackground(0x52); _GetPalette(0x22);
+	//   2. _GetAnimation(gameAni); _NewAnimation(0xcd, 0x6c, ...)
+	//      _GetAnimation(bookAni); _NewAnimation(0,    99,   ...)
+	//      (case type 1 also: _NewAnimation(0x68, 0x8b, nancyAni))
+	//   3. _UpdateAnimations(); _FadeIn();
+	//   4. while (frame != gameNum) { _CheckFrameRate(); _UpdateAnimations(); }
+	//        — cycles through the entire game animation once. Click skips.
+	//   5. _PlayInSequence(seqId, ...) — plays a follow-up sequence based
+	//      on partner + case type.
+	//   6. _DisplayClue(InitBlock + 2, 1) — the briefing dialogue.
+	//   7. _OnSites[startSite] = 1.
+	//
+	// gameAni / bookAni / nancyAni values verified directly from Ghidra:
+	//   gameAni  = 0x17 (Jake) / 0x3b (Jenny)
+	//   bookAni  = 0x18 (Jake) / 0x3c (Jenny)
+	//   nancyAni = 0x19 (case type 1 only)
+	if (!_mystery.isLoaded())
+		return;
+
+	const byte *ib = _mystery.initBlock();
+	if (!ib)
+		return;
+
+	const uint16 startSite = READ_LE_UINT16(ib + 2);
+	if (startSite < Mystery::kVisitedSiteCap)
+		_mystery._onSites[startSite] = 1;
+	_mystery._siteNumber = startSite;
+	_mystery._lastSite = startSite;
+
+	setSitePalette(0x22);
+	Picture bg;
+	if (_picsArchive.getPicture(0x52, bg))
+		blitAt(bg, 0, 0);
+
+	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
+	const uint bookAni = _partner == 0 ? 0x18 : 0x3c;
+	Animation game, book, nancy;
+	const bool haveGame  = _aniArchive.loadAnimation(gameAni, game) && !game.empty();
+	const bool haveBook  = _aniArchive.loadAnimation(bookAni, book) && !book.empty();
+
+	const uint16 caseType = READ_LE_UINT16(ib);
+	const bool haveNancy = (caseType == 1)
+						  && _aniArchive.loadAnimation(0x19, nancy)
+						  && !nancy.empty();
+
+	auto blitMaskedAt = [&](const Picture &p, int x, int y) {
+		const byte transp = (byte)(p.flags >> 8);
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (!screen) return;
+		for (int row = 0; row < p.surface.h; row++) {
+			const int dstY = y + row;
+			if (dstY < 0 || dstY >= screen->h) continue;
+			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+			byte *dst = (byte *)screen->getBasePtr(0, dstY);
+			for (int col = 0; col < p.surface.w; col++) {
+				const int dstX = x + col;
+				if (dstX < 0 || dstX >= screen->w) continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+		g_system->unlockScreen();
+	};
+
+	// Step 4 — cycle through the game animation once before the briefing.
+	// Mirrors the `while (uVar9 != gameNum)` loop. The original calls
+	// `_UpdateAnimations` per `_CheckFrameRate` tick (~10 fps). We use
+	// 100 ms ticks for the same cadence. Click / key skips.
+	if (haveGame || haveBook || haveNancy) {
+		const uint frameCount = haveGame ? game.size() : 8;
+		bool skip = false;
+		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
+			// Restore BG + advance frame.
+			if (_picsArchive.getPicture(0x52, bg))
+				blitAt(bg, 0, 0);
+			if (haveGame)
+				blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+			if (haveBook)
+				blitMaskedAt(book[frame % book.size()], 0, 99);
+			if (haveNancy)
+				blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+			g_system->updateScreen();
+
+			// Wait 100 ms or until input.
+			const uint32 wakeup = g_system->getMillis() + 100;
+			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_LBUTTONDOWN ||
+						ev.type == Common::EVENT_KEYDOWN) {
+						skip = true;
+						break;
+					}
+				}
+				g_system->delayMillis(10);
+			}
+		}
+	}
+
+	// Composite the final frames (or first frames if skipped) so the BG
+	// is in a sensible state when displayClue overlays the speaker.
+	if (_picsArchive.getPicture(0x52, bg))
+		blitAt(bg, 0, 0);
+	if (haveGame)
+		blitMaskedAt(game[0], 0xcd, 0x6c);
+	if (haveBook)
+		blitMaskedAt(book[0], 0, 99);
+	if (haveNancy)
+		blitMaskedAt(nancy[0], 0x68, 0x8b);
+	g_system->updateScreen();
+
+	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
+	//   Jake (partner=0):
+	//     caseType=1 → anim 0x38 at (0xcd, 0x6d)
+	//     caseType=2 → anim 0x37 at (0xcd, 0x6c)
+	//     caseType=3 → anim 0x39 at (0xcd, 0x6c)
+	//   Jenny (partner=1):
+	//     caseType=2 → anim 0x3a at (0xcd, 0x6c)
+	//     caseType=3 → anim 0x3d at (0xcd, 0x6c)
+	// `_PlayInSequence @ 172b:2d03` plays each frame at (sx-w, sy-rowoff)
+	// with mask blit, advancing one frame per `_CheckFrameRate` tick.
+	uint16 seqAni = 0xFFFF;
+	uint16 seqY   = 0x6c;
+	if (_partner == 0) {
+		switch (caseType) {
+		case 1: seqAni = 0x38; seqY = 0x6d; break;
+		case 2: seqAni = 0x37; seqY = 0x6c; break;
+		case 3: seqAni = 0x39; seqY = 0x6c; break;
+		default: break;
+		}
+	} else {
+		switch (caseType) {
+		case 2: seqAni = 0x3a; seqY = 0x6c; break;
+		case 3: seqAni = 0x3d; seqY = 0x6c; break;
+		default: break;
+		}
+	}
+	if (seqAni != 0xFFFF) {
+		Animation seq;
+		if (_aniArchive.loadAnimation(seqAni, seq) && !seq.empty()) {
+			bool skip = false;
+			for (uint frame = 0; frame < seq.size() && !shouldQuit() && !skip;
+				 frame++) {
+				const Picture &fr = seq[frame];
+				// Restore BG + base anim frames so each new frame
+				// composites cleanly.
+				if (_picsArchive.getPicture(0x52, bg))
+					blitAt(bg, 0, 0);
+				if (haveGame)
+					blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+				if (haveBook)
+					blitMaskedAt(book[frame % book.size()], 0, 99);
+				if (haveNancy)
+					blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+				// Anchor: original blits at `(sx - frame.width,
+				// sy - frame.rowoff)`. `frame.rowoff` is the y-anchor
+				// in our PicData. We use width/height directly since
+				// loadAnimation places anchor at (0, 0).
+				const int dstX = (int)0xcd - (int)fr.surface.w;
+				const int dstY = (int)seqY - (int)fr.rowoff;
+				blitMaskedAt(fr, dstX, dstY);
+				g_system->updateScreen();
+				const uint32 wakeup = g_system->getMillis() + 100;
+				while (g_system->getMillis() < wakeup &&
+					   !shouldQuit() && !skip) {
+					Common::Event ev;
+					while (g_system->getEventManager()->pollEvent(ev)) {
+						if (ev.type == Common::EVENT_LBUTTONDOWN ||
+							ev.type == Common::EVENT_KEYDOWN) {
+							skip = true;
+							break;
+						}
+					}
+					g_system->delayMillis(10);
+				}
+			}
+		}
+	}
+
+	// Step 6 — case briefing dialogue.
+	displayClue(ib + 4);
+}
+
+/// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that
+/// the original engine uses as placeholders. Only the two we encounter most
+/// often (player name = 0x80, partner first name = 0x82) are substituted;
+/// other 0x8N opcodes are stripped. The original engine also handles
+/// hyphenation marks and a hint placeholder (0x89) we ignore for now.
+Common::String EEMEngine::parseString(const Common::String &raw,
+									  const Common::String &playerName,
+									  uint partner) const {
+	// Substitution opcodes from `_ParseString` @ 1b66:07c3, jump-table
+	// at 1b66:0cbe. Each handler reads `_Partner` (16-bit at 0x7918)
+	// and indexes the name table at 29be:0c28 ({Jake, Jennifer, he,
+	// she, him, her, his} as far pointers).
+	//   0x80 — player's typed name (auto-cap word starts) — uses _PlayerRecord
+	//   0x81 — _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
+	//   0x82 — _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
+	//   0x83 — _Partner == 0 ? "he"       : "she"
+	//   0x84 — _Partner == 0 ? "him"      : "her"
+	//   0x85 — _Partner == 0 ? "his"      : "her"
+	//   0x86..0x88 read a different gender flag at 0x7985 — left alone
+	//     until that flag's source is traced.
+	//   0x89 — KD hint placeholder (handled by caller).
+	const bool isJake = (partner == 0);
+	Common::String out;
+	for (uint i = 0; i < raw.size(); i++) {
+		const byte c = (byte)raw[i];
+		switch (c) {
+		case 0x80:
+			out += playerName;
+			break;
+		case 0x81:
+			out += isJake ? "Jake" : "Jennifer";
+			break;
+		case 0x82:
+			out += isJake ? "Jennifer" : "Jake";
+			break;
+		case 0x83:
+			out += isJake ? "he" : "she";
+			break;
+		case 0x84:
+			out += isJake ? "him" : "her";
+			break;
+		case 0x85:
+			out += isJake ? "his" : "her";
+			break;
+		case 0x86:
+		case 0x87:
+		case 0x88:
+		case 0x89:
+			// Eaten silently — see comment above.
+			break;
+		case 0:
+			return out;
+		case '\r':
+			break;
+		default:
+			out += (char)c;
+			break;
+		}
+	}
+	return out;
+}
+
+void EEMEngine::applyClueSideEffects(const byte *c) {
+	for (uint j = 0; j < 5; j++) {
+		const uint16 note = READ_LE_UINT16(c + 0x30 + j * 2);
+		if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
+			_mystery._cluesFound[note] = 1;
+
+		const uint16 galIdx = READ_LE_UINT16(c + 0x26 + j * 2);
+		if (galIdx != 0xFFFF && galIdx < Mystery::kGalleryCap) {
+			const uint8 phys = _mystery._newOrder[galIdx];
+			if (phys < Mystery::kGalleryCap)
+				_mystery._inGallery[phys] = 1;
+		}
+
+		const uint16 siteIdx = READ_LE_UINT16(c + 0x1c + j * 2);
+		if (siteIdx != 0xFFFF) {
+			const uint16 siteVal = siteIdx & 0x7FFF;
+			if (siteVal < Mystery::kVisitedSiteCap)
+				_mystery._onSites[siteVal] = 1;
+			if (siteIdx & 0x8000)
+				_mystery._sawCONSITEs = true;
+		}
+	}
+}
+
+void EEMEngine::displayClue(const byte *clueBlock) {
+	if (!clueBlock || !_mystery.isLoaded())
+		return;
+
+	// ClueBlock layout (verified against M0.BIN):
+	//   +0..1: number (entry count)
+	//   +2..3: pic ID for entry 0 (entry N>0 uses prev entry's last 2 bytes)
+	//   +4..:  array of 62-byte entries
+	const uint16 number = READ_LE_UINT16(clueBlock);
+	debugC(1, kDebugScript, "displayClue: %u entries", number);
+	if (number == 0 || number > 32) {
+		// number==0 = no briefing (e.g. mystery 0 case-type 4); >32 is a
+		// guard against bad pointers.
+		return;
+	}
+
+	// Snapshot the current screen as the BG so character pics from
+	// earlier entries don't stack on top of each other.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	bg.clear();
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)bg.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+		}
+	}
+
+	for (uint i = 0; i < number && !shouldQuit(); i++) {
+		// Restore BG before drawing this entry's portrait + balloon.
+		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+		const byte *c = clueBlock + 4 + i * 62;
+		// Per-partner fields:
+		//   +0..1, +2..3: tx, ty (partner 0)
+		//   +4..5, +6..7: tx, ty (partner 1)
+		//   +8..9, +10..11: bubText offset for partner 0/1 (rel. TextBlock)
+		//   +12..13, +14..15: balloon picture ID for partner 0/1
+		//   +16..17, +18..19: bubX, bubY
+		//   +0x3a..+0x3b:    KD-anim number (-1 = none)
+		// Per `_DisplayClue` @ 2404:05e6: partner 1 uses its own field
+		// set ONLY when bubText1 is not -1; otherwise it falls back to
+		// the partner 0 fields entirely. Partner 0 always uses field 0.
+
+		// Per-clue partner reaction animation. `_DisplayClue` @
+		// 2404:0635-064b checks `clueEntry[+0x3a]` and, when not -1,
+		// calls `_DoKDAnim(num)` BEFORE drawing the speaker portrait.
+		// This is what surfaces "Jenny takes a picture with a camera"
+		// (and the matching Jake gestures) during NPC searches.
+		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
+		if (kdAnimNum != -1)
+			playKdAnim((uint16)kdAnimNum);
+
+		const bool useP1 = (_partner == 1) &&
+			(READ_LE_UINT16(c + 10) != 0xFFFF);
+		const uint partner = useP1 ? 1 : 0;
+		const uint16 textOff = READ_LE_UINT16(c + 8 + partner * 2);
+		const bool hasText = (textOff != 0xFFFF);
+		// Partner 1 bubX/bubY at +0x14/+0x16; partner 0 at +0x10/+0x12.
+		const uint16 bubX = READ_LE_UINT16(c + (useP1 ? 0x14 : 0x10));
+		const uint16 bubY = READ_LE_UINT16(c + (useP1 ? 0x16 : 0x12));
+		const uint16 bubNum = READ_LE_UINT16(c + (useP1 ? 0x0E : 0x0C));
+		const char *raw   = hasText ? _mystery.textAt(textOff) : "";
+
+		// Speaker portrait. Mirrors `_DisplayClue`'s `pic[clues+i*62-2]`:
+		// for entry 0 the pic ID is in the ClueBlock header at +2; for
+		// later entries it sits in the previous entry's last 2 bytes.
+		// Speaker portrait position uses partner 0 fields (+0..+3) when
+		// _partner==0 or when partner 1 falls back; otherwise partner 1
+		// fields (+4..+7). Same logic as the original.
+		const uint16 charX  = READ_LE_UINT16(c + (useP1 ? 4 : 0));
+		const uint16 charY  = READ_LE_UINT16(c + (useP1 ? 6 : 2));
+		const uint16 charPicId = (i == 0)
+			? READ_LE_UINT16(clueBlock + 2)
+			: READ_LE_UINT16(c - 2);
+		if (charPicId != 0 && charPicId != 0xFFFF) {
+			Picture charPic;
+			if (_picsArchive.getPicture(charPicId, charPic) &&
+				charX < 320 && charY < 200) {
+				const int w = MIN<int>(charPic.surface.w, 320 - charX);
+				const int h = MIN<int>(charPic.surface.h, 200 - charY);
+				if (w > 0 && h > 0)
+					g_system->copyRectToScreen(charPic.surface.getPixels(),
+						charPic.surface.pitch, charX, charY, w, h);
+			}
+		}
+
+		// Substitute control bytes (0x80..0x89) — see `parseString` for
+		// the table. 0x81 = chosen detective, 0x82 = the other one.
+		const Common::String text = parseString(raw ? raw : "",
+												_playerName, _partner);
+
+		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
+		// `_DisplayClue`. The original looks up per-balloon text-area
+		// metadata in a table at offset 0x875 (within `_DisplayClue`'s
+		// segment); we don't have that table decoded yet, so we use a
+		// fixed inset of 8 px from the balloon's top-left.
+		Picture balloon;
+		const uint16 balloonId = bubNum & 0x7F;
+		const bool haveBalloon = bubNum != 0xFFFF &&
+			_balloonArchive.size() > balloonId &&
+			_balloonArchive.loadEntry(balloonId, balloon);
+
+		if (_font.isLoaded() && !text.empty()) {
+			// Snapshot the current screen, overlay balloon + text, then
+			// copy the changed band back. This preserves the site BG
+			// underneath unchanged regions.
+			Graphics::Surface *screen = g_system->lockScreen();
+			if (!screen) break;
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+
+			int textX = bubX;
+			int textY = bubY;
+			int textW = MIN<int>(320 - bubX, 200);
+			int copyY = bubY;
+			int copyH = _font.getFontHeight() * 4 + 8;
+
+			if (haveBalloon) {
+				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
+				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
+				// `_AddPicBackground` passes `pic->miscflags >> 8` as
+				// the transparent colour to `_Rect_Move_Mask`. The
+				// on-disk u16 at file offset 0 maps to `Picture::flags`.
+				const byte transp = (byte)(balloon.flags >> 8);
+				// `_GetBalloon @ 172b:1d7d` mirrors the picture horizontally
+				// when `(bubNum & 0x80)` is set — used for right-side
+				// speakers so the tail points the other way.
+				const bool flipBalloon = (bubNum & 0x80) != 0;
+				if (bw > 0 && bh > 0) {
+					for (int row = 0; row < bh; row++) {
+						const byte *src =
+							(const byte *)balloon.surface.getBasePtr(0, row);
+						byte *dst = (byte *)scratch.getBasePtr(bubX, bubY + row);
+						for (int col = 0; col < bw; col++) {
+							const int srcCol = flipBalloon
+								? (balloon.surface.w - 1 - col)
+								: col;
+							const byte px = src[srcCol];
+							if (px != transp)
+								dst[col] = px;
+						}
+					}
+				}
+				// Per-balloon metadata table verified from 29be:0875 —
+				// 10-byte entries indexed by `(bubNum & 0x7f)`. Layout:
+				//   +0..1 textX inset, +2..3 textY inset, +4..5 width,
+				//   +6..7 height, +8..9 tail offset.
+				// 52 entries total; insets vary (3, 5, 6, or 8 px).
+				// The original `_DisplayClue` does:
+				//   _WordWrap(bubX + table[bubNum].x, bubY + table[bubNum].y,
+				//             table[bubNum].w, ...);
+				static const struct { uint16 x, y, w; } kBalloonTable[] = {
+					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+					{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+				};
+				const uint kBalloonTableSize = sizeof(kBalloonTable) /
+											   sizeof(kBalloonTable[0]);
+				const uint balloonIdx = balloonId < kBalloonTableSize
+										? balloonId : 0;
+				const auto &bm = kBalloonTable[balloonIdx];
+				textX = bubX + bm.x;
+				textY = bubY + bm.y;
+				textW = bm.w;
+				copyH = bh;
+			} else {
+				// No balloon — clear a band so old pixels don't bleed.
+				const Common::Rect band(0, bubY, 320,
+					MIN<int>(bubY + copyH, 200));
+				scratch.fillRect(band, 0);
+				copyY = bubY;
+			}
+
+			// `_DisplayClue` @ 2404:07fe passes fontColor=0 (palette
+			// index 0 of the case-briefing palette 0x22) to `_WordWrap`.
+			// Hard-coding 0xF here gave the wrong colour.
+			_font.drawWordWrapped(&scratch, textX, textY,
+				MAX<int>(8, textW), text, 0);
+
+			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
+				scratch.pitch, 0, copyY, 320,
+				MIN<int>(copyH, 200 - copyY));
+			g_system->updateScreen();
+		}
+
+		// Wait for click/key to advance — only if we drew something.
+		// ESC skips the entire dialogue rather than just one entry.
+		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
+			bool advance = false;
+			bool skipAll = false;
+			while (!advance && !shouldQuit()) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_QUIT ||
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+						advance = true;
+						break;
+					}
+					if (ev.type == Common::EVENT_KEYDOWN &&
+						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						advance = true;
+						skipAll = true;
+						break;
+					}
+					if (ev.type == Common::EVENT_LBUTTONDOWN ||
+						ev.type == Common::EVENT_KEYDOWN) {
+						advance = true;
+						break;
+					}
+				}
+				// Tick the screen so the OSystem cursor follows the
+				// mouse — ScummVM redraws the cursor overlay only on
+				// updateScreen.
+				g_system->updateScreen();
+				g_system->delayMillis(10);
+			}
+			if (skipAll) {
+				// Apply remaining side-effects without rendering. The
+				// original silently runs the state updates even when the
+				// player skips ahead.
+				for (uint k = i; k < number; k++)
+					applyClueSideEffects(clueBlock + 4 + k * 62);
+				return;
+			}
+		}
+
+		applyClueSideEffects(c);
+	}
+}
+
+bool EEMEngine::areYouSure() {
+	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
+	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
+	// text dialog that preserves the screen behind it.
+	if (!_font.isLoaded())
+		return true;
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	Graphics::ManagedSurface saved(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	if (screen) {
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)saved.getBasePtr(0, row),
+				   (const byte *)screen->getBasePtr(0, row), 320);
+		}
+		g_system->unlockScreen();
+	}
+
+	const Common::Rect dlg(60, 70, 260, 140);
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	for (int row = 0; row < 200; row++)
+		memcpy((byte *)scratch.getBasePtr(0, row),
+			   (const byte *)saved.getBasePtr(0, row), 320);
+	scratch.fillRect(dlg, 0);
+	scratch.frameRect(dlg, 0xF);
+	_font.drawString(&scratch, "Are you sure you want to quit?", dlg.left + 8, dlg.top + 8, 320, 0xF);
+	_font.drawString(&scratch, "Y - Yes", dlg.left + 16, dlg.top + 36, 320, 0xF);
+	_font.drawString(&scratch, "N - No", dlg.left + 100, dlg.top + 36, 320, 0xF);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+
+	bool result = false;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool decided = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				result = true;
+				decided = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_y ||
+					ev.kbd.keycode == Common::KEYCODE_RETURN) {
+					result = true; decided = true; break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_n ||
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					result = false; decided = true; break;
+				}
+			}
+		}
+		if (decided)
+			break;
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+
+	// Restore the screen so the caller's UI is intact.
+	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+	return result;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 594fba894ef..cdbc7e4ac37 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -52,20 +52,12 @@ const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720
 // Picture / palette IDs from the original code (1-based picture IDs).
 const uint kPicEAKidsLogo      = 0x54;  ///< _ShowEAKids: GetPicture(0x54)
 const uint kPicHighScoreLogo   = 0x20c; ///< _ShowHScoreLogo: GetPicture(0x20c)
-const uint kPicChooseBackground = 0x8c; ///< _DoChoosePartner: GetBackground(0x8c)
 const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
 
-// Animation IDs (0-based per ANI.DBX). _DoChoosePartner uses GetAnimation(8/9).
-const uint kAniBoy  = 8;
-const uint kAniGirl = 9;
-
-// On-screen positions for the boy and girl partner sprites, from
-// _DoChoosePartner: NewAnimation(0xe2, 0x62, ...) and (0x42, 0x60, ...).
-const int kBoyX  = 0xe2; // 226
-const int kBoyY  = 0x62; // 98
-const int kGirlX = 0x42; // 66
-const int kGirlY = 0x60; // 96
+// Save format. Used by `saveGameState` / `loadGameState`.
+const uint32 kSaveMagic = MKTAG('E', 'E', 'M', '0');
+const byte   kSaveVer   = 3;  ///< v2: _mysteriesSolved tracker; v3: player name
 
 // 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
 // _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
@@ -447,829 +439,6 @@ void EEMEngine::showHighScoreLogo() {
 	waitForInput(2500);
 }
 
-void EEMEngine::doNewPlayer() {
-	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
-	// 0x104 + character peek pic 0x107, then shows "Please type your
-	// name" and accepts up to 12 characters until Enter. We render a
-	// minimal version: black screen + prompt.
-	if (!_font.isLoaded()) {
-		_playerName = "Detective";
-		return;
-	}
-
-	Common::String name;
-	const int maxChars = 12;
-
-	// Mirror the original: load PIC 0x104 as the name-entry backdrop.
-	// The original also slides in PIC 0x107 (a peeking character).
-	Picture bg;
-	const bool haveBG = _picsArchive.getPicture(0x104, bg);
-
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveBG) {
-			const int w = MIN<int>(bg.surface.w, 320);
-			const int h = MIN<int>(bg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.surface.getBasePtr(0, row), w);
-		}
-		// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
-		// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
-		// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
-		_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
-		Common::String shown = name + "_";
-		_font.drawString(&scratch, shown, 80, 80, 240, 0xF);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-	draw();
-
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool dirty = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type != Common::EVENT_KEYDOWN)
-				continue;
-			const Common::KeyCode k = ev.kbd.keycode;
-			if (k == Common::KEYCODE_RETURN) {
-				if (name.empty())
-					name = "Detective";
-				_playerName = name;
-				return;
-			}
-			if (k == Common::KEYCODE_ESCAPE) {
-				_playerName = "Detective";
-				return;
-			}
-			if (k == Common::KEYCODE_BACKSPACE) {
-				if (!name.empty()) {
-					name.deleteLastChar();
-					dirty = true;
-				}
-				continue;
-			}
-			if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
-				(int)name.size() < maxChars) {
-				name += (char)ev.kbd.ascii;
-				dirty = true;
-			}
-		}
-		if (dirty)
-			draw();
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-}
-
-void EEMEngine::doChoosePartner() {
-	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
-	// animations on a backdrop and polls four click rectangles (two per
-	// character) for the player's choice. We approximate by splitting the
-	// screen at x=160: left half = girl (Jenny), right half = boy (Jake).
-	Picture background;
-	if (!_picsArchive.getPicture(kPicChooseBackground, background)) {
-		warning("ChoosePartner background (%u) load failed", kPicChooseBackground);
-		return;
-	}
-
-	Animation boyAnim;
-	if (!_aniArchive.loadAnimation(kAniBoy, boyAnim) || boyAnim.empty()) {
-		warning("Boy animation (%u) load failed", kAniBoy);
-		return;
-	}
-	Animation girlAnim;
-	if (!_aniArchive.loadAnimation(kAniGirl, girlAnim) || girlAnim.empty()) {
-		warning("Girl animation (%u) load failed", kAniGirl);
-		return;
-	}
-
-	setAnmPalette(Common::Path("TITLE.ANM"));
-
-	// `_DoHappiness @ 172b:27b5`: the cursor's X column picks one of 4
-	// rects (29be:030f, all full-height); past rect 3 → "level 4".
-	// Each level swaps the partner's sequence script to a more / less
-	// "happy" cycle. Boy seqs at 29be:0337 (5 × 0x14 bytes), girl seqs
-	// at 29be:039b. Both cycle through 9 frames (the boy/girl anim
-	// cells contain 10 cells = pairs of "neutral, smile" at increasing
-	// intensity). Lifted verbatim from the binary so the gestures
-	// match the original beat-for-beat.
-	static const Common::Rect kHappyZones[4] = {
-		Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
-		Common::Rect( 70, 0, 126, 200), // girl's column
-		Common::Rect(126, 0, 182, 200), // middle
-		Common::Rect(182, 0, 235, 200), // boy's column
-	};
-	static const uint8 kBoySeqs[5][9] = {
-		{ 0,0,0,0,0,0,0,1,0 }, // level 0
-		{ 2,2,2,2,2,2,2,3,2 }, // level 1
-		{ 4,4,4,4,4,4,4,5,4 }, // level 2
-		{ 6,6,6,6,6,6,7,6,6 }, // level 3
-		{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
-	};
-	static const uint8 kGirlSeqs[5][9] = {
-		{ 8,9,8,8,8,8,8,8,8 },
-		{ 6,6,6,7,6,6,6,6,6 },
-		{ 4,4,5,4,4,4,4,4,4 },
-		{ 2,2,2,2,2,2,3,2,2 },
-		{ 0,0,0,0,0,1,0,0,0 },
-	};
-	auto happinessLevel = [](int x) {
-		for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
-			if (kHappyZones[i].contains(x, 100))
-				return (uint)i;
-		}
-		return 4u; // past zone 3 → max level
-	};
-
-	int curMouseX = 0xa0;  // _DoChoosePartner sets `_SetMousePos(0xa0, 0x96)` on entry
-	int curMouseY = 0x96;
-	uint level = happinessLevel(curMouseX);
-	uint seqIdx = 0;       // step within the 9-frame seq
-
-	auto draw = [&]() {
-		blitAt(background, 0, 0);
-		const uint girlFrame = kGirlSeqs[level][seqIdx % 9];
-		const uint boyFrame  = kBoySeqs [level][seqIdx % 9];
-		blitAt(girlAnim[girlFrame % girlAnim.size()], kGirlX, kGirlY);
-		blitAt(boyAnim[boyFrame  % boyAnim.size()],  kBoyX,  kBoyY);
-		g_system->updateScreen();
-	};
-	draw();
-
-	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
-		   "%u girl frames at (%d,%d)",
-		   (uint)boyAnim.size(), kBoyX, kBoyY,
-		   (uint)girlAnim.size(), kGirlX, kGirlY);
-
-	uint32 lastTick = g_system->getMillis();
-	while (!shouldQuit()) {
-		// Advance through the 9-frame seq at 100 ms — `_CheckFrameRate`
-		// cadence. The seq is short and loops; matches the original
-		// `_UpdateAnimations` which restarts at curIdx=0 on the 0x80
-		// marker. Mirrors `_DoHappiness`'s rewriting of `curIdx = 0xFFFF`
-		// when the cursor crosses zones (we restart `seqIdx` instead).
-		if (g_system->getMillis() - lastTick > 100) {
-			lastTick = g_system->getMillis();
-			seqIdx = (seqIdx + 1) % 9;
-			draw();
-		}
-
-		Common::Event ev;
-		bool done = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				done = true;
-				break;
-			}
-			if (ev.type == Common::EVENT_MOUSEMOVE) {
-				curMouseX = ev.mouse.x;
-				curMouseY = ev.mouse.y;
-				const uint newLevel = happinessLevel(curMouseX);
-				if (newLevel != level) {
-					level = newLevel;
-					seqIdx = 0; // restart cycle so the gesture pops
-					draw();
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				_partner = (ev.mouse.x >= 160) ? 0 : 1;
-				debugC(1, kDebugGeneral, "Partner picked: %s",
-					   _partner == 0 ? "Jake" : "Jennifer");
-				done = true;
-				break;
-			}
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
-					_partner = 1; done = true; break;
-				}
-				if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					_partner = 0; done = true; break;
-				}
-				if (ev.kbd.keycode == Common::KEYCODE_RETURN ||
-					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					done = true; break;
-				}
-			}
-		}
-		if (done)
-			break;
-		g_system->updateScreen();
-		g_system->delayMillis(20);
-	}
-}
-
-void EEMEngine::doCaseSelection() {
-	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
-	// (chooser background) plus a centred "Book %d" / "Challenge Book"
-	// header at (y=12) and then calls `_DoChoose(list)` to render the
-	// menu via `DrawList` @ 1c33:040d at (_TextBox+3, DAT_29be_0d02) =
-	// (61, 35), 12 rows × 10 px line height. The menu list itself is
-	// the static array at 29be:0d6a (verified via `push 0x0d6a` at
-	// 1c33:1ab4). Strings are at 29be:0ef4 onwards. Layout:
-	//   list[0]  = "----------------------------------"
-	//   list[1]  = "         Choose A Mystery"
-	//   list[2..10] = alternating menu items + separators
-	// Five selectable items: Choose A Mystery / Practice Mystery /
-	// See ScrapBook 1/2/3.
-	const uint kMaxMystery = 54;
-
-	enum MenuPick {
-		kPickChoose = 0,
-		kPickPractice,
-		kPickScrap1,
-		kPickScrap2,
-		kPickScrap3,
-		kNumPicks
-	};
-	const char *kPickLabel[kNumPicks] = {
-		"         Choose A Mystery",
-		"         Practice Mystery",
-		"         See ScrapBook 1",
-		"         See ScrapBook 2",
-		"         See ScrapBook 3"
-	};
-	// ScrapBooks aren't implemented yet — grey them so the player can't
-	// stop on them, mirroring the original `_Greys` mask.
-	const bool kPickEnabled[kNumPicks] = { true, true, false, false, false };
-	uint pick = kPickChoose;
-
-	const char *kSeparator = "----------------------------------";
-
-	// Click rectangles from the original `_DoChoose` @ 1c33:0514 — each
-	// `_InRect(_MouseX, _MouseY, addr, 0x29be)` reads one 4×u16 rect at
-	// the listed offset in segment 29be ({x1, y1, x2, y2}). We use
-	// `Common::Rect` (left/top/right/bottom) which also gives us
-	// `contains(x, y)` for hit testing.
-	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
-	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
-	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
-	const Common::Rect kUpArrowRect (240,  31, 250,  43); // 29be:0cf0 scroll up
-	const Common::Rect kDnArrowRect (240, 148, 250, 159); // 29be:0cf8 scroll dn
-	const Common::Rect kListRect    ( 58,  35, 238, 158); // 29be:0d00 list panel
-
-	// The original `_NewPlayer` set `_MouseCursor = 1` on exit; the
-	// chain of screens after it expects the cursor to stay visible.
-	// Reassert here in case anything between hid it.
-	CursorMan.showMouse(true);
-
-	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
-	Picture caseBg;
-	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
-
-	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` (1c33:0b7e-0ba1)
-	// loads anim 0x15 (Jake-paired) or 0x16 (Jenny-paired) and registers
-	// `_NewAnimation(0x112, 0x50, ..., seqnum=0x15, prior=1)` — partner-
-	// dependent because the host KD changes who's "with him" on the
-	// briefing intro frame. Runs continuously through the menu loop via
-	// `_UpdateAnimations`. We approximate with millis-based frame cycling.
-	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
-	Animation kdAnim;
-	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
-							 && !kdAnim.empty();
-	const int kKdAnimX = 0x112;
-	const int kKdAnimY = 0x50;
-
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveCaseBg) {
-			const int w = MIN<int>(caseBg.surface.w, 320);
-			const int h = MIN<int>(caseBg.surface.h, 200);
-			for (int row = 0; row < h; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
-			}
-		}
-
-		// KD greeter frame — masked-blit current animation cell at
-		// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
-		if (haveKdAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % kdAnim.size());
-			const Picture &fr = kdAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kKdAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kKdAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-		if (_font.isLoaded()) {
-			// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
-			// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
-			// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
-			const int kListX  = 58 + 3;
-			const int kListW  = 238 - kListX;
-			const int kListY0 = 35;
-			const int kLineH  = 10;
-
-			// Top centred "Book %d" / "Challenge Book" title — sprintf
-			// format strings at 29be:0deb / 29be:0dfa shown via
-			// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
-			// original. We don't track challenge tier yet so always
-			// show "Book 1".
-			const Common::String book = "Book 1";
-			const int titleW = _font.getStringWidth(book);
-			const int titleX = (0xba - titleW) / 2 + 0x3c;
-			_font.drawString(&scratch, book, titleX, 12, 320, 0xF);
-
-			// Render 11 list rows: separator + menu item pairs.
-			//   row 0  separator
-			//   row 1  Choose A Mystery
-			//   row 2  separator
-			//   row 3  Practice Mystery
-			//   ...
-			//   row 9  See ScrapBook 3
-			//   row 10 separator
-			for (int r = 0; r < 11; r++) {
-				const int y = kListY0 + r * kLineH;
-				if ((r & 1) == 0) {
-					_font.drawString(&scratch, kSeparator, kListX, y, kListW, 0x7);
-					continue;
-				}
-				const uint mp = (uint)(r >> 1);
-				const bool isSel  = (mp == pick);
-				const byte color  = isSel        ? 0xF :
-									kPickEnabled[mp] ? 0x7 : 0x8;
-				_font.drawString(&scratch, kPickLabel[mp], kListX, y, kListW, color);
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	auto pickPrev = [&]() {
-		for (int i = 0; i < (int)kNumPicks; i++) {
-			pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
-			if (kPickEnabled[pick])
-				break;
-		}
-	};
-	auto pickNext = [&]() {
-		for (int i = 0; i < (int)kNumPicks; i++) {
-			pick = (pick + 1) % kNumPicks;
-			if (kPickEnabled[pick])
-				break;
-		}
-	};
-
-	draw();
-	uint32 lastTick = g_system->getMillis();
-
-	bool exitChosen = false;
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool confirmed = false;
-		// Redraw every 100 ms so the KD greeter cycles. Mirrors the
-		// `_CheckFrameRate` cadence in `_CaseSelection`'s main loop.
-		const uint32 now = g_system->getMillis();
-		if (haveKdAnim && now - lastTick >= 100) {
-			lastTick = now;
-			draw();
-		}
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// OK / EXIT / HELP buttons (rectangles from `_DoChoose`).
-				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					confirmed = true;
-					break;
-				}
-				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
-					exitChosen = true;
-					confirmed = true;
-					break;
-				}
-				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// HELP placeholder — original calls `_DisplayHint`;
-					// our help screen is wired to `H` later in the flow.
-					continue;
-				}
-				// List panel: click on a non-separator row selects the
-				// menu entry under the cursor.
-				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
-					const int kLineH = 10;
-					const int row = (ev.mouse.y - kListRect.top) / kLineH;
-					if ((row & 1) == 1) {
-						const uint mp = (uint)(row >> 1);
-						if (mp < kNumPicks && kPickEnabled[mp]) {
-							pick = mp;
-							draw();
-							continue;
-						}
-					}
-				}
-			}
-			if (ev.type != Common::EVENT_KEYDOWN)
-				continue;
-			const Common::KeyCode k = ev.kbd.keycode;
-			if (k == Common::KEYCODE_ESCAPE) {
-				exitChosen = true;
-				confirmed = true;
-				break;
-			}
-			if (k == Common::KEYCODE_RETURN) {
-				confirmed = true;
-				break;
-			}
-			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
-				pickPrev();
-				draw();
-				continue;
-			}
-			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
-				k == Common::KEYCODE_TAB) {
-				pickNext();
-				draw();
-				continue;
-			}
-		}
-		if (confirmed) {
-			draw();
-			break;
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-
-	if (shouldQuit())
-		return;
-
-	if (exitChosen) {
-		_mystery.clear();
-		_nextScreen = kScreenInvalid;
-		return;
-	}
-
-	// "Practice Mystery" is the tutorial → mystery 0.
-	if (pick == kPickPractice) {
-		if (!_mystery.load(0, &_rng)) {
-			warning("doCaseSelection: failed to load practice mystery");
-			_mystery.clear();
-		}
-		return;
-	}
-
-	if (pick != kPickChoose) {
-		// ScrapBooks aren't implemented; bail back to the menu loop.
-		_mystery.clear();
-		return;
-	}
-
-	// "Choose A Mystery" sub-screen: pick a specific case from the
-	// 55-mystery roster. The original opens a different list here;
-	// we approximate with the tier-aware numeric chooser we used
-	// before. Default to the first unsolved mystery.
-	uint sel = 0;
-	for (uint i = 0; i <= kMaxMystery; i++) {
-		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
-			sel = i;
-			break;
-		}
-	}
-
-	auto drawSubmenu = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveCaseBg) {
-			const int w = MIN<int>(caseBg.surface.w, 320);
-			const int h = MIN<int>(caseBg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
-		}
-		if (_font.isLoaded()) {
-			const int kListX  = 61;
-			const int kListW  = 238 - kListX;
-			const int kListY0 = 35;
-			const int kLineH  = 10;
-			const int kVisible = 12;
-			int top = (int)sel - kVisible / 2;
-			if (top < 0) top = 0;
-			if (top + kVisible > (int)kMaxMystery + 1)
-				top = (int)kMaxMystery + 1 - kVisible;
-			for (int r = 0; r < kVisible; r++) {
-				const int idx = top + r;
-				if (idx > (int)kMaxMystery)
-					break;
-				char marker = ' ';
-				if ((uint)idx < sizeof(_mysteriesSolved)) {
-					if (_mysteriesSolved[idx] == 2) marker = '*';
-					else if (_mysteriesSolved[idx] == 1) marker = '+';
-				}
-				const char arrow = ((uint)idx == sel) ? '>' : ' ';
-				_font.drawString(&scratch,
-								 Common::String::format("%c %c Mystery %d", arrow, marker, idx),
-								 kListX, kListY0 + r * kLineH, kListW, 0xF);
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawSubmenu();
-	bool confirmed = false;
-	while (!confirmed && !shouldQuit()) {
-		Common::Event ev;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Same `_DoChoose` rectangles as the top-level menu.
-				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					confirmed = true;
-					break;
-				}
-				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
-					_mystery.clear();
-					return;
-				}
-				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel == 0) ? kMaxMystery : sel - 1;
-					drawSubmenu();
-					continue;
-				}
-				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
-					drawSubmenu();
-					continue;
-				}
-				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Pick the row under the cursor.
-					const int kLineH = 10;
-					const int kVisible = 12;
-					int top = (int)sel - kVisible / 2;
-					if (top < 0) top = 0;
-					if (top + kVisible > (int)kMaxMystery + 1)
-						top = (int)kMaxMystery + 1 - kVisible;
-					const int row = (ev.mouse.y - kListRect.top) / kLineH;
-					const int idx = top + row;
-					if (idx >= 0 && idx <= (int)kMaxMystery) {
-						sel = (uint)idx;
-						drawSubmenu();
-					}
-					continue;
-				}
-			}
-			if (ev.type != Common::EVENT_KEYDOWN)
-				continue;
-			const Common::KeyCode k = ev.kbd.keycode;
-			if (k == Common::KEYCODE_ESCAPE) {
-				_mystery.clear();
-				return;
-			}
-			if (k == Common::KEYCODE_RETURN) {
-				confirmed = true;
-				break;
-			}
-			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
-				sel = (uint)(k - Common::KEYCODE_0);
-				drawSubmenu();
-				continue;
-			}
-			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
-				sel = (sel >= kMaxMystery) ? 0 : sel + 1;
-				drawSubmenu();
-				continue;
-			}
-			if (k == Common::KEYCODE_UP) {
-				sel = (sel == 0) ? kMaxMystery : sel - 1;
-				drawSubmenu();
-				continue;
-			}
-			if (k == Common::KEYCODE_PAGEDOWN) {
-				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
-				drawSubmenu();
-				continue;
-			}
-			if (k == Common::KEYCODE_PAGEUP) {
-				sel = (sel < 10) ? 0 : sel - 10;
-				drawSubmenu();
-				continue;
-			}
-			if (k == Common::KEYCODE_HOME) { sel = 0; drawSubmenu(); continue; }
-			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; drawSubmenu(); continue; }
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-
-	if (!_mystery.load(sel, &_rng)) {
-		warning("doCaseSelection: failed to load mystery %u", sel);
-		_mystery.clear();
-		return;
-	}
-	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
-		   sel, _mystery.numSites(), _mystery.numSuspects());
-}
-
-void EEMEngine::doInitClues() {
-	// Mirrors `_DoInitClues` @ 1a35:0411. The original does:
-	//   1. _AllBlack(); _GetBackground(0x52); _GetPalette(0x22);
-	//   2. _GetAnimation(gameAni); _NewAnimation(0xcd, 0x6c, ...)
-	//      _GetAnimation(bookAni); _NewAnimation(0,    99,   ...)
-	//      (case type 1 also: _NewAnimation(0x68, 0x8b, nancyAni))
-	//   3. _UpdateAnimations(); _FadeIn();
-	//   4. while (frame != gameNum) { _CheckFrameRate(); _UpdateAnimations(); }
-	//        — cycles through the entire game animation once. Click skips.
-	//   5. _PlayInSequence(seqId, ...) — plays a follow-up sequence based
-	//      on partner + case type.
-	//   6. _DisplayClue(InitBlock + 2, 1) — the briefing dialogue.
-	//   7. _OnSites[startSite] = 1.
-	//
-	// gameAni / bookAni / nancyAni values verified directly from Ghidra:
-	//   gameAni  = 0x17 (Jake) / 0x3b (Jenny)
-	//   bookAni  = 0x18 (Jake) / 0x3c (Jenny)
-	//   nancyAni = 0x19 (case type 1 only)
-	if (!_mystery.isLoaded())
-		return;
-
-	const byte *ib = _mystery.initBlock();
-	if (!ib)
-		return;
-
-	const uint16 startSite = READ_LE_UINT16(ib + 2);
-	if (startSite < Mystery::kVisitedSiteCap)
-		_mystery._onSites[startSite] = 1;
-	_mystery._siteNumber = startSite;
-	_mystery._lastSite = startSite;
-
-	setSitePalette(0x22);
-	Picture bg;
-	if (_picsArchive.getPicture(0x52, bg))
-		blitAt(bg, 0, 0);
-
-	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
-	const uint bookAni = _partner == 0 ? 0x18 : 0x3c;
-	Animation game, book, nancy;
-	const bool haveGame  = _aniArchive.loadAnimation(gameAni, game) && !game.empty();
-	const bool haveBook  = _aniArchive.loadAnimation(bookAni, book) && !book.empty();
-
-	const uint16 caseType = READ_LE_UINT16(ib);
-	const bool haveNancy = (caseType == 1)
-						  && _aniArchive.loadAnimation(0x19, nancy)
-						  && !nancy.empty();
-
-	auto blitMaskedAt = [&](const Picture &p, int x, int y) {
-		const byte transp = (byte)(p.flags >> 8);
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (!screen) return;
-		for (int row = 0; row < p.surface.h; row++) {
-			const int dstY = y + row;
-			if (dstY < 0 || dstY >= screen->h) continue;
-			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-			byte *dst = (byte *)screen->getBasePtr(0, dstY);
-			for (int col = 0; col < p.surface.w; col++) {
-				const int dstX = x + col;
-				if (dstX < 0 || dstX >= screen->w) continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
-		g_system->unlockScreen();
-	};
-
-	// Step 4 — cycle through the game animation once before the briefing.
-	// Mirrors the `while (uVar9 != gameNum)` loop. The original calls
-	// `_UpdateAnimations` per `_CheckFrameRate` tick (~10 fps). We use
-	// 100 ms ticks for the same cadence. Click / key skips.
-	if (haveGame || haveBook || haveNancy) {
-		const uint frameCount = haveGame ? game.size() : 8;
-		bool skip = false;
-		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
-			// Restore BG + advance frame.
-			if (_picsArchive.getPicture(0x52, bg))
-				blitAt(bg, 0, 0);
-			if (haveGame)
-				blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
-			if (haveBook)
-				blitMaskedAt(book[frame % book.size()], 0, 99);
-			if (haveNancy)
-				blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
-			g_system->updateScreen();
-
-			// Wait 100 ms or until input.
-			const uint32 wakeup = g_system->getMillis() + 100;
-			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
-				Common::Event ev;
-				while (g_system->getEventManager()->pollEvent(ev)) {
-					if (ev.type == Common::EVENT_LBUTTONDOWN ||
-						ev.type == Common::EVENT_KEYDOWN) {
-						skip = true;
-						break;
-					}
-				}
-				g_system->delayMillis(10);
-			}
-		}
-	}
-
-	// Composite the final frames (or first frames if skipped) so the BG
-	// is in a sensible state when displayClue overlays the speaker.
-	if (_picsArchive.getPicture(0x52, bg))
-		blitAt(bg, 0, 0);
-	if (haveGame)
-		blitMaskedAt(game[0], 0xcd, 0x6c);
-	if (haveBook)
-		blitMaskedAt(book[0], 0, 99);
-	if (haveNancy)
-		blitMaskedAt(nancy[0], 0x68, 0x8b);
-	g_system->updateScreen();
-
-	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
-	//   Jake (partner=0):
-	//     caseType=1 → anim 0x38 at (0xcd, 0x6d)
-	//     caseType=2 → anim 0x37 at (0xcd, 0x6c)
-	//     caseType=3 → anim 0x39 at (0xcd, 0x6c)
-	//   Jenny (partner=1):
-	//     caseType=2 → anim 0x3a at (0xcd, 0x6c)
-	//     caseType=3 → anim 0x3d at (0xcd, 0x6c)
-	// `_PlayInSequence @ 172b:2d03` plays each frame at (sx-w, sy-rowoff)
-	// with mask blit, advancing one frame per `_CheckFrameRate` tick.
-	uint16 seqAni = 0xFFFF;
-	uint16 seqY   = 0x6c;
-	if (_partner == 0) {
-		switch (caseType) {
-		case 1: seqAni = 0x38; seqY = 0x6d; break;
-		case 2: seqAni = 0x37; seqY = 0x6c; break;
-		case 3: seqAni = 0x39; seqY = 0x6c; break;
-		default: break;
-		}
-	} else {
-		switch (caseType) {
-		case 2: seqAni = 0x3a; seqY = 0x6c; break;
-		case 3: seqAni = 0x3d; seqY = 0x6c; break;
-		default: break;
-		}
-	}
-	if (seqAni != 0xFFFF) {
-		Animation seq;
-		if (_aniArchive.loadAnimation(seqAni, seq) && !seq.empty()) {
-			bool skip = false;
-			for (uint frame = 0; frame < seq.size() && !shouldQuit() && !skip;
-				 frame++) {
-				const Picture &fr = seq[frame];
-				// Restore BG + base anim frames so each new frame
-				// composites cleanly.
-				if (_picsArchive.getPicture(0x52, bg))
-					blitAt(bg, 0, 0);
-				if (haveGame)
-					blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
-				if (haveBook)
-					blitMaskedAt(book[frame % book.size()], 0, 99);
-				if (haveNancy)
-					blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
-				// Anchor: original blits at `(sx - frame.width,
-				// sy - frame.rowoff)`. `frame.rowoff` is the y-anchor
-				// in our PicData. We use width/height directly since
-				// loadAnimation places anchor at (0, 0).
-				const int dstX = (int)0xcd - (int)fr.surface.w;
-				const int dstY = (int)seqY - (int)fr.rowoff;
-				blitMaskedAt(fr, dstX, dstY);
-				g_system->updateScreen();
-				const uint32 wakeup = g_system->getMillis() + 100;
-				while (g_system->getMillis() < wakeup &&
-					   !shouldQuit() && !skip) {
-					Common::Event ev;
-					while (g_system->getEventManager()->pollEvent(ev)) {
-						if (ev.type == Common::EVENT_LBUTTONDOWN ||
-							ev.type == Common::EVENT_KEYDOWN) {
-							skip = true;
-							break;
-						}
-					}
-					g_system->delayMillis(10);
-				}
-			}
-		}
-	}
-
-	// Step 6 — case briefing dialogue.
-	displayClue(ib + 4);
-}
-
 void EEMEngine::doSiteLoop() {
 	// Mirrors the per-mystery site loop. SiteScreen::run() handles
 	// hotspot clicks plus M (map), N (notebook), G (gallery), A (accuse),
@@ -1278,2434 +447,6 @@ void EEMEngine::doSiteLoop() {
 	screen.run();
 }
 
-/// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that
-/// the original engine uses as placeholders. Only the two we encounter most
-/// often (player name = 0x80, partner first name = 0x82) are substituted;
-/// other 0x8N opcodes are stripped. The original engine also handles
-/// hyphenation marks and a hint placeholder (0x89) we ignore for now.
-static Common::String parseString(const Common::String &raw,
-								  const Common::String &playerName,
-								  uint partner) {
-	// Substitution opcodes from `_ParseString` @ 1b66:07c3, jump-table
-	// at 1b66:0cbe. Each handler reads `_Partner` (16-bit at 0x7918)
-	// and indexes the name table at 29be:0c28 ({Jake, Jennifer, he,
-	// she, him, her, his} as far pointers).
-	//   0x80 — player's typed name (auto-cap word starts) — uses _PlayerRecord
-	//   0x81 — _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
-	//   0x82 — _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
-	//   0x83 — _Partner == 0 ? "he"       : "she"
-	//   0x84 — _Partner == 0 ? "him"      : "her"
-	//   0x85 — _Partner == 0 ? "his"      : "her"
-	//   0x86..0x88 read a different gender flag at 0x7985 — left alone
-	//     until that flag's source is traced.
-	//   0x89 — KD hint placeholder (handled by caller).
-	const bool isJake = (partner == 0);
-	Common::String out;
-	for (uint i = 0; i < raw.size(); i++) {
-		const byte c = (byte)raw[i];
-		switch (c) {
-		case 0x80:
-			out += playerName;
-			break;
-		case 0x81:
-			out += isJake ? "Jake" : "Jennifer";
-			break;
-		case 0x82:
-			out += isJake ? "Jennifer" : "Jake";
-			break;
-		case 0x83:
-			out += isJake ? "he" : "she";
-			break;
-		case 0x84:
-			out += isJake ? "him" : "her";
-			break;
-		case 0x85:
-			out += isJake ? "his" : "her";
-			break;
-		case 0x86:
-		case 0x87:
-		case 0x88:
-		case 0x89:
-			// Eaten silently — see comment above.
-			break;
-		case 0:
-			return out;
-		case '\r':
-			break;
-		default:
-			out += (char)c;
-			break;
-		}
-	}
-	return out;
-}
-
-void EEMEngine::applyClueSideEffects(const byte *c) {
-	for (uint j = 0; j < 5; j++) {
-		const uint16 note = READ_LE_UINT16(c + 0x30 + j * 2);
-		if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
-			_mystery._cluesFound[note] = 1;
-
-		const uint16 galIdx = READ_LE_UINT16(c + 0x26 + j * 2);
-		if (galIdx != 0xFFFF && galIdx < Mystery::kGalleryCap) {
-			const uint8 phys = _mystery._newOrder[galIdx];
-			if (phys < Mystery::kGalleryCap)
-				_mystery._inGallery[phys] = 1;
-		}
-
-		const uint16 siteIdx = READ_LE_UINT16(c + 0x1c + j * 2);
-		if (siteIdx != 0xFFFF) {
-			const uint16 siteVal = siteIdx & 0x7FFF;
-			if (siteVal < Mystery::kVisitedSiteCap)
-				_mystery._onSites[siteVal] = 1;
-			if (siteIdx & 0x8000)
-				_mystery._sawCONSITEs = true;
-		}
-	}
-}
-
-void EEMEngine::displayClue(const byte *clueBlock) {
-	if (!clueBlock || !_mystery.isLoaded())
-		return;
-
-	// ClueBlock layout (verified against M0.BIN):
-	//   +0..1: number (entry count)
-	//   +2..3: pic ID for entry 0 (entry N>0 uses prev entry's last 2 bytes)
-	//   +4..:  array of 62-byte entries
-	const uint16 number = READ_LE_UINT16(clueBlock);
-	debugC(1, kDebugScript, "displayClue: %u entries", number);
-	if (number == 0 || number > 32) {
-		// number==0 = no briefing (e.g. mystery 0 case-type 4); >32 is a
-		// guard against bad pointers.
-		return;
-	}
-
-	// Snapshot the current screen as the BG so character pics from
-	// earlier entries don't stack on top of each other.
-	Graphics::ManagedSurface bg(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	bg.clear();
-	{
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (screen) {
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)bg.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
-			g_system->unlockScreen();
-		}
-	}
-
-	for (uint i = 0; i < number && !shouldQuit(); i++) {
-		// Restore BG before drawing this entry's portrait + balloon.
-		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
-		const byte *c = clueBlock + 4 + i * 62;
-		// Per-partner fields:
-		//   +0..1, +2..3: tx, ty (partner 0)
-		//   +4..5, +6..7: tx, ty (partner 1)
-		//   +8..9, +10..11: bubText offset for partner 0/1 (rel. TextBlock)
-		//   +12..13, +14..15: balloon picture ID for partner 0/1
-		//   +16..17, +18..19: bubX, bubY
-		//   +0x3a..+0x3b:    KD-anim number (-1 = none)
-		// Per `_DisplayClue` @ 2404:05e6: partner 1 uses its own field
-		// set ONLY when bubText1 is not -1; otherwise it falls back to
-		// the partner 0 fields entirely. Partner 0 always uses field 0.
-
-		// Per-clue partner reaction animation. `_DisplayClue` @
-		// 2404:0635-064b checks `clueEntry[+0x3a]` and, when not -1,
-		// calls `_DoKDAnim(num)` BEFORE drawing the speaker portrait.
-		// This is what surfaces "Jenny takes a picture with a camera"
-		// (and the matching Jake gestures) during NPC searches.
-		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
-		if (kdAnimNum != -1)
-			playKdAnim((uint16)kdAnimNum);
-
-		const bool useP1 = (_partner == 1) &&
-			(READ_LE_UINT16(c + 10) != 0xFFFF);
-		const uint partner = useP1 ? 1 : 0;
-		const uint16 textOff = READ_LE_UINT16(c + 8 + partner * 2);
-		const bool hasText = (textOff != 0xFFFF);
-		// Partner 1 bubX/bubY at +0x14/+0x16; partner 0 at +0x10/+0x12.
-		const uint16 bubX = READ_LE_UINT16(c + (useP1 ? 0x14 : 0x10));
-		const uint16 bubY = READ_LE_UINT16(c + (useP1 ? 0x16 : 0x12));
-		const uint16 bubNum = READ_LE_UINT16(c + (useP1 ? 0x0E : 0x0C));
-		const char *raw   = hasText ? _mystery.textAt(textOff) : "";
-
-		// Speaker portrait. Mirrors `_DisplayClue`'s `pic[clues+i*62-2]`:
-		// for entry 0 the pic ID is in the ClueBlock header at +2; for
-		// later entries it sits in the previous entry's last 2 bytes.
-		// Speaker portrait position uses partner 0 fields (+0..+3) when
-		// _partner==0 or when partner 1 falls back; otherwise partner 1
-		// fields (+4..+7). Same logic as the original.
-		const uint16 charX  = READ_LE_UINT16(c + (useP1 ? 4 : 0));
-		const uint16 charY  = READ_LE_UINT16(c + (useP1 ? 6 : 2));
-		const uint16 charPicId = (i == 0)
-			? READ_LE_UINT16(clueBlock + 2)
-			: READ_LE_UINT16(c - 2);
-		if (charPicId != 0 && charPicId != 0xFFFF) {
-			Picture charPic;
-			if (_picsArchive.getPicture(charPicId, charPic) &&
-				charX < 320 && charY < 200) {
-				const int w = MIN<int>(charPic.surface.w, 320 - charX);
-				const int h = MIN<int>(charPic.surface.h, 200 - charY);
-				if (w > 0 && h > 0)
-					g_system->copyRectToScreen(charPic.surface.getPixels(),
-						charPic.surface.pitch, charX, charY, w, h);
-			}
-		}
-
-		// Substitute control bytes (0x80..0x89) — see `parseString` for
-		// the table. 0x81 = chosen detective, 0x82 = the other one.
-		const Common::String text = parseString(raw ? raw : "",
-												_playerName, _partner);
-
-		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
-		// `_DisplayClue`. The original looks up per-balloon text-area
-		// metadata in a table at offset 0x875 (within `_DisplayClue`'s
-		// segment); we don't have that table decoded yet, so we use a
-		// fixed inset of 8 px from the balloon's top-left.
-		Picture balloon;
-		const uint16 balloonId = bubNum & 0x7F;
-		const bool haveBalloon = bubNum != 0xFFFF &&
-			_balloonArchive.size() > balloonId &&
-			_balloonArchive.loadEntry(balloonId, balloon);
-
-		if (_font.isLoaded() && !text.empty()) {
-			// Snapshot the current screen, overlay balloon + text, then
-			// copy the changed band back. This preserves the site BG
-			// underneath unchanged regions.
-			Graphics::Surface *screen = g_system->lockScreen();
-			if (!screen) break;
-			Graphics::ManagedSurface scratch(320, 200,
-				Graphics::PixelFormat::createFormatCLUT8());
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
-			g_system->unlockScreen();
-
-			int textX = bubX;
-			int textY = bubY;
-			int textW = MIN<int>(320 - bubX, 200);
-			int copyY = bubY;
-			int copyH = _font.getFontHeight() * 4 + 8;
-
-			if (haveBalloon) {
-				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
-				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
-				// `_AddPicBackground` passes `pic->miscflags >> 8` as
-				// the transparent colour to `_Rect_Move_Mask`. The
-				// on-disk u16 at file offset 0 maps to `Picture::flags`.
-				const byte transp = (byte)(balloon.flags >> 8);
-				// `_GetBalloon @ 172b:1d7d` mirrors the picture horizontally
-				// when `(bubNum & 0x80)` is set — used for right-side
-				// speakers so the tail points the other way.
-				const bool flipBalloon = (bubNum & 0x80) != 0;
-				if (bw > 0 && bh > 0) {
-					for (int row = 0; row < bh; row++) {
-						const byte *src =
-							(const byte *)balloon.surface.getBasePtr(0, row);
-						byte *dst = (byte *)scratch.getBasePtr(bubX, bubY + row);
-						for (int col = 0; col < bw; col++) {
-							const int srcCol = flipBalloon
-								? (balloon.surface.w - 1 - col)
-								: col;
-							const byte px = src[srcCol];
-							if (px != transp)
-								dst[col] = px;
-						}
-					}
-				}
-				// Per-balloon metadata table verified from 29be:0875 —
-				// 10-byte entries indexed by `(bubNum & 0x7f)`. Layout:
-				//   +0..1 textX inset, +2..3 textY inset, +4..5 width,
-				//   +6..7 height, +8..9 tail offset.
-				// 52 entries total; insets vary (3, 5, 6, or 8 px).
-				// The original `_DisplayClue` does:
-				//   _WordWrap(bubX + table[bubNum].x, bubY + table[bubNum].y,
-				//             table[bubNum].w, ...);
-				static const struct { uint16 x, y, w; } kBalloonTable[] = {
-					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-					{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
-				};
-				const uint kBalloonTableSize = sizeof(kBalloonTable) /
-											   sizeof(kBalloonTable[0]);
-				const uint balloonIdx = balloonId < kBalloonTableSize
-										? balloonId : 0;
-				const auto &bm = kBalloonTable[balloonIdx];
-				textX = bubX + bm.x;
-				textY = bubY + bm.y;
-				textW = bm.w;
-				copyH = bh;
-			} else {
-				// No balloon — clear a band so old pixels don't bleed.
-				const Common::Rect band(0, bubY, 320,
-					MIN<int>(bubY + copyH, 200));
-				scratch.fillRect(band, 0);
-				copyY = bubY;
-			}
-
-			// `_DisplayClue` @ 2404:07fe passes fontColor=0 (palette
-			// index 0 of the case-briefing palette 0x22) to `_WordWrap`.
-			// Hard-coding 0xF here gave the wrong colour.
-			_font.drawWordWrapped(&scratch, textX, textY,
-				MAX<int>(8, textW), text, 0);
-
-			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
-				scratch.pitch, 0, copyY, 320,
-				MIN<int>(copyH, 200 - copyY));
-			g_system->updateScreen();
-		}
-
-		// Wait for click/key to advance — only if we drew something.
-		// ESC skips the entire dialogue rather than just one entry.
-		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
-			bool advance = false;
-			bool skipAll = false;
-			while (!advance && !shouldQuit()) {
-				Common::Event ev;
-				while (g_system->getEventManager()->pollEvent(ev)) {
-					if (ev.type == Common::EVENT_QUIT ||
-						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-						advance = true;
-						break;
-					}
-					if (ev.type == Common::EVENT_KEYDOWN &&
-						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						advance = true;
-						skipAll = true;
-						break;
-					}
-					if (ev.type == Common::EVENT_LBUTTONDOWN ||
-						ev.type == Common::EVENT_KEYDOWN) {
-						advance = true;
-						break;
-					}
-				}
-				// Tick the screen so the OSystem cursor follows the
-				// mouse — ScummVM redraws the cursor overlay only on
-				// updateScreen.
-				g_system->updateScreen();
-				g_system->delayMillis(10);
-			}
-			if (skipAll) {
-				// Apply remaining side-effects without rendering. The
-				// original silently runs the state updates even when the
-				// player skips ahead.
-				for (uint k = i; k < number; k++)
-					applyClueSideEffects(clueBlock + 4 + k * 62);
-				return;
-			}
-		}
-
-		applyClueSideEffects(c);
-	}
-}
-
-void EEMEngine::doNotebook() {
-	// Mirrors `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
-	// `_HandleNoteButton @ 161e:03cb`.
-	//
-	// Layout (verified from Ghidra labels in 29be:013f / 29be:0147):
-	//   _NotebookRect = (78, 12, 288, 152)   — note display rectangle.
-	//   _NoteButtons (11 entries, 8 bytes each, at 29be:0147):
-	//     [0]  (134, 174, 155, 190)  decorative — `_HandleNoteButton(0)`
-	//                                returns immediately (i-1 unsigned > 9).
-	//     [1]  (93,  174, 115, 190)  → `_InterfaceHelp(0)` (handler 0x3f9)
-	//     [2]  (157, 174, 178, 190)  → handler 0x477   (page nav)
-	//     [3]  (5,   80,  44, 110)   → `_KDHelp` (host hint, 0x403)
-	//     [4]  (180, 174, 201, 190)  → solve / accuse  (0x436)
-	//     [5]  (204, 174, 224, 190)  → `_NextScreen = 5` (gallery, 0x489)
-	//     [6]  (226, 174, 247, 190)  → handler 0x4ab
-	//     [7]  (7,   177,  57, 200)  → handler 0x480   (back to map)
-	//     [8]  (35,  111,  56, 136)  → `_NextScreen = 3` (site)
-	//     [9]  (0, 0, 0, 0)          → same exit as [8]
-	//     [10] (66,  79, 267, 174)   → `_InterfaceHelp(0)` (note area)
-	//   Background: PIC 0x3f.
-	//   Partner anim: anim 1 (Jake) / 0xb (Jenny) at (5, 80).
-	if (!_mystery.isLoaded() || !_font.isLoaded())
-		return;
-
-	// Button rects from `_NoteButtons @ 29be:0147` matched to handler
-	// addresses via the jump table at `_HandleNoteButton + 0xec` (i.e.
-	// 161e:04ec). Decoded handlers (i = rect_index, dispatch = handler[i-1]):
-	//   rect 0 (134,155) → no handler (i-1 underflows; original treats
-	//                      this as a decorative/no-op slot)
-	//   rect 1 (93,115)  → 0x03f9 = `_InterfaceHelp(0)`           (HELP)
-	//   rect 2 (157,178) → 0x0477 = `_NextScreen = 5`             (GALLERY)
-	//   rect 3 (5,80)    → 0x0403 = `_KDHelp`                     (host hint)
-	//   rect 4 (180,201) → 0x0436 = `_SolvedCheck` -> NextScreen=7 (SOLVE)
-	//   rect 5 (204,224) → 0x0489 = `_EraseNotes` + `_DrawNotes`  (PAGE NEXT)
-	//   rect 6 (226,247) → 0x04ab = decrement CurrentPage + redraw (PAGE PREV)
-	//   rect 7 (7,177)   → 0x0480 = `_NextScreen = 2`             (MAP)
-	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
-	//   rect 9 (0,0)     → 0x03ed = same as rect 8
-	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
-	const Common::Rect kNotebookRect(78, 12, 288, 152);
-	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1] HELP
-	const Common::Rect kBtnGallery (157, 174, 178, 190);  // [2] GALLERY
-	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3] KD HELP
-	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4] SOLVE
-	const Common::Rect kBtnPageNext(204, 174, 224, 190);  // [5] PAGE NEXT
-	const Common::Rect kBtnPagePrev(226, 174, 247, 190);  // [6] PAGE PREV
-	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7] MAP
-	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8] SITE
-	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10] note area
-
-	CursorMan.showMouse(true);
-
-	int page = 0;
-	int hoveredNoteSlot = -1;
-
-	// Build a list of found-clue indices, identical ordering to the
-	// original's iteration through `_CluesFound[]`.
-	auto buildFound = [&]() {
-		Common::Array<uint> found;
-		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
-			if (_mystery._cluesFound[i])
-				found.push_back(i);
-		return found;
-	};
-
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		// PIC 0x3f frame.
-		Picture frame;
-		if (_picsArchive.getPicture(0x3f, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
-		const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
-		Animation partnerAni;
-		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-			const Picture &fr = partnerAni[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = 80 + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = 5 + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
-		// rendering each found clue's text inside `_NotebookRect` with
-		// word-wrap. Selected clues are highlighted (color 0x3c in the
-		// original's case-briefing palette).
-		const Common::Array<uint> found = buildFound();
-		const byte *ni = _mystery.noteIndex();
-		const uint16 niCount = _mystery.noteIndexCount();
-
-		const int kRectX = kNotebookRect.left;
-		const int kRectY = kNotebookRect.top;
-		const int kRectW = kNotebookRect.width();
-		const int kRectH = kNotebookRect.height();
-
-		// Walk forward to the start clue of the current page.
-		// Each page renders as many clues as fit in `kRectH`.
-		int clueCursor = 0;
-		Common::Array<int> pageStarts;
-		pageStarts.push_back(0);
-		{
-			const int lineH = _font.getFontHeight() + 1;
-			int y = kRectY;
-			while (clueCursor < (int)found.size()) {
-				const uint clueId = found[clueCursor];
-				Common::String txt;
-				if (ni && clueId < niCount) {
-					const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-					txt = parseString(_mystery.textAt(textOff),
-									  _playerName, _partner);
-				}
-				// Measure height by wrapping the text without drawing.
-				Common::Array<Common::String> wrapped;
-				_font.wordWrapText(txt, kRectW, wrapped);
-				const int h = (int)wrapped.size() * lineH;
-				if (y + h + 7 > kRectY + kRectH) {
-					// Page break before this clue.
-					y = kRectY;
-					pageStarts.push_back(clueCursor);
-				}
-				y += h + 7;
-				clueCursor++;
-			}
-			if (page >= (int)pageStarts.size())
-				page = (int)pageStarts.size() - 1;
-			if (page < 0)
-				page = 0;
-		}
-
-		// Track per-slot rectangles so the click handler can map a
-		// click in `kNoteArea` back to a clue index.
-		Common::Array<Common::Rect> slotRects;
-		Common::Array<uint> slotClues;
-
-		const int startClue = (page < (int)pageStarts.size())
-								? pageStarts[page] : 0;
-		const int endClue   = (page + 1 < (int)pageStarts.size())
-								? pageStarts[page + 1] : (int)found.size();
-
-		int y = kRectY;
-		for (int i = startClue; i < endClue; i++) {
-			const uint clueId = found[i];
-			Common::String txt;
-			if (ni && clueId < niCount) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								  _playerName, _partner);
-			}
-			if (txt.empty())
-				txt = Common::String::format("clue %u", clueId);
-			// Per `_DrawNotes @ 161e:01d0`: text uses
-			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
-			// (light yellow-white) for selected. Both contrast cleanly
-			// with the PDA screen's natural blue, so we draw text
-			// directly on PIC 0x3f without an extra fill rectangle —
-			// matches the original design.
-			Common::Array<Common::String> wrapped;
-			_font.wordWrapText(txt, kRectW, wrapped);
-			const int lineH = _font.getFontHeight() + 1;
-			const int h = (int)wrapped.size() * lineH;
-			const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
-			for (uint li = 0; li < wrapped.size(); li++) {
-				_font.drawString(&scratch, wrapped[li], kRectX,
-								 y + (int)li * lineH, kRectW, color);
-			}
-			slotRects.push_back(Common::Rect(kRectX, y,
-											  kRectX + kRectW, y + h));
-			slotClues.push_back(clueId);
-			y += h + 7;
-		}
-
-		// Page indicator + selected-points counter directly on PIC.
-		_font.drawString(&scratch, Common::String::format("p%d/%d",
-								   page + 1, (int)pageStarts.size()),
-						 270, 4, 320, 0x5C);
-		_font.drawString(&scratch, Common::String::format("%d pts",
-								   _mystery.selectedPoints()),
-						 270, 14, 320, 0x5C);
-		(void)hoveredNoteSlot;
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-
-		// Stash slot info on the captures so the click handler below
-		// can use it via the closure.
-		_notebookSlotRects = slotRects;
-		_notebookSlotClues = slotClues;
-	};
-
-	draw();
-
-	uint32 lastDraw = g_system->getMillis();
-
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool dirty = false;
-		bool exitFlag = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				exitFlag = true;
-				break;
-			}
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					exitFlag = true;
-					break;
-				}
-				if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
-					ev.kbd.keycode == Common::KEYCODE_PAGEUP) {
-					if (page > 0) page--;
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT ||
-						   ev.kbd.keycode == Common::KEYCODE_PAGEDOWN ||
-						   ev.kbd.keycode == Common::KEYCODE_TAB) {
-					page++;
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_h) {
-					doHelp();
-					dirty = true;
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Test buttons in the order the original would —
-				// button 0 / 9 are dead zones, so check the actionable
-				// rects directly. Earlier rects "win" when overlapping
-				// (matches `_FindButton`).
-				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
-					exitFlag = true;
-					break;  // back to site
-				}
-				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
-					doBigMap();
-					exitFlag = true;
-					break;
-				}
-				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
-					doHelp();              // _KDHelp = host hint
-					dirty = true;
-					continue;
-				}
-				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
-					doAccuse();
-					exitFlag = true;
-					break;
-				}
-				if (kBtnGallery.contains(ev.mouse.x, ev.mouse.y)) {
-					doGallery();
-					dirty = true;
-					continue;
-				}
-				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
-					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
-					// blits PICs 0x63 / 0x1ae fullscreen for click-through.
-					doInterfaceHelp(0);
-					dirty = true;
-					continue;
-				}
-				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
-					if (page > 0) page--;
-					dirty = true;
-					continue;
-				}
-				if (kBtnPageNext.contains(ev.mouse.x, ev.mouse.y)) {
-					page++;
-					dirty = true;
-					continue;
-				}
-				if (kNoteArea.contains(ev.mouse.x, ev.mouse.y)) {
-					// Toggle the selection on whichever clue's text
-					// the click landed in. The original calls
-					// `_InterfaceHelp` here; that's the help screen,
-					// not selection — selection is in the Accuse
-					// screen. We use the area for selection because
-					// keyboard 1..9 toggling is awkward, and the
-					// resulting `_NoteSelected` state is what
-					// `_SolvedCheck` reads.
-					for (uint i = 0; i < _notebookSlotRects.size(); i++) {
-						if (_notebookSlotRects[i].contains(ev.mouse.x,
-														   ev.mouse.y)) {
-							const uint clueId = _notebookSlotClues[i];
-							_mystery._noteSelected[clueId] ^= 1;
-							dirty = true;
-							break;
-						}
-					}
-					continue;
-				}
-			}
-		}
-		if (exitFlag)
-			break;
-
-		const uint32 now = g_system->getMillis();
-		// Re-render every 100 ms so the partner sprite cycles frames.
-		if (dirty || now - lastDraw >= 100) {
-			draw();
-			lastDraw = now;
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-}
-
-void EEMEngine::doGallery() {
-	// Mirrors `_DoGallery @ 158f:065b` and `_DrawGallery @ 158f:0046`.
-	// Verified directly from the disassembly:
-	//   * Background: PIC 0x3f (same as PDA).
-	//   * Partner sprite at (5, 0x50): anim 2 (Jake) / 0x10 (Jenny).
-	//     `_NewAnimation(5, 0x50, ...)`. NOTE: gallery uses anim 2/0x10,
-	//     PDA uses 1/0xb — different sprites.
-	//   * Five fixed slot positions at `29be:0x116` (4 bytes per slot,
-	//     `{u16 x, u16 y}`):
-	//         slot 0 = ( 83,  14)   slot 3 = (119,  90)
-	//         slot 1 = (155,  14)   slot 4 = (191,  90)
-	//         slot 2 = (227,  14)
-	//   * For each logical suspect i in 0..NumSuspects-1:
-	//         picId   = `*(u16 *)(_GalleryData + i * 0x46)` (entry +0).
-	//         visible = `_InGallery[_NewOrder[i]] != 0`.
-	//         drawX   = positions[_NewOrder[i]].x
-	//         drawY   = positions[_NewOrder[i]].y + (0x48 - pic.height)
-	//     So portraits are BOTTOM-aligned to baselines 0x48 + pos.y.
-	//   * Click on portrait via `_SearchSuspects` → `MoreInfo(i)` shows
-	//     the suspect detail page. ESC returns to PDA.
-	//   * Frame-cycled @ 100ms via `_CheckFrameRate` + `_UpdateAnimations`
-	//     + `_GizmoColorCycle`.
-	if (!_mystery.isLoaded())
-		return;
-
-	const byte *gd = _mystery.galleryData();
-	if (!gd) {
-		warning("doGallery: no GalleryData in mystery %u", _mystery.number());
-		return;
-	}
-
-	CursorMan.showMouse(true);
-
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, // 0
-		{ 155,  14 }, // 1
-		{ 227,  14 }, // 2
-		{ 119,  90 }, // 3
-		{ 191,  90 }  // 4
-	};
-
-	// Pre-load static elements once.
-	Picture galBg;
-	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
-
-	// Gallery partner anim — `_DoGallery` calls `_GetAnimation(uVar6)` with
-	// uVar6 = 2 (Jake) / 0x10 (Jenny). Different from PDA (1 / 0xb).
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
-							  && !partnerAni.empty();
-
-	const uint8 num = _mystery.numSuspects();
-
-	// Cache slot rects for click hit-testing.
-	Common::Array<Common::Rect> slotRects;
-	Common::Array<int> slotSuspect; // logical suspect index in [0, num)
-	slotRects.resize(num);
-	slotSuspect.resize(num);
-	for (uint i = 0; i < num; i++) {
-		slotSuspect[i] = -1;
-	}
-
-	auto drawFrame = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		if (haveBg) {
-			const int bw = MIN<int>(galBg.surface.w, 320);
-			const int bh = MIN<int>(galBg.surface.h, 200);
-			for (int row = 0; row < bh; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-			}
-		}
-
-		// Partner sprite frame @ (5, 0x50).
-		if (havePartner) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-			const Picture &fr = partnerAni[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			const int px = 5, py = 0x50;
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = py + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = px + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1
-		// and only renders those flagged in `_InGallery[NewOrder[i]]`.
-		// Undiscovered slots are left empty in the original. We render
-		// a darkened placeholder + "?" so the player has visual feedback
-		// that suspects exist but are still unknown.
-		uint discoveredCount = 0;
-		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
-			slotRects[i] = Common::Rect();
-			slotSuspect[i] = -1;
-
-			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5)
-				continue;
-			const Slot &s = kGallerySlots[phys];
-
-			const bool discovered = _mystery._inGallery[phys] != 0;
-			if (discovered) {
-				const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-				Picture portrait;
-				if (picId == 0 ||
-					!_picsArchive.getPicture(picId, portrait))
-					continue;
-
-				const int placeX = s.x;
-				const int placeY = s.y + (0x48 - portrait.surface.h);
-				const byte transp = (byte)(portrait.flags >> 8);
-				const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-				const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-				if (w <= 0 || h <= 0)
-					continue;
-				for (int row = 0; row < h; row++) {
-					const int dstY = placeY + row;
-					if (dstY < 0) continue;
-					const byte *src =
-						(const byte *)portrait.surface.getBasePtr(0, row);
-					byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-					for (int col = 0; col < w; col++) {
-						const int dstX = placeX + col;
-						if (src[col] != transp)
-							dst[dstX] = src[col];
-					}
-				}
-				slotRects[i] = Common::Rect(placeX, placeY,
-											 placeX + w, placeY + h);
-				slotSuspect[i] = (int)i;
-				discoveredCount++;
-			} else {
-				// Undiscovered placeholder — small framed "?" box at
-				// (s.x, s.y) sized 0x40 × 0x48 (typical portrait size).
-				const int phW = 0x40, phH = 0x48;
-				const int phX = s.x, phY = s.y;
-				if (phX + phW <= 320 && phY + phH <= 200) {
-					scratch.fillRect(Common::Rect(phX, phY,
-						phX + phW, phY + phH), 0x20);
-					scratch.frameRect(Common::Rect(phX, phY,
-						phX + phW, phY + phH), 0x5C);
-					if (_font.isLoaded()) {
-						_font.drawString(&scratch, "?",
-							phX + phW / 2 - 3,
-							phY + phH / 2 - 4, phW, 0x5C);
-					}
-				}
-			}
-		}
-		(void)discoveredCount;
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawFrame();
-	uint32 lastDraw = g_system->getMillis();
-
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool exitFlag = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				return;
-			}
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					exitFlag = true;
-					break;
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// PDA bottom-bar buttons mirror `_NoteButtons @ 29be:0147`.
-				// `_DoGallery @ 158f:065b` shares the SAME button table
-				// with `_DoNotebook` (both call `_FindButton` against the
-				// 11-entry table at 0x147). `_HandleGalleryButton @
-				// 158f:05c0` dispatches via a different jump table
-				// (158f:0645). Verified gallery button mapping:
-				//   rect 0 (134,155) → 0x05ef = `_NextScreen = 4` (NOTEBOOK)
-				//   rect 1 (93,115)  → 0x0625 = `_InterfaceHelp` (HELP)
-				//   rect 2 (157,178) → 0x0638 = generic exit (no-op)
-				//   rect 3 (5,80)    → 0x061e = `_KDHelp` (host hint)
-				//   rect 4 (180,201) → 0x05ff = `_SolvedCheck` -> SOLVE
-				//   rect 5 (204,224) → 0x0638 = generic exit
-				//   rect 6 (226,247) → 0x0638 = generic exit
-				//   rect 7 (7,177)   → 0x05f7 = `_NextScreen = 2` (MAP)
-				//   rect 8 (35,111)  → 0x05e4 = `_NextScreen = 3` (SITE)
-				const Common::Rect kBtnSite    ( 35, 111,  56, 136); // [8] SITE
-				const Common::Rect kBtnMap     (  7, 177,  57, 200); // [7] MAP
-				const Common::Rect kBtnAccuse  (180, 174, 201, 190); // [4] SOLVE
-				const Common::Rect kBtnNotebook(134, 174, 155, 190); // [0] NOTEBOOK (back to PDA notes)
-				const Common::Rect kBtnHelp    ( 93, 174, 115, 190); // [1] HELP
-				const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
-				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
-					exitFlag = true; break;
-				}
-				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
-					doBigMap();
-					exitFlag = true; break;
-				}
-				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
-					doAccuse();
-					exitFlag = true; break;
-				}
-				if (kBtnNotebook.contains(ev.mouse.x, ev.mouse.y)) {
-					// Already came from notebook; exiting returns to it.
-					exitFlag = true; break;
-				}
-				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {
-					// Gallery rect 1 → `_InterfaceHelp(0)` per jmp table at
-					// 158f:0625 (HandleGalleryButton). Same picture sequence
-					// as the notebook HELP button.
-					doInterfaceHelp(0);
-					lastDraw = 0;
-					continue;
-				}
-				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
-					doHelp();
-					lastDraw = 0;
-					continue;
-				}
-				// `_SearchSuspects` walks the per-slot rects and returns
-				// the suspect index. We mirror that with cached rects.
-				bool clicked = false;
-				for (uint i = 0; i < slotRects.size(); i++) {
-					if (slotSuspect[i] < 0) continue;
-					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
-						// `MoreInfo(i)` — show the suspect detail page.
-						// Mirrors `MoreInfo @ 158f:0419`:
-						//   _RefreshGalleryBackground();
-						//   _GetPicture(*(u16*)(gd + i*0x46));
-						//   _AddPicBackground(pic, 0x94, 0xf);
-						//   _DrawGalleryNotes(gd + i*0x46);
-						//   loop until ESC or button click.
-						// Suspect data layout (verified against M1):
-						//   +0..1: picId (used here AND for gallery slot)
-						//   +8..9: number of clues for this suspect
-						//   +0xa..??: array of u16 clue IDs (terminated
-						//             by 0xFFFF if shorter than count).
-						const uint suspectIdx = (uint)slotSuspect[i];
-						const byte *suspect = gd + suspectIdx * 0x46;
-						const uint16 detailPic =
-							READ_LE_UINT16(suspect + 0);
-						const uint16 clueCount =
-							READ_LE_UINT16(suspect + 8);
-
-						Graphics::ManagedSurface ms(320, 200,
-							Graphics::PixelFormat::createFormatCLUT8());
-						ms.clear();
-						if (haveBg) {
-							const int bw = MIN<int>(galBg.surface.w, 320);
-							const int bh = MIN<int>(galBg.surface.h, 200);
-							for (int row = 0; row < bh; row++) {
-								memcpy((byte *)ms.getBasePtr(0, row),
-									   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-							}
-						}
-						// Full suspect picture at (0x94, 0xf).
-						Picture detail;
-						if (_picsArchive.getPicture(detailPic, detail)) {
-							const byte transp =
-								(byte)(detail.flags >> 8);
-							const int dx = 0x94, dy = 0x0f;
-							const int dw = MIN<int>(detail.surface.w, 320 - dx);
-							const int dh = MIN<int>(detail.surface.h, 200 - dy);
-							for (int row = 0; row < dh; row++) {
-								const byte *src =
-									(const byte *)detail.surface.getBasePtr(0, row);
-								byte *dst =
-									(byte *)ms.getBasePtr(0, dy + row);
-								for (int col = 0; col < dw; col++) {
-									if (src[col] != transp)
-										dst[dx + col] = src[col];
-								}
-							}
-						}
-						// Suspect's clue notes inside _GalleryNoteRect
-						// = (78, 93, 288, 152), per 29be:0100. Cyan text
-						// renders directly on the PDA's natural blue
-						// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
-						const int rx = 78, ry = 93;
-						const int rw = 288 - 78, rh = 152 - 93;
-
-						const byte *ni = _mystery.noteIndex();
-						const uint16 niCount = _mystery.noteIndexCount();
-						int yPos = ry;
-						const int lineH = _font.getFontHeight() + 1;
-						bool drewAny = false;
-						for (uint k = 0; k < clueCount && k < 30; k++) {
-							const uint16 clueId =
-								READ_LE_UINT16(suspect + 0xa + k * 2);
-							if (clueId == 0xFFFF) break;
-							if (clueId >= Mystery::kCluesFoundCap ||
-								!_mystery._cluesFound[clueId])
-								continue;
-							if (!ni || clueId >= niCount) continue;
-							const uint16 textOff =
-								READ_LE_UINT16(ni + clueId * 4);
-							Common::String txt =
-								parseString(_mystery.textAt(textOff),
-											_playerName, _partner);
-							if (txt.empty()) continue;
-							const byte color =
-								_mystery._noteSelected[clueId] ? 0x3C : 0x5C;
-							const int hLine = _font.drawWordWrapped(
-								&ms, rx, yPos, rw, txt, color);
-							yPos += hLine + 7;
-							drewAny = true;
-							if (yPos + lineH > ry + rh) break;
-						}
-						if (!drewAny && _font.isLoaded()) {
-							_font.drawString(&ms,
-								"No clues yet for this suspect.",
-								rx, ry, rw, 0x5C);
-						}
-						// Header / footer text.
-						if (_font.isLoaded()) {
-							_font.drawString(&ms, "SUSPECT FILE",
-											  rx, ry - 11, rw, 0x3C);
-							_font.drawString(&ms, "(click / ESC: back)",
-											  rx, ry + rh + 2, rw, 0x3C);
-						}
-						g_system->copyRectToScreen(ms.getPixels(),
-							ms.pitch, 0, 0, 320, 200);
-						g_system->updateScreen();
-
-						// Wait for click or ESC. Drain the queued
-						// LBUTTONDOWN that triggered this MoreInfo first
-						// so we don't immediately accept it as the
-						// dismiss event.
-						g_system->delayMillis(150);
-						{
-							Common::Event drain;
-							while (g_system->getEventManager()->pollEvent(drain)) {
-								if (drain.type == Common::EVENT_QUIT ||
-									drain.type == Common::EVENT_RETURN_TO_LAUNCHER)
-									return;
-							}
-						}
-						bool back = false;
-						while (!back && !shouldQuit()) {
-							Common::Event e2;
-							while (g_system->getEventManager()->pollEvent(e2)) {
-								if (e2.type == Common::EVENT_LBUTTONDOWN ||
-									(e2.type == Common::EVENT_KEYDOWN &&
-									 (e2.kbd.keycode == Common::KEYCODE_ESCAPE ||
-									  e2.kbd.keycode == Common::KEYCODE_RETURN))) {
-									back = true;
-									break;
-								}
-								if (e2.type == Common::EVENT_QUIT ||
-									e2.type == Common::EVENT_RETURN_TO_LAUNCHER)
-									return;
-							}
-							g_system->delayMillis(20);
-						}
-						// Force gallery redraw immediately so the
-						// player isn't left looking at the dismissed
-						// MoreInfo screen until the next 100 ms tick.
-						drawFrame();
-						lastDraw = g_system->getMillis();
-						clicked = true;
-						break;
-					}
-				}
-				(void)clicked;
-			}
-		}
-		if (exitFlag) break;
-
-		const uint32 now = g_system->getMillis();
-		if (now - lastDraw >= 100) {
-			drawFrame();
-			lastDraw = now;
-		}
-		g_system->delayMillis(15);
-	}
-}
-
-void EEMEngine::doBigMap() {
-	// Two-stage flow that mirrors the original screen-1 wrapper at
-	// 20fe:120b and `_DoBigMap @ 20fe:09e7`:
-	//
-	//   STAGE 1 — Overview. PIC 0x42 + site icons drawn via the
-	//   `_DrawBigMapButtons` algorithm at BigMap coords MapData[+4/+6].
-	//   The original `_DoBigMap` returns sx/sy = (mouseX*2 - 0x74,
-	//   mouseY*2 - 0x55) when the player clicks inside `BigMapWindow`,
-	//   which is the scroll position into the SmallMap.
-	//
-	//   STAGE 2 — Detail zoom. PIC 0x43 frame + a 0xe9 × 0xab viewport
-	//   into BIGMAP.PIC at (2, 2), drawn by `DrawMap @ 20fe:1058` with
-	//   the (sx, sy) returned from stage 1. Site icons are stamped at
-	//   SmallMap coords MapData[+8/+0xa] via `_StampButtons`. Click on
-	//   a site icon → travel.
-	//
-	// MapData entry layout (14 bytes), verified directly from the
-	// disassembly of `_DrawBigMapButtons @ 20fe:0877` (`PUSH ES:[BX+4]`
-	// for X, `PUSH ES:[BX+6]` for Y, `CMP ES:[BX+0xc], 0` for crime)
-	// and `_StampButtons @ 20fe:0d2f` (`MOV AX, ES:[BX+8]`,
-	// `MOV AX, ES:[BX+0xa]`):
-	//   +0..3   ??? (not yet decoded)
-	//   +4..5   BigMap X
-	//   +6..7   BigMap Y
-	//   +8..9   SmallMap X
-	//   +0xa..b SmallMap Y
-	//   +0xc..d crime-flag
-
-	if (!_mystery.isLoaded())
-		return;
-
-	CursorMan.showMouse(true);
-
-	// `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
-	setSitePalette(0x24);
-
-	const Common::Rect kSetupRect(0xc7, 0x12, 0xc7 + 0x32, 0x12 + 0xa); // approx; original from globals
-	(void)kSetupRect; // not yet wired into our overlay
-
-	// ------------------------------------------------------------------
-	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
-	// ------------------------------------------------------------------
-
-	// `_DoBigMap @ 20fe:09e7` (20fe:0a44-0a99) registers a partner sprite
-	// on the overview frame. The animation depends on `_LastScreen`:
-	//   * When LastScreen == 2 (came from the site loop) the original
-	//     plays an entrance anim (`anum-1` for Jake / Jenny) at
-	//     (0x102, 0x50), then on END swaps to the idle anim at (0xfd,
-	//     0x50). We don't track LastScreen finely enough to distinguish,
-	//     so we render the IDLE pose at (0xfd, 0x50) which is what the
-	//     player sees the rest of the time anyway.
-	//   * Idle anim ID: Jake = 0x14 (20), Jenny = 0x12 (18).
-	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
-	Animation mapAnim;
-	const bool haveMapAnim = _aniArchive.loadAnimation(kMapAniId, mapAnim)
-							   && !mapAnim.empty();
-	const int kMapAnimX = 0xfd;
-	const int kMapAnimY = 0x50;
-
-	auto drawOverview = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		Picture frame;
-		if (_picsArchive.getPicture(0x42, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
-		// once at boot via `_GetPicture` (1-based IDs → entries N-1):
-		//   _DoneMarker  = PIC 0x20d  (already-searched site)
-		//   _SiteMarker  = PIC 0xc5   (default available site)
-		//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
-		// Picked per-site by `_DrawBigMapButtons @ 20fe:0877`:
-		//   1. SaveSiteComplete[i] → DoneMarker
-		//   2. else MapData[+0xc] != 0 → CrimeMarker
-		//   3. else SiteMarker
-		Picture done, normal, crimeM;
-		const bool haveDone   = _picsArchive.getPicture(0x20d, done);
-		const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
-		const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
-
-		auto blitMarker = [&](const Picture &m, int x, int y) {
-			const byte transp = (byte)(m.flags >> 8);
-			for (int row = 0; row < m.surface.h; row++) {
-				const int dstY = y + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)m.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < m.surface.w; col++) {
-					const int dstX = x + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		};
-
-		for (uint i = 0; i < _mystery.numSites(); i++) {
-			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-				continue;
-			const byte *entry = _mystery.mapEntry(i);
-			if (!entry)
-				continue;
-			const uint16 mx    = READ_LE_UINT16(entry + 0x4);
-			const uint16 my    = READ_LE_UINT16(entry + 0x6);
-			const uint16 crime = READ_LE_UINT16(entry + 0xc);
-			const bool   done_ = (i < Mystery::kVisitedSiteCap)
-								  && _mystery._visitedSite[i];
-
-			const Picture *m = nullptr;
-			if (done_ && haveDone)            m = &done;
-			else if (crime != 0 && haveCrime) m = &crimeM;
-			else if (haveNormal)              m = &normal;
-
-			if (m)
-				blitMarker(*m, (int)mx, (int)my);
-			else {
-				// Fallback if the markers couldn't be loaded.
-				const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
-				scratch.fillRect(mark, 0x0F);
-			}
-		}
-
-		// Partner sprite — masked-blit at (0xfd, 0x50). Same per-tick
-		// idle the original would run via `_UpdateAnimations` once the
-		// entrance one-shot transitions out (see `_DoBigMap` 0xae3-0xae7
-		// where it swaps animId on the 0x80 marker).
-		if (haveMapAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % mapAnim.size());
-			const Picture &fr = mapAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kMapAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kMapAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawOverview();
-	uint32 mapLastTick = g_system->getMillis();
-
-	// Static rectangles read directly from the binary at the labelled
-	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
-	const Common::Rect kBigMapWindow   (  0,   0, 247, 192); // 29be:1596
-	const Common::Rect kSetupBtnRect   (252,   4, 315,  42); // 29be:15ce
-
-	bool wantZoom = false;
-	int  zoomX = 0, zoomY = 0;
-	while (!shouldQuit()) {
-		Common::Event ev;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_KEYDOWN &&
-				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-				return;
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// SetupButtonRect → `_NextScreen = 6` (the original's
-				// settings screen). We use it as "back to menu":
-				// abandon the current mystery and return to case
-				// selection.
-				if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
-					_mystery.clear();
-					_nextScreen = kScreenInvalid;
-					return;
-				}
-				// Click in the BigMapWindow → zoom. Original formula:
-				//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
-				if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
-					int sx = ev.mouse.x * 2;
-					int sy = ev.mouse.y * 2;
-					sx = (sx < 0x75) ? 0 : sx - 0x74;
-					sy = (sy < 0x56) ? 0 : sy - 0x55;
-					zoomX = sx;
-					zoomY = sy;
-					wantZoom = true;
-					break;
-				}
-			}
-		}
-		if (wantZoom)
-			break;
-		// Cycle the partner-sprite frame every 100 ms (matching the
-		// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
-		const uint32 now = g_system->getMillis();
-		if (haveMapAnim && now - mapLastTick >= 100) {
-			mapLastTick = now;
-			drawOverview();
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(10);
-	}
-
-	if (!wantZoom)
-		return;
-
-	// ------------------------------------------------------------------
-	// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
-	// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
-	// ------------------------------------------------------------------
-
-	Common::File f;
-	if (!f.open(Common::Path("BIGMAP.PIC"))) {
-		warning("doBigMap: BIGMAP.PIC missing for detail view");
-		return;
-	}
-	const uint16 mapH = f.readUint16LE();
-	const uint16 mapW = f.readUint16LE();
-	if (mapW == 0 || mapH == 0)
-		return;
-	Common::Array<byte> mapPixels((uint32)mapW * mapH);
-	if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
-		warning("doBigMap: short read on BIGMAP.PIC for detail view");
-		return;
-	}
-
-	const int kMapWinW = 0xe9; // 233
-	const int kMapWinH = 0xab; // 171
-	const int kMapWinX = 2;
-	const int kMapWinY = 2;
-
-	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
-	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
-
-	// `_DoMapScreen @ 20fe:120b` (20fe:12cd-12f0): partner sprite on
-	// the detail-zoom screen. Jake = anim 0x13 (19), Jenny = anim 0x11
-	// (17). Position (0x101, 0x50) = (257, 80), seqnum 0x13. The cells
-	// here have a "looking at the map" pose, distinct from the BigMap
-	// overview entrance/idle.
-	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
-	Animation detailAnim;
-	const bool haveDetailAnim = _aniArchive.loadAnimation(kDetailAniId,
-														   detailAnim)
-								  && !detailAnim.empty();
-	const int kDetailAnimX = 0x101;
-	const int kDetailAnimY = 0x50;
-
-	auto drawDetail = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		Picture frame;
-		if (_picsArchive.getPicture(0x43, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
-		const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
-		for (int row = 0; row < copyH; row++) {
-			memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
-				   mapPixels.data() + (scrollY + row) * mapW + scrollX,
-				   copyW);
-		}
-
-		// Stamped site buttons. `_StampButtons @ 20fe:0d2f` does:
-		//   button = _GetButton(MapData[+0])      // BUTTON.DBD entry
-		//   destX  = MapData[+8],  destY = MapData[+0xa]
-		// then bakes the button PIC into the map bitmap. Each button
-		// sprite carries the site name baked in. We blit them on top
-		// of the BIGMAP.PIC viewport at the same SmallMap coords.
-		for (uint i = 0; i < _mystery.numSites(); i++) {
-			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-				continue;
-			const byte *entry = _mystery.mapEntry(i);
-			if (!entry) continue;
-			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
-			const uint16 mx       = READ_LE_UINT16(entry + 0x8);
-			const uint16 my       = READ_LE_UINT16(entry + 0xa);
-
-			Picture button;
-			if (!_buttonArchive.loadEntry(buttonId, button))
-				continue;
-			const int sx = (int)mx - scrollX + kMapWinX;
-			const int sy = (int)my - scrollY + kMapWinY;
-			const byte transp = (byte)(button.flags >> 8);
-
-			// Crop blit against the viewport.
-			const int x0 = MAX<int>(sx, kMapWinX);
-			const int y0 = MAX<int>(sy, kMapWinY);
-			const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
-			const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
-			for (int row = y0; row < y1; row++) {
-				const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
-				byte *dst = (byte *)scratch.getBasePtr(0, row);
-				for (int col = x0; col < x1; col++) {
-					const byte px = src[col - sx];
-					if (px != transp)
-						dst[col] = px;
-				}
-			}
-		}
-
-		// Partner sprite on the detail map. Drawn last so it sits over
-		// the frame and the BIGMAP.PIC viewport.
-		if (haveDetailAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx =
-				(uint)((now / 100) % detailAnim.size());
-			const Picture &fr = detailAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kDetailAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kDetailAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawDetail();
-	uint32 detailLastTick = g_system->getMillis();
-
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool dirty = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return;  // exit detail back to caller (site loop / engine)
-				const int kStep = 16;
-				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
-					scrollX = MAX<int>(0, scrollX - kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW),
-						scrollX + kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
-					scrollY = MAX<int>(0, scrollY - kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
-					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH),
-						scrollY + kStep);
-					dirty = true;
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Scroll arrows + slider rects live in `SmallMapButtons`
-				// at 29be:0x159e (six 8-byte rects in order: Y-up, Y-down,
-				// X-left, X-right, right-panel, top-right) plus the
-				// dedicated `XSliderRect @ 29be:15d6` and
-				// `YSliderRect @ 29be:15de`. Format {x1,y1,x2,y2}.
-				const Common::Rect kArrowYUp   (237,   2, 247,  11);
-				const Common::Rect kArrowYDown (237, 163, 247, 172);
-				const Common::Rect kArrowXLeft (  2, 175,  12, 185);
-				const Common::Rect kArrowXRight(224, 175, 234, 185);
-				const Common::Rect kXSlider    ( 15, 175, 221, 185);
-				const Common::Rect kYSlider    (237,  14, 247, 160);
-				const Common::Rect kSetupBtn   (252,   4, 315,  42);
-
-				const int kArrowStep = 16;
-				const int kSliderRange = mapW - kMapWinW;
-				const int kSliderRangeY = mapH - kMapWinH;
-
-				if (kSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
-					// Setup button on detail too — `_NextScreen = 6` in
-					// the original. We treat it the same way: bail back
-					// to case selection.
-					_mystery.clear();
-					_nextScreen = kScreenInvalid;
-					return;
-				}
-				if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollY = MAX<int>(0, scrollY - kArrowStep);
-					dirty = true;
-				} else if (kArrowYDown.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollY = MIN<int>(MAX<int>(0, kSliderRangeY),
-						scrollY + kArrowStep);
-					dirty = true;
-				} else if (kArrowXLeft.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollX = MAX<int>(0, scrollX - kArrowStep);
-					dirty = true;
-				} else if (kArrowXRight.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollX = MIN<int>(MAX<int>(0, kSliderRange),
-						scrollX + kArrowStep);
-					dirty = true;
-				} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
-					// Click on X slider track → jump scrollX so the
-					// click position maps proportionally into the map.
-					if (kSliderRange > 0) {
-						const int t = ev.mouse.x - kXSlider.left;
-						const int tw = kXSlider.width();
-						scrollX = MAX<int>(0, MIN<int>(kSliderRange,
-							t * kSliderRange / MAX<int>(1, tw)));
-						dirty = true;
-					}
-				} else if (kYSlider.contains(ev.mouse.x, ev.mouse.y)) {
-					if (kSliderRangeY > 0) {
-						const int t = ev.mouse.y - kYSlider.top;
-						const int th = kYSlider.height();
-						scrollY = MAX<int>(0, MIN<int>(kSliderRangeY,
-							t * kSliderRangeY / MAX<int>(1, th)));
-						dirty = true;
-					}
-				} else if (ev.mouse.x >= kMapWinX &&
-						   ev.mouse.x < kMapWinX + kMapWinW &&
-						   ev.mouse.y >= kMapWinY &&
-						   ev.mouse.y < kMapWinY + kMapWinH) {
-					// Hit-test the per-site button at its actual bbox
-					// (`_StampButtons` records the rect at SmallMap +8/+0xa
-					// with the button PIC's width/height).
-					for (uint i = 0; i < _mystery.numSites(); i++) {
-						if (!_mystery._onSites[i] &&
-							i != _mystery._siteNumber)
-							continue;
-						const byte *entry = _mystery.mapEntry(i);
-						if (!entry) continue;
-						const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
-						const uint16 mx       = READ_LE_UINT16(entry + 0x8);
-						const uint16 my       = READ_LE_UINT16(entry + 0xa);
-						Picture button;
-						int bw = 16, bh = 16;
-						if (_buttonArchive.loadEntry(buttonId, button)) {
-							bw = button.surface.w;
-							bh = button.surface.h;
-						}
-						const int sx = (int)mx - scrollX + kMapWinX;
-						const int sy = (int)my - scrollY + kMapWinY;
-						if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
-							ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
-							_mystery._lastSite = _mystery._siteNumber;
-							_mystery._siteNumber = (uint16)i;
-							return;
-						}
-					}
-				}
-			}
-		}
-		// Cycle the partner sprite at 100 ms ticks (same cadence as
-		// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
-		const uint32 now = g_system->getMillis();
-		if (haveDetailAnim && now - detailLastTick >= 100) {
-			detailLastTick = now;
-			dirty = true;
-		}
-		if (dirty)
-			drawDetail();
-		g_system->updateScreen();
-		g_system->delayMillis(10);
-	}
-}
-
-void EEMEngine::doHelp() {
-	// `_KDHelp` reads two hint TextBlock offsets from `_KDTextIndex`:
-	//   word @ +0xe : first-time hint
-	//   word @ +0x10: second-time hint (cycles back to first if missing)
-	// `_SawHelpHint` toggles between them.
-	if (!_mystery.isLoaded() || !_font.isLoaded())
-		return;
-
-	const byte *kd = _mystery.kdTextIndex();
-	if (!kd)
-		return;
-
-	const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
-	const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
-	uint16 use = _mystery._sawHelpHint && hintSecond != 0xFFFF ? hintSecond : hintFirst;
-	if (use == 0xFFFF) {
-		debugC(1, kDebugScript, "doHelp: no hint configured");
-		return;
-	}
-	if (!_mystery._sawHelpHint && hintFirst != 0xFFFF)
-		_mystery._sawHelpHint = true;
-
-	const Common::String raw = _mystery.textAt(use);
-	const Common::String text = parseString(raw, _playerName, _partner);
-
-	Graphics::ManagedSurface scratch(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
-	_font.drawString(&scratch, "HELP", 8, 4, 320, 0xF);
-	_font.drawWordWrapped(&scratch, 8, 24, 304, text, 0xF);
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
-	waitForInput(60000);
-}
-
-void EEMEngine::doInterfaceHelp(uint num) {
-	// Mirrors `_InterfaceHelp(num)` @ 1560:0205. The original walks
-	// `HelpData @ 29be:00c8` (5-byte entries: u8 count, then up to 2
-	// u16 picIds), `_GetPicture`s each one, blits it fullscreen via
-	// `_Rect_Move_Mask(0, 0, ...)`, and waits for click / key. ESC ends
-	// the cycle; any other input advances to the next pic.
-	//
-	// Verified from Ghidra HelpData bytes:
-	//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
-	//   entry 1: count=2, picIds = 0x0192, 0x01b1
-	// Only entry 0 is reachable from the PDA notebook (rect 1) and the
-	// gallery (rect 1) — both call `_InterfaceHelp(0)`.
-	static const uint16 kHelpPics[][2] = {
-		{ 0x0063, 0x01ae },
-		{ 0x0192, 0x01b1 },
-	};
-	if (num >= ARRAYSIZE(kHelpPics))
-		return;
-
-	debugC(1, kDebugScript, "doInterfaceHelp(%u): showing pics 0x%x, 0x%x",
-		   num, kHelpPics[num][0], kHelpPics[num][1]);
-
-	for (uint i = 0; i < 2; i++) {
-		const uint16 picId = kHelpPics[num][i];
-		Picture pic;
-		if (!_picsArchive.getPicture(picId, pic)) {
-			warning("doInterfaceHelp: getPicture(0x%x) failed", picId);
-			continue;
-		}
-		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
-			   picId, pic.surface.w, pic.surface.h, pic.flags);
-
-		// Compose a 320x200 frame (cleared) and blit the help pic at (0,0)
-		// with the original's masked-blit semantics: pixels equal to the
-		// pic's sub-mode (high byte of `pic[0]`, see `_Rect_Move_Mask`
-		// param_10 at 1000:03fc) are treated as transparent and skipped.
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		const byte transp = (byte)(pic.flags >> 8);
-		const int w = MIN<int>(pic.surface.w, 320);
-		const int h = MIN<int>(pic.surface.h, 200);
-		for (int row = 0; row < h; row++) {
-			const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, row);
-			for (int col = 0; col < w; col++) {
-				if (src[col] != transp)
-					dst[col] = src[col];
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-
-		bool escape = false;
-		while (!shouldQuit() && !escape) {
-			Common::Event ev;
-			bool advance = false;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-					return;
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					advance = true;
-					break;
-				}
-				if (ev.type == Common::EVENT_KEYDOWN) {
-					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-						escape = true;
-					else
-						advance = true;
-					break;
-				}
-			}
-			if (advance || escape)
-				break;
-			g_system->updateScreen();
-			g_system->delayMillis(15);
-		}
-		if (escape)
-			break;
-	}
-}
-
-uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
-	// Mirrors `_GetKDTextBalloon @ 1df2:0105`:
-	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
-	//   else                              bub = *(u16*)(29be:0fe6+0x1e+c*2)
-	// `ctype` is Borland's `_ctype_` array at `29be:2be1`. Bit 1 (0x02) is
-	// set only for digits '0'..'9' (verified by reading the table — '0'..'9'
-	// each map to byte 0x02; everything else has bit 1 clear).
-	// Lookup table at 29be:1064 (= 29be:0fe6 + 0x1e + '0'*2):
-	//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
-	//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
-	// Note `*(u16*)29be:1068` (= entry for '2') is the same byte the
-	// non-digit fallback returns — the original encodes the constant by
-	// reusing the digit-2 slot.
-	if (firstChar < '0' || firstChar > '9')
-		return 0x17;
-	static const uint16 kDigitBalloons[10] = {
-		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
-	};
-	return kDigitBalloons[firstChar - '0'];
-}
-
-void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
-	if (bg && bg->w == 320 && bg->h == 200) {
-		_partnerEraseBg.create(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)_partnerEraseBg.getBasePtr(0, row),
-				   (const byte *)bg->getBasePtr(0, row), 320);
-		}
-	} else {
-		_partnerEraseBg.free();
-	}
-}
-
-void EEMEngine::playKdAnim(uint16 num) {
-	// Mirrors `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
-	//   _SuspendAnimation(WaitHandle);
-	//   anim   = WaitAnims[1+num].anim[partner]   (table @ 29be:0228)
-	//   x      = WaitAnims[1+num].x[partner]
-	//   y      = WaitAnims[1+num].y[partner]
-	//   _PlayAnimation(anim, x, y, WaitHandle)
-	//     → registers a state-4 (one-shot) animation slot and lets
-	//       `_UpdateAnimations` walk the sequence script until 0x80,
-	//       then frees this slot and re-activates `WaitHandle`.
-	// Our port renders the partner's idle inline in each redraw rather
-	// than via a slot system, so we play the one-shot synchronously here
-	// (blocking) and resume normal idle rendering when the caller
-	// returns. That matches the user-visible effect: the partner's
-	// gesture (Jenny taking a picture, etc.) finishes before the
-	// speaker portrait + speech balloon appear.
-	//
-	// Six valid kdAnimNum entries (0..5). Verified bytes from
-	// `29be:0228`. Layout per entry: { animJake, animJenny, xJake,
-	// xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
-	static const uint16 kKdAnimTable[6][6] = {
-		{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
-		{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
-		{ 0x04, 0x0d, 6, 6, 80, 80 }, // 2
-		{ 0x02, 0x10, 6, 6, 80, 80 }, // 3 — same as gallery
-		{ 0x05, 0x05, 6, 6, 80, 80 }, // 4 — same anim both partners
-		{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
-	};
-	if (num >= ARRAYSIZE(kKdAnimTable))
-		return;
-
-	const uint partner = (_partner == 0) ? 0 : 1;
-	const uint16 animId = kKdAnimTable[num][partner];
-	const int    px     = (int)kKdAnimTable[num][2 + partner];
-	const int    py     = (int)kKdAnimTable[num][4 + partner];
-
-	Animation anim;
-	if (!_aniArchive.loadAnimation(animId, anim) || anim.empty()) {
-		warning("playKdAnim(%u): anim %u failed to load", num, animId);
-		return;
-	}
-
-	// Sequence-script lookup. Entries copied verbatim from
-	// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
-	// Each script is a u16[] of frame indices terminated by 0x80; we
-	// don't yet handle 0x81 jumps (none of the kdAnim sequences use
-	// them — verified). seqnum == animId for these calls (per
-	// `_PlayAnimation` 172b:1f5d push order).
-	struct Script {
-		uint16 seqnum;
-		uint8 len;
-		uint8 frames[20];  // long enough for any kdAnim script
-	};
-	static const Script kScripts[] = {
-		// seqnum 1 (29be:188a) — head bob
-		{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-		// seqnum 2 (29be:18aa) — short blip then long pause
-		{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
-		// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
-		{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
-		// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
-		{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-		// seqnum 5 (29be:1910) — held idle with a single peak
-		{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
-		// seqnum 6 (29be:192c) — empty (immediate END)
-		{ 0x06,  0, { 0 } },
-		// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
-		{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-		// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
-		{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
-		// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
-		{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-		// seqnum 0x10 (29be:1956) — Jenny short anim
-		{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
-	};
-	const uint8 *frames = nullptr;
-	uint frameCount = 0;
-	for (uint i = 0; i < ARRAYSIZE(kScripts); i++) {
-		if (kScripts[i].seqnum == animId) {
-			frames = kScripts[i].frames;
-			frameCount = kScripts[i].len;
-			break;
-		}
-	}
-	if (frameCount == 0) {
-		// Fallback: linear playback through anim cells (better than
-		// nothing if a future kdAnim references an unscripted anim).
-		frameCount = (uint)anim.size();
-	}
-
-	// Erase-source for between-frame redraw. Prefer the partner-less
-	// backdrop the caller stashed via `setPartnerEraseBg` (e.g. the
-	// site's `_bgSnapshot`, which has the static drops + frame but no
-	// partner sprite). Without that, fall back to whatever's currently
-	// on screen — which works for full-screen contexts (PDA / accuse /
-	// briefing) where there is no separate idle partner overlay to
-	// erase, but produces visible "ghosting" against the site's idle
-	// partner cell at (6, 80) because it has the resting pose baked in.
-	Graphics::ManagedSurface bg(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)bg.getBasePtr(0, row),
-				   (const byte *)_partnerEraseBg.getBasePtr(0, row), 320);
-		}
-	} else {
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (!screen)
-			return;
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)bg.getBasePtr(0, row),
-				   (const byte *)screen->getBasePtr(0, row), 320);
-		}
-		g_system->unlockScreen();
-	}
-
-	for (uint i = 0; i < frameCount && !shouldQuit(); i++) {
-		const uint frameIdx = frames ? (uint)frames[i] : i;
-		if (frameIdx >= anim.size())
-			continue;
-		const Picture &fr = anim[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-
-		// Restore BG, then masked-blit the next frame.
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)bg.getBasePtr(0, row), 320);
-		}
-		const int w = MIN<int>(fr.surface.w, 320 - px);
-		const int h = MIN<int>(fr.surface.h, 200 - py);
-		for (int row = 0; row < h; row++) {
-			const int dstY = py + row;
-			if (dstY < 0) continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < w; col++) {
-				const int dstX = px + col;
-				if (dstX < 0) continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-
-		// One frame per `_CheckFrameRate` tick. The original calibrates
-		// this to ~10 fps; 100 ms matches what the rest of the engine
-		// uses for partner / NPC frame cycling.
-		const uint32 wakeup = g_system->getMillis() + 100;
-		while (g_system->getMillis() < wakeup && !shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				// Drain events but don't allow skipping mid-animation —
-				// the speaker portrait + balloon haven't been drawn yet
-				// and a click would otherwise eat the upcoming clue.
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-					return;
-			}
-			g_system->delayMillis(10);
-		}
-	}
-
-	// Restore BG so the next caller (speaker portrait blit) starts clean.
-	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
-	g_system->updateScreen();
-}
-
-bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
-								  uint16 &yInset, uint16 &textW) const {
-	// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
-	// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
-	// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read here:
-	//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
-	// (+6/+8 = balloon h / tail offset, both unused for text layout).
-	static const struct { uint16 x, y, w; } kTable[] = {
-		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-		{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
-	};
-	const uint idx = bubNum & 0x7F;
-	if (idx >= ARRAYSIZE(kTable))
-		return false;
-	xInset = kTable[idx].x;
-	yInset = kTable[idx].y;
-	textW  = kTable[idx].w;
-	return true;
-}
-
-bool EEMEngine::areYouSure() {
-	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
-	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
-	// text dialog that preserves the screen behind it.
-	if (!_font.isLoaded())
-		return true;
-
-	Graphics::Surface *screen = g_system->lockScreen();
-	Graphics::ManagedSurface saved(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	if (screen) {
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)saved.getBasePtr(0, row),
-				   (const byte *)screen->getBasePtr(0, row), 320);
-		}
-		g_system->unlockScreen();
-	}
-
-	const Common::Rect dlg(60, 70, 260, 140);
-	Graphics::ManagedSurface scratch(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	for (int row = 0; row < 200; row++)
-		memcpy((byte *)scratch.getBasePtr(0, row),
-			   (const byte *)saved.getBasePtr(0, row), 320);
-	scratch.fillRect(dlg, 0);
-	scratch.frameRect(dlg, 0xF);
-	_font.drawString(&scratch, "Are you sure you want to quit?", dlg.left + 8, dlg.top + 8, 320, 0xF);
-	_font.drawString(&scratch, "Y - Yes", dlg.left + 16, dlg.top + 36, 320, 0xF);
-	_font.drawString(&scratch, "N - No", dlg.left + 100, dlg.top + 36, 320, 0xF);
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
-
-	bool result = false;
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool decided = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				result = true;
-				decided = true;
-				break;
-			}
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_y ||
-					ev.kbd.keycode == Common::KEYCODE_RETURN) {
-					result = true; decided = true; break;
-				}
-				if (ev.kbd.keycode == Common::KEYCODE_n ||
-					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					result = false; decided = true; break;
-				}
-			}
-		}
-		if (decided)
-			break;
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-
-	// Restore the screen so the caller's UI is intact.
-	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, 320, 200);
-	g_system->updateScreen();
-	return result;
-}
-
-void EEMEngine::doAccuse() {
-	if (!_mystery.isLoaded())
-		return;
-
-	// Mirrors `_DoAccuseGallery @ 1df2:0a31`:
-	//   1. Show KD's hint balloon (KDTextIndex[+8] text).
-	//   2. `_GetBackground(0x3f)` — same backdrop as PDA / gallery.
-	//   3. `_DrawGallery()` — renders portraits at the standard 5 slots
-	//      (positions verified at 29be:0x116, bottom-aligned baseline 0x48).
-	//   4. Click loop dispatching on `_NoteButtons` (same table as PDA)
-	//      with a separate `_HandleAccuseNoteButton` jump table.
-	const uint8 num = _mystery.numSuspects();
-	if (num == 0)
-		return;
-
-	const byte *gd = _mystery.galleryData();
-
-	// Verbatim from 29be:0x116 — same five suspect slot positions as
-	// `_DrawGallery @ 158f:0046`.
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, { 155,  14 }, { 227,  14 },
-		{ 119,  90 }, { 191,  90 }
-	};
-
-	Picture accuseBg;
-	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
-
-	Common::Array<Common::Rect> slotRects;
-	Common::Array<int> slotSuspect;
-	slotRects.resize(num);
-	slotSuspect.resize(num);
-	for (uint i = 0; i < num; i++)
-		slotSuspect[i] = -1;
-
-	int highlighted = 0;
-	auto drawGallery = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveAccuseBg) {
-			const int bw = MIN<int>(accuseBg.surface.w, 320);
-			const int bh = MIN<int>(accuseBg.surface.h, 200);
-			for (int row = 0; row < bh; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
-			}
-		}
-
-		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
-			slotRects[i] = Common::Rect();
-			slotSuspect[i] = -1;
-			if (!gd) continue;
-			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5) continue;
-			// `_DrawGallery @ 158f:00b9` skips suspects whose
-			// `_InGallery[phys]` flag is 0 — that's the original gate
-			// (some suspects only become visible after being met or
-			// stay hidden after a wrong accusation removes them).
-			if (_mystery._inGallery[phys] == 0) continue;
-			const Slot &s = kGallerySlots[phys];
-
-			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-			if (picId == 0) continue;
-			Picture portrait;
-			if (!_picsArchive.getPicture(picId, portrait))
-				continue;
-
-			const int placeX = s.x;
-			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const byte transp = (byte)(portrait.flags >> 8);
-			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-			if (w <= 0 || h <= 0) continue;
-			for (int row = 0; row < h; row++) {
-				const int dstY = placeY + row;
-				if (dstY < 0) continue;
-				const byte *src =
-					(const byte *)portrait.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < w; col++) {
-					const int dstX = placeX + col;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-			slotRects[i] = Common::Rect(placeX, placeY,
-										 placeX + w, placeY + h);
-			slotSuspect[i] = (int)i;
-		}
-
-		// Highlight indicator. The original moves the mouse cursor
-		// to the centre of the highlighted suspect via `_PutMouseInRect`
-		// (1df2:0b8e) — we draw a 1px outline in palette index 0xFE
-		// (within the marching-ants cycle range 0xF9..0xFE) which is
-		// unambiguously visible under any palette without warping the
-		// player's cursor.
-		if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
-			!slotRects[highlighted].isEmpty()) {
-			Common::Rect r = slotRects[highlighted];
-			r.grow(1);
-			scratch.frameRect(r, 0xFE);
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
-	// (1df2:0a4c-1df2:0afe):
-	//   text  = TextBlock + KDTextIndex[+8]               (1df2:0a4c-0a57)
-	//   bub   = _GetKDTextBalloon(text[0])                (1df2:0a6d)
-	//   GetBalloon(bub)                                   (1df2:0a7c)
-	//   y     = (h < 0x4e) ? (0x50 - h) >> 1 : 1          (1df2:0a8b-0aa5)
-	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
-	//   WordWrap(0x21+tbl[bub].x, y+tbl[bub].y, tbl[bub].w, text, color=0)
-	//     tbl @ 29be:0875, 10-byte entries (1df2:0ad6-0af1)
-	const byte *kdIdx = _mystery.kdTextIndex();
-	if (kdIdx) {
-		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
-		if (textOff != -1) {
-			const char *raw = _mystery.textAt((uint16)textOff);
-			Common::String hint =
-				parseString(raw ? raw : "", _playerName, _partner);
-			if (!hint.empty()) {
-				// First-char dispatch via getKDTextBalloon (1df2:0105).
-				// Note: we pass the *parsed* first char; the original
-				// reads it BEFORE `_ParseString`, but the player-name /
-				// partner-name substitutions never start with digits, so
-				// the dispatch result is the same either way.
-				const byte firstChar =
-					hint.empty() ? (byte)0 : (byte)hint[0];
-				const uint16 bubNum = getKDTextBalloon(firstChar);
-				Picture balloon;
-				const bool haveBalloon =
-					_balloonArchive.size() > (bubNum & 0x7F) &&
-					_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
-
-				// 1df2:0a8b-1df2:0aa5: y = (h < 0x4e) ? (0x50-h)>>1 : 1
-				const int balloonX = 0x21;
-				int balloonY = 1;
-				if (haveBalloon && balloon.surface.h < 0x4e)
-					balloonY = (0x50 - balloon.surface.h) / 2;
-
-				Graphics::ManagedSurface ms(320, 200,
-					Graphics::PixelFormat::createFormatCLUT8());
-				ms.clear();
-				if (haveAccuseBg) {
-					const int bw = MIN<int>(accuseBg.surface.w, 320);
-					const int bh = MIN<int>(accuseBg.surface.h, 200);
-					for (int row = 0; row < bh; row++) {
-						memcpy((byte *)ms.getBasePtr(0, row),
-							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
-					}
-				}
-				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)
-				// skips pixels equal to `pic[0] >> 8`.
-				if (haveBalloon) {
-					const byte transp = (byte)(balloon.flags >> 8);
-					const int bw = MIN<int>(balloon.surface.w, 320 - balloonX);
-					const int bh = MIN<int>(balloon.surface.h, 200 - balloonY);
-					for (int row = 0; row < bh; row++) {
-						const byte *src = (const byte *)balloon.surface.getBasePtr(0, row);
-						byte *dst = (byte *)ms.getBasePtr(balloonX, balloonY + row);
-						for (int col = 0; col < bw; col++) {
-							if (src[col] != transp)
-								dst[col] = src[col];
-						}
-					}
-				}
-				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
-				uint16 tx = 5, ty = 4, tw = 155;
-				getBalloonInsets(bubNum, tx, ty, tw);
-				if (_font.isLoaded()) {
-					_font.drawWordWrapped(&ms, balloonX + tx,
-										  balloonY + ty, tw, hint,
-										  haveBalloon ? 0 : 0xF);
-				}
-				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-					0, 0, 320, 200);
-				g_system->updateScreen();
-				waitForInput(8000);
-			}
-		}
-	}
-
-	// Helper to find the next "alive" slot (one whose `_inGallery[phys]`
-	// flag is still set so a portrait was actually drawn). Mirrors the
-	// way the original wraps DI past empty slots.
-	auto nextLiveSlot = [&](int from, int dir) -> int {
-		const int n = (int)slotRects.size();
-		if (n <= 0) return 0;
-		for (int step = 1; step <= n; step++) {
-			int idx = (from + dir * step) % n;
-			if (idx < 0) idx += n;
-			if (!slotRects[idx].isEmpty())
-				return idx;
-		}
-		return from;
-	};
-	if (slotRects[highlighted].isEmpty())
-		highlighted = nextLiveSlot(highlighted, +1);
-
-	drawGallery();
-
-	// Wait-for-pick loop. Mirrors `_DoAccuseGallery` 1df2:0b26-1df2:0bc8:
-	//   * `_CheckFrameRate` + `_UpdateAnimations` per tick (1df2:0b2a-0b33)
-	//   * 5-entry input dispatch table @ 1df2:0bc9:
-	//       0x09 (TAB)   → handler 0x0b94 (cycle highlight)
-	//       0x0d (Enter) → handler 0x0b72 (pick = _SearchSuspects)
-	//       0x4b (LEFT)  → handler 0x0b94
-	//       0x4d (RIGHT) → handler 0x0b94
-	//       0xFFFF (mb)  → handler 0x0b72
-	//   * 0x0b94: `INC DI` + wraparound + `_PutMouseInRect(&Guys[DI])`,
-	//     i.e. advance highlight and warp cursor (1df2:0b94-0bb1).
-	//   * 0x0b72: `_SearchSuspects` (158f:0584) — mouse-rect hit-test;
-	//     if non-0xFFFF, pick that suspect.
-	// We don't warp the cursor (unfriendly under SDL); instead the
-	// highlight is drawn as a 1px outline and Enter picks it.
-	int picked = -1;
-	uint32 lastTick = g_system->getMillis();
-	bool dirty = false;
-	while (picked < 0 && !shouldQuit()) {
-		Common::Event ev;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				switch (ev.kbd.keycode) {
-				case Common::KEYCODE_ESCAPE:
-					return;
-				case Common::KEYCODE_TAB:
-				case Common::KEYCODE_RIGHT:
-					highlighted = nextLiveSlot(highlighted, +1);
-					dirty = true;
-					break;
-				case Common::KEYCODE_LEFT:
-					// 1df2:0b94 increments DI for LEFT too — but a
-					// keyboard-driven UX is friendlier with separate
-					// directions, so we mirror Right=+1 / Left=-1.
-					highlighted = nextLiveSlot(highlighted, -1);
-					dirty = true;
-					break;
-				case Common::KEYCODE_RETURN:
-				case Common::KEYCODE_KP_ENTER:
-					if (highlighted >= 0 &&
-						highlighted < (int)slotRects.size() &&
-						!slotRects[highlighted].isEmpty()) {
-						picked = highlighted;
-					}
-					break;
-				default: {
-					const int k = (int)ev.kbd.keycode;
-					if (k >= Common::KEYCODE_1 && k <= Common::KEYCODE_9) {
-						const int idx = k - Common::KEYCODE_1;
-						if (idx < num &&
-							!slotRects[idx].isEmpty())
-							picked = idx;
-					}
-					break;
-				}
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				for (uint i = 0; i < slotRects.size(); i++) {
-					if (slotSuspect[i] < 0) continue;
-					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
-						picked = (int)i;
-						break;
-					}
-				}
-			}
-		}
-		// 100 ms tick — the original calls `_UpdateAnimations` per
-		// `_CheckFrameRate` (1df2:0b33). The accuse screen has no
-		// animations registered, so the tick is just a redraw cadence.
-		// We still re-render whenever the highlight moves (`dirty`).
-		const uint32 now = g_system->getMillis();
-		if (dirty || now - lastTick >= 100) {
-			drawGallery();
-			lastTick = now;
-			dirty = false;
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(10);
-	}
-	if (picked < 0)
-		return;
-
-	// Real chain evaluation: sum point values of clues the player marked
-	// "selected" in the notebook. Mirrors `_SolvedCheck` @ 1df2:00ec.
-	const int points = _mystery.selectedPoints();
-	const bool guessedRight = _mystery.solvedCheck();
-	debugC(1, kDebugScript, "doAccuse: picked=%d selectedPts=%d -> %s",
-		   picked, points, guessedRight ? "correct" : "wrong");
-
-	// If the player hasn't marked any evidence yet, give them a hint
-	// rather than an instant fail. Mirrors the original "We're not ready
-	// to solve this mystery yet..." string at 29be:10f0.
-	if (points == 0 && _font.isLoaded()) {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		_font.drawWordWrapped(&scratch, 16, 80, 288,
-			"We're not ready to solve this mystery yet. "
-			"Let's keep investigating until we have some "
-			"more solid evidence to make our case! "
-			"(Press N in the site screen to mark clues.)",
-			0xF);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-		waitForInput(15000);
-		return;
-	}
-
-	// Pick the ending based on the chain. For a correct accusation the
-	// original would call `_DisplayClue(_Mystery + AChain[0])`, play
-	// SCRAPBK.ANI and save progress. We load the matching `E<n>.BIN`
-	// ending text and render its pages with prev/next navigation.
-	const int endingNum = guessedRight ? picked : 0;
-	const Common::String fname = Common::String::format("E%d.BIN", endingNum);
-	Common::File f;
-	if (!f.open(Common::Path(fname))) {
-		warning("doAccuse: %s missing", fname.c_str());
-		return;
-	}
-
-	// E<n>.BIN format (verified against `_DisplayEndingPage` @ 1df2:044c):
-	//   u16 numPages
-	//   per page (10 bytes header + NUL-string):
-	//     u16 picNum
-	//     u16 x1, y1, x2, y2  (story rect)
-	//     bytes[] NUL-terminated text
-	const uint32 fileLen = f.size();
-	Common::Array<byte> blob(fileLen);
-	if (f.read(blob.data(), fileLen) != fileLen)
-		return;
-	const byte *e = blob.data();
-	const uint16 pages = READ_LE_UINT16(e);
-
-	uint pageIdx = 0;
-
-	while (!shouldQuit()) {
-		// Walk to pageIdx.
-		uint pos = 2;
-		uint cur = 0;
-		while (cur < pageIdx && pos + 10 < fileLen) {
-			const char *t = (const char *)(e + pos + 10);
-			pos += 10 + strlen(t) + 1;
-			cur++;
-		}
-		if (pos + 10 >= fileLen)
-			break;
-
-		const uint16 picNum = READ_LE_UINT16(e + pos + 0);
-		const uint16 x1     = READ_LE_UINT16(e + pos + 2);
-		const uint16 y1     = READ_LE_UINT16(e + pos + 4);
-		const uint16 x2     = READ_LE_UINT16(e + pos + 6);
-		const uint16 y2     = READ_LE_UINT16(e + pos + 8);
-		const char *raw     = (const char *)(e + pos + 10);
-		const Common::String txt = parseString(raw, _playerName, _partner);
-
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		// Page background.
-		if (picNum != 0) {
-			Picture bg;
-			if (_picsArchive.getPicture(picNum, bg)) {
-				const int w = MIN<int>(bg.surface.w, 320);
-				const int h = MIN<int>(bg.surface.h, 200);
-				for (int row = 0; row < h; row++) {
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)bg.surface.getBasePtr(0, row), w);
-				}
-			}
-		}
-
-		if (_font.isLoaded()) {
-			Common::String banner = "Not enough evidence";
-			if (guessedRight)
-				banner = _mystery._firstTry ? "CORRECT - FIRST TRY!" : "CORRECT!";
-			_font.drawString(&scratch, banner, 8, 4, 320, 0xF);
-			_font.drawString(&scratch, Common::String::format("Evidence: %d/100  Suspect: %d",
-									   points, picked + 1), 8, 16, 320, 0xF);
-			const int wrapW = MAX<int>(16, x2 - x1);
-			const int wrapY = MAX<int>(28, (int)y1);
-			(void)y2;
-			_font.drawWordWrapped(&scratch, x1, wrapY, wrapW, txt, 0xF);
-			_font.drawString(&scratch, Common::String::format("page %u/%u  (Left/Right or click)",
-									   pageIdx + 1, pages), 8, 188, 320, 0xF);
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-
-		// Page navigation.
-		bool advance = false;
-		bool back    = false;
-		bool exit    = false;
-		while (!advance && !back && !exit && !shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-					exit = true; break;
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					advance = true; break;
-				}
-				if (ev.type == Common::EVENT_KEYDOWN) {
-					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-						exit = true;
-					else if (ev.kbd.keycode == Common::KEYCODE_LEFT)
-						back = true;
-					else
-						advance = true;
-					break;
-				}
-			}
-			g_system->updateScreen();
-			g_system->delayMillis(15);
-		}
-		if (exit) break;
-		if (advance) {
-			if (pageIdx + 1 >= pages) break;
-			pageIdx++;
-		} else if (back) {
-			if (pageIdx > 0) pageIdx--;
-		}
-	}
-
-	// Mirror `_DisplayCorrect`'s scrap-book animation + solved tracking +
-	// auto-save (the original calls `_SavePlayerRecord` after a win).
-	if (guessedRight) {
-		const uint mn = _mystery.number();
-		if (mn < sizeof(_mysteriesSolved)) {
-			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
-		}
-		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
-
-		// Auto-save into slot 0 (the engine's quicksave slot).
-		const Common::String desc = Common::String::format(
-			"%s — solved mystery %u", _playerName.c_str(), mn);
-		Common::Error err = saveGameState(0, desc, true);
-		if (err.getCode() != Common::kNoError)
-			warning("auto-save after solve failed: %s",
-					err.getDesc().c_str());
-	} else {
-		_mystery._firstTry = false;
-	}
-}
-
-// -------------------- save / load --------------------
-
-namespace {
-const uint32 kSaveMagic = MKTAG('E', 'E', 'M', '0');
-const byte   kSaveVer   = 3;  ///< v2: _mysteriesSolved tracker; v3: player name
-} // anonymous namespace
-
 bool EEMEngine::hasFeature(EngineFeature f) const {
 	// We support saving any time but loading only at startup (via the
 	// `--save-slot=N` resume path or a slot picked from the launcher).
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 7b5c26a4be7..3777e5ac6ca 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -116,6 +116,13 @@ public:
 	/// ctype-bit-1 (= digit) at `29be:2be1 + char`.
 	uint16 getKDTextBalloon(byte firstChar) const;
 
+	/// Substitute the 0x80..0x89 control bytes the engine uses inside
+	/// `TextBlock` strings. Mirrors `_ParseString @ 1b66:07c3`; jump
+	/// table at 1b66:0cbe. Used by every clue / hint / balloon caller.
+	Common::String parseString(const Common::String &raw,
+							   const Common::String &playerName,
+							   uint partner) const;
+
 	/// Play the partner's one-shot reaction animation slot @num. Mirrors
 	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`. The
 	/// per-partner (animId, x, y) come from `_WaitAnims[1+num] @ 29be:0228`,
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
new file mode 100644
index 00000000000..857dcb80ecc
--- /dev/null
+++ b/engines/eem/graphics.cpp
@@ -0,0 +1,209 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/savefile.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+
+#include "graphics/cursorman.h"
+#include "graphics/managed_surface.h"
+
+#include "eem/detection.h"
+#include "eem/eem.h"
+
+// EEM — graphics helpers (KDGRAPH.C + KDHELP.C, the latter merged because
+// it has only three functions). Animation playback, balloon-table lookup,
+// and the help-screen primitives that share the same balloon machinery.
+
+namespace EEM {
+
+void EEMEngine::doHelp() {
+	// `_KDHelp` reads two hint TextBlock offsets from `_KDTextIndex`:
+	//   word @ +0xe : first-time hint
+	//   word @ +0x10: second-time hint (cycles back to first if missing)
+	// `_SawHelpHint` toggles between them.
+	if (!_mystery.isLoaded() || !_font.isLoaded())
+		return;
+
+	const byte *kd = _mystery.kdTextIndex();
+	if (!kd)
+		return;
+
+	const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
+	const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
+	uint16 use = _mystery._sawHelpHint && hintSecond != 0xFFFF ? hintSecond : hintFirst;
+	if (use == 0xFFFF) {
+		debugC(1, kDebugScript, "doHelp: no hint configured");
+		return;
+	}
+	if (!_mystery._sawHelpHint && hintFirst != 0xFFFF)
+		_mystery._sawHelpHint = true;
+
+	const Common::String raw = _mystery.textAt(use);
+	const Common::String text = parseString(raw, _playerName, _partner);
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	_font.drawString(&scratch, "HELP", 8, 4, 320, 0xF);
+	_font.drawWordWrapped(&scratch, 8, 24, 304, text, 0xF);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+	waitForInput(60000);
+}
+
+void EEMEngine::doInterfaceHelp(uint num) {
+	// Mirrors `_InterfaceHelp(num)` @ 1560:0205. The original walks
+	// `HelpData @ 29be:00c8` (5-byte entries: u8 count, then up to 2
+	// u16 picIds), `_GetPicture`s each one, blits it fullscreen via
+	// `_Rect_Move_Mask(0, 0, ...)`, and waits for click / key. ESC ends
+	// the cycle; any other input advances to the next pic.
+	//
+	// Verified from Ghidra HelpData bytes:
+	//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
+	//   entry 1: count=2, picIds = 0x0192, 0x01b1
+	// Only entry 0 is reachable from the PDA notebook (rect 1) and the
+	// gallery (rect 1) — both call `_InterfaceHelp(0)`.
+	static const uint16 kHelpPics[][2] = {
+		{ 0x0063, 0x01ae },
+		{ 0x0192, 0x01b1 },
+	};
+	if (num >= ARRAYSIZE(kHelpPics))
+		return;
+
+	debugC(1, kDebugScript, "doInterfaceHelp(%u): showing pics 0x%x, 0x%x",
+		   num, kHelpPics[num][0], kHelpPics[num][1]);
+
+	for (uint i = 0; i < 2; i++) {
+		const uint16 picId = kHelpPics[num][i];
+		Picture pic;
+		if (!_picsArchive.getPicture(picId, pic)) {
+			warning("doInterfaceHelp: getPicture(0x%x) failed", picId);
+			continue;
+		}
+		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
+			   picId, pic.surface.w, pic.surface.h, pic.flags);
+
+		// Compose a 320x200 frame (cleared) and blit the help pic at (0,0)
+		// with the original's masked-blit semantics: pixels equal to the
+		// pic's sub-mode (high byte of `pic[0]`, see `_Rect_Move_Mask`
+		// param_10 at 1000:03fc) are treated as transparent and skipped.
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		const byte transp = (byte)(pic.flags >> 8);
+		const int w = MIN<int>(pic.surface.w, 320);
+		const int h = MIN<int>(pic.surface.h, 200);
+		for (int row = 0; row < h; row++) {
+			const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, row);
+			for (int col = 0; col < w; col++) {
+				if (src[col] != transp)
+					dst[col] = src[col];
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		bool escape = false;
+		while (!shouldQuit() && !escape) {
+			Common::Event ev;
+			bool advance = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					return;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					advance = true;
+					break;
+				}
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+						escape = true;
+					else
+						advance = true;
+					break;
+				}
+			}
+			if (advance || escape)
+				break;
+			g_system->updateScreen();
+			g_system->delayMillis(15);
+		}
+		if (escape)
+			break;
+	}
+}
+
+void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
+	if (bg && bg->w == 320 && bg->h == 200) {
+		_partnerEraseBg.create(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)_partnerEraseBg.getBasePtr(0, row),
+				   (const byte *)bg->getBasePtr(0, row), 320);
+		}
+	} else {
+		_partnerEraseBg.free();
+	}
+}
+
+bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
+								  uint16 &yInset, uint16 &textW) const {
+	// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
+	// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
+	// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read here:
+	//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
+	// (+6/+8 = balloon h / tail offset, both unused for text layout).
+	static const struct { uint16 x, y, w; } kTable[] = {
+		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+		{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+	};
+	const uint idx = bubNum & 0x7F;
+	if (idx >= ARRAYSIZE(kTable))
+		return false;
+	xInset = kTable[idx].x;
+	yInset = kTable[idx].y;
+	textW  = kTable[idx].w;
+	return true;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index 047409575c9..5583335ffd2 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -2,12 +2,15 @@ MODULE := engines/eem
 
 MODULE_OBJS = \
 	animation.o \
+	clues.o \
 	eem.o \
 	font.o \
+	graphics.o \
 	metaengine.o \
 	mystery.o \
 	resource.o \
-	site.o
+	site.o \
+	ui.o
 
 # This module can be built as a plugin
 ifeq ($(ENABLE_EEM), DYNAMIC_PLUGIN)
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 8e119ab07f3..1e7f388e6eb 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -919,4 +919,174 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	// Caller (`SiteScreen::run`) re-renders the site after this returns.
 }
 
+void EEMEngine::playKdAnim(uint16 num) {
+	// Mirrors `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
+	//   _SuspendAnimation(WaitHandle);
+	//   anim   = WaitAnims[1+num].anim[partner]   (table @ 29be:0228)
+	//   x      = WaitAnims[1+num].x[partner]
+	//   y      = WaitAnims[1+num].y[partner]
+	//   _PlayAnimation(anim, x, y, WaitHandle)
+	//     → registers a state-4 (one-shot) animation slot and lets
+	//       `_UpdateAnimations` walk the sequence script until 0x80,
+	//       then frees this slot and re-activates `WaitHandle`.
+	// Our port renders the partner's idle inline in each redraw rather
+	// than via a slot system, so we play the one-shot synchronously here
+	// (blocking) and resume normal idle rendering when the caller
+	// returns. That matches the user-visible effect: the partner's
+	// gesture (Jenny taking a picture, etc.) finishes before the
+	// speaker portrait + speech balloon appear.
+	//
+	// Six valid kdAnimNum entries (0..5). Verified bytes from
+	// `29be:0228`. Layout per entry: { animJake, animJenny, xJake,
+	// xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
+	static const uint16 kKdAnimTable[6][6] = {
+		{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
+		{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
+		{ 0x04, 0x0d, 6, 6, 80, 80 }, // 2
+		{ 0x02, 0x10, 6, 6, 80, 80 }, // 3 — same as gallery
+		{ 0x05, 0x05, 6, 6, 80, 80 }, // 4 — same anim both partners
+		{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
+	};
+	if (num >= ARRAYSIZE(kKdAnimTable))
+		return;
+
+	const uint partner = (_partner == 0) ? 0 : 1;
+	const uint16 animId = kKdAnimTable[num][partner];
+	const int    px     = (int)kKdAnimTable[num][2 + partner];
+	const int    py     = (int)kKdAnimTable[num][4 + partner];
+
+	Animation anim;
+	if (!_aniArchive.loadAnimation(animId, anim) || anim.empty()) {
+		warning("playKdAnim(%u): anim %u failed to load", num, animId);
+		return;
+	}
+
+	// Sequence-script lookup. Entries copied verbatim from
+	// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
+	// Each script is a u16[] of frame indices terminated by 0x80; we
+	// don't yet handle 0x81 jumps (none of the kdAnim sequences use
+	// them — verified). seqnum == animId for these calls (per
+	// `_PlayAnimation` 172b:1f5d push order).
+	struct Script {
+		uint16 seqnum;
+		uint8 len;
+		uint8 frames[20];  // long enough for any kdAnim script
+	};
+	static const Script kScripts[] = {
+		// seqnum 1 (29be:188a) — head bob
+		{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+		// seqnum 2 (29be:18aa) — short blip then long pause
+		{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
+		// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
+		{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
+		// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
+		{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+		// seqnum 5 (29be:1910) — held idle with a single peak
+		{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
+		// seqnum 6 (29be:192c) — empty (immediate END)
+		{ 0x06,  0, { 0 } },
+		// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
+		{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+		// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
+		{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
+		// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
+		{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+		// seqnum 0x10 (29be:1956) — Jenny short anim
+		{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
+	};
+	const uint8 *frames = nullptr;
+	uint frameCount = 0;
+	for (uint i = 0; i < ARRAYSIZE(kScripts); i++) {
+		if (kScripts[i].seqnum == animId) {
+			frames = kScripts[i].frames;
+			frameCount = kScripts[i].len;
+			break;
+		}
+	}
+	if (frameCount == 0) {
+		// Fallback: linear playback through anim cells (better than
+		// nothing if a future kdAnim references an unscripted anim).
+		frameCount = (uint)anim.size();
+	}
+
+	// Erase-source for between-frame redraw. Prefer the partner-less
+	// backdrop the caller stashed via `setPartnerEraseBg` (e.g. the
+	// site's `_bgSnapshot`, which has the static drops + frame but no
+	// partner sprite). Without that, fall back to whatever's currently
+	// on screen — which works for full-screen contexts (PDA / accuse /
+	// briefing) where there is no separate idle partner overlay to
+	// erase, but produces visible "ghosting" against the site's idle
+	// partner cell at (6, 80) because it has the resting pose baked in.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)bg.getBasePtr(0, row),
+				   (const byte *)_partnerEraseBg.getBasePtr(0, row), 320);
+		}
+	} else {
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (!screen)
+			return;
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)bg.getBasePtr(0, row),
+				   (const byte *)screen->getBasePtr(0, row), 320);
+		}
+		g_system->unlockScreen();
+	}
+
+	for (uint i = 0; i < frameCount && !shouldQuit(); i++) {
+		const uint frameIdx = frames ? (uint)frames[i] : i;
+		if (frameIdx >= anim.size())
+			continue;
+		const Picture &fr = anim[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+
+		// Restore BG, then masked-blit the next frame.
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		for (int row = 0; row < 200; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)bg.getBasePtr(0, row), 320);
+		}
+		const int w = MIN<int>(fr.surface.w, 320 - px);
+		const int h = MIN<int>(fr.surface.h, 200 - py);
+		for (int row = 0; row < h; row++) {
+			const int dstY = py + row;
+			if (dstY < 0) continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < w; col++) {
+				const int dstX = px + col;
+				if (dstX < 0) continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// One frame per `_CheckFrameRate` tick. The original calibrates
+		// this to ~10 fps; 100 ms matches what the rest of the engine
+		// uses for partner / NPC frame cycling.
+		const uint32 wakeup = g_system->getMillis() + 100;
+		while (g_system->getMillis() < wakeup && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				// Drain events but don't allow skipping mid-animation —
+				// the speaker portrait + balloon haven't been drawn yet
+				// and a click would otherwise eat the upcoming clue.
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+					return;
+			}
+			g_system->delayMillis(10);
+		}
+	}
+
+	// Restore BG so the next caller (speaker portrait blit) starts clean.
+	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+}
 } // End of namespace EEM
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
new file mode 100644
index 00000000000..03bd77f609d
--- /dev/null
+++ b/engines/eem/ui.cpp
@@ -0,0 +1,2222 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/debug.h"
+#include "common/events.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/savefile.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+
+#include "graphics/cursorman.h"
+#include "graphics/managed_surface.h"
+
+#include "eem/detection.h"
+#include "eem/eem.h"
+
+// EEM — UI screens (NOTE.C, GALLERY.C, ACCUSE.C, MAP.C, CHOOSE.C combined).
+// Each function is a self-contained modal `EEMEngine::doX()` reachable from
+// the site loop. They share the same wait-for-input idiom and PIC 0x3f /
+// 0x41 / 0x42 / 0x43 backdrops.
+
+namespace EEM {
+
+void EEMEngine::doNewPlayer() {
+	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
+	// 0x104 + character peek pic 0x107, then shows "Please type your
+	// name" and accepts up to 12 characters until Enter. We render a
+	// minimal version: black screen + prompt.
+	if (!_font.isLoaded()) {
+		_playerName = "Detective";
+		return;
+	}
+
+	Common::String name;
+	const int maxChars = 12;
+
+	// Mirror the original: load PIC 0x104 as the name-entry backdrop.
+	// The original also slides in PIC 0x107 (a peeking character).
+	Picture bg;
+	const bool haveBG = _picsArchive.getPicture(0x104, bg);
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveBG) {
+			const int w = MIN<int>(bg.surface.w, 320);
+			const int h = MIN<int>(bg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.surface.getBasePtr(0, row), w);
+		}
+		// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
+		// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
+		// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
+		_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
+		Common::String shown = name + "_";
+		_font.drawString(&scratch, shown, 80, 80, 240, 0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_RETURN) {
+				if (name.empty())
+					name = "Detective";
+				_playerName = name;
+				return;
+			}
+			if (k == Common::KEYCODE_ESCAPE) {
+				_playerName = "Detective";
+				return;
+			}
+			if (k == Common::KEYCODE_BACKSPACE) {
+				if (!name.empty()) {
+					name.deleteLastChar();
+					dirty = true;
+				}
+				continue;
+			}
+			if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
+				(int)name.size() < maxChars) {
+				name += (char)ev.kbd.ascii;
+				dirty = true;
+			}
+		}
+		if (dirty)
+			draw();
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doCaseSelection() {
+	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
+	// (chooser background) plus a centred "Book %d" / "Challenge Book"
+	// header at (y=12) and then calls `_DoChoose(list)` to render the
+	// menu via `DrawList` @ 1c33:040d at (_TextBox+3, DAT_29be_0d02) =
+	// (61, 35), 12 rows × 10 px line height. The menu list itself is
+	// the static array at 29be:0d6a (verified via `push 0x0d6a` at
+	// 1c33:1ab4). Strings are at 29be:0ef4 onwards. Layout:
+	//   list[0]  = "----------------------------------"
+	//   list[1]  = "         Choose A Mystery"
+	//   list[2..10] = alternating menu items + separators
+	// Five selectable items: Choose A Mystery / Practice Mystery /
+	// See ScrapBook 1/2/3.
+	const uint kMaxMystery = 54;
+
+	enum MenuPick {
+		kPickChoose = 0,
+		kPickPractice,
+		kPickScrap1,
+		kPickScrap2,
+		kPickScrap3,
+		kNumPicks
+	};
+	const char *kPickLabel[kNumPicks] = {
+		"         Choose A Mystery",
+		"         Practice Mystery",
+		"         See ScrapBook 1",
+		"         See ScrapBook 2",
+		"         See ScrapBook 3"
+	};
+	// ScrapBooks aren't implemented yet — grey them so the player can't
+	// stop on them, mirroring the original `_Greys` mask.
+	const bool kPickEnabled[kNumPicks] = { true, true, false, false, false };
+	uint pick = kPickChoose;
+
+	const char *kSeparator = "----------------------------------";
+
+	// Click rectangles from the original `_DoChoose` @ 1c33:0514 — each
+	// `_InRect(_MouseX, _MouseY, addr, 0x29be)` reads one 4×u16 rect at
+	// the listed offset in segment 29be ({x1, y1, x2, y2}). We use
+	// `Common::Rect` (left/top/right/bottom) which also gives us
+	// `contains(x, y)` for hit testing.
+	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
+	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
+	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
+	const Common::Rect kUpArrowRect (240,  31, 250,  43); // 29be:0cf0 scroll up
+	const Common::Rect kDnArrowRect (240, 148, 250, 159); // 29be:0cf8 scroll dn
+	const Common::Rect kListRect    ( 58,  35, 238, 158); // 29be:0d00 list panel
+
+	// The original `_NewPlayer` set `_MouseCursor = 1` on exit; the
+	// chain of screens after it expects the cursor to stay visible.
+	// Reassert here in case anything between hid it.
+	CursorMan.showMouse(true);
+
+	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
+	Picture caseBg;
+	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
+
+	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` (1c33:0b7e-0ba1)
+	// loads anim 0x15 (Jake-paired) or 0x16 (Jenny-paired) and registers
+	// `_NewAnimation(0x112, 0x50, ..., seqnum=0x15, prior=1)` — partner-
+	// dependent because the host KD changes who's "with him" on the
+	// briefing intro frame. Runs continuously through the menu loop via
+	// `_UpdateAnimations`. We approximate with millis-based frame cycling.
+	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
+	Animation kdAnim;
+	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
+							 && !kdAnim.empty();
+	const int kKdAnimX = 0x112;
+	const int kKdAnimY = 0x50;
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveCaseBg) {
+			const int w = MIN<int>(caseBg.surface.w, 320);
+			const int h = MIN<int>(caseBg.surface.h, 200);
+			for (int row = 0; row < h; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
+			}
+		}
+
+		// KD greeter frame — masked-blit current animation cell at
+		// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
+		if (haveKdAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % kdAnim.size());
+			const Picture &fr = kdAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kKdAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kKdAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+		if (_font.isLoaded()) {
+			// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
+			// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
+			// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
+			const int kListX  = 58 + 3;
+			const int kListW  = 238 - kListX;
+			const int kListY0 = 35;
+			const int kLineH  = 10;
+
+			// Top centred "Book %d" / "Challenge Book" title — sprintf
+			// format strings at 29be:0deb / 29be:0dfa shown via
+			// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
+			// original. We don't track challenge tier yet so always
+			// show "Book 1".
+			const Common::String book = "Book 1";
+			const int titleW = _font.getStringWidth(book);
+			const int titleX = (0xba - titleW) / 2 + 0x3c;
+			_font.drawString(&scratch, book, titleX, 12, 320, 0xF);
+
+			// Render 11 list rows: separator + menu item pairs.
+			//   row 0  separator
+			//   row 1  Choose A Mystery
+			//   row 2  separator
+			//   row 3  Practice Mystery
+			//   ...
+			//   row 9  See ScrapBook 3
+			//   row 10 separator
+			for (int r = 0; r < 11; r++) {
+				const int y = kListY0 + r * kLineH;
+				if ((r & 1) == 0) {
+					_font.drawString(&scratch, kSeparator, kListX, y, kListW, 0x7);
+					continue;
+				}
+				const uint mp = (uint)(r >> 1);
+				const bool isSel  = (mp == pick);
+				const byte color  = isSel        ? 0xF :
+									kPickEnabled[mp] ? 0x7 : 0x8;
+				_font.drawString(&scratch, kPickLabel[mp], kListX, y, kListW, color);
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	auto pickPrev = [&]() {
+		for (int i = 0; i < (int)kNumPicks; i++) {
+			pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
+			if (kPickEnabled[pick])
+				break;
+		}
+	};
+	auto pickNext = [&]() {
+		for (int i = 0; i < (int)kNumPicks; i++) {
+			pick = (pick + 1) % kNumPicks;
+			if (kPickEnabled[pick])
+				break;
+		}
+	};
+
+	draw();
+	uint32 lastTick = g_system->getMillis();
+
+	bool exitChosen = false;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool confirmed = false;
+		// Redraw every 100 ms so the KD greeter cycles. Mirrors the
+		// `_CheckFrameRate` cadence in `_CaseSelection`'s main loop.
+		const uint32 now = g_system->getMillis();
+		if (haveKdAnim && now - lastTick >= 100) {
+			lastTick = now;
+			draw();
+		}
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// OK / EXIT / HELP buttons (rectangles from `_DoChoose`).
+				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
+					confirmed = true;
+					break;
+				}
+				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
+					exitChosen = true;
+					confirmed = true;
+					break;
+				}
+				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// HELP placeholder — original calls `_DisplayHint`;
+					// our help screen is wired to `H` later in the flow.
+					continue;
+				}
+				// List panel: click on a non-separator row selects the
+				// menu entry under the cursor.
+				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
+					const int kLineH = 10;
+					const int row = (ev.mouse.y - kListRect.top) / kLineH;
+					if ((row & 1) == 1) {
+						const uint mp = (uint)(row >> 1);
+						if (mp < kNumPicks && kPickEnabled[mp]) {
+							pick = mp;
+							draw();
+							continue;
+						}
+					}
+				}
+			}
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_ESCAPE) {
+				exitChosen = true;
+				confirmed = true;
+				break;
+			}
+			if (k == Common::KEYCODE_RETURN) {
+				confirmed = true;
+				break;
+			}
+			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
+				pickPrev();
+				draw();
+				continue;
+			}
+			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
+				k == Common::KEYCODE_TAB) {
+				pickNext();
+				draw();
+				continue;
+			}
+		}
+		if (confirmed) {
+			draw();
+			break;
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+
+	if (shouldQuit())
+		return;
+
+	if (exitChosen) {
+		_mystery.clear();
+		_nextScreen = kScreenInvalid;
+		return;
+	}
+
+	// "Practice Mystery" is the tutorial → mystery 0.
+	if (pick == kPickPractice) {
+		if (!_mystery.load(0, &_rng)) {
+			warning("doCaseSelection: failed to load practice mystery");
+			_mystery.clear();
+		}
+		return;
+	}
+
+	if (pick != kPickChoose) {
+		// ScrapBooks aren't implemented; bail back to the menu loop.
+		_mystery.clear();
+		return;
+	}
+
+	// "Choose A Mystery" sub-screen: pick a specific case from the
+	// 55-mystery roster. The original opens a different list here;
+	// we approximate with the tier-aware numeric chooser we used
+	// before. Default to the first unsolved mystery.
+	uint sel = 0;
+	for (uint i = 0; i <= kMaxMystery; i++) {
+		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
+			sel = i;
+			break;
+		}
+	}
+
+	auto drawSubmenu = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveCaseBg) {
+			const int w = MIN<int>(caseBg.surface.w, 320);
+			const int h = MIN<int>(caseBg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
+		}
+		if (_font.isLoaded()) {
+			const int kListX  = 61;
+			const int kListW  = 238 - kListX;
+			const int kListY0 = 35;
+			const int kLineH  = 10;
+			const int kVisible = 12;
+			int top = (int)sel - kVisible / 2;
+			if (top < 0) top = 0;
+			if (top + kVisible > (int)kMaxMystery + 1)
+				top = (int)kMaxMystery + 1 - kVisible;
+			for (int r = 0; r < kVisible; r++) {
+				const int idx = top + r;
+				if (idx > (int)kMaxMystery)
+					break;
+				char marker = ' ';
+				if ((uint)idx < sizeof(_mysteriesSolved)) {
+					if (_mysteriesSolved[idx] == 2) marker = '*';
+					else if (_mysteriesSolved[idx] == 1) marker = '+';
+				}
+				const char arrow = ((uint)idx == sel) ? '>' : ' ';
+				_font.drawString(&scratch,
+								 Common::String::format("%c %c Mystery %d", arrow, marker, idx),
+								 kListX, kListY0 + r * kLineH, kListW, 0xF);
+			}
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	drawSubmenu();
+	bool confirmed = false;
+	while (!confirmed && !shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Same `_DoChoose` rectangles as the top-level menu.
+				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
+					confirmed = true;
+					break;
+				}
+				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
+					_mystery.clear();
+					return;
+				}
+				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					sel = (sel == 0) ? kMaxMystery : sel - 1;
+					drawSubmenu();
+					continue;
+				}
+				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+					drawSubmenu();
+					continue;
+				}
+				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// Pick the row under the cursor.
+					const int kLineH = 10;
+					const int kVisible = 12;
+					int top = (int)sel - kVisible / 2;
+					if (top < 0) top = 0;
+					if (top + kVisible > (int)kMaxMystery + 1)
+						top = (int)kMaxMystery + 1 - kVisible;
+					const int row = (ev.mouse.y - kListRect.top) / kLineH;
+					const int idx = top + row;
+					if (idx >= 0 && idx <= (int)kMaxMystery) {
+						sel = (uint)idx;
+						drawSubmenu();
+					}
+					continue;
+				}
+			}
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (k == Common::KEYCODE_ESCAPE) {
+				_mystery.clear();
+				return;
+			}
+			if (k == Common::KEYCODE_RETURN) {
+				confirmed = true;
+				break;
+			}
+			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
+				sel = (uint)(k - Common::KEYCODE_0);
+				drawSubmenu();
+				continue;
+			}
+			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
+				sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+				drawSubmenu();
+				continue;
+			}
+			if (k == Common::KEYCODE_UP) {
+				sel = (sel == 0) ? kMaxMystery : sel - 1;
+				drawSubmenu();
+				continue;
+			}
+			if (k == Common::KEYCODE_PAGEDOWN) {
+				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
+				drawSubmenu();
+				continue;
+			}
+			if (k == Common::KEYCODE_PAGEUP) {
+				sel = (sel < 10) ? 0 : sel - 10;
+				drawSubmenu();
+				continue;
+			}
+			if (k == Common::KEYCODE_HOME) { sel = 0; drawSubmenu(); continue; }
+			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; drawSubmenu(); continue; }
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+
+	if (!_mystery.load(sel, &_rng)) {
+		warning("doCaseSelection: failed to load mystery %u", sel);
+		_mystery.clear();
+		return;
+	}
+	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
+		   sel, _mystery.numSites(), _mystery.numSuspects());
+}
+
+void EEMEngine::doNotebook() {
+	// Mirrors `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
+	// `_HandleNoteButton @ 161e:03cb`.
+	//
+	// Layout (verified from Ghidra labels in 29be:013f / 29be:0147):
+	//   _NotebookRect = (78, 12, 288, 152)   — note display rectangle.
+	//   _NoteButtons (11 entries, 8 bytes each, at 29be:0147):
+	//     [0]  (134, 174, 155, 190)  decorative — `_HandleNoteButton(0)`
+	//                                returns immediately (i-1 unsigned > 9).
+	//     [1]  (93,  174, 115, 190)  → `_InterfaceHelp(0)` (handler 0x3f9)
+	//     [2]  (157, 174, 178, 190)  → handler 0x477   (page nav)
+	//     [3]  (5,   80,  44, 110)   → `_KDHelp` (host hint, 0x403)
+	//     [4]  (180, 174, 201, 190)  → solve / accuse  (0x436)
+	//     [5]  (204, 174, 224, 190)  → `_NextScreen = 5` (gallery, 0x489)
+	//     [6]  (226, 174, 247, 190)  → handler 0x4ab
+	//     [7]  (7,   177,  57, 200)  → handler 0x480   (back to map)
+	//     [8]  (35,  111,  56, 136)  → `_NextScreen = 3` (site)
+	//     [9]  (0, 0, 0, 0)          → same exit as [8]
+	//     [10] (66,  79, 267, 174)   → `_InterfaceHelp(0)` (note area)
+	//   Background: PIC 0x3f.
+	//   Partner anim: anim 1 (Jake) / 0xb (Jenny) at (5, 80).
+	if (!_mystery.isLoaded() || !_font.isLoaded())
+		return;
+
+	// Button rects from `_NoteButtons @ 29be:0147` matched to handler
+	// addresses via the jump table at `_HandleNoteButton + 0xec` (i.e.
+	// 161e:04ec). Decoded handlers (i = rect_index, dispatch = handler[i-1]):
+	//   rect 0 (134,155) → no handler (i-1 underflows; original treats
+	//                      this as a decorative/no-op slot)
+	//   rect 1 (93,115)  → 0x03f9 = `_InterfaceHelp(0)`           (HELP)
+	//   rect 2 (157,178) → 0x0477 = `_NextScreen = 5`             (GALLERY)
+	//   rect 3 (5,80)    → 0x0403 = `_KDHelp`                     (host hint)
+	//   rect 4 (180,201) → 0x0436 = `_SolvedCheck` -> NextScreen=7 (SOLVE)
+	//   rect 5 (204,224) → 0x0489 = `_EraseNotes` + `_DrawNotes`  (PAGE NEXT)
+	//   rect 6 (226,247) → 0x04ab = decrement CurrentPage + redraw (PAGE PREV)
+	//   rect 7 (7,177)   → 0x0480 = `_NextScreen = 2`             (MAP)
+	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
+	//   rect 9 (0,0)     → 0x03ed = same as rect 8
+	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
+	const Common::Rect kNotebookRect(78, 12, 288, 152);
+	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1] HELP
+	const Common::Rect kBtnGallery (157, 174, 178, 190);  // [2] GALLERY
+	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3] KD HELP
+	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4] SOLVE
+	const Common::Rect kBtnPageNext(204, 174, 224, 190);  // [5] PAGE NEXT
+	const Common::Rect kBtnPagePrev(226, 174, 247, 190);  // [6] PAGE PREV
+	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7] MAP
+	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8] SITE
+	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10] note area
+
+	CursorMan.showMouse(true);
+
+	int page = 0;
+	int hoveredNoteSlot = -1;
+
+	// Build a list of found-clue indices, identical ordering to the
+	// original's iteration through `_CluesFound[]`.
+	auto buildFound = [&]() {
+		Common::Array<uint> found;
+		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
+			if (_mystery._cluesFound[i])
+				found.push_back(i);
+		return found;
+	};
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		// PIC 0x3f frame.
+		Picture frame;
+		if (_picsArchive.getPicture(0x3f, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+		}
+
+		// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
+		const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
+		Animation partnerAni;
+		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+			const Picture &fr = partnerAni[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = 80 + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = 5 + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
+		// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
+		// rendering each found clue's text inside `_NotebookRect` with
+		// word-wrap. Selected clues are highlighted (color 0x3c in the
+		// original's case-briefing palette).
+		const Common::Array<uint> found = buildFound();
+		const byte *ni = _mystery.noteIndex();
+		const uint16 niCount = _mystery.noteIndexCount();
+
+		const int kRectX = kNotebookRect.left;
+		const int kRectY = kNotebookRect.top;
+		const int kRectW = kNotebookRect.width();
+		const int kRectH = kNotebookRect.height();
+
+		// Walk forward to the start clue of the current page.
+		// Each page renders as many clues as fit in `kRectH`.
+		int clueCursor = 0;
+		Common::Array<int> pageStarts;
+		pageStarts.push_back(0);
+		{
+			const int lineH = _font.getFontHeight() + 1;
+			int y = kRectY;
+			while (clueCursor < (int)found.size()) {
+				const uint clueId = found[clueCursor];
+				Common::String txt;
+				if (ni && clueId < niCount) {
+					const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+					txt = parseString(_mystery.textAt(textOff),
+									  _playerName, _partner);
+				}
+				// Measure height by wrapping the text without drawing.
+				Common::Array<Common::String> wrapped;
+				_font.wordWrapText(txt, kRectW, wrapped);
+				const int h = (int)wrapped.size() * lineH;
+				if (y + h + 7 > kRectY + kRectH) {
+					// Page break before this clue.
+					y = kRectY;
+					pageStarts.push_back(clueCursor);
+				}
+				y += h + 7;
+				clueCursor++;
+			}
+			if (page >= (int)pageStarts.size())
+				page = (int)pageStarts.size() - 1;
+			if (page < 0)
+				page = 0;
+		}
+
+		// Track per-slot rectangles so the click handler can map a
+		// click in `kNoteArea` back to a clue index.
+		Common::Array<Common::Rect> slotRects;
+		Common::Array<uint> slotClues;
+
+		const int startClue = (page < (int)pageStarts.size())
+								? pageStarts[page] : 0;
+		const int endClue   = (page + 1 < (int)pageStarts.size())
+								? pageStarts[page + 1] : (int)found.size();
+
+		int y = kRectY;
+		for (int i = startClue; i < endClue; i++) {
+			const uint clueId = found[i];
+			Common::String txt;
+			if (ni && clueId < niCount) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				txt = parseString(_mystery.textAt(textOff),
+								  _playerName, _partner);
+			}
+			if (txt.empty())
+				txt = Common::String::format("clue %u", clueId);
+			// Per `_DrawNotes @ 161e:01d0`: text uses
+			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
+			// (light yellow-white) for selected. Both contrast cleanly
+			// with the PDA screen's natural blue, so we draw text
+			// directly on PIC 0x3f without an extra fill rectangle —
+			// matches the original design.
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, kRectW, wrapped);
+			const int lineH = _font.getFontHeight() + 1;
+			const int h = (int)wrapped.size() * lineH;
+			const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
+			for (uint li = 0; li < wrapped.size(); li++) {
+				_font.drawString(&scratch, wrapped[li], kRectX,
+								 y + (int)li * lineH, kRectW, color);
+			}
+			slotRects.push_back(Common::Rect(kRectX, y,
+											  kRectX + kRectW, y + h));
+			slotClues.push_back(clueId);
+			y += h + 7;
+		}
+
+		// Page indicator + selected-points counter directly on PIC.
+		_font.drawString(&scratch, Common::String::format("p%d/%d",
+								   page + 1, (int)pageStarts.size()),
+						 270, 4, 320, 0x5C);
+		_font.drawString(&scratch, Common::String::format("%d pts",
+								   _mystery.selectedPoints()),
+						 270, 14, 320, 0x5C);
+		(void)hoveredNoteSlot;
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Stash slot info on the captures so the click handler below
+		// can use it via the closure.
+		_notebookSlotRects = slotRects;
+		_notebookSlotClues = slotClues;
+	};
+
+	draw();
+
+	uint32 lastDraw = g_system->getMillis();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		bool exitFlag = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				exitFlag = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					exitFlag = true;
+					break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
+					ev.kbd.keycode == Common::KEYCODE_PAGEUP) {
+					if (page > 0) page--;
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT ||
+						   ev.kbd.keycode == Common::KEYCODE_PAGEDOWN ||
+						   ev.kbd.keycode == Common::KEYCODE_TAB) {
+					page++;
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_h) {
+					doHelp();
+					dirty = true;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Test buttons in the order the original would —
+				// button 0 / 9 are dead zones, so check the actionable
+				// rects directly. Earlier rects "win" when overlapping
+				// (matches `_FindButton`).
+				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					exitFlag = true;
+					break;  // back to site
+				}
+				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+					doBigMap();
+					exitFlag = true;
+					break;
+				}
+				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+					doHelp();              // _KDHelp = host hint
+					dirty = true;
+					continue;
+				}
+				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+					doAccuse();
+					exitFlag = true;
+					break;
+				}
+				if (kBtnGallery.contains(ev.mouse.x, ev.mouse.y)) {
+					doGallery();
+					dirty = true;
+					continue;
+				}
+				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
+					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
+					// blits PICs 0x63 / 0x1ae fullscreen for click-through.
+					doInterfaceHelp(0);
+					dirty = true;
+					continue;
+				}
+				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
+					if (page > 0) page--;
+					dirty = true;
+					continue;
+				}
+				if (kBtnPageNext.contains(ev.mouse.x, ev.mouse.y)) {
+					page++;
+					dirty = true;
+					continue;
+				}
+				if (kNoteArea.contains(ev.mouse.x, ev.mouse.y)) {
+					// Toggle the selection on whichever clue's text
+					// the click landed in. The original calls
+					// `_InterfaceHelp` here; that's the help screen,
+					// not selection — selection is in the Accuse
+					// screen. We use the area for selection because
+					// keyboard 1..9 toggling is awkward, and the
+					// resulting `_NoteSelected` state is what
+					// `_SolvedCheck` reads.
+					for (uint i = 0; i < _notebookSlotRects.size(); i++) {
+						if (_notebookSlotRects[i].contains(ev.mouse.x,
+														   ev.mouse.y)) {
+							const uint clueId = _notebookSlotClues[i];
+							_mystery._noteSelected[clueId] ^= 1;
+							dirty = true;
+							break;
+						}
+					}
+					continue;
+				}
+			}
+		}
+		if (exitFlag)
+			break;
+
+		const uint32 now = g_system->getMillis();
+		// Re-render every 100 ms so the partner sprite cycles frames.
+		if (dirty || now - lastDraw >= 100) {
+			draw();
+			lastDraw = now;
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doGallery() {
+	// Mirrors `_DoGallery @ 158f:065b` and `_DrawGallery @ 158f:0046`.
+	// Verified directly from the disassembly:
+	//   * Background: PIC 0x3f (same as PDA).
+	//   * Partner sprite at (5, 0x50): anim 2 (Jake) / 0x10 (Jenny).
+	//     `_NewAnimation(5, 0x50, ...)`. NOTE: gallery uses anim 2/0x10,
+	//     PDA uses 1/0xb — different sprites.
+	//   * Five fixed slot positions at `29be:0x116` (4 bytes per slot,
+	//     `{u16 x, u16 y}`):
+	//         slot 0 = ( 83,  14)   slot 3 = (119,  90)
+	//         slot 1 = (155,  14)   slot 4 = (191,  90)
+	//         slot 2 = (227,  14)
+	//   * For each logical suspect i in 0..NumSuspects-1:
+	//         picId   = `*(u16 *)(_GalleryData + i * 0x46)` (entry +0).
+	//         visible = `_InGallery[_NewOrder[i]] != 0`.
+	//         drawX   = positions[_NewOrder[i]].x
+	//         drawY   = positions[_NewOrder[i]].y + (0x48 - pic.height)
+	//     So portraits are BOTTOM-aligned to baselines 0x48 + pos.y.
+	//   * Click on portrait via `_SearchSuspects` → `MoreInfo(i)` shows
+	//     the suspect detail page. ESC returns to PDA.
+	//   * Frame-cycled @ 100ms via `_CheckFrameRate` + `_UpdateAnimations`
+	//     + `_GizmoColorCycle`.
+	if (!_mystery.isLoaded())
+		return;
+
+	const byte *gd = _mystery.galleryData();
+	if (!gd) {
+		warning("doGallery: no GalleryData in mystery %u", _mystery.number());
+		return;
+	}
+
+	CursorMan.showMouse(true);
+
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, // 0
+		{ 155,  14 }, // 1
+		{ 227,  14 }, // 2
+		{ 119,  90 }, // 3
+		{ 191,  90 }  // 4
+	};
+
+	// Pre-load static elements once.
+	Picture galBg;
+	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
+
+	// Gallery partner anim — `_DoGallery` calls `_GetAnimation(uVar6)` with
+	// uVar6 = 2 (Jake) / 0x10 (Jenny). Different from PDA (1 / 0xb).
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
+							  && !partnerAni.empty();
+
+	const uint8 num = _mystery.numSuspects();
+
+	// Cache slot rects for click hit-testing.
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<int> slotSuspect; // logical suspect index in [0, num)
+	slotRects.resize(num);
+	slotSuspect.resize(num);
+	for (uint i = 0; i < num; i++) {
+		slotSuspect[i] = -1;
+	}
+
+	auto drawFrame = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		if (haveBg) {
+			const int bw = MIN<int>(galBg.surface.w, 320);
+			const int bh = MIN<int>(galBg.surface.h, 200);
+			for (int row = 0; row < bh; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+			}
+		}
+
+		// Partner sprite frame @ (5, 0x50).
+		if (havePartner) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+			const Picture &fr = partnerAni[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			const int px = 5, py = 0x50;
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = py + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = px + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
+		// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1
+		// and only renders those flagged in `_InGallery[NewOrder[i]]`.
+		// Undiscovered slots are left empty in the original. We render
+		// a darkened placeholder + "?" so the player has visual feedback
+		// that suspects exist but are still unknown.
+		uint discoveredCount = 0;
+		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
+			slotRects[i] = Common::Rect();
+			slotSuspect[i] = -1;
+
+			const uint8 phys = _mystery._newOrder[i];
+			if (phys >= 5)
+				continue;
+			const Slot &s = kGallerySlots[phys];
+
+			const bool discovered = _mystery._inGallery[phys] != 0;
+			if (discovered) {
+				const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+				Picture portrait;
+				if (picId == 0 ||
+					!_picsArchive.getPicture(picId, portrait))
+					continue;
+
+				const int placeX = s.x;
+				const int placeY = s.y + (0x48 - portrait.surface.h);
+				const byte transp = (byte)(portrait.flags >> 8);
+				const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+				const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+				if (w <= 0 || h <= 0)
+					continue;
+				for (int row = 0; row < h; row++) {
+					const int dstY = placeY + row;
+					if (dstY < 0) continue;
+					const byte *src =
+						(const byte *)portrait.surface.getBasePtr(0, row);
+					byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+					for (int col = 0; col < w; col++) {
+						const int dstX = placeX + col;
+						if (src[col] != transp)
+							dst[dstX] = src[col];
+					}
+				}
+				slotRects[i] = Common::Rect(placeX, placeY,
+											 placeX + w, placeY + h);
+				slotSuspect[i] = (int)i;
+				discoveredCount++;
+			} else {
+				// Undiscovered placeholder — small framed "?" box at
+				// (s.x, s.y) sized 0x40 × 0x48 (typical portrait size).
+				const int phW = 0x40, phH = 0x48;
+				const int phX = s.x, phY = s.y;
+				if (phX + phW <= 320 && phY + phH <= 200) {
+					scratch.fillRect(Common::Rect(phX, phY,
+						phX + phW, phY + phH), 0x20);
+					scratch.frameRect(Common::Rect(phX, phY,
+						phX + phW, phY + phH), 0x5C);
+					if (_font.isLoaded()) {
+						_font.drawString(&scratch, "?",
+							phX + phW / 2 - 3,
+							phY + phH / 2 - 4, phW, 0x5C);
+					}
+				}
+			}
+		}
+		(void)discoveredCount;
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	drawFrame();
+	uint32 lastDraw = g_system->getMillis();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool exitFlag = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					exitFlag = true;
+					break;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// PDA bottom-bar buttons mirror `_NoteButtons @ 29be:0147`.
+				// `_DoGallery @ 158f:065b` shares the SAME button table
+				// with `_DoNotebook` (both call `_FindButton` against the
+				// 11-entry table at 0x147). `_HandleGalleryButton @
+				// 158f:05c0` dispatches via a different jump table
+				// (158f:0645). Verified gallery button mapping:
+				//   rect 0 (134,155) → 0x05ef = `_NextScreen = 4` (NOTEBOOK)
+				//   rect 1 (93,115)  → 0x0625 = `_InterfaceHelp` (HELP)
+				//   rect 2 (157,178) → 0x0638 = generic exit (no-op)
+				//   rect 3 (5,80)    → 0x061e = `_KDHelp` (host hint)
+				//   rect 4 (180,201) → 0x05ff = `_SolvedCheck` -> SOLVE
+				//   rect 5 (204,224) → 0x0638 = generic exit
+				//   rect 6 (226,247) → 0x0638 = generic exit
+				//   rect 7 (7,177)   → 0x05f7 = `_NextScreen = 2` (MAP)
+				//   rect 8 (35,111)  → 0x05e4 = `_NextScreen = 3` (SITE)
+				const Common::Rect kBtnSite    ( 35, 111,  56, 136); // [8] SITE
+				const Common::Rect kBtnMap     (  7, 177,  57, 200); // [7] MAP
+				const Common::Rect kBtnAccuse  (180, 174, 201, 190); // [4] SOLVE
+				const Common::Rect kBtnNotebook(134, 174, 155, 190); // [0] NOTEBOOK (back to PDA notes)
+				const Common::Rect kBtnHelp    ( 93, 174, 115, 190); // [1] HELP
+				const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
+				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					exitFlag = true; break;
+				}
+				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+					doBigMap();
+					exitFlag = true; break;
+				}
+				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+					doAccuse();
+					exitFlag = true; break;
+				}
+				if (kBtnNotebook.contains(ev.mouse.x, ev.mouse.y)) {
+					// Already came from notebook; exiting returns to it.
+					exitFlag = true; break;
+				}
+				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {
+					// Gallery rect 1 → `_InterfaceHelp(0)` per jmp table at
+					// 158f:0625 (HandleGalleryButton). Same picture sequence
+					// as the notebook HELP button.
+					doInterfaceHelp(0);
+					lastDraw = 0;
+					continue;
+				}
+				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+					doHelp();
+					lastDraw = 0;
+					continue;
+				}
+				// `_SearchSuspects` walks the per-slot rects and returns
+				// the suspect index. We mirror that with cached rects.
+				bool clicked = false;
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotSuspect[i] < 0) continue;
+					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
+						// `MoreInfo(i)` — show the suspect detail page.
+						// Mirrors `MoreInfo @ 158f:0419`:
+						//   _RefreshGalleryBackground();
+						//   _GetPicture(*(u16*)(gd + i*0x46));
+						//   _AddPicBackground(pic, 0x94, 0xf);
+						//   _DrawGalleryNotes(gd + i*0x46);
+						//   loop until ESC or button click.
+						// Suspect data layout (verified against M1):
+						//   +0..1: picId (used here AND for gallery slot)
+						//   +8..9: number of clues for this suspect
+						//   +0xa..??: array of u16 clue IDs (terminated
+						//             by 0xFFFF if shorter than count).
+						const uint suspectIdx = (uint)slotSuspect[i];
+						const byte *suspect = gd + suspectIdx * 0x46;
+						const uint16 detailPic =
+							READ_LE_UINT16(suspect + 0);
+						const uint16 clueCount =
+							READ_LE_UINT16(suspect + 8);
+
+						Graphics::ManagedSurface ms(320, 200,
+							Graphics::PixelFormat::createFormatCLUT8());
+						ms.clear();
+						if (haveBg) {
+							const int bw = MIN<int>(galBg.surface.w, 320);
+							const int bh = MIN<int>(galBg.surface.h, 200);
+							for (int row = 0; row < bh; row++) {
+								memcpy((byte *)ms.getBasePtr(0, row),
+									   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+							}
+						}
+						// Full suspect picture at (0x94, 0xf).
+						Picture detail;
+						if (_picsArchive.getPicture(detailPic, detail)) {
+							const byte transp =
+								(byte)(detail.flags >> 8);
+							const int dx = 0x94, dy = 0x0f;
+							const int dw = MIN<int>(detail.surface.w, 320 - dx);
+							const int dh = MIN<int>(detail.surface.h, 200 - dy);
+							for (int row = 0; row < dh; row++) {
+								const byte *src =
+									(const byte *)detail.surface.getBasePtr(0, row);
+								byte *dst =
+									(byte *)ms.getBasePtr(0, dy + row);
+								for (int col = 0; col < dw; col++) {
+									if (src[col] != transp)
+										dst[dx + col] = src[col];
+								}
+							}
+						}
+						// Suspect's clue notes inside _GalleryNoteRect
+						// = (78, 93, 288, 152), per 29be:0100. Cyan text
+						// renders directly on the PDA's natural blue
+						// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
+						const int rx = 78, ry = 93;
+						const int rw = 288 - 78, rh = 152 - 93;
+
+						const byte *ni = _mystery.noteIndex();
+						const uint16 niCount = _mystery.noteIndexCount();
+						int yPos = ry;
+						const int lineH = _font.getFontHeight() + 1;
+						bool drewAny = false;
+						for (uint k = 0; k < clueCount && k < 30; k++) {
+							const uint16 clueId =
+								READ_LE_UINT16(suspect + 0xa + k * 2);
+							if (clueId == 0xFFFF) break;
+							if (clueId >= Mystery::kCluesFoundCap ||
+								!_mystery._cluesFound[clueId])
+								continue;
+							if (!ni || clueId >= niCount) continue;
+							const uint16 textOff =
+								READ_LE_UINT16(ni + clueId * 4);
+							Common::String txt =
+								parseString(_mystery.textAt(textOff),
+											_playerName, _partner);
+							if (txt.empty()) continue;
+							const byte color =
+								_mystery._noteSelected[clueId] ? 0x3C : 0x5C;
+							const int hLine = _font.drawWordWrapped(
+								&ms, rx, yPos, rw, txt, color);
+							yPos += hLine + 7;
+							drewAny = true;
+							if (yPos + lineH > ry + rh) break;
+						}
+						if (!drewAny && _font.isLoaded()) {
+							_font.drawString(&ms,
+								"No clues yet for this suspect.",
+								rx, ry, rw, 0x5C);
+						}
+						// Header / footer text.
+						if (_font.isLoaded()) {
+							_font.drawString(&ms, "SUSPECT FILE",
+											  rx, ry - 11, rw, 0x3C);
+							_font.drawString(&ms, "(click / ESC: back)",
+											  rx, ry + rh + 2, rw, 0x3C);
+						}
+						g_system->copyRectToScreen(ms.getPixels(),
+							ms.pitch, 0, 0, 320, 200);
+						g_system->updateScreen();
+
+						// Wait for click or ESC. Drain the queued
+						// LBUTTONDOWN that triggered this MoreInfo first
+						// so we don't immediately accept it as the
+						// dismiss event.
+						g_system->delayMillis(150);
+						{
+							Common::Event drain;
+							while (g_system->getEventManager()->pollEvent(drain)) {
+								if (drain.type == Common::EVENT_QUIT ||
+									drain.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									return;
+							}
+						}
+						bool back = false;
+						while (!back && !shouldQuit()) {
+							Common::Event e2;
+							while (g_system->getEventManager()->pollEvent(e2)) {
+								if (e2.type == Common::EVENT_LBUTTONDOWN ||
+									(e2.type == Common::EVENT_KEYDOWN &&
+									 (e2.kbd.keycode == Common::KEYCODE_ESCAPE ||
+									  e2.kbd.keycode == Common::KEYCODE_RETURN))) {
+									back = true;
+									break;
+								}
+								if (e2.type == Common::EVENT_QUIT ||
+									e2.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									return;
+							}
+							g_system->delayMillis(20);
+						}
+						// Force gallery redraw immediately so the
+						// player isn't left looking at the dismissed
+						// MoreInfo screen until the next 100 ms tick.
+						drawFrame();
+						lastDraw = g_system->getMillis();
+						clicked = true;
+						break;
+					}
+				}
+				(void)clicked;
+			}
+		}
+		if (exitFlag) break;
+
+		const uint32 now = g_system->getMillis();
+		if (now - lastDraw >= 100) {
+			drawFrame();
+			lastDraw = now;
+		}
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doBigMap() {
+	// Two-stage flow that mirrors the original screen-1 wrapper at
+	// 20fe:120b and `_DoBigMap @ 20fe:09e7`:
+	//
+	//   STAGE 1 — Overview. PIC 0x42 + site icons drawn via the
+	//   `_DrawBigMapButtons` algorithm at BigMap coords MapData[+4/+6].
+	//   The original `_DoBigMap` returns sx/sy = (mouseX*2 - 0x74,
+	//   mouseY*2 - 0x55) when the player clicks inside `BigMapWindow`,
+	//   which is the scroll position into the SmallMap.
+	//
+	//   STAGE 2 — Detail zoom. PIC 0x43 frame + a 0xe9 × 0xab viewport
+	//   into BIGMAP.PIC at (2, 2), drawn by `DrawMap @ 20fe:1058` with
+	//   the (sx, sy) returned from stage 1. Site icons are stamped at
+	//   SmallMap coords MapData[+8/+0xa] via `_StampButtons`. Click on
+	//   a site icon → travel.
+	//
+	// MapData entry layout (14 bytes), verified directly from the
+	// disassembly of `_DrawBigMapButtons @ 20fe:0877` (`PUSH ES:[BX+4]`
+	// for X, `PUSH ES:[BX+6]` for Y, `CMP ES:[BX+0xc], 0` for crime)
+	// and `_StampButtons @ 20fe:0d2f` (`MOV AX, ES:[BX+8]`,
+	// `MOV AX, ES:[BX+0xa]`):
+	//   +0..3   ??? (not yet decoded)
+	//   +4..5   BigMap X
+	//   +6..7   BigMap Y
+	//   +8..9   SmallMap X
+	//   +0xa..b SmallMap Y
+	//   +0xc..d crime-flag
+
+	if (!_mystery.isLoaded())
+		return;
+
+	CursorMan.showMouse(true);
+
+	// `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
+	setSitePalette(0x24);
+
+	const Common::Rect kSetupRect(0xc7, 0x12, 0xc7 + 0x32, 0x12 + 0xa); // approx; original from globals
+	(void)kSetupRect; // not yet wired into our overlay
+
+	// ------------------------------------------------------------------
+	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
+	// ------------------------------------------------------------------
+
+	// `_DoBigMap @ 20fe:09e7` (20fe:0a44-0a99) registers a partner sprite
+	// on the overview frame. The animation depends on `_LastScreen`:
+	//   * When LastScreen == 2 (came from the site loop) the original
+	//     plays an entrance anim (`anum-1` for Jake / Jenny) at
+	//     (0x102, 0x50), then on END swaps to the idle anim at (0xfd,
+	//     0x50). We don't track LastScreen finely enough to distinguish,
+	//     so we render the IDLE pose at (0xfd, 0x50) which is what the
+	//     player sees the rest of the time anyway.
+	//   * Idle anim ID: Jake = 0x14 (20), Jenny = 0x12 (18).
+	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
+	Animation mapAnim;
+	const bool haveMapAnim = _aniArchive.loadAnimation(kMapAniId, mapAnim)
+							   && !mapAnim.empty();
+	const int kMapAnimX = 0xfd;
+	const int kMapAnimY = 0x50;
+
+	auto drawOverview = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		Picture frame;
+		if (_picsArchive.getPicture(0x42, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+		}
+
+		// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
+		// once at boot via `_GetPicture` (1-based IDs → entries N-1):
+		//   _DoneMarker  = PIC 0x20d  (already-searched site)
+		//   _SiteMarker  = PIC 0xc5   (default available site)
+		//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
+		// Picked per-site by `_DrawBigMapButtons @ 20fe:0877`:
+		//   1. SaveSiteComplete[i] → DoneMarker
+		//   2. else MapData[+0xc] != 0 → CrimeMarker
+		//   3. else SiteMarker
+		Picture done, normal, crimeM;
+		const bool haveDone   = _picsArchive.getPicture(0x20d, done);
+		const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
+		const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
+
+		auto blitMarker = [&](const Picture &m, int x, int y) {
+			const byte transp = (byte)(m.flags >> 8);
+			for (int row = 0; row < m.surface.h; row++) {
+				const int dstY = y + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)m.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < m.surface.w; col++) {
+					const int dstX = x + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		};
+
+		for (uint i = 0; i < _mystery.numSites(); i++) {
+			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+				continue;
+			const byte *entry = _mystery.mapEntry(i);
+			if (!entry)
+				continue;
+			const uint16 mx    = READ_LE_UINT16(entry + 0x4);
+			const uint16 my    = READ_LE_UINT16(entry + 0x6);
+			const uint16 crime = READ_LE_UINT16(entry + 0xc);
+			const bool   done_ = (i < Mystery::kVisitedSiteCap)
+								  && _mystery._visitedSite[i];
+
+			const Picture *m = nullptr;
+			if (done_ && haveDone)            m = &done;
+			else if (crime != 0 && haveCrime) m = &crimeM;
+			else if (haveNormal)              m = &normal;
+
+			if (m)
+				blitMarker(*m, (int)mx, (int)my);
+			else {
+				// Fallback if the markers couldn't be loaded.
+				const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
+				scratch.fillRect(mark, 0x0F);
+			}
+		}
+
+		// Partner sprite — masked-blit at (0xfd, 0x50). Same per-tick
+		// idle the original would run via `_UpdateAnimations` once the
+		// entrance one-shot transitions out (see `_DoBigMap` 0xae3-0xae7
+		// where it swaps animId on the 0x80 marker).
+		if (haveMapAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = (uint)((now / 100) % mapAnim.size());
+			const Picture &fr = mapAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kMapAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kMapAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	drawOverview();
+	uint32 mapLastTick = g_system->getMillis();
+
+	// Static rectangles read directly from the binary at the labelled
+	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
+	const Common::Rect kBigMapWindow   (  0,   0, 247, 192); // 29be:1596
+	const Common::Rect kSetupBtnRect   (252,   4, 315,  42); // 29be:15ce
+
+	bool wantZoom = false;
+	int  zoomX = 0, zoomY = 0;
+	while (!shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN &&
+				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+				return;
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// SetupButtonRect → `_NextScreen = 6` (the original's
+				// settings screen). We use it as "back to menu":
+				// abandon the current mystery and return to case
+				// selection.
+				if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
+					_mystery.clear();
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				// Click in the BigMapWindow → zoom. Original formula:
+				//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
+				if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
+					int sx = ev.mouse.x * 2;
+					int sy = ev.mouse.y * 2;
+					sx = (sx < 0x75) ? 0 : sx - 0x74;
+					sy = (sy < 0x56) ? 0 : sy - 0x55;
+					zoomX = sx;
+					zoomY = sy;
+					wantZoom = true;
+					break;
+				}
+			}
+		}
+		if (wantZoom)
+			break;
+		// Cycle the partner-sprite frame every 100 ms (matching the
+		// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
+		const uint32 now = g_system->getMillis();
+		if (haveMapAnim && now - mapLastTick >= 100) {
+			mapLastTick = now;
+			drawOverview();
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+
+	if (!wantZoom)
+		return;
+
+	// ------------------------------------------------------------------
+	// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
+	// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
+	// ------------------------------------------------------------------
+
+	Common::File f;
+	if (!f.open(Common::Path("BIGMAP.PIC"))) {
+		warning("doBigMap: BIGMAP.PIC missing for detail view");
+		return;
+	}
+	const uint16 mapH = f.readUint16LE();
+	const uint16 mapW = f.readUint16LE();
+	if (mapW == 0 || mapH == 0)
+		return;
+	Common::Array<byte> mapPixels((uint32)mapW * mapH);
+	if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
+		warning("doBigMap: short read on BIGMAP.PIC for detail view");
+		return;
+	}
+
+	const int kMapWinW = 0xe9; // 233
+	const int kMapWinH = 0xab; // 171
+	const int kMapWinX = 2;
+	const int kMapWinY = 2;
+
+	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
+	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
+
+	// `_DoMapScreen @ 20fe:120b` (20fe:12cd-12f0): partner sprite on
+	// the detail-zoom screen. Jake = anim 0x13 (19), Jenny = anim 0x11
+	// (17). Position (0x101, 0x50) = (257, 80), seqnum 0x13. The cells
+	// here have a "looking at the map" pose, distinct from the BigMap
+	// overview entrance/idle.
+	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
+	Animation detailAnim;
+	const bool haveDetailAnim = _aniArchive.loadAnimation(kDetailAniId,
+														   detailAnim)
+								  && !detailAnim.empty();
+	const int kDetailAnimX = 0x101;
+	const int kDetailAnimY = 0x50;
+
+	auto drawDetail = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		Picture frame;
+		if (_picsArchive.getPicture(0x43, frame)) {
+			const int w = MIN<int>(frame.surface.w, 320);
+			const int h = MIN<int>(frame.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)frame.surface.getBasePtr(0, row), w);
+		}
+
+		const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
+		const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
+		for (int row = 0; row < copyH; row++) {
+			memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
+				   mapPixels.data() + (scrollY + row) * mapW + scrollX,
+				   copyW);
+		}
+
+		// Stamped site buttons. `_StampButtons @ 20fe:0d2f` does:
+		//   button = _GetButton(MapData[+0])      // BUTTON.DBD entry
+		//   destX  = MapData[+8],  destY = MapData[+0xa]
+		// then bakes the button PIC into the map bitmap. Each button
+		// sprite carries the site name baked in. We blit them on top
+		// of the BIGMAP.PIC viewport at the same SmallMap coords.
+		for (uint i = 0; i < _mystery.numSites(); i++) {
+			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+				continue;
+			const byte *entry = _mystery.mapEntry(i);
+			if (!entry) continue;
+			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+			const uint16 mx       = READ_LE_UINT16(entry + 0x8);
+			const uint16 my       = READ_LE_UINT16(entry + 0xa);
+
+			Picture button;
+			if (!_buttonArchive.loadEntry(buttonId, button))
+				continue;
+			const int sx = (int)mx - scrollX + kMapWinX;
+			const int sy = (int)my - scrollY + kMapWinY;
+			const byte transp = (byte)(button.flags >> 8);
+
+			// Crop blit against the viewport.
+			const int x0 = MAX<int>(sx, kMapWinX);
+			const int y0 = MAX<int>(sy, kMapWinY);
+			const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
+			const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
+			for (int row = y0; row < y1; row++) {
+				const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
+				byte *dst = (byte *)scratch.getBasePtr(0, row);
+				for (int col = x0; col < x1; col++) {
+					const byte px = src[col - sx];
+					if (px != transp)
+						dst[col] = px;
+				}
+			}
+		}
+
+		// Partner sprite on the detail map. Drawn last so it sits over
+		// the frame and the BIGMAP.PIC viewport.
+		if (haveDetailAnim) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx =
+				(uint)((now / 100) % detailAnim.size());
+			const Picture &fr = detailAnim[frameIdx];
+			const byte transp = (byte)(fr.flags >> 8);
+			for (int row = 0; row < fr.surface.h; row++) {
+				const int dstY = kDetailAnimY + row;
+				if (dstY < 0 || dstY >= 200) continue;
+				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < fr.surface.w; col++) {
+					const int dstX = kDetailAnimX + col;
+					if (dstX < 0 || dstX >= 320) continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	drawDetail();
+	uint32 detailLastTick = g_system->getMillis();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return;  // exit detail back to caller (site loop / engine)
+				const int kStep = 16;
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					scrollX = MAX<int>(0, scrollX - kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW),
+						scrollX + kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
+					scrollY = MAX<int>(0, scrollY - kStep);
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
+					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH),
+						scrollY + kStep);
+					dirty = true;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				// Scroll arrows + slider rects live in `SmallMapButtons`
+				// at 29be:0x159e (six 8-byte rects in order: Y-up, Y-down,
+				// X-left, X-right, right-panel, top-right) plus the
+				// dedicated `XSliderRect @ 29be:15d6` and
+				// `YSliderRect @ 29be:15de`. Format {x1,y1,x2,y2}.
+				const Common::Rect kArrowYUp   (237,   2, 247,  11);
+				const Common::Rect kArrowYDown (237, 163, 247, 172);
+				const Common::Rect kArrowXLeft (  2, 175,  12, 185);
+				const Common::Rect kArrowXRight(224, 175, 234, 185);
+				const Common::Rect kXSlider    ( 15, 175, 221, 185);
+				const Common::Rect kYSlider    (237,  14, 247, 160);
+				const Common::Rect kSetupBtn   (252,   4, 315,  42);
+
+				const int kArrowStep = 16;
+				const int kSliderRange = mapW - kMapWinW;
+				const int kSliderRangeY = mapH - kMapWinH;
+
+				if (kSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
+					// Setup button on detail too — `_NextScreen = 6` in
+					// the original. We treat it the same way: bail back
+					// to case selection.
+					_mystery.clear();
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollY = MAX<int>(0, scrollY - kArrowStep);
+					dirty = true;
+				} else if (kArrowYDown.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollY = MIN<int>(MAX<int>(0, kSliderRangeY),
+						scrollY + kArrowStep);
+					dirty = true;
+				} else if (kArrowXLeft.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollX = MAX<int>(0, scrollX - kArrowStep);
+					dirty = true;
+				} else if (kArrowXRight.contains(ev.mouse.x, ev.mouse.y)) {
+					scrollX = MIN<int>(MAX<int>(0, kSliderRange),
+						scrollX + kArrowStep);
+					dirty = true;
+				} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
+					// Click on X slider track → jump scrollX so the
+					// click position maps proportionally into the map.
+					if (kSliderRange > 0) {
+						const int t = ev.mouse.x - kXSlider.left;
+						const int tw = kXSlider.width();
+						scrollX = MAX<int>(0, MIN<int>(kSliderRange,
+							t * kSliderRange / MAX<int>(1, tw)));
+						dirty = true;
+					}
+				} else if (kYSlider.contains(ev.mouse.x, ev.mouse.y)) {
+					if (kSliderRangeY > 0) {
+						const int t = ev.mouse.y - kYSlider.top;
+						const int th = kYSlider.height();
+						scrollY = MAX<int>(0, MIN<int>(kSliderRangeY,
+							t * kSliderRangeY / MAX<int>(1, th)));
+						dirty = true;
+					}
+				} else if (ev.mouse.x >= kMapWinX &&
+						   ev.mouse.x < kMapWinX + kMapWinW &&
+						   ev.mouse.y >= kMapWinY &&
+						   ev.mouse.y < kMapWinY + kMapWinH) {
+					// Hit-test the per-site button at its actual bbox
+					// (`_StampButtons` records the rect at SmallMap +8/+0xa
+					// with the button PIC's width/height).
+					for (uint i = 0; i < _mystery.numSites(); i++) {
+						if (!_mystery._onSites[i] &&
+							i != _mystery._siteNumber)
+							continue;
+						const byte *entry = _mystery.mapEntry(i);
+						if (!entry) continue;
+						const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+						const uint16 mx       = READ_LE_UINT16(entry + 0x8);
+						const uint16 my       = READ_LE_UINT16(entry + 0xa);
+						Picture button;
+						int bw = 16, bh = 16;
+						if (_buttonArchive.loadEntry(buttonId, button)) {
+							bw = button.surface.w;
+							bh = button.surface.h;
+						}
+						const int sx = (int)mx - scrollX + kMapWinX;
+						const int sy = (int)my - scrollY + kMapWinY;
+						if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
+							ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
+							_mystery._lastSite = _mystery._siteNumber;
+							_mystery._siteNumber = (uint16)i;
+							return;
+						}
+					}
+				}
+			}
+		}
+		// Cycle the partner sprite at 100 ms ticks (same cadence as
+		// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
+		const uint32 now = g_system->getMillis();
+		if (haveDetailAnim && now - detailLastTick >= 100) {
+			detailLastTick = now;
+			dirty = true;
+		}
+		if (dirty)
+			drawDetail();
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+}
+
+uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
+	// Mirrors `_GetKDTextBalloon @ 1df2:0105`:
+	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
+	//   else                              bub = *(u16*)(29be:0fe6+0x1e+c*2)
+	// `ctype` is Borland's `_ctype_` array at `29be:2be1`. Bit 1 (0x02) is
+	// set only for digits '0'..'9' (verified by reading the table — '0'..'9'
+	// each map to byte 0x02; everything else has bit 1 clear).
+	// Lookup table at 29be:1064 (= 29be:0fe6 + 0x1e + '0'*2):
+	//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
+	//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
+	// Note `*(u16*)29be:1068` (= entry for '2') is the same byte the
+	// non-digit fallback returns — the original encodes the constant by
+	// reusing the digit-2 slot.
+	if (firstChar < '0' || firstChar > '9')
+		return 0x17;
+	static const uint16 kDigitBalloons[10] = {
+		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
+	};
+	return kDigitBalloons[firstChar - '0'];
+}
+
+void EEMEngine::doAccuse() {
+	if (!_mystery.isLoaded())
+		return;
+
+	// Mirrors `_DoAccuseGallery @ 1df2:0a31`:
+	//   1. Show KD's hint balloon (KDTextIndex[+8] text).
+	//   2. `_GetBackground(0x3f)` — same backdrop as PDA / gallery.
+	//   3. `_DrawGallery()` — renders portraits at the standard 5 slots
+	//      (positions verified at 29be:0x116, bottom-aligned baseline 0x48).
+	//   4. Click loop dispatching on `_NoteButtons` (same table as PDA)
+	//      with a separate `_HandleAccuseNoteButton` jump table.
+	const uint8 num = _mystery.numSuspects();
+	if (num == 0)
+		return;
+
+	const byte *gd = _mystery.galleryData();
+
+	// Verbatim from 29be:0x116 — same five suspect slot positions as
+	// `_DrawGallery @ 158f:0046`.
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, { 155,  14 }, { 227,  14 },
+		{ 119,  90 }, { 191,  90 }
+	};
+
+	Picture accuseBg;
+	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
+
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<int> slotSuspect;
+	slotRects.resize(num);
+	slotSuspect.resize(num);
+	for (uint i = 0; i < num; i++)
+		slotSuspect[i] = -1;
+
+	int highlighted = 0;
+	auto drawGallery = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveAccuseBg) {
+			const int bw = MIN<int>(accuseBg.surface.w, 320);
+			const int bh = MIN<int>(accuseBg.surface.h, 200);
+			for (int row = 0; row < bh; row++) {
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+			}
+		}
+
+		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
+			slotRects[i] = Common::Rect();
+			slotSuspect[i] = -1;
+			if (!gd) continue;
+			const uint8 phys = _mystery._newOrder[i];
+			if (phys >= 5) continue;
+			// `_DrawGallery @ 158f:00b9` skips suspects whose
+			// `_InGallery[phys]` flag is 0 — that's the original gate
+			// (some suspects only become visible after being met or
+			// stay hidden after a wrong accusation removes them).
+			if (_mystery._inGallery[phys] == 0) continue;
+			const Slot &s = kGallerySlots[phys];
+
+			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+			if (picId == 0) continue;
+			Picture portrait;
+			if (!_picsArchive.getPicture(picId, portrait))
+				continue;
+
+			const int placeX = s.x;
+			const int placeY = s.y + (0x48 - portrait.surface.h);
+			const byte transp = (byte)(portrait.flags >> 8);
+			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+			if (w <= 0 || h <= 0) continue;
+			for (int row = 0; row < h; row++) {
+				const int dstY = placeY + row;
+				if (dstY < 0) continue;
+				const byte *src =
+					(const byte *)portrait.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < w; col++) {
+					const int dstX = placeX + col;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+			slotRects[i] = Common::Rect(placeX, placeY,
+										 placeX + w, placeY + h);
+			slotSuspect[i] = (int)i;
+		}
+
+		// Highlight indicator. The original moves the mouse cursor
+		// to the centre of the highlighted suspect via `_PutMouseInRect`
+		// (1df2:0b8e) — we draw a 1px outline in palette index 0xFE
+		// (within the marching-ants cycle range 0xF9..0xFE) which is
+		// unambiguously visible under any palette without warping the
+		// player's cursor.
+		if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
+			!slotRects[highlighted].isEmpty()) {
+			Common::Rect r = slotRects[highlighted];
+			r.grow(1);
+			scratch.frameRect(r, 0xFE);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
+	// (1df2:0a4c-1df2:0afe):
+	//   text  = TextBlock + KDTextIndex[+8]               (1df2:0a4c-0a57)
+	//   bub   = _GetKDTextBalloon(text[0])                (1df2:0a6d)
+	//   GetBalloon(bub)                                   (1df2:0a7c)
+	//   y     = (h < 0x4e) ? (0x50 - h) >> 1 : 1          (1df2:0a8b-0aa5)
+	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
+	//   WordWrap(0x21+tbl[bub].x, y+tbl[bub].y, tbl[bub].w, text, color=0)
+	//     tbl @ 29be:0875, 10-byte entries (1df2:0ad6-0af1)
+	const byte *kdIdx = _mystery.kdTextIndex();
+	if (kdIdx) {
+		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
+		if (textOff != -1) {
+			const char *raw = _mystery.textAt((uint16)textOff);
+			Common::String hint =
+				parseString(raw ? raw : "", _playerName, _partner);
+			if (!hint.empty()) {
+				// First-char dispatch via getKDTextBalloon (1df2:0105).
+				// Note: we pass the *parsed* first char; the original
+				// reads it BEFORE `_ParseString`, but the player-name /
+				// partner-name substitutions never start with digits, so
+				// the dispatch result is the same either way.
+				const byte firstChar =
+					hint.empty() ? (byte)0 : (byte)hint[0];
+				const uint16 bubNum = getKDTextBalloon(firstChar);
+				Picture balloon;
+				const bool haveBalloon =
+					_balloonArchive.size() > (bubNum & 0x7F) &&
+					_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+
+				// 1df2:0a8b-1df2:0aa5: y = (h < 0x4e) ? (0x50-h)>>1 : 1
+				const int balloonX = 0x21;
+				int balloonY = 1;
+				if (haveBalloon && balloon.surface.h < 0x4e)
+					balloonY = (0x50 - balloon.surface.h) / 2;
+
+				Graphics::ManagedSurface ms(320, 200,
+					Graphics::PixelFormat::createFormatCLUT8());
+				ms.clear();
+				if (haveAccuseBg) {
+					const int bw = MIN<int>(accuseBg.surface.w, 320);
+					const int bh = MIN<int>(accuseBg.surface.h, 200);
+					for (int row = 0; row < bh; row++) {
+						memcpy((byte *)ms.getBasePtr(0, row),
+							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+					}
+				}
+				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)
+				// skips pixels equal to `pic[0] >> 8`.
+				if (haveBalloon) {
+					const byte transp = (byte)(balloon.flags >> 8);
+					const int bw = MIN<int>(balloon.surface.w, 320 - balloonX);
+					const int bh = MIN<int>(balloon.surface.h, 200 - balloonY);
+					for (int row = 0; row < bh; row++) {
+						const byte *src = (const byte *)balloon.surface.getBasePtr(0, row);
+						byte *dst = (byte *)ms.getBasePtr(balloonX, balloonY + row);
+						for (int col = 0; col < bw; col++) {
+							if (src[col] != transp)
+								dst[col] = src[col];
+						}
+					}
+				}
+				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
+				uint16 tx = 5, ty = 4, tw = 155;
+				getBalloonInsets(bubNum, tx, ty, tw);
+				if (_font.isLoaded()) {
+					_font.drawWordWrapped(&ms, balloonX + tx,
+										  balloonY + ty, tw, hint,
+										  haveBalloon ? 0 : 0xF);
+				}
+				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
+					0, 0, 320, 200);
+				g_system->updateScreen();
+				waitForInput(8000);
+			}
+		}
+	}
+
+	// Helper to find the next "alive" slot (one whose `_inGallery[phys]`
+	// flag is still set so a portrait was actually drawn). Mirrors the
+	// way the original wraps DI past empty slots.
+	auto nextLiveSlot = [&](int from, int dir) -> int {
+		const int n = (int)slotRects.size();
+		if (n <= 0) return 0;
+		for (int step = 1; step <= n; step++) {
+			int idx = (from + dir * step) % n;
+			if (idx < 0) idx += n;
+			if (!slotRects[idx].isEmpty())
+				return idx;
+		}
+		return from;
+	};
+	if (slotRects[highlighted].isEmpty())
+		highlighted = nextLiveSlot(highlighted, +1);
+
+	drawGallery();
+
+	// Wait-for-pick loop. Mirrors `_DoAccuseGallery` 1df2:0b26-1df2:0bc8:
+	//   * `_CheckFrameRate` + `_UpdateAnimations` per tick (1df2:0b2a-0b33)
+	//   * 5-entry input dispatch table @ 1df2:0bc9:
+	//       0x09 (TAB)   → handler 0x0b94 (cycle highlight)
+	//       0x0d (Enter) → handler 0x0b72 (pick = _SearchSuspects)
+	//       0x4b (LEFT)  → handler 0x0b94
+	//       0x4d (RIGHT) → handler 0x0b94
+	//       0xFFFF (mb)  → handler 0x0b72
+	//   * 0x0b94: `INC DI` + wraparound + `_PutMouseInRect(&Guys[DI])`,
+	//     i.e. advance highlight and warp cursor (1df2:0b94-0bb1).
+	//   * 0x0b72: `_SearchSuspects` (158f:0584) — mouse-rect hit-test;
+	//     if non-0xFFFF, pick that suspect.
+	// We don't warp the cursor (unfriendly under SDL); instead the
+	// highlight is drawn as a 1px outline and Enter picks it.
+	int picked = -1;
+	uint32 lastTick = g_system->getMillis();
+	bool dirty = false;
+	while (picked < 0 && !shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				switch (ev.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
+					return;
+				case Common::KEYCODE_TAB:
+				case Common::KEYCODE_RIGHT:
+					highlighted = nextLiveSlot(highlighted, +1);
+					dirty = true;
+					break;
+				case Common::KEYCODE_LEFT:
+					// 1df2:0b94 increments DI for LEFT too — but a
+					// keyboard-driven UX is friendlier with separate
+					// directions, so we mirror Right=+1 / Left=-1.
+					highlighted = nextLiveSlot(highlighted, -1);
+					dirty = true;
+					break;
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER:
+					if (highlighted >= 0 &&
+						highlighted < (int)slotRects.size() &&
+						!slotRects[highlighted].isEmpty()) {
+						picked = highlighted;
+					}
+					break;
+				default: {
+					const int k = (int)ev.kbd.keycode;
+					if (k >= Common::KEYCODE_1 && k <= Common::KEYCODE_9) {
+						const int idx = k - Common::KEYCODE_1;
+						if (idx < num &&
+							!slotRects[idx].isEmpty())
+							picked = idx;
+					}
+					break;
+				}
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotSuspect[i] < 0) continue;
+					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
+						picked = (int)i;
+						break;
+					}
+				}
+			}
+		}
+		// 100 ms tick — the original calls `_UpdateAnimations` per
+		// `_CheckFrameRate` (1df2:0b33). The accuse screen has no
+		// animations registered, so the tick is just a redraw cadence.
+		// We still re-render whenever the highlight moves (`dirty`).
+		const uint32 now = g_system->getMillis();
+		if (dirty || now - lastTick >= 100) {
+			drawGallery();
+			lastTick = now;
+			dirty = false;
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+	if (picked < 0)
+		return;
+
+	// Real chain evaluation: sum point values of clues the player marked
+	// "selected" in the notebook. Mirrors `_SolvedCheck` @ 1df2:00ec.
+	const int points = _mystery.selectedPoints();
+	const bool guessedRight = _mystery.solvedCheck();
+	debugC(1, kDebugScript, "doAccuse: picked=%d selectedPts=%d -> %s",
+		   picked, points, guessedRight ? "correct" : "wrong");
+
+	// If the player hasn't marked any evidence yet, give them a hint
+	// rather than an instant fail. Mirrors the original "We're not ready
+	// to solve this mystery yet..." string at 29be:10f0.
+	if (points == 0 && _font.isLoaded()) {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		_font.drawWordWrapped(&scratch, 16, 80, 288,
+			"We're not ready to solve this mystery yet. "
+			"Let's keep investigating until we have some "
+			"more solid evidence to make our case! "
+			"(Press N in the site screen to mark clues.)",
+			0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+		waitForInput(15000);
+		return;
+	}
+
+	// Pick the ending based on the chain. For a correct accusation the
+	// original would call `_DisplayClue(_Mystery + AChain[0])`, play
+	// SCRAPBK.ANI and save progress. We load the matching `E<n>.BIN`
+	// ending text and render its pages with prev/next navigation.
+	const int endingNum = guessedRight ? picked : 0;
+	const Common::String fname = Common::String::format("E%d.BIN", endingNum);
+	Common::File f;
+	if (!f.open(Common::Path(fname))) {
+		warning("doAccuse: %s missing", fname.c_str());
+		return;
+	}
+
+	// E<n>.BIN format (verified against `_DisplayEndingPage` @ 1df2:044c):
+	//   u16 numPages
+	//   per page (10 bytes header + NUL-string):
+	//     u16 picNum
+	//     u16 x1, y1, x2, y2  (story rect)
+	//     bytes[] NUL-terminated text
+	const uint32 fileLen = f.size();
+	Common::Array<byte> blob(fileLen);
+	if (f.read(blob.data(), fileLen) != fileLen)
+		return;
+	const byte *e = blob.data();
+	const uint16 pages = READ_LE_UINT16(e);
+
+	uint pageIdx = 0;
+
+	while (!shouldQuit()) {
+		// Walk to pageIdx.
+		uint pos = 2;
+		uint cur = 0;
+		while (cur < pageIdx && pos + 10 < fileLen) {
+			const char *t = (const char *)(e + pos + 10);
+			pos += 10 + strlen(t) + 1;
+			cur++;
+		}
+		if (pos + 10 >= fileLen)
+			break;
+
+		const uint16 picNum = READ_LE_UINT16(e + pos + 0);
+		const uint16 x1     = READ_LE_UINT16(e + pos + 2);
+		const uint16 y1     = READ_LE_UINT16(e + pos + 4);
+		const uint16 x2     = READ_LE_UINT16(e + pos + 6);
+		const uint16 y2     = READ_LE_UINT16(e + pos + 8);
+		const char *raw     = (const char *)(e + pos + 10);
+		const Common::String txt = parseString(raw, _playerName, _partner);
+
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+
+		// Page background.
+		if (picNum != 0) {
+			Picture bg;
+			if (_picsArchive.getPicture(picNum, bg)) {
+				const int w = MIN<int>(bg.surface.w, 320);
+				const int h = MIN<int>(bg.surface.h, 200);
+				for (int row = 0; row < h; row++) {
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)bg.surface.getBasePtr(0, row), w);
+				}
+			}
+		}
+
+		if (_font.isLoaded()) {
+			Common::String banner = "Not enough evidence";
+			if (guessedRight)
+				banner = _mystery._firstTry ? "CORRECT - FIRST TRY!" : "CORRECT!";
+			_font.drawString(&scratch, banner, 8, 4, 320, 0xF);
+			_font.drawString(&scratch, Common::String::format("Evidence: %d/100  Suspect: %d",
+									   points, picked + 1), 8, 16, 320, 0xF);
+			const int wrapW = MAX<int>(16, x2 - x1);
+			const int wrapY = MAX<int>(28, (int)y1);
+			(void)y2;
+			_font.drawWordWrapped(&scratch, x1, wrapY, wrapW, txt, 0xF);
+			_font.drawString(&scratch, Common::String::format("page %u/%u  (Left/Right or click)",
+									   pageIdx + 1, pages), 8, 188, 320, 0xF);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Page navigation.
+		bool advance = false;
+		bool back    = false;
+		bool exit    = false;
+		while (!advance && !back && !exit && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					exit = true; break;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					advance = true; break;
+				}
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+						exit = true;
+					else if (ev.kbd.keycode == Common::KEYCODE_LEFT)
+						back = true;
+					else
+						advance = true;
+					break;
+				}
+			}
+			g_system->updateScreen();
+			g_system->delayMillis(15);
+		}
+		if (exit) break;
+		if (advance) {
+			if (pageIdx + 1 >= pages) break;
+			pageIdx++;
+		} else if (back) {
+			if (pageIdx > 0) pageIdx--;
+		}
+	}
+
+	// Mirror `_DisplayCorrect`'s scrap-book animation + solved tracking +
+	// auto-save (the original calls `_SavePlayerRecord` after a win).
+	if (guessedRight) {
+		const uint mn = _mystery.number();
+		if (mn < sizeof(_mysteriesSolved)) {
+			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
+		}
+		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
+
+		// Auto-save into slot 0 (the engine's quicksave slot).
+		const Common::String desc = Common::String::format(
+			"%s — solved mystery %u", _playerName.c_str(), mn);
+		Common::Error err = saveGameState(0, desc, true);
+		if (err.getCode() != Common::kNoError)
+			warning("auto-save after solve failed: %s",
+					err.getDesc().c_str());
+	} else {
+		_mystery._firstTry = false;
+	}
+}
+
+} // End of namespace EEM


Commit: 7bb7d3b82e69e044876a35ca8ddff7964cddd5e4
    https://github.com/scummvm/scummvm/commit/7bb7d3b82e69e044876a35ca8ddff7964cddd5e4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:40+02:00

Commit Message:
EEM: enforce code standards

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index a12136c304f..f667ce49a73 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -242,15 +242,18 @@ void EEMEngine::doInitClues() {
 	auto blitMaskedAt = [&](const Picture &p, int x, int y) {
 		const byte transp = (byte)(p.flags >> 8);
 		Graphics::Surface *screen = g_system->lockScreen();
-		if (!screen) return;
+		if (!screen)
+			return;
 		for (int row = 0; row < p.surface.h; row++) {
 			const int dstY = y + row;
-			if (dstY < 0 || dstY >= screen->h) continue;
+			if (dstY < 0 || dstY >= screen->h)
+				continue;
 			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
 			byte *dst = (byte *)screen->getBasePtr(0, dstY);
 			for (int col = 0; col < p.surface.w; col++) {
 				const int dstX = x + col;
-				if (dstX < 0 || dstX >= screen->w) continue;
+				if (dstX < 0 || dstX >= screen->w)
+					continue;
 				if (src[col] != transp)
 					dst[dstX] = src[col];
 			}
@@ -319,16 +322,33 @@ void EEMEngine::doInitClues() {
 	uint16 seqY   = 0x6c;
 	if (_partner == 0) {
 		switch (caseType) {
-		case 1: seqAni = 0x38; seqY = 0x6d; break;
-		case 2: seqAni = 0x37; seqY = 0x6c; break;
-		case 3: seqAni = 0x39; seqY = 0x6c; break;
-		default: break;
+		case 1:
+			seqAni = 0x38;
+			seqY = 0x6d;
+			break;
+		case 2:
+			seqAni = 0x37;
+			seqY = 0x6c;
+			break;
+		case 3:
+			seqAni = 0x39;
+			seqY = 0x6c;
+			break;
+		default:
+			break;
 		}
 	} else {
 		switch (caseType) {
-		case 2: seqAni = 0x3a; seqY = 0x6c; break;
-		case 3: seqAni = 0x3d; seqY = 0x6c; break;
-		default: break;
+		case 2:
+			seqAni = 0x3a;
+			seqY = 0x6c;
+			break;
+		case 3:
+			seqAni = 0x3d;
+			seqY = 0x6c;
+			break;
+		default:
+			break;
 		}
 	}
 	if (seqAni != 0xFFFF) {
@@ -574,7 +594,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			// copy the changed band back. This preserves the site BG
 			// underneath unchanged regions.
 			Graphics::Surface *screen = g_system->lockScreen();
-			if (!screen) break;
+			if (!screen)
+				break;
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
 			for (int row = 0; row < 200; row++) {
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index cdbc7e4ac37..b33fb699ae1 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -147,7 +147,8 @@ Common::Error EEMEngine::run() {
 				doSiteLoop();
 			while (!shouldQuit()) {
 				doCaseSelection();
-				if (!_mystery.isLoaded()) break;
+				if (!_mystery.isLoaded())
+					break;
 				doInitClues();
 				doBigMap();
 				if (_mystery.isLoaded())
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 1e7f388e6eb..f0eb4342801 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -465,12 +465,14 @@ static void blitMaskedSurface(Graphics::Surface *screen,
 	const byte transp = (byte)(p.flags >> 8);
 	for (int row = 0; row < p.surface.h; row++) {
 		const int dstY = y + row;
-		if (dstY < 0 || dstY >= screen->h) continue;
+		if (dstY < 0 || dstY >= screen->h)
+			continue;
 		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
 		byte *dst = (byte *)screen->getBasePtr(0, dstY);
 		for (int col = 0; col < p.surface.w; col++) {
 			const int dstX = x + col;
-			if (dstX < 0 || dstX >= screen->w) continue;
+			if (dstX < 0 || dstX >= screen->w)
+				continue;
 			if (src[col] != transp)
 				dst[dstX] = src[col];
 		}
@@ -598,12 +600,15 @@ void SiteScreen::applyColorCycles() {
 	// 0xf9..0xfe for hotspot marching ants (the `_ColorCycle(0xf9,
 	// 0xfe)` call at the bottom of `_DoSiteLoop`'s main loop).
 	auto rotate = [&](uint8 start, uint8 end) {
-		if (end <= start) return;
+		if (end <= start)
+			return;
 		const uint count = (uint)end - (uint)start + 1;
 		byte buf[256 * 3];
 		g_system->getPaletteManager()->grabPalette(buf, start, count);
 		// Save first triplet, shift, restore at end.
-		byte savedR = buf[0], savedG = buf[1], savedB = buf[2];
+		const byte savedR = buf[0];
+		const byte savedG = buf[1];
+		const byte savedB = buf[2];
 		for (uint i = 0; i + 1 < count; i++) {
 			buf[i * 3 + 0] = buf[(i + 1) * 3 + 0];
 			buf[i * 3 + 1] = buf[(i + 1) * 3 + 1];
@@ -1053,12 +1058,14 @@ void EEMEngine::playKdAnim(uint16 num) {
 		const int h = MIN<int>(fr.surface.h, 200 - py);
 		for (int row = 0; row < h; row++) {
 			const int dstY = py + row;
-			if (dstY < 0) continue;
+			if (dstY < 0)
+				continue;
 			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 			for (int col = 0; col < w; col++) {
 				const int dstX = px + col;
-				if (dstX < 0) continue;
+				if (dstX < 0)
+					continue;
 				if (src[col] != transp)
 					dst[dstX] = src[col];
 			}
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 03bd77f609d..724e88e81ca 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -214,12 +214,14 @@ void EEMEngine::doCaseSelection() {
 			const byte transp = (byte)(fr.flags >> 8);
 			for (int row = 0; row < fr.surface.h; row++) {
 				const int dstY = kKdAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < fr.surface.w; col++) {
 					const int dstX = kKdAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -420,7 +422,8 @@ void EEMEngine::doCaseSelection() {
 			const int kLineH  = 10;
 			const int kVisible = 12;
 			int top = (int)sel - kVisible / 2;
-			if (top < 0) top = 0;
+			if (top < 0)
+				top = 0;
 			if (top + kVisible > (int)kMaxMystery + 1)
 				top = (int)kMaxMystery + 1 - kVisible;
 			for (int r = 0; r < kVisible; r++) {
@@ -429,8 +432,10 @@ void EEMEngine::doCaseSelection() {
 					break;
 				char marker = ' ';
 				if ((uint)idx < sizeof(_mysteriesSolved)) {
-					if (_mysteriesSolved[idx] == 2) marker = '*';
-					else if (_mysteriesSolved[idx] == 1) marker = '+';
+					if (_mysteriesSolved[idx] == 2)
+						marker = '*';
+					else if (_mysteriesSolved[idx] == 1)
+						marker = '+';
 				}
 				const char arrow = ((uint)idx == sel) ? '>' : ' ';
 				_font.drawString(&scratch,
@@ -476,7 +481,8 @@ void EEMEngine::doCaseSelection() {
 					const int kLineH = 10;
 					const int kVisible = 12;
 					int top = (int)sel - kVisible / 2;
-					if (top < 0) top = 0;
+					if (top < 0)
+						top = 0;
 					if (top + kVisible > (int)kMaxMystery + 1)
 						top = (int)kMaxMystery + 1 - kVisible;
 					const int row = (ev.mouse.y - kListRect.top) / kLineH;
@@ -630,12 +636,14 @@ void EEMEngine::doNotebook() {
 			const byte transp = (byte)(fr.flags >> 8);
 			for (int row = 0; row < fr.surface.h; row++) {
 				const int dstY = 80 + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < fr.surface.w; col++) {
 					const int dstX = 5 + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -771,7 +779,8 @@ void EEMEngine::doNotebook() {
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
 					ev.kbd.keycode == Common::KEYCODE_PAGEUP) {
-					if (page > 0) page--;
+					if (page > 0)
+						page--;
 					dirty = true;
 				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT ||
 						   ev.kbd.keycode == Common::KEYCODE_PAGEDOWN ||
@@ -820,7 +829,8 @@ void EEMEngine::doNotebook() {
 					continue;
 				}
 				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
-					if (page > 0) page--;
+					if (page > 0)
+						page--;
 					dirty = true;
 					continue;
 				}
@@ -952,12 +962,14 @@ void EEMEngine::doGallery() {
 			const int px = 5, py = 0x50;
 			for (int row = 0; row < fr.surface.h; row++) {
 				const int dstY = py + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < fr.surface.w; col++) {
 					const int dstX = px + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -996,7 +1008,8 @@ void EEMEngine::doGallery() {
 					continue;
 				for (int row = 0; row < h; row++) {
 					const int dstY = placeY + row;
-					if (dstY < 0) continue;
+					if (dstY < 0)
+						continue;
 					const byte *src =
 						(const byte *)portrait.surface.getBasePtr(0, row);
 					byte *dst = (byte *)scratch.getBasePtr(0, dstY);
@@ -1106,7 +1119,8 @@ void EEMEngine::doGallery() {
 				// the suspect index. We mirror that with cached rects.
 				bool clicked = false;
 				for (uint i = 0; i < slotRects.size(); i++) {
-					if (slotSuspect[i] < 0) continue;
+					if (slotSuspect[i] < 0)
+						continue;
 					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
 						// `MoreInfo(i)` — show the suspect detail page.
 						// Mirrors `MoreInfo @ 158f:0419`:
@@ -1172,24 +1186,28 @@ void EEMEngine::doGallery() {
 						for (uint k = 0; k < clueCount && k < 30; k++) {
 							const uint16 clueId =
 								READ_LE_UINT16(suspect + 0xa + k * 2);
-							if (clueId == 0xFFFF) break;
+							if (clueId == 0xFFFF)
+								break;
 							if (clueId >= Mystery::kCluesFoundCap ||
 								!_mystery._cluesFound[clueId])
 								continue;
-							if (!ni || clueId >= niCount) continue;
+							if (!ni || clueId >= niCount)
+								continue;
 							const uint16 textOff =
 								READ_LE_UINT16(ni + clueId * 4);
 							Common::String txt =
 								parseString(_mystery.textAt(textOff),
 											_playerName, _partner);
-							if (txt.empty()) continue;
+							if (txt.empty())
+								continue;
 							const byte color =
 								_mystery._noteSelected[clueId] ? 0x3C : 0x5C;
 							const int hLine = _font.drawWordWrapped(
 								&ms, rx, yPos, rw, txt, color);
 							yPos += hLine + 7;
 							drewAny = true;
-							if (yPos + lineH > ry + rh) break;
+							if (yPos + lineH > ry + rh)
+								break;
 						}
 						if (!drewAny && _font.isLoaded()) {
 							_font.drawString(&ms,
@@ -1249,7 +1267,8 @@ void EEMEngine::doGallery() {
 				(void)clicked;
 			}
 		}
-		if (exitFlag) break;
+		if (exitFlag)
+			break;
 
 		const uint32 now = g_system->getMillis();
 		if (now - lastDraw >= 100) {
@@ -1351,12 +1370,14 @@ void EEMEngine::doBigMap() {
 			const byte transp = (byte)(m.flags >> 8);
 			for (int row = 0; row < m.surface.h; row++) {
 				const int dstY = y + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)m.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < m.surface.w; col++) {
 					const int dstX = x + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -1376,9 +1397,12 @@ void EEMEngine::doBigMap() {
 								  && _mystery._visitedSite[i];
 
 			const Picture *m = nullptr;
-			if (done_ && haveDone)            m = &done;
-			else if (crime != 0 && haveCrime) m = &crimeM;
-			else if (haveNormal)              m = &normal;
+			if (done_ && haveDone)
+				m = &done;
+			else if (crime != 0 && haveCrime)
+				m = &crimeM;
+			else if (haveNormal)
+				m = &normal;
 
 			if (m)
 				blitMarker(*m, (int)mx, (int)my);
@@ -1400,12 +1424,14 @@ void EEMEngine::doBigMap() {
 			const byte transp = (byte)(fr.flags >> 8);
 			for (int row = 0; row < fr.surface.h; row++) {
 				const int dstY = kMapAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < fr.surface.w; col++) {
 					const int dstX = kMapAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -1426,7 +1452,8 @@ void EEMEngine::doBigMap() {
 	const Common::Rect kSetupBtnRect   (252,   4, 315,  42); // 29be:15ce
 
 	bool wantZoom = false;
-	int  zoomX = 0, zoomY = 0;
+	int zoomX = 0;
+	int zoomY = 0;
 	while (!shouldQuit()) {
 		Common::Event ev;
 		while (g_system->getEventManager()->pollEvent(ev)) {
@@ -1549,7 +1576,8 @@ void EEMEngine::doBigMap() {
 			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 				continue;
 			const byte *entry = _mystery.mapEntry(i);
-			if (!entry) continue;
+			if (!entry)
+				continue;
 			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
 			const uint16 mx       = READ_LE_UINT16(entry + 0x8);
 			const uint16 my       = READ_LE_UINT16(entry + 0xa);
@@ -1587,12 +1615,14 @@ void EEMEngine::doBigMap() {
 			const byte transp = (byte)(fr.flags >> 8);
 			for (int row = 0; row < fr.surface.h; row++) {
 				const int dstY = kDetailAnimY + row;
-				if (dstY < 0 || dstY >= 200) continue;
+				if (dstY < 0 || dstY >= 200)
+					continue;
 				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
 				for (int col = 0; col < fr.surface.w; col++) {
 					const int dstX = kDetailAnimX + col;
-					if (dstX < 0 || dstX >= 320) continue;
+					if (dstX < 0 || dstX >= 320)
+						continue;
 					if (src[col] != transp)
 						dst[dstX] = src[col];
 				}
@@ -1704,12 +1734,14 @@ void EEMEngine::doBigMap() {
 							i != _mystery._siteNumber)
 							continue;
 						const byte *entry = _mystery.mapEntry(i);
-						if (!entry) continue;
+						if (!entry)
+							continue;
 						const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
 						const uint16 mx       = READ_LE_UINT16(entry + 0x8);
 						const uint16 my       = READ_LE_UINT16(entry + 0xa);
 						Picture button;
-						int bw = 16, bh = 16;
+						int bw = 16;
+						int bh = 16;
 						if (_buttonArchive.loadEntry(buttonId, button)) {
 							bw = button.surface.w;
 							bh = button.surface.h;
@@ -1813,18 +1845,22 @@ void EEMEngine::doAccuse() {
 		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
 			slotRects[i] = Common::Rect();
 			slotSuspect[i] = -1;
-			if (!gd) continue;
+			if (!gd)
+				continue;
 			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5) continue;
+			if (phys >= 5)
+				continue;
 			// `_DrawGallery @ 158f:00b9` skips suspects whose
 			// `_InGallery[phys]` flag is 0 — that's the original gate
 			// (some suspects only become visible after being met or
 			// stay hidden after a wrong accusation removes them).
-			if (_mystery._inGallery[phys] == 0) continue;
+			if (_mystery._inGallery[phys] == 0)
+				continue;
 			const Slot &s = kGallerySlots[phys];
 
 			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-			if (picId == 0) continue;
+			if (picId == 0)
+				continue;
 			Picture portrait;
 			if (!_picsArchive.getPicture(picId, portrait))
 				continue;
@@ -1834,10 +1870,12 @@ void EEMEngine::doAccuse() {
 			const byte transp = (byte)(portrait.flags >> 8);
 			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
 			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-			if (w <= 0 || h <= 0) continue;
+			if (w <= 0 || h <= 0)
+				continue;
 			for (int row = 0; row < h; row++) {
 				const int dstY = placeY + row;
-				if (dstY < 0) continue;
+				if (dstY < 0)
+					continue;
 				const byte *src =
 					(const byte *)portrait.surface.getBasePtr(0, row);
 				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
@@ -1933,7 +1971,9 @@ void EEMEngine::doAccuse() {
 					}
 				}
 				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
-				uint16 tx = 5, ty = 4, tw = 155;
+				uint16 tx = 5;
+				uint16 ty = 4;
+				uint16 tw = 155;
 				getBalloonInsets(bubNum, tx, ty, tw);
 				if (_font.isLoaded()) {
 					_font.drawWordWrapped(&ms, balloonX + tx,
@@ -1953,10 +1993,12 @@ void EEMEngine::doAccuse() {
 	// way the original wraps DI past empty slots.
 	auto nextLiveSlot = [&](int from, int dir) -> int {
 		const int n = (int)slotRects.size();
-		if (n <= 0) return 0;
+		if (n <= 0)
+			return 0;
 		for (int step = 1; step <= n; step++) {
 			int idx = (from + dir * step) % n;
-			if (idx < 0) idx += n;
+			if (idx < 0)
+				idx += n;
 			if (!slotRects[idx].isEmpty())
 				return idx;
 		}
@@ -2028,7 +2070,8 @@ void EEMEngine::doAccuse() {
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				for (uint i = 0; i < slotRects.size(); i++) {
-					if (slotSuspect[i] < 0) continue;
+					if (slotSuspect[i] < 0)
+						continue;
 					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
 						picked = (int)i;
 						break;
@@ -2189,12 +2232,15 @@ void EEMEngine::doAccuse() {
 			g_system->updateScreen();
 			g_system->delayMillis(15);
 		}
-		if (exit) break;
+		if (exit)
+			break;
 		if (advance) {
-			if (pageIdx + 1 >= pages) break;
+			if (pageIdx + 1 >= pages)
+				break;
 			pageIdx++;
 		} else if (back) {
-			if (pageIdx > 0) pageIdx--;
+			if (pageIdx > 0)
+				pageIdx--;
 		}
 	}
 


Commit: a4dd19f31d7285fa9e00ac8d78a339a76cae1751
    https://github.com/scummvm/scummvm/commit/a4dd19f31d7285fa9e00ac8d78a339a76cae1751
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:40+02:00

Commit Message:
EEM: removed lambdas

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index f667ce49a73..da14c8990ca 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -46,6 +46,47 @@ const uint kPicChooseBackground = 0x8c; ///< `_GetBackground(0x8c)`
 const uint kAniBoy  = 8;                 ///< `_GetAnimation(8)` (Jake)
 const uint kAniGirl = 9;                 ///< `_GetAnimation(9)` (Jenny)
 
+// `_DoHappiness @ 172b:27b5`: cursor X picks one of 4 rects; past
+// rect 3 is treated as level 4. Verbatim from `29be:030f`.
+const Common::Rect kHappyZones[4] = {
+	Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
+	Common::Rect( 70, 0, 126, 200), // girl's column
+	Common::Rect(126, 0, 182, 200), // middle
+	Common::Rect(182, 0, 235, 200), // boy's column
+};
+
+uint happinessLevel(int x) {
+	for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
+		if (kHappyZones[i].contains(x, 100))
+			return i;
+	}
+	return 4; // past zone 3 → max level
+}
+
+// Lock the framebuffer, masked-blit `p` at (x, y), unlock. The transparent
+// colour is the high byte of `p.flags` per `_Rect_Move_Mask @ 1000:03fc`.
+void blitMaskedToScreen(const Picture &p, int x, int y) {
+	const byte transp = (byte)(p.flags >> 8);
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+	for (int row = 0; row < p.surface.h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= screen->h)
+			continue;
+		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+		byte *dst = (byte *)screen->getBasePtr(0, dstY);
+		for (int col = 0; col < p.surface.w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= screen->w)
+				continue;
+			if (src[col] != transp)
+				dst[dstX] = src[col];
+		}
+	}
+	g_system->unlockScreen();
+}
+
 // On-screen positions verified from `_NewAnimation` calls @ 1a35:07b9 / 07d5.
 const int kBoyX  = 0xe2; // 226
 const int kBoyY  = 0x62; // 98
@@ -85,12 +126,6 @@ void EEMEngine::doChoosePartner() {
 	// cells contain 10 cells = pairs of "neutral, smile" at increasing
 	// intensity). Lifted verbatim from the binary so the gestures
 	// match the original beat-for-beat.
-	static const Common::Rect kHappyZones[4] = {
-		Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
-		Common::Rect( 70, 0, 126, 200), // girl's column
-		Common::Rect(126, 0, 182, 200), // middle
-		Common::Rect(182, 0, 235, 200), // boy's column
-	};
 	static const uint8 kBoySeqs[5][9] = {
 		{ 0,0,0,0,0,0,0,1,0 }, // level 0
 		{ 2,2,2,2,2,2,2,3,2 }, // level 1
@@ -105,13 +140,6 @@ void EEMEngine::doChoosePartner() {
 		{ 2,2,2,2,2,2,3,2,2 },
 		{ 0,0,0,0,0,1,0,0,0 },
 	};
-	auto happinessLevel = [](int x) {
-		for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
-			if (kHappyZones[i].contains(x, 100))
-				return (uint)i;
-		}
-		return 4u; // past zone 3 → max level
-	};
 
 	// `_DoChoosePartner` opens with `_SetMousePos(0xa0, 0x96)` so the
 	// cursor lands centred between the two partners — start the
@@ -120,15 +148,13 @@ void EEMEngine::doChoosePartner() {
 	uint level = happinessLevel(curMouseX);
 	uint seqIdx = 0;       // step within the 9-frame seq
 
-	auto draw = [&]() {
-		blitAt(background, 0, 0);
-		const uint girlFrame = kGirlSeqs[level][seqIdx % 9];
-		const uint boyFrame  = kBoySeqs [level][seqIdx % 9];
-		blitAt(girlAnim[girlFrame % girlAnim.size()], kGirlX, kGirlY);
-		blitAt(boyAnim[boyFrame  % boyAnim.size()],  kBoyX,  kBoyY);
-		g_system->updateScreen();
-	};
-	draw();
+	// Initial render — pose 0 of whichever zone the cursor opens in.
+	blitAt(background, 0, 0);
+	blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
+		   kGirlX, kGirlY);
+	blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
+		   kBoyX, kBoyY);
+	g_system->updateScreen();
 
 	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
 		   "%u girl frames at (%d,%d)",
@@ -145,7 +171,12 @@ void EEMEngine::doChoosePartner() {
 		if (g_system->getMillis() - lastTick > 100) {
 			lastTick = g_system->getMillis();
 			seqIdx = (seqIdx + 1) % 9;
-			draw();
+			blitAt(background, 0, 0);
+			blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
+				   kGirlX, kGirlY);
+			blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
+				   kBoyX, kBoyY);
+			g_system->updateScreen();
 		}
 
 		Common::Event ev;
@@ -162,7 +193,12 @@ void EEMEngine::doChoosePartner() {
 				if (newLevel != level) {
 					level = newLevel;
 					seqIdx = 0; // restart cycle so the gesture pops
-					draw();
+					blitAt(background, 0, 0);
+					blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
+						   kGirlX, kGirlY);
+					blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
+						   kBoyX, kBoyY);
+					g_system->updateScreen();
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -239,28 +275,6 @@ void EEMEngine::doInitClues() {
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
 
-	auto blitMaskedAt = [&](const Picture &p, int x, int y) {
-		const byte transp = (byte)(p.flags >> 8);
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (!screen)
-			return;
-		for (int row = 0; row < p.surface.h; row++) {
-			const int dstY = y + row;
-			if (dstY < 0 || dstY >= screen->h)
-				continue;
-			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-			byte *dst = (byte *)screen->getBasePtr(0, dstY);
-			for (int col = 0; col < p.surface.w; col++) {
-				const int dstX = x + col;
-				if (dstX < 0 || dstX >= screen->w)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
-		g_system->unlockScreen();
-	};
-
 	// Step 4 — cycle through the game animation once before the briefing.
 	// Mirrors the `while (uVar9 != gameNum)` loop. The original calls
 	// `_UpdateAnimations` per `_CheckFrameRate` tick (~10 fps). We use
@@ -273,11 +287,11 @@ void EEMEngine::doInitClues() {
 			if (_picsArchive.getPicture(0x52, bg))
 				blitAt(bg, 0, 0);
 			if (haveGame)
-				blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+				blitMaskedToScreen(game[frame % game.size()], 0xcd, 0x6c);
 			if (haveBook)
-				blitMaskedAt(book[frame % book.size()], 0, 99);
+				blitMaskedToScreen(book[frame % book.size()], 0, 99);
 			if (haveNancy)
-				blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+				blitMaskedToScreen(nancy[frame % nancy.size()], 0x68, 0x8b);
 			g_system->updateScreen();
 
 			// Wait 100 ms or until input.
@@ -301,11 +315,11 @@ void EEMEngine::doInitClues() {
 	if (_picsArchive.getPicture(0x52, bg))
 		blitAt(bg, 0, 0);
 	if (haveGame)
-		blitMaskedAt(game[0], 0xcd, 0x6c);
+		blitMaskedToScreen(game[0], 0xcd, 0x6c);
 	if (haveBook)
-		blitMaskedAt(book[0], 0, 99);
+		blitMaskedToScreen(book[0], 0, 99);
 	if (haveNancy)
-		blitMaskedAt(nancy[0], 0x68, 0x8b);
+		blitMaskedToScreen(nancy[0], 0x68, 0x8b);
 	g_system->updateScreen();
 
 	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
@@ -363,18 +377,18 @@ void EEMEngine::doInitClues() {
 				if (_picsArchive.getPicture(0x52, bg))
 					blitAt(bg, 0, 0);
 				if (haveGame)
-					blitMaskedAt(game[frame % game.size()], 0xcd, 0x6c);
+					blitMaskedToScreen(game[frame % game.size()], 0xcd, 0x6c);
 				if (haveBook)
-					blitMaskedAt(book[frame % book.size()], 0, 99);
+					blitMaskedToScreen(book[frame % book.size()], 0, 99);
 				if (haveNancy)
-					blitMaskedAt(nancy[frame % nancy.size()], 0x68, 0x8b);
+					blitMaskedToScreen(nancy[frame % nancy.size()], 0x68, 0x8b);
 				// Anchor: original blits at `(sx - frame.width,
 				// sy - frame.rowoff)`. `frame.rowoff` is the y-anchor
 				// in our PicData. We use width/height directly since
 				// loadAnimation places anchor at (0, 0).
 				const int dstX = (int)0xcd - (int)fr.surface.w;
 				const int dstY = (int)seqY - (int)fr.rowoff;
-				blitMaskedAt(fr, dstX, dstY);
+				blitMaskedToScreen(fr, dstX, dstY);
 				g_system->updateScreen();
 				const uint32 wakeup = g_system->getMillis() + 100;
 				while (g_system->getMillis() < wakeup &&
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 3777e5ac6ca..35bf5100ef4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -163,6 +163,24 @@ private:
 	 */
 	void screenDriver();
 
+	/// Re-render helpers used by the corresponding `doX()` modal screens.
+	/// Each replaces what would otherwise be a `[&]()` capture-everything
+	/// lambda inside the `doX()` body; called from the screen's redraw
+	/// triggers (input changes, frame ticks). The state these need is
+	/// passed via reference parameters or read off engine members.
+	void drawNotebookFrame(int &page);
+	void drawGalleryFrame(const byte *gd, uint8 numSuspects,
+						  Common::Array<Common::Rect> &slotRects,
+						  Common::Array<int> &slotSuspect);
+	void drawBigMapOverview();
+	void drawBigMapDetail(int scrollX, int scrollY,
+						  const Common::Array<byte> &mapPixels,
+						  uint16 mapW, uint16 mapH);
+	void drawAccuseGallery(uint8 numSuspects, const byte *gd,
+						   int highlighted,
+						   Common::Array<Common::Rect> &slotRects,
+						   Common::Array<int> &slotSuspect);
+
 	/**
 	 * Open the five .DBD/.DBX archive pairs the way _InitGraphicsSystem
 	 * @ 172b:0145 does at boot.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index f0eb4342801..df3bc56cbd3 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -33,6 +33,58 @@
 
 namespace EEM {
 
+namespace {
+
+// Masked blit a Picture into a ManagedSurface. Pixels equal to `transp`
+// (the high byte of `pic.flags`, per `_Rect_Move_Mask @ 1000:03fc`) are
+// skipped. Used by `enterSiteAnim` for both skateboard + KD slide-in
+// passes; the surface is the in-memory frame buffer that gets pushed
+// to the screen each tick.
+void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
+			   int x, int y, byte transp) {
+	const int w = p.surface.w;
+	const int h = p.surface.h;
+	for (int row = 0; row < h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= 200)
+			continue;
+		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+		byte *out = (byte *)dst.getBasePtr(0, dstY);
+		for (int col = 0; col < w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= 320)
+				continue;
+			if (src[col] != transp)
+				out[dstX] = src[col];
+		}
+	}
+}
+
+// Rotate one VGA palette range by one slot. Mirrors `_ColorCycle @
+// 172b:2015` — used by both the per-site Loop-1 ColorCycle entries and
+// the always-on hotspot marching-ants range 0xF9..0xFE.
+void cyclePaletteRange(uint8 start, uint8 end) {
+	if (end <= start)
+		return;
+	const uint count = (uint)end - (uint)start + 1;
+	byte buf[256 * 3];
+	g_system->getPaletteManager()->grabPalette(buf, start, count);
+	const byte savedR = buf[0];
+	const byte savedG = buf[1];
+	const byte savedB = buf[2];
+	for (uint i = 0; i + 1 < count; i++) {
+		buf[i * 3 + 0] = buf[(i + 1) * 3 + 0];
+		buf[i * 3 + 1] = buf[(i + 1) * 3 + 1];
+		buf[i * 3 + 2] = buf[(i + 1) * 3 + 2];
+	}
+	buf[(count - 1) * 3 + 0] = savedR;
+	buf[(count - 1) * 3 + 1] = savedG;
+	buf[(count - 1) * 3 + 2] = savedB;
+	g_system->getPaletteManager()->setPalette(buf, start, count);
+}
+
+} // anonymous namespace
+
 void SiteScreen::enter(uint siteNum) {
 	if (!_mystery || !_mystery->isLoaded()) {
 		warning("SiteScreen::enter: no mystery loaded");
@@ -353,25 +405,6 @@ void SiteScreen::enterSiteAnim() {
 	}
 	g_system->unlockScreen();
 
-	auto blitFrame = [](Graphics::ManagedSurface &dst, const Picture &p,
-						int x, int y, byte transp) {
-		const int w = p.surface.w, h = p.surface.h;
-		for (int row = 0; row < h; row++) {
-			const int dstY = y + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-			byte *out = (byte *)dst.getBasePtr(0, dstY);
-			for (int col = 0; col < w; col++) {
-				const int dstX = x + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					out[dstX] = src[col];
-			}
-		}
-	};
-
 	// Phase 1 — skateboard scroll. `_GetAnimation(6 | 0xe)`.
 	Animation skate;
 	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
@@ -599,31 +632,11 @@ void SiteScreen::applyColorCycles() {
 	// We do the same against ScummVM's palette manager. Always rotate
 	// 0xf9..0xfe for hotspot marching ants (the `_ColorCycle(0xf9,
 	// 0xfe)` call at the bottom of `_DoSiteLoop`'s main loop).
-	auto rotate = [&](uint8 start, uint8 end) {
-		if (end <= start)
-			return;
-		const uint count = (uint)end - (uint)start + 1;
-		byte buf[256 * 3];
-		g_system->getPaletteManager()->grabPalette(buf, start, count);
-		// Save first triplet, shift, restore at end.
-		const byte savedR = buf[0];
-		const byte savedG = buf[1];
-		const byte savedB = buf[2];
-		for (uint i = 0; i + 1 < count; i++) {
-			buf[i * 3 + 0] = buf[(i + 1) * 3 + 0];
-			buf[i * 3 + 1] = buf[(i + 1) * 3 + 1];
-			buf[i * 3 + 2] = buf[(i + 1) * 3 + 2];
-		}
-		buf[(count - 1) * 3 + 0] = savedR;
-		buf[(count - 1) * 3 + 1] = savedG;
-		buf[(count - 1) * 3 + 2] = savedB;
-		g_system->getPaletteManager()->setPalette(buf, start, count);
-	};
 	for (uint i = 0; i < _colorCycles.size(); i++) {
-		rotate(_colorCycles[i].start, _colorCycles[i].end);
+		cyclePaletteRange(_colorCycles[i].start, _colorCycles[i].end);
 	}
 	// Hotspot marching ants — always cycled.
-	rotate(0xF9, 0xFE);
+	cyclePaletteRange(0xF9, 0xFE);
 }
 
 void SiteScreen::captureBgSnapshot() {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 724e88e81ca..2ea76273497 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -40,6 +40,183 @@
 
 namespace EEM {
 
+namespace {
+
+// Return the next non-empty slot in `slotRects` starting from `from`,
+// stepping by `dir` (+1 or -1) with wraparound. Used by the accuse
+// gallery's keyboard-cycle (TAB / arrow keys) — mirrors the way
+// `_PutMouseInRect` skips eliminated suspects in the original.
+int nextLiveSlot(const Common::Array<Common::Rect> &slotRects,
+				 int from, int dir) {
+	const int n = (int)slotRects.size();
+	if (n <= 0)
+		return 0;
+	for (int step = 1; step <= n; step++) {
+		int idx = (from + dir * step) % n;
+		if (idx < 0)
+			idx += n;
+		if (!slotRects[idx].isEmpty())
+			return idx;
+	}
+	return from;
+}
+
+// Snapshot of `doCaseSelection`'s captured locals, used by
+// `drawCaseSelectionFrame` (which replaces the original lambda). Lives
+// on the stack inside `doCaseSelection`; never escapes.
+struct CaseSelectionView {
+	EEMEngine *vm;
+	const Picture *caseBg;
+	bool haveCaseBg;
+	const Animation *kdAnim;
+	bool haveKdAnim;
+	int kdAnimX;
+	int kdAnimY;
+	const char *separator;
+	const char *const *pickLabel;
+	const bool *pickEnabled;
+	uint pick;
+};
+
+// Per-mystery sub-chooser ("Choose A Mystery") view.
+struct CaseSubmenuView {
+	EEMEngine *vm;
+	const Picture *caseBg;
+	bool haveCaseBg;
+	const byte *mysteriesSolved;
+	uint mysteriesSolvedSize;
+	uint sel;
+	uint maxMystery;
+};
+
+void drawCaseSubmenu(const CaseSubmenuView &v) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (v.haveCaseBg) {
+		const int w = MIN<int>(v.caseBg->surface.w, 320);
+		const int h = MIN<int>(v.caseBg->surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)v.caseBg->surface.getBasePtr(0, row), w);
+	}
+	if (v.vm->getFont().isLoaded()) {
+		const int kListX  = 61;
+		const int kListW  = 238 - kListX;
+		const int kListY0 = 35;
+		const int kLineH  = 10;
+		const int kVisible = 12;
+		int top = (int)v.sel - kVisible / 2;
+		if (top < 0)
+			top = 0;
+		if (top + kVisible > (int)v.maxMystery + 1)
+			top = (int)v.maxMystery + 1 - kVisible;
+		for (int r = 0; r < kVisible; r++) {
+			const int idx = top + r;
+			if (idx > (int)v.maxMystery)
+				break;
+			char marker = ' ';
+			if ((uint)idx < v.mysteriesSolvedSize) {
+				if (v.mysteriesSolved[idx] == 2)
+					marker = '*';
+				else if (v.mysteriesSolved[idx] == 1)
+					marker = '+';
+			}
+			const char arrow = ((uint)idx == v.sel) ? '>' : ' ';
+			v.vm->getFont().drawString(&scratch,
+				Common::String::format("%c %c Mystery %d", arrow, marker, idx),
+				kListX, kListY0 + r * kLineH, kListW, 0xF);
+		}
+	}
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
+void drawCaseSelectionFrame(const CaseSelectionView &v) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (v.haveCaseBg) {
+		const int w = MIN<int>(v.caseBg->surface.w, 320);
+		const int h = MIN<int>(v.caseBg->surface.h, 200);
+		for (int row = 0; row < h; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)v.caseBg->surface.getBasePtr(0, row), w);
+		}
+	}
+
+	// KD greeter frame — masked-blit current animation cell at
+	// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
+	if (v.haveKdAnim) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = (uint)((now / 100) % v.kdAnim->size());
+		const Picture &fr = (*v.kdAnim)[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+		for (int row = 0; row < fr.surface.h; row++) {
+			const int dstY = v.kdAnimY + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < fr.surface.w; col++) {
+				const int dstX = v.kdAnimX + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	}
+	if (v.vm->getFont().isLoaded()) {
+		// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
+		// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
+		// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
+		const int kListX  = 58 + 3;
+		const int kListW  = 238 - kListX;
+		const int kListY0 = 35;
+		const int kLineH  = 10;
+
+		// Top centred "Book %d" / "Challenge Book" title — sprintf
+		// format strings at 29be:0deb / 29be:0dfa shown via
+		// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
+		// original. We don't track challenge tier yet so always
+		// show "Book 1".
+		const Common::String book = "Book 1";
+		const int titleW = v.vm->getFont().getStringWidth(book);
+		const int titleX = (0xba - titleW) / 2 + 0x3c;
+		v.vm->getFont().drawString(&scratch, book, titleX, 12, 320, 0xF);
+
+		// Render 11 list rows: separator + menu item pairs.
+		//   row 0  separator
+		//   row 1  Choose A Mystery
+		//   row 2  separator
+		//   row 3  Practice Mystery
+		//   ...
+		//   row 9  See ScrapBook 3
+		//   row 10 separator
+		for (int r = 0; r < 11; r++) {
+			const int y = kListY0 + r * kLineH;
+			if ((r & 1) == 0) {
+				v.vm->getFont().drawString(&scratch, v.separator,
+										   kListX, y, kListW, 0x7);
+				continue;
+			}
+			const uint mp = (uint)(r >> 1);
+			const bool isSel  = (mp == v.pick);
+			const byte color  = isSel             ? 0xF :
+								v.pickEnabled[mp] ? 0x7 : 0x8;
+			v.vm->getFont().drawString(&scratch, v.pickLabel[mp],
+									   kListX, y, kListW, color);
+		}
+	}
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
+} // anonymous namespace
+
 void EEMEngine::doNewPlayer() {
 	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
 	// 0x104 + character peek pic 0x107, then shows "Please type your
@@ -58,28 +235,24 @@ void EEMEngine::doNewPlayer() {
 	Picture bg;
 	const bool haveBG = _picsArchive.getPicture(0x104, bg);
 
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveBG) {
-			const int w = MIN<int>(bg.surface.w, 320);
-			const int h = MIN<int>(bg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.surface.getBasePtr(0, row), w);
-		}
-		// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
-		// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
-		// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
-		_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
-		Common::String shown = name + "_";
-		_font.drawString(&scratch, shown, 80, 80, 240, 0xF);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-	draw();
+	// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
+	// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
+	// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (haveBG) {
+		const int w = MIN<int>(bg.surface.w, 320);
+		const int h = MIN<int>(bg.surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)bg.surface.getBasePtr(0, row), w);
+	}
+	_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
+	_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -114,8 +287,24 @@ void EEMEngine::doNewPlayer() {
 				dirty = true;
 			}
 		}
-		if (dirty)
-			draw();
+		if (dirty) {
+			// Re-render with the updated `name`. Same body as the
+			// initial render above — only `name + "_"` changes.
+			scratch.clear();
+			if (haveBG) {
+				const int w = MIN<int>(bg.surface.w, 320);
+				const int h = MIN<int>(bg.surface.h, 200);
+				for (int row = 0; row < h; row++)
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)bg.surface.getBasePtr(0, row), w);
+			}
+			_font.drawString(&scratch, "Please type your name:",
+							 80, 40, 240, 0xF);
+			_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -192,102 +381,20 @@ void EEMEngine::doCaseSelection() {
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
 
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveCaseBg) {
-			const int w = MIN<int>(caseBg.surface.w, 320);
-			const int h = MIN<int>(caseBg.surface.h, 200);
-			for (int row = 0; row < h; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
-			}
-		}
-
-		// KD greeter frame — masked-blit current animation cell at
-		// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
-		if (haveKdAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % kdAnim.size());
-			const Picture &fr = kdAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kKdAnimY + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kKdAnimX + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-		if (_font.isLoaded()) {
-			// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
-			// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
-			// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
-			const int kListX  = 58 + 3;
-			const int kListW  = 238 - kListX;
-			const int kListY0 = 35;
-			const int kLineH  = 10;
-
-			// Top centred "Book %d" / "Challenge Book" title — sprintf
-			// format strings at 29be:0deb / 29be:0dfa shown via
-			// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
-			// original. We don't track challenge tier yet so always
-			// show "Book 1".
-			const Common::String book = "Book 1";
-			const int titleW = _font.getStringWidth(book);
-			const int titleX = (0xba - titleW) / 2 + 0x3c;
-			_font.drawString(&scratch, book, titleX, 12, 320, 0xF);
-
-			// Render 11 list rows: separator + menu item pairs.
-			//   row 0  separator
-			//   row 1  Choose A Mystery
-			//   row 2  separator
-			//   row 3  Practice Mystery
-			//   ...
-			//   row 9  See ScrapBook 3
-			//   row 10 separator
-			for (int r = 0; r < 11; r++) {
-				const int y = kListY0 + r * kLineH;
-				if ((r & 1) == 0) {
-					_font.drawString(&scratch, kSeparator, kListX, y, kListW, 0x7);
-					continue;
-				}
-				const uint mp = (uint)(r >> 1);
-				const bool isSel  = (mp == pick);
-				const byte color  = isSel        ? 0xF :
-									kPickEnabled[mp] ? 0x7 : 0x8;
-				_font.drawString(&scratch, kPickLabel[mp], kListX, y, kListW, color);
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	auto pickPrev = [&]() {
-		for (int i = 0; i < (int)kNumPicks; i++) {
-			pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
-			if (kPickEnabled[pick])
-				break;
-		}
-	};
-	auto pickNext = [&]() {
-		for (int i = 0; i < (int)kNumPicks; i++) {
-			pick = (pick + 1) % kNumPicks;
-			if (kPickEnabled[pick])
-				break;
-		}
-	};
-
-	draw();
+	CaseSelectionView v;
+	v.vm = this;
+	v.caseBg = &caseBg;
+	v.haveCaseBg = haveCaseBg;
+	v.kdAnim = &kdAnim;
+	v.haveKdAnim = haveKdAnim;
+	v.kdAnimX = kKdAnimX;
+	v.kdAnimY = kKdAnimY;
+	v.separator = kSeparator;
+	v.pickLabel = kPickLabel;
+	v.pickEnabled = kPickEnabled;
+	v.pick = pick;
+
+	drawCaseSelectionFrame(v);
 	uint32 lastTick = g_system->getMillis();
 
 	bool exitChosen = false;
@@ -299,7 +406,8 @@ void EEMEngine::doCaseSelection() {
 		const uint32 now = g_system->getMillis();
 		if (haveKdAnim && now - lastTick >= 100) {
 			lastTick = now;
-			draw();
+			v.pick = pick;
+			drawCaseSelectionFrame(v);
 		}
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
@@ -330,7 +438,8 @@ void EEMEngine::doCaseSelection() {
 						const uint mp = (uint)(row >> 1);
 						if (mp < kNumPicks && kPickEnabled[mp]) {
 							pick = mp;
-							draw();
+							v.pick = pick;
+							drawCaseSelectionFrame(v);
 							continue;
 						}
 					}
@@ -349,19 +458,34 @@ void EEMEngine::doCaseSelection() {
 				break;
 			}
 			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
-				pickPrev();
-				draw();
+				// Cycle backwards through enabled picks (mirrors the
+				// `_DoChoose` arrow handlers @ 1c33:0514). Loop is
+				// bounded by `kNumPicks` so a row of all-disabled picks
+				// can't spin forever.
+				for (int i = 0; i < (int)kNumPicks; i++) {
+					pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
+					if (kPickEnabled[pick])
+						break;
+				}
+				v.pick = pick;
+				drawCaseSelectionFrame(v);
 				continue;
 			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
 				k == Common::KEYCODE_TAB) {
-				pickNext();
-				draw();
+				for (int i = 0; i < (int)kNumPicks; i++) {
+					pick = (pick + 1) % kNumPicks;
+					if (kPickEnabled[pick])
+						break;
+				}
+				v.pick = pick;
+				drawCaseSelectionFrame(v);
 				continue;
 			}
 		}
 		if (confirmed) {
-			draw();
+			v.pick = pick;
+			drawCaseSelectionFrame(v);
 			break;
 		}
 		g_system->updateScreen();
@@ -404,51 +528,16 @@ void EEMEngine::doCaseSelection() {
 		}
 	}
 
-	auto drawSubmenu = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveCaseBg) {
-			const int w = MIN<int>(caseBg.surface.w, 320);
-			const int h = MIN<int>(caseBg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)caseBg.surface.getBasePtr(0, row), w);
-		}
-		if (_font.isLoaded()) {
-			const int kListX  = 61;
-			const int kListW  = 238 - kListX;
-			const int kListY0 = 35;
-			const int kLineH  = 10;
-			const int kVisible = 12;
-			int top = (int)sel - kVisible / 2;
-			if (top < 0)
-				top = 0;
-			if (top + kVisible > (int)kMaxMystery + 1)
-				top = (int)kMaxMystery + 1 - kVisible;
-			for (int r = 0; r < kVisible; r++) {
-				const int idx = top + r;
-				if (idx > (int)kMaxMystery)
-					break;
-				char marker = ' ';
-				if ((uint)idx < sizeof(_mysteriesSolved)) {
-					if (_mysteriesSolved[idx] == 2)
-						marker = '*';
-					else if (_mysteriesSolved[idx] == 1)
-						marker = '+';
-				}
-				const char arrow = ((uint)idx == sel) ? '>' : ' ';
-				_font.drawString(&scratch,
-								 Common::String::format("%c %c Mystery %d", arrow, marker, idx),
-								 kListX, kListY0 + r * kLineH, kListW, 0xF);
-			}
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
+	CaseSubmenuView sv;
+	sv.vm = this;
+	sv.caseBg = &caseBg;
+	sv.haveCaseBg = haveCaseBg;
+	sv.mysteriesSolved = _mysteriesSolved;
+	sv.mysteriesSolvedSize = sizeof(_mysteriesSolved);
+	sv.sel = sel;
+	sv.maxMystery = kMaxMystery;
 
-	drawSubmenu();
+	drawCaseSubmenu(sv);
 	bool confirmed = false;
 	while (!confirmed && !shouldQuit()) {
 		Common::Event ev;
@@ -468,12 +557,14 @@ void EEMEngine::doCaseSelection() {
 				}
 				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
 					sel = (sel == 0) ? kMaxMystery : sel - 1;
-					drawSubmenu();
+					sv.sel = sel;
+					drawCaseSubmenu(sv);
 					continue;
 				}
 				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
 					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
-					drawSubmenu();
+					sv.sel = sel;
+					drawCaseSubmenu(sv);
 					continue;
 				}
 				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -489,7 +580,8 @@ void EEMEngine::doCaseSelection() {
 					const int idx = top + row;
 					if (idx >= 0 && idx <= (int)kMaxMystery) {
 						sel = (uint)idx;
-						drawSubmenu();
+						sv.sel = sel;
+						drawCaseSubmenu(sv);
 					}
 					continue;
 				}
@@ -507,31 +599,36 @@ void EEMEngine::doCaseSelection() {
 			}
 			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
 				sel = (uint)(k - Common::KEYCODE_0);
-				drawSubmenu();
+				sv.sel = sel;
+				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
 				sel = (sel >= kMaxMystery) ? 0 : sel + 1;
-				drawSubmenu();
+				sv.sel = sel;
+				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_UP) {
 				sel = (sel == 0) ? kMaxMystery : sel - 1;
-				drawSubmenu();
+				sv.sel = sel;
+				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEDOWN) {
 				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
-				drawSubmenu();
+				sv.sel = sel;
+				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEUP) {
 				sel = (sel < 10) ? 0 : sel - 10;
-				drawSubmenu();
+				sv.sel = sel;
+				drawCaseSubmenu(sv);
 				continue;
 			}
-			if (k == Common::KEYCODE_HOME) { sel = 0; drawSubmenu(); continue; }
-			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; drawSubmenu(); continue; }
+			if (k == Common::KEYCODE_HOME) { sel = 0; sv.sel = sel; drawCaseSubmenu(sv); continue; }
+			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; sv.sel = sel; drawCaseSubmenu(sv); continue; }
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
@@ -585,7 +682,6 @@ void EEMEngine::doNotebook() {
 	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
 	//   rect 9 (0,0)     → 0x03ed = same as rect 8
 	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
-	const Common::Rect kNotebookRect(78, 12, 288, 152);
 	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1] HELP
 	const Common::Rect kBtnGallery (157, 174, 178, 190);  // [2] GALLERY
 	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3] KD HELP
@@ -600,165 +696,9 @@ void EEMEngine::doNotebook() {
 
 	int page = 0;
 	int hoveredNoteSlot = -1;
+	(void)hoveredNoteSlot;
 
-	// Build a list of found-clue indices, identical ordering to the
-	// original's iteration through `_CluesFound[]`.
-	auto buildFound = [&]() {
-		Common::Array<uint> found;
-		for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
-			if (_mystery._cluesFound[i])
-				found.push_back(i);
-		return found;
-	};
-
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		// PIC 0x3f frame.
-		Picture frame;
-		if (_picsArchive.getPicture(0x3f, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
-		const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
-		Animation partnerAni;
-		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-			const Picture &fr = partnerAni[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = 80 + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = 5 + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
-		// rendering each found clue's text inside `_NotebookRect` with
-		// word-wrap. Selected clues are highlighted (color 0x3c in the
-		// original's case-briefing palette).
-		const Common::Array<uint> found = buildFound();
-		const byte *ni = _mystery.noteIndex();
-		const uint16 niCount = _mystery.noteIndexCount();
-
-		const int kRectX = kNotebookRect.left;
-		const int kRectY = kNotebookRect.top;
-		const int kRectW = kNotebookRect.width();
-		const int kRectH = kNotebookRect.height();
-
-		// Walk forward to the start clue of the current page.
-		// Each page renders as many clues as fit in `kRectH`.
-		int clueCursor = 0;
-		Common::Array<int> pageStarts;
-		pageStarts.push_back(0);
-		{
-			const int lineH = _font.getFontHeight() + 1;
-			int y = kRectY;
-			while (clueCursor < (int)found.size()) {
-				const uint clueId = found[clueCursor];
-				Common::String txt;
-				if (ni && clueId < niCount) {
-					const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-					txt = parseString(_mystery.textAt(textOff),
-									  _playerName, _partner);
-				}
-				// Measure height by wrapping the text without drawing.
-				Common::Array<Common::String> wrapped;
-				_font.wordWrapText(txt, kRectW, wrapped);
-				const int h = (int)wrapped.size() * lineH;
-				if (y + h + 7 > kRectY + kRectH) {
-					// Page break before this clue.
-					y = kRectY;
-					pageStarts.push_back(clueCursor);
-				}
-				y += h + 7;
-				clueCursor++;
-			}
-			if (page >= (int)pageStarts.size())
-				page = (int)pageStarts.size() - 1;
-			if (page < 0)
-				page = 0;
-		}
-
-		// Track per-slot rectangles so the click handler can map a
-		// click in `kNoteArea` back to a clue index.
-		Common::Array<Common::Rect> slotRects;
-		Common::Array<uint> slotClues;
-
-		const int startClue = (page < (int)pageStarts.size())
-								? pageStarts[page] : 0;
-		const int endClue   = (page + 1 < (int)pageStarts.size())
-								? pageStarts[page + 1] : (int)found.size();
-
-		int y = kRectY;
-		for (int i = startClue; i < endClue; i++) {
-			const uint clueId = found[i];
-			Common::String txt;
-			if (ni && clueId < niCount) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								  _playerName, _partner);
-			}
-			if (txt.empty())
-				txt = Common::String::format("clue %u", clueId);
-			// Per `_DrawNotes @ 161e:01d0`: text uses
-			// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
-			// (light yellow-white) for selected. Both contrast cleanly
-			// with the PDA screen's natural blue, so we draw text
-			// directly on PIC 0x3f without an extra fill rectangle —
-			// matches the original design.
-			Common::Array<Common::String> wrapped;
-			_font.wordWrapText(txt, kRectW, wrapped);
-			const int lineH = _font.getFontHeight() + 1;
-			const int h = (int)wrapped.size() * lineH;
-			const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
-			for (uint li = 0; li < wrapped.size(); li++) {
-				_font.drawString(&scratch, wrapped[li], kRectX,
-								 y + (int)li * lineH, kRectW, color);
-			}
-			slotRects.push_back(Common::Rect(kRectX, y,
-											  kRectX + kRectW, y + h));
-			slotClues.push_back(clueId);
-			y += h + 7;
-		}
-
-		// Page indicator + selected-points counter directly on PIC.
-		_font.drawString(&scratch, Common::String::format("p%d/%d",
-								   page + 1, (int)pageStarts.size()),
-						 270, 4, 320, 0x5C);
-		_font.drawString(&scratch, Common::String::format("%d pts",
-								   _mystery.selectedPoints()),
-						 270, 14, 320, 0x5C);
-		(void)hoveredNoteSlot;
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-
-		// Stash slot info on the captures so the click handler below
-		// can use it via the closure.
-		_notebookSlotRects = slotRects;
-		_notebookSlotClues = slotClues;
-	};
-
-	draw();
+	drawNotebookFrame(page);
 
 	uint32 lastDraw = g_system->getMillis();
 
@@ -867,7 +807,7 @@ void EEMEngine::doNotebook() {
 		const uint32 now = g_system->getMillis();
 		// Re-render every 100 ms so the partner sprite cycles frames.
 		if (dirty || now - lastDraw >= 100) {
-			draw();
+			drawNotebookFrame(page);
 			lastDraw = now;
 		}
 		g_system->updateScreen();
@@ -875,6 +815,165 @@ void EEMEngine::doNotebook() {
 	}
 }
 
+void EEMEngine::drawNotebookFrame(int &page) {
+	// PDA notebook redraw — formerly the `draw` lambda inside `doNotebook`.
+	// Mirrors `_DrawNotes @ 161e:01d0` for the per-page note layout, plus
+	// the partner-sprite blit at (5, 80) (`_NewAnimation` from
+	// `_DoNotebook @ 161e:0500`). Uses `_notebookSlotRects` /
+	// `_notebookSlotClues` to publish the per-page slot layout to the
+	// click handler in `doNotebook`.
+	const Common::Rect kNotebookRect(78, 12, 288, 152);
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+
+	// PIC 0x3f frame.
+	Picture frame;
+	if (_picsArchive.getPicture(0x3f, frame)) {
+		const int w = MIN<int>(frame.surface.w, 320);
+		const int h = MIN<int>(frame.surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)frame.surface.getBasePtr(0, row), w);
+	}
+
+	// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
+	const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
+	Animation partnerAni;
+	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+		const Picture &fr = partnerAni[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+		for (int row = 0; row < fr.surface.h; row++) {
+			const int dstY = 80 + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < fr.surface.w; col++) {
+				const int dstX = 5 + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	}
+
+	// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
+	// rendering each found clue's text inside `_NotebookRect` with
+	// word-wrap. Selected clues are highlighted (color 0x3c in the
+	// original's case-briefing palette).
+	// Build a list of found-clue indices, identical ordering to the
+	// original's iteration through `_CluesFound[]`.
+	Common::Array<uint> found;
+	for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
+		if (_mystery._cluesFound[i])
+			found.push_back(i);
+	}
+	const byte *ni = _mystery.noteIndex();
+	const uint16 niCount = _mystery.noteIndexCount();
+
+	const int kRectX = kNotebookRect.left;
+	const int kRectY = kNotebookRect.top;
+	const int kRectW = kNotebookRect.width();
+	const int kRectH = kNotebookRect.height();
+
+	// Walk forward to the start clue of the current page.
+	// Each page renders as many clues as fit in `kRectH`.
+	int clueCursor = 0;
+	Common::Array<int> pageStarts;
+	pageStarts.push_back(0);
+	{
+		const int lineH = _font.getFontHeight() + 1;
+		int y = kRectY;
+		while (clueCursor < (int)found.size()) {
+			const uint clueId = found[clueCursor];
+			Common::String txt;
+			if (ni && clueId < niCount) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				txt = parseString(_mystery.textAt(textOff),
+								  _playerName, _partner);
+			}
+			// Measure height by wrapping the text without drawing.
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, kRectW, wrapped);
+			const int h = (int)wrapped.size() * lineH;
+			if (y + h + 7 > kRectY + kRectH) {
+				// Page break before this clue.
+				y = kRectY;
+				pageStarts.push_back(clueCursor);
+			}
+			y += h + 7;
+			clueCursor++;
+		}
+		if (page >= (int)pageStarts.size())
+			page = (int)pageStarts.size() - 1;
+		if (page < 0)
+			page = 0;
+	}
+
+	// Track per-slot rectangles so the click handler can map a
+	// click in `kNoteArea` back to a clue index.
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<uint> slotClues;
+
+	const int startClue = (page < (int)pageStarts.size())
+							? pageStarts[page] : 0;
+	const int endClue   = (page + 1 < (int)pageStarts.size())
+							? pageStarts[page + 1] : (int)found.size();
+
+	int y = kRectY;
+	for (int i = startClue; i < endClue; i++) {
+		const uint clueId = found[i];
+		Common::String txt;
+		if (ni && clueId < niCount) {
+			const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+			txt = parseString(_mystery.textAt(textOff),
+							  _playerName, _partner);
+		}
+		if (txt.empty())
+			txt = Common::String::format("clue %u", clueId);
+		// Per `_DrawNotes @ 161e:01d0`: text uses
+		// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
+		// (light yellow-white) for selected. Both contrast cleanly
+		// with the PDA screen's natural blue, so we draw text
+		// directly on PIC 0x3f without an extra fill rectangle —
+		// matches the original design.
+		Common::Array<Common::String> wrapped;
+		_font.wordWrapText(txt, kRectW, wrapped);
+		const int lineH = _font.getFontHeight() + 1;
+		const int h = (int)wrapped.size() * lineH;
+		const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
+		for (uint li = 0; li < wrapped.size(); li++) {
+			_font.drawString(&scratch, wrapped[li], kRectX,
+							 y + (int)li * lineH, kRectW, color);
+		}
+		slotRects.push_back(Common::Rect(kRectX, y,
+										  kRectX + kRectW, y + h));
+		slotClues.push_back(clueId);
+		y += h + 7;
+	}
+
+	// Page indicator + selected-points counter directly on PIC.
+	_font.drawString(&scratch, Common::String::format("p%d/%d",
+							   page + 1, (int)pageStarts.size()),
+					 270, 4, 320, 0x5C);
+	_font.drawString(&scratch, Common::String::format("%d pts",
+							   _mystery.selectedPoints()),
+					 270, 14, 320, 0x5C);
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+
+	// Publish slot info to `doNotebook`'s click handler.
+	_notebookSlotRects = slotRects;
+	_notebookSlotClues = slotClues;
+}
+
 void EEMEngine::doGallery() {
 	// Mirrors `_DoGallery @ 158f:065b` and `_DrawGallery @ 158f:0046`.
 	// Verified directly from the disassembly:
@@ -908,26 +1007,11 @@ void EEMEngine::doGallery() {
 
 	CursorMan.showMouse(true);
 
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, // 0
-		{ 155,  14 }, // 1
-		{ 227,  14 }, // 2
-		{ 119,  90 }, // 3
-		{ 191,  90 }  // 4
-	};
-
-	// Pre-load static elements once.
+	// Pre-load PIC 0x3f for the MoreInfo backdrop blit further down.
+	// (`drawGalleryFrame` reloads it on its own per-call too.)
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
-	// Gallery partner anim — `_DoGallery` calls `_GetAnimation(uVar6)` with
-	// uVar6 = 2 (Jake) / 0x10 (Jenny). Different from PDA (1 / 0xb).
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
-							  && !partnerAni.empty();
-
 	const uint8 num = _mystery.numSuspects();
 
 	// Cache slot rects for click hit-testing.
@@ -939,116 +1023,7 @@ void EEMEngine::doGallery() {
 		slotSuspect[i] = -1;
 	}
 
-	auto drawFrame = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		if (haveBg) {
-			const int bw = MIN<int>(galBg.surface.w, 320);
-			const int bh = MIN<int>(galBg.surface.h, 200);
-			for (int row = 0; row < bh; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-			}
-		}
-
-		// Partner sprite frame @ (5, 0x50).
-		if (havePartner) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-			const Picture &fr = partnerAni[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			const int px = 5, py = 0x50;
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = py + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = px + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1
-		// and only renders those flagged in `_InGallery[NewOrder[i]]`.
-		// Undiscovered slots are left empty in the original. We render
-		// a darkened placeholder + "?" so the player has visual feedback
-		// that suspects exist but are still unknown.
-		uint discoveredCount = 0;
-		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
-			slotRects[i] = Common::Rect();
-			slotSuspect[i] = -1;
-
-			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5)
-				continue;
-			const Slot &s = kGallerySlots[phys];
-
-			const bool discovered = _mystery._inGallery[phys] != 0;
-			if (discovered) {
-				const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-				Picture portrait;
-				if (picId == 0 ||
-					!_picsArchive.getPicture(picId, portrait))
-					continue;
-
-				const int placeX = s.x;
-				const int placeY = s.y + (0x48 - portrait.surface.h);
-				const byte transp = (byte)(portrait.flags >> 8);
-				const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-				const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-				if (w <= 0 || h <= 0)
-					continue;
-				for (int row = 0; row < h; row++) {
-					const int dstY = placeY + row;
-					if (dstY < 0)
-						continue;
-					const byte *src =
-						(const byte *)portrait.surface.getBasePtr(0, row);
-					byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-					for (int col = 0; col < w; col++) {
-						const int dstX = placeX + col;
-						if (src[col] != transp)
-							dst[dstX] = src[col];
-					}
-				}
-				slotRects[i] = Common::Rect(placeX, placeY,
-											 placeX + w, placeY + h);
-				slotSuspect[i] = (int)i;
-				discoveredCount++;
-			} else {
-				// Undiscovered placeholder — small framed "?" box at
-				// (s.x, s.y) sized 0x40 × 0x48 (typical portrait size).
-				const int phW = 0x40, phH = 0x48;
-				const int phX = s.x, phY = s.y;
-				if (phX + phW <= 320 && phY + phH <= 200) {
-					scratch.fillRect(Common::Rect(phX, phY,
-						phX + phW, phY + phH), 0x20);
-					scratch.frameRect(Common::Rect(phX, phY,
-						phX + phW, phY + phH), 0x5C);
-					if (_font.isLoaded()) {
-						_font.drawString(&scratch, "?",
-							phX + phW / 2 - 3,
-							phY + phH / 2 - 4, phW, 0x5C);
-					}
-				}
-			}
-		}
-		(void)discoveredCount;
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawFrame();
+	drawGalleryFrame(gd, num, slotRects, slotSuspect);
 	uint32 lastDraw = g_system->getMillis();
 
 	while (!shouldQuit()) {
@@ -1258,7 +1233,7 @@ void EEMEngine::doGallery() {
 						// Force gallery redraw immediately so the
 						// player isn't left looking at the dismissed
 						// MoreInfo screen until the next 100 ms tick.
-						drawFrame();
+						drawGalleryFrame(gd, num, slotRects, slotSuspect);
 						lastDraw = g_system->getMillis();
 						clicked = true;
 						break;
@@ -1272,13 +1247,139 @@ void EEMEngine::doGallery() {
 
 		const uint32 now = g_system->getMillis();
 		if (now - lastDraw >= 100) {
-			drawFrame();
+			drawGalleryFrame(gd, num, slotRects, slotSuspect);
 			lastDraw = now;
 		}
 		g_system->delayMillis(15);
 	}
 }
 
+void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
+								  Common::Array<Common::Rect> &slotRects,
+								  Common::Array<int> &slotSuspect) {
+	// Gallery redraw — formerly the `drawFrame` lambda inside `doGallery`.
+	// Mirrors `_DrawGallery @ 158f:0046`: PIC 0x3f frame + partner sprite
+	// at (5, 0x50) + suspect portraits in their `_NewOrder` slots.
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, // 0
+		{ 155,  14 }, // 1
+		{ 227,  14 }, // 2
+		{ 119,  90 }, // 3
+		{ 191,  90 }  // 4
+	};
+
+	Picture galBg;
+	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
+							  && !partnerAni.empty();
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+
+	if (haveBg) {
+		const int bw = MIN<int>(galBg.surface.w, 320);
+		const int bh = MIN<int>(galBg.surface.h, 200);
+		for (int row = 0; row < bh; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+		}
+	}
+
+	// Partner sprite frame @ (5, 0x50).
+	if (havePartner) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = (uint)((now / 100) % partnerAni.size());
+		const Picture &fr = partnerAni[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+		const int px = 5;
+		const int py = 0x50;
+		for (int row = 0; row < fr.surface.h; row++) {
+			const int dstY = py + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < fr.surface.w; col++) {
+				const int dstX = px + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	}
+
+	// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1 and
+	// only renders those flagged in `_InGallery[NewOrder[i]]`.
+	for (uint i = 0; i < numSuspects && i < Mystery::kGalleryCap; i++) {
+		slotRects[i] = Common::Rect();
+		slotSuspect[i] = -1;
+
+		const uint8 phys = _mystery._newOrder[i];
+		if (phys >= 5)
+			continue;
+		const Slot &s = kGallerySlots[phys];
+
+		const bool discovered = _mystery._inGallery[phys] != 0;
+		if (discovered) {
+			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+			Picture portrait;
+			if (picId == 0 ||
+				!_picsArchive.getPicture(picId, portrait))
+				continue;
+
+			const int placeX = s.x;
+			const int placeY = s.y + (0x48 - portrait.surface.h);
+			const byte transp = (byte)(portrait.flags >> 8);
+			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+			if (w <= 0 || h <= 0)
+				continue;
+			for (int row = 0; row < h; row++) {
+				const int dstY = placeY + row;
+				if (dstY < 0)
+					continue;
+				const byte *src =
+					(const byte *)portrait.surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < w; col++) {
+					const int dstX = placeX + col;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+			slotRects[i] = Common::Rect(placeX, placeY,
+										 placeX + w, placeY + h);
+			slotSuspect[i] = (int)i;
+		} else {
+			// Undiscovered placeholder — small framed "?" box.
+			const int phW = 0x40;
+			const int phH = 0x48;
+			const int phX = s.x;
+			const int phY = s.y;
+			if (phX + phW <= 320 && phY + phH <= 200) {
+				scratch.fillRect(Common::Rect(phX, phY,
+					phX + phW, phY + phH), 0x20);
+				scratch.frameRect(Common::Rect(phX, phY,
+					phX + phW, phY + phH), 0x5C);
+				if (_font.isLoaded()) {
+					_font.drawString(&scratch, "?",
+						phX + phW / 2 - 3,
+						phY + phH / 2 - 4, phW, 0x5C);
+				}
+			}
+		}
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 void EEMEngine::doBigMap() {
 	// Two-stage flow that mirrors the original screen-1 wrapper at
 	// 20fe:120b and `_DoBigMap @ 20fe:09e7`:
@@ -1322,128 +1423,7 @@ void EEMEngine::doBigMap() {
 	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
 	// ------------------------------------------------------------------
 
-	// `_DoBigMap @ 20fe:09e7` (20fe:0a44-0a99) registers a partner sprite
-	// on the overview frame. The animation depends on `_LastScreen`:
-	//   * When LastScreen == 2 (came from the site loop) the original
-	//     plays an entrance anim (`anum-1` for Jake / Jenny) at
-	//     (0x102, 0x50), then on END swaps to the idle anim at (0xfd,
-	//     0x50). We don't track LastScreen finely enough to distinguish,
-	//     so we render the IDLE pose at (0xfd, 0x50) which is what the
-	//     player sees the rest of the time anyway.
-	//   * Idle anim ID: Jake = 0x14 (20), Jenny = 0x12 (18).
-	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
-	Animation mapAnim;
-	const bool haveMapAnim = _aniArchive.loadAnimation(kMapAniId, mapAnim)
-							   && !mapAnim.empty();
-	const int kMapAnimX = 0xfd;
-	const int kMapAnimY = 0x50;
-
-	auto drawOverview = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		Picture frame;
-		if (_picsArchive.getPicture(0x42, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
-		// once at boot via `_GetPicture` (1-based IDs → entries N-1):
-		//   _DoneMarker  = PIC 0x20d  (already-searched site)
-		//   _SiteMarker  = PIC 0xc5   (default available site)
-		//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
-		// Picked per-site by `_DrawBigMapButtons @ 20fe:0877`:
-		//   1. SaveSiteComplete[i] → DoneMarker
-		//   2. else MapData[+0xc] != 0 → CrimeMarker
-		//   3. else SiteMarker
-		Picture done, normal, crimeM;
-		const bool haveDone   = _picsArchive.getPicture(0x20d, done);
-		const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
-		const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
-
-		auto blitMarker = [&](const Picture &m, int x, int y) {
-			const byte transp = (byte)(m.flags >> 8);
-			for (int row = 0; row < m.surface.h; row++) {
-				const int dstY = y + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)m.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < m.surface.w; col++) {
-					const int dstX = x + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		};
-
-		for (uint i = 0; i < _mystery.numSites(); i++) {
-			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-				continue;
-			const byte *entry = _mystery.mapEntry(i);
-			if (!entry)
-				continue;
-			const uint16 mx    = READ_LE_UINT16(entry + 0x4);
-			const uint16 my    = READ_LE_UINT16(entry + 0x6);
-			const uint16 crime = READ_LE_UINT16(entry + 0xc);
-			const bool   done_ = (i < Mystery::kVisitedSiteCap)
-								  && _mystery._visitedSite[i];
-
-			const Picture *m = nullptr;
-			if (done_ && haveDone)
-				m = &done;
-			else if (crime != 0 && haveCrime)
-				m = &crimeM;
-			else if (haveNormal)
-				m = &normal;
-
-			if (m)
-				blitMarker(*m, (int)mx, (int)my);
-			else {
-				// Fallback if the markers couldn't be loaded.
-				const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
-				scratch.fillRect(mark, 0x0F);
-			}
-		}
-
-		// Partner sprite — masked-blit at (0xfd, 0x50). Same per-tick
-		// idle the original would run via `_UpdateAnimations` once the
-		// entrance one-shot transitions out (see `_DoBigMap` 0xae3-0xae7
-		// where it swaps animId on the 0x80 marker).
-		if (haveMapAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = (uint)((now / 100) % mapAnim.size());
-			const Picture &fr = mapAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kMapAnimY + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kMapAnimX + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawOverview();
+	drawBigMapOverview();
 	uint32 mapLastTick = g_system->getMillis();
 
 	// Static rectangles read directly from the binary at the labelled
@@ -1492,9 +1472,9 @@ void EEMEngine::doBigMap() {
 		// Cycle the partner-sprite frame every 100 ms (matching the
 		// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
 		const uint32 now = g_system->getMillis();
-		if (haveMapAnim && now - mapLastTick >= 100) {
+		if (now - mapLastTick >= 100) {
 			mapLastTick = now;
-			drawOverview();
+			drawBigMapOverview();
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
@@ -1531,110 +1511,7 @@ void EEMEngine::doBigMap() {
 	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
-	// `_DoMapScreen @ 20fe:120b` (20fe:12cd-12f0): partner sprite on
-	// the detail-zoom screen. Jake = anim 0x13 (19), Jenny = anim 0x11
-	// (17). Position (0x101, 0x50) = (257, 80), seqnum 0x13. The cells
-	// here have a "looking at the map" pose, distinct from the BigMap
-	// overview entrance/idle.
-	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
-	Animation detailAnim;
-	const bool haveDetailAnim = _aniArchive.loadAnimation(kDetailAniId,
-														   detailAnim)
-								  && !detailAnim.empty();
-	const int kDetailAnimX = 0x101;
-	const int kDetailAnimY = 0x50;
-
-	auto drawDetail = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-
-		Picture frame;
-		if (_picsArchive.getPicture(0x43, frame)) {
-			const int w = MIN<int>(frame.surface.w, 320);
-			const int h = MIN<int>(frame.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)frame.surface.getBasePtr(0, row), w);
-		}
-
-		const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
-		const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
-		for (int row = 0; row < copyH; row++) {
-			memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
-				   mapPixels.data() + (scrollY + row) * mapW + scrollX,
-				   copyW);
-		}
-
-		// Stamped site buttons. `_StampButtons @ 20fe:0d2f` does:
-		//   button = _GetButton(MapData[+0])      // BUTTON.DBD entry
-		//   destX  = MapData[+8],  destY = MapData[+0xa]
-		// then bakes the button PIC into the map bitmap. Each button
-		// sprite carries the site name baked in. We blit them on top
-		// of the BIGMAP.PIC viewport at the same SmallMap coords.
-		for (uint i = 0; i < _mystery.numSites(); i++) {
-			if (!_mystery._onSites[i] && i != _mystery._siteNumber)
-				continue;
-			const byte *entry = _mystery.mapEntry(i);
-			if (!entry)
-				continue;
-			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
-			const uint16 mx       = READ_LE_UINT16(entry + 0x8);
-			const uint16 my       = READ_LE_UINT16(entry + 0xa);
-
-			Picture button;
-			if (!_buttonArchive.loadEntry(buttonId, button))
-				continue;
-			const int sx = (int)mx - scrollX + kMapWinX;
-			const int sy = (int)my - scrollY + kMapWinY;
-			const byte transp = (byte)(button.flags >> 8);
-
-			// Crop blit against the viewport.
-			const int x0 = MAX<int>(sx, kMapWinX);
-			const int y0 = MAX<int>(sy, kMapWinY);
-			const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
-			const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
-			for (int row = y0; row < y1; row++) {
-				const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
-				byte *dst = (byte *)scratch.getBasePtr(0, row);
-				for (int col = x0; col < x1; col++) {
-					const byte px = src[col - sx];
-					if (px != transp)
-						dst[col] = px;
-				}
-			}
-		}
-
-		// Partner sprite on the detail map. Drawn last so it sits over
-		// the frame and the BIGMAP.PIC viewport.
-		if (haveDetailAnim) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx =
-				(uint)((now / 100) % detailAnim.size());
-			const Picture &fr = detailAnim[frameIdx];
-			const byte transp = (byte)(fr.flags >> 8);
-			for (int row = 0; row < fr.surface.h; row++) {
-				const int dstY = kDetailAnimY + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < fr.surface.w; col++) {
-					const int dstX = kDetailAnimX + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
-	drawDetail();
+	drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH);
 	uint32 detailLastTick = g_system->getMillis();
 
 	while (!shouldQuit()) {
@@ -1761,17 +1638,231 @@ void EEMEngine::doBigMap() {
 		// Cycle the partner sprite at 100 ms ticks (same cadence as
 		// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
 		const uint32 now = g_system->getMillis();
-		if (haveDetailAnim && now - detailLastTick >= 100) {
+		if (now - detailLastTick >= 100) {
 			detailLastTick = now;
 			dirty = true;
 		}
 		if (dirty)
-			drawDetail();
+			drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH);
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
 }
 
+void EEMEngine::drawBigMapOverview() {
+	// Map-overview redraw — formerly the `drawOverview` lambda inside
+	// `doBigMap`. PIC 0x42 frame + per-site marker (Done / Crime / Site
+	// per `_DrawBigMapButtons @ 20fe:0877`) + the partner idle sprite.
+	// `_DoBigMap @ 20fe:09e7` (`_NewAnimation` block at 20fe:0a44-0a99)
+	// registers the partner: when `_LastScreen == 2` it plays an
+	// entrance one-shot at (0x102, 0x50) and on END swaps to the idle
+	// at (0xfd, 0x50). We don't track LastScreen finely enough so we
+	// always render the IDLE pose at (0xfd, 0x50). Idle anim ID:
+	// Jake = 0x14 (20), Jenny = 0x12 (18).
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+
+	Picture frame;
+	if (_picsArchive.getPicture(0x42, frame)) {
+		const int w = MIN<int>(frame.surface.w, 320);
+		const int h = MIN<int>(frame.surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)frame.surface.getBasePtr(0, row), w);
+	}
+
+	// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
+	// once at boot via `_GetPicture` (1-based IDs):
+	//   _DoneMarker  = PIC 0x20d  (already-searched site)
+	//   _SiteMarker  = PIC 0xc5   (default available site)
+	//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
+	Picture done;
+	Picture normal;
+	Picture crimeM;
+	const bool haveDone   = _picsArchive.getPicture(0x20d, done);
+	const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
+	const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
+
+	for (uint i = 0; i < _mystery.numSites(); i++) {
+		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+			continue;
+		const byte *entry = _mystery.mapEntry(i);
+		if (!entry)
+			continue;
+		const uint16 mx    = READ_LE_UINT16(entry + 0x4);
+		const uint16 my    = READ_LE_UINT16(entry + 0x6);
+		const uint16 crime = READ_LE_UINT16(entry + 0xc);
+		const bool   done_ = (i < Mystery::kVisitedSiteCap)
+							  && _mystery._visitedSite[i];
+
+		const Picture *m = nullptr;
+		if (done_ && haveDone)
+			m = &done;
+		else if (crime != 0 && haveCrime)
+			m = &crimeM;
+		else if (haveNormal)
+			m = &normal;
+
+		if (m) {
+			// Masked-blit the marker PIC.
+			const byte transp = (byte)(m->flags >> 8);
+			for (int row = 0; row < m->surface.h; row++) {
+				const int dstY = (int)my + row;
+				if (dstY < 0 || dstY >= 200)
+					continue;
+				const byte *src = (const byte *)m->surface.getBasePtr(0, row);
+				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+				for (int col = 0; col < m->surface.w; col++) {
+					const int dstX = (int)mx + col;
+					if (dstX < 0 || dstX >= 320)
+						continue;
+					if (src[col] != transp)
+						dst[dstX] = src[col];
+				}
+			}
+		} else {
+			// Fallback if the markers couldn't be loaded.
+			const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
+			scratch.fillRect(mark, 0x0F);
+		}
+	}
+
+	// Partner idle sprite at (0xfd, 0x50). Jake = anim 0x14, Jenny = 0x12.
+	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
+	Animation mapAnim;
+	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = (uint)((now / 100) % mapAnim.size());
+		const Picture &fr = mapAnim[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+		const int kMapAnimX = 0xfd;
+		const int kMapAnimY = 0x50;
+		for (int row = 0; row < fr.surface.h; row++) {
+			const int dstY = kMapAnimY + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < fr.surface.w; col++) {
+				const int dstX = kMapAnimX + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
+void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
+								 const Common::Array<byte> &mapPixels,
+								 uint16 mapW, uint16 mapH) {
+	// Map-detail redraw — formerly the `drawDetail` lambda inside
+	// `doBigMap`. PIC 0x43 frame + a 0xe9 × 0xab BIGMAP.PIC viewport at
+	// (2, 2), stamped site buttons, and the partner sprite at (0x101,
+	// 0x50) — `_DoMapScreen @ 20fe:120b` (`_NewAnimation` at
+	// 20fe:12cd-12f0, anim 0x13 Jake / 0x11 Jenny, seqnum 0x13).
+	const int kMapWinW = 0xe9;
+	const int kMapWinH = 0xab;
+	const int kMapWinX = 2;
+	const int kMapWinY = 2;
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+
+	Picture frame;
+	if (_picsArchive.getPicture(0x43, frame)) {
+		const int w = MIN<int>(frame.surface.w, 320);
+		const int h = MIN<int>(frame.surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)frame.surface.getBasePtr(0, row), w);
+	}
+
+	const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
+	const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
+	for (int row = 0; row < copyH; row++) {
+		memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
+			   mapPixels.data() + (scrollY + row) * mapW + scrollX,
+			   copyW);
+	}
+
+	// Stamped site buttons. `_StampButtons @ 20fe:0d2f`:
+	//   button = _GetButton(MapData[+0])
+	//   destX  = MapData[+8]
+	//   destY  = MapData[+0xa]
+	for (uint i = 0; i < _mystery.numSites(); i++) {
+		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+			continue;
+		const byte *entry = _mystery.mapEntry(i);
+		if (!entry)
+			continue;
+		const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+		const uint16 mx       = READ_LE_UINT16(entry + 0x8);
+		const uint16 my       = READ_LE_UINT16(entry + 0xa);
+
+		Picture button;
+		if (!_buttonArchive.loadEntry(buttonId, button))
+			continue;
+		const int sx = (int)mx - scrollX + kMapWinX;
+		const int sy = (int)my - scrollY + kMapWinY;
+		const byte transp = (byte)(button.flags >> 8);
+
+		// Crop blit against the viewport.
+		const int x0 = MAX<int>(sx, kMapWinX);
+		const int y0 = MAX<int>(sy, kMapWinY);
+		const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
+		const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
+		for (int row = y0; row < y1; row++) {
+			const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
+			byte *dst = (byte *)scratch.getBasePtr(0, row);
+			for (int col = x0; col < x1; col++) {
+				const byte px = src[col - sx];
+				if (px != transp)
+					dst[col] = px;
+			}
+		}
+	}
+
+	// Partner sprite on the detail map (drawn last to sit over the
+	// frame and the BIGMAP.PIC viewport).
+	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
+	Animation detailAnim;
+	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
+		!detailAnim.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = (uint)((now / 100) % detailAnim.size());
+		const Picture &fr = detailAnim[frameIdx];
+		const byte transp = (byte)(fr.flags >> 8);
+		const int kDetailAnimX = 0x101;
+		const int kDetailAnimY = 0x50;
+		for (int row = 0; row < fr.surface.h; row++) {
+			const int dstY = kDetailAnimY + row;
+			if (dstY < 0 || dstY >= 200)
+				continue;
+			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < fr.surface.w; col++) {
+				const int dstX = kDetailAnimX + col;
+				if (dstX < 0 || dstX >= 320)
+					continue;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
 	// Mirrors `_GetKDTextBalloon @ 1df2:0105`:
 	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
@@ -1812,12 +1903,6 @@ void EEMEngine::doAccuse() {
 
 	// Verbatim from 29be:0x116 — same five suspect slot positions as
 	// `_DrawGallery @ 158f:0046`.
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, { 155,  14 }, { 227,  14 },
-		{ 119,  90 }, { 191,  90 }
-	};
-
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -1829,84 +1914,6 @@ void EEMEngine::doAccuse() {
 		slotSuspect[i] = -1;
 
 	int highlighted = 0;
-	auto drawGallery = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveAccuseBg) {
-			const int bw = MIN<int>(accuseBg.surface.w, 320);
-			const int bh = MIN<int>(accuseBg.surface.h, 200);
-			for (int row = 0; row < bh; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
-			}
-		}
-
-		for (uint i = 0; i < num && i < Mystery::kGalleryCap; i++) {
-			slotRects[i] = Common::Rect();
-			slotSuspect[i] = -1;
-			if (!gd)
-				continue;
-			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5)
-				continue;
-			// `_DrawGallery @ 158f:00b9` skips suspects whose
-			// `_InGallery[phys]` flag is 0 — that's the original gate
-			// (some suspects only become visible after being met or
-			// stay hidden after a wrong accusation removes them).
-			if (_mystery._inGallery[phys] == 0)
-				continue;
-			const Slot &s = kGallerySlots[phys];
-
-			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
-			if (picId == 0)
-				continue;
-			Picture portrait;
-			if (!_picsArchive.getPicture(picId, portrait))
-				continue;
-
-			const int placeX = s.x;
-			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const byte transp = (byte)(portrait.flags >> 8);
-			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
-			if (w <= 0 || h <= 0)
-				continue;
-			for (int row = 0; row < h; row++) {
-				const int dstY = placeY + row;
-				if (dstY < 0)
-					continue;
-				const byte *src =
-					(const byte *)portrait.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < w; col++) {
-					const int dstX = placeX + col;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
-			slotRects[i] = Common::Rect(placeX, placeY,
-										 placeX + w, placeY + h);
-			slotSuspect[i] = (int)i;
-		}
-
-		// Highlight indicator. The original moves the mouse cursor
-		// to the centre of the highlighted suspect via `_PutMouseInRect`
-		// (1df2:0b8e) — we draw a 1px outline in palette index 0xFE
-		// (within the marching-ants cycle range 0xF9..0xFE) which is
-		// unambiguously visible under any palette without warping the
-		// player's cursor.
-		if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
-			!slotRects[highlighted].isEmpty()) {
-			Common::Rect r = slotRects[highlighted];
-			r.grow(1);
-			scratch.frameRect(r, 0xFE);
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
 
 	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
 	// (1df2:0a4c-1df2:0afe):
@@ -1991,23 +1998,10 @@ void EEMEngine::doAccuse() {
 	// Helper to find the next "alive" slot (one whose `_inGallery[phys]`
 	// flag is still set so a portrait was actually drawn). Mirrors the
 	// way the original wraps DI past empty slots.
-	auto nextLiveSlot = [&](int from, int dir) -> int {
-		const int n = (int)slotRects.size();
-		if (n <= 0)
-			return 0;
-		for (int step = 1; step <= n; step++) {
-			int idx = (from + dir * step) % n;
-			if (idx < 0)
-				idx += n;
-			if (!slotRects[idx].isEmpty())
-				return idx;
-		}
-		return from;
-	};
 	if (slotRects[highlighted].isEmpty())
-		highlighted = nextLiveSlot(highlighted, +1);
+		highlighted = nextLiveSlot(slotRects, highlighted, +1);
 
-	drawGallery();
+	drawAccuseGallery(num, gd, highlighted, slotRects, slotSuspect);
 
 	// Wait-for-pick loop. Mirrors `_DoAccuseGallery` 1df2:0b26-1df2:0bc8:
 	//   * `_CheckFrameRate` + `_UpdateAnimations` per tick (1df2:0b2a-0b33)
@@ -2038,14 +2032,14 @@ void EEMEngine::doAccuse() {
 					return;
 				case Common::KEYCODE_TAB:
 				case Common::KEYCODE_RIGHT:
-					highlighted = nextLiveSlot(highlighted, +1);
+					highlighted = nextLiveSlot(slotRects, highlighted, +1);
 					dirty = true;
 					break;
 				case Common::KEYCODE_LEFT:
 					// 1df2:0b94 increments DI for LEFT too — but a
 					// keyboard-driven UX is friendlier with separate
 					// directions, so we mirror Right=+1 / Left=-1.
-					highlighted = nextLiveSlot(highlighted, -1);
+					highlighted = nextLiveSlot(slotRects, highlighted, -1);
 					dirty = true;
 					break;
 				case Common::KEYCODE_RETURN:
@@ -2085,7 +2079,7 @@ void EEMEngine::doAccuse() {
 		// We still re-render whenever the highlight moves (`dirty`).
 		const uint32 now = g_system->getMillis();
 		if (dirty || now - lastTick >= 100) {
-			drawGallery();
+			drawAccuseGallery(num, gd, highlighted, slotRects, slotSuspect);
 			lastTick = now;
 			dirty = false;
 		}
@@ -2265,4 +2259,96 @@ void EEMEngine::doAccuse() {
 	}
 }
 
+void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
+								   int highlighted,
+								   Common::Array<Common::Rect> &slotRects,
+								   Common::Array<int> &slotSuspect) {
+	// Accuse-gallery redraw — formerly the `drawGallery` lambda inside
+	// `doAccuse`. Mirrors `_DoAccuseGallery @ 1df2:0a31` portrait grid:
+	// PIC 0x3f backdrop, suspect portraits at the 5 fixed slots, and a
+	// 1-px outline (palette index 0xFE) around the highlighted slot.
+	struct Slot { int x; int y; };
+	static const Slot kGallerySlots[5] = {
+		{  83,  14 }, { 155,  14 }, { 227,  14 },
+		{ 119,  90 }, { 191,  90 }
+	};
+
+	Picture accuseBg;
+	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
+
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (haveAccuseBg) {
+		const int bw = MIN<int>(accuseBg.surface.w, 320);
+		const int bh = MIN<int>(accuseBg.surface.h, 200);
+		for (int row = 0; row < bh; row++) {
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+		}
+	}
+
+	for (uint i = 0; i < numSuspects && i < Mystery::kGalleryCap; i++) {
+		slotRects[i] = Common::Rect();
+		slotSuspect[i] = -1;
+		if (!gd)
+			continue;
+		const uint8 phys = _mystery._newOrder[i];
+		if (phys >= 5)
+			continue;
+		// `_DrawGallery @ 158f:00b9` skips suspects whose
+		// `_InGallery[phys]` flag is 0 — that's the original gate.
+		if (_mystery._inGallery[phys] == 0)
+			continue;
+		const Slot &s = kGallerySlots[phys];
+
+		const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+		if (picId == 0)
+			continue;
+		Picture portrait;
+		if (!_picsArchive.getPicture(picId, portrait))
+			continue;
+
+		const int placeX = s.x;
+		const int placeY = s.y + (0x48 - portrait.surface.h);
+		const byte transp = (byte)(portrait.flags >> 8);
+		const int w = MIN<int>(portrait.surface.w, 320 - placeX);
+		const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+		if (w <= 0 || h <= 0)
+			continue;
+		for (int row = 0; row < h; row++) {
+			const int dstY = placeY + row;
+			if (dstY < 0)
+				continue;
+			const byte *src =
+				(const byte *)portrait.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
+			for (int col = 0; col < w; col++) {
+				const int dstX = placeX + col;
+				if (src[col] != transp)
+					dst[dstX] = src[col];
+			}
+		}
+		slotRects[i] = Common::Rect(placeX, placeY,
+									 placeX + w, placeY + h);
+		slotSuspect[i] = (int)i;
+	}
+
+	// Highlight indicator. The original moves the mouse cursor to the
+	// centre of the highlighted suspect via `_PutMouseInRect` (1df2:0b8e);
+	// we draw a 1-px outline in palette index 0xFE instead, which sits
+	// inside the marching-ants cycle range 0xF9..0xFE and is visible
+	// under any palette without warping the player's cursor.
+	if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
+		!slotRects[highlighted].isEmpty()) {
+		Common::Rect r = slotRects[highlighted];
+		r.grow(1);
+		scratch.frameRect(r, 0xFE);
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 } // End of namespace EEM


Commit: 2fac0e6820d5d8b739c839f9d3e3718455c254f3
    https://github.com/scummvm/scummvm/commit/2fac0e6820d5d8b739c839f9d3e3718455c254f3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:41+02:00

Commit Message:
EEM: removed static and use only EEM namespace

Changed paths:
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/font.cpp
    engines/eem/graphics.cpp
    engines/eem/resource.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index da14c8990ca..e5fc4df255c 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -40,8 +40,9 @@
 
 namespace EEM {
 
-namespace {
 // Picture / animation IDs verified against `_DoChoosePartner @ 1a35:0756`.
+// `const` at namespace scope already implies internal linkage in C++,
+// so no `static` needed.
 const uint kPicChooseBackground = 0x8c; ///< `_GetBackground(0x8c)`
 const uint kAniBoy  = 8;                 ///< `_GetAnimation(8)` (Jake)
 const uint kAniGirl = 9;                 ///< `_GetAnimation(9)` (Jenny)
@@ -55,6 +56,32 @@ const Common::Rect kHappyZones[4] = {
 	Common::Rect(182, 0, 235, 200), // boy's column
 };
 
+// On-screen positions verified from `_NewAnimation` calls @ 1a35:07b9 / 07d5.
+const int kBoyX  = 0xe2; // 226
+const int kBoyY  = 0x62; // 98
+const int kGirlX = 0x42; // 66
+const int kGirlY = 0x60; // 96
+
+// `_DoHappiness @ 172b:27b5`: each cursor zone swaps the partner's
+// sequence script to a more / less "happy" cycle. Boy seqs lifted
+// verbatim from `29be:0337` (5 × 0x14 bytes), girl seqs from
+// `29be:039b`. Both cycle through 9 frames (the boy/girl anim cells
+// contain 10 cells = pairs of "neutral, smile" at increasing intensity).
+const uint8 kBoySeqs[5][9] = {
+	{ 0,0,0,0,0,0,0,1,0 }, // level 0
+	{ 2,2,2,2,2,2,2,3,2 }, // level 1
+	{ 4,4,4,4,4,4,4,5,4 }, // level 2
+	{ 6,6,6,6,6,6,7,6,6 }, // level 3
+	{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
+};
+const uint8 kGirlSeqs[5][9] = {
+	{ 8,9,8,8,8,8,8,8,8 },
+	{ 6,6,6,7,6,6,6,6,6 },
+	{ 4,4,5,4,4,4,4,4,4 },
+	{ 2,2,2,2,2,2,3,2,2 },
+	{ 0,0,0,0,0,1,0,0,0 },
+};
+
 uint happinessLevel(int x) {
 	for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
 		if (kHappyZones[i].contains(x, 100))
@@ -87,13 +114,6 @@ void blitMaskedToScreen(const Picture &p, int x, int y) {
 	g_system->unlockScreen();
 }
 
-// On-screen positions verified from `_NewAnimation` calls @ 1a35:07b9 / 07d5.
-const int kBoyX  = 0xe2; // 226
-const int kBoyY  = 0x62; // 98
-const int kGirlX = 0x42; // 66
-const int kGirlY = 0x60; // 96
-} // anonymous namespace
-
 void EEMEngine::doChoosePartner() {
 	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
 	// animations on a backdrop and polls four click rectangles (two per
@@ -119,27 +139,9 @@ void EEMEngine::doChoosePartner() {
 	setAnmPalette(Common::Path("TITLE.ANM"));
 
 	// `_DoHappiness @ 172b:27b5`: the cursor's X column picks one of 4
-	// rects (29be:030f, all full-height); past rect 3 → "level 4".
-	// Each level swaps the partner's sequence script to a more / less
-	// "happy" cycle. Boy seqs at 29be:0337 (5 × 0x14 bytes), girl seqs
-	// at 29be:039b. Both cycle through 9 frames (the boy/girl anim
-	// cells contain 10 cells = pairs of "neutral, smile" at increasing
-	// intensity). Lifted verbatim from the binary so the gestures
-	// match the original beat-for-beat.
-	static const uint8 kBoySeqs[5][9] = {
-		{ 0,0,0,0,0,0,0,1,0 }, // level 0
-		{ 2,2,2,2,2,2,2,3,2 }, // level 1
-		{ 4,4,4,4,4,4,4,5,4 }, // level 2
-		{ 6,6,6,6,6,6,7,6,6 }, // level 3
-		{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
-	};
-	static const uint8 kGirlSeqs[5][9] = {
-		{ 8,9,8,8,8,8,8,8,8 },
-		{ 6,6,6,7,6,6,6,6,6 },
-		{ 4,4,5,4,4,4,4,4,4 },
-		{ 2,2,2,2,2,2,3,2,2 },
-		{ 0,0,0,0,0,1,0,0,0 },
-	};
+	// rects (29be:030f, all full-height); past rect 3 → "level 4". The
+	// per-zone sequence scripts (`kBoySeqs` / `kGirlSeqs`) live at file
+	// scope above so the gestures match the original beat-for-beat.
 
 	// `_DoChoosePartner` opens with `_SetMousePos(0xa0, 0x96)` so the
 	// cursor lands centred between the two partners — start the
@@ -650,39 +652,19 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						}
 					}
 				}
-				// Per-balloon metadata table verified from 29be:0875 —
-				// 10-byte entries indexed by `(bubNum & 0x7f)`. Layout:
-				//   +0..1 textX inset, +2..3 textY inset, +4..5 width,
-				//   +6..7 height, +8..9 tail offset.
-				// 52 entries total; insets vary (3, 5, 6, or 8 px).
-				// The original `_DisplayClue` does:
-				//   _WordWrap(bubX + table[bubNum].x, bubY + table[bubNum].y,
-				//             table[bubNum].w, ...);
-				static const struct { uint16 x, y, w; } kBalloonTable[] = {
-					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-					{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-					{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-					{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-					{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-					{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-					{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-					{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-					{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
-				};
-				const uint kBalloonTableSize = sizeof(kBalloonTable) /
-											   sizeof(kBalloonTable[0]);
-				const uint balloonIdx = balloonId < kBalloonTableSize
-										? balloonId : 0;
-				const auto &bm = kBalloonTable[balloonIdx];
-				textX = bubX + bm.x;
-				textY = bubY + bm.y;
-				textW = bm.w;
+				// Per-balloon metadata from `29be:0875` (52 × 10 bytes,
+				// indexed by `bubNum & 0x7F`). The original `_DisplayClue`
+				// does `_WordWrap(bubX + table[bub].x, bubY + table[bub].y,
+				// table[bub].w, ...)`. `getBalloonInsets` is the shared
+				// accessor (defined in `graphics.cpp`); fall back to the
+				// (5, 4, 155) entry-23 inset if the lookup fails.
+				uint16 bx = 5;
+				uint16 by = 4;
+				uint16 bw_ = 155;
+				getBalloonInsets(balloonId, bx, by, bw_);
+				textX = bubX + bx;
+				textY = bubY + by;
+				textW = bw_;
 				copyH = bh;
 			} else {
 				// No balloon — clear a band so old pixels don't bleed.
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 0666827a3db..27371f0c29e 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -26,7 +26,7 @@
 
 namespace EEM {
 
-static const PlainGameDescriptor eemGames[] = {
+const PlainGameDescriptor eemGames[] = {
 	{ "eem", "Eagle Eye Mysteries" },
 	{ nullptr, nullptr }
 };
@@ -46,7 +46,7 @@ const ADGameDescription gameDescriptions[] = {
 	AD_TABLE_END_MARKER
 };
 
-static const DebugChannelDef debugFlagList[] = {
+const DebugChannelDef debugFlagList[] = {
 	{ kDebugGeneral, "general", "General debug" },
 	{ kDebugScript,  "script",  "Script execution" },
 	{ kDebugMystery, "mystery", "Mystery loading and state" },
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index b33fb699ae1..86ddd0a9096 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -45,7 +45,6 @@
 
 namespace EEM {
 
-namespace {
 const uint kPalSize = 768;     ///< 256 colors * 3 bytes
 const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720)
 
@@ -87,7 +86,6 @@ const byte kCursorPalette[] = {
 	0x00, 0x00, 0x00, // 1 — outline
 	0xFF, 0xFF, 0xFF  // 2 — fill
 };
-} // anonymous namespace
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	: Engine(syst), _gameDescription(gameDesc), _rng("eem"),
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index 19f40c1b77e..4d00ab79fb1 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -42,7 +42,7 @@ namespace EEM {
 // and 'a' to the lowercase glyph (so the original engine renders all
 // text in lowercase). We route uppercase ASCII letters to the uppercase
 // glyph slots (33..58) for proper mixed-case rendering.
-static const byte kCharToGlyph[128] = {
+const byte kCharToGlyph[128] = {
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -61,7 +61,7 @@ static const byte kCharToGlyph[128] = {
 	0x52, 0x53, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00  // 0x78..0x7F 'x','y','z'..
 };
 
-static inline byte mapChar(uint32 c) {
+inline byte mapChar(uint32 c) {
 	return c < 128 ? kCharToGlyph[c] : 0;
 }
 
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 857dcb80ecc..3955914eb98 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -39,6 +39,42 @@
 
 namespace EEM {
 
+// `_InterfaceHelp(num)` @ 1560:0205 reads `HelpData @ 29be:00c8` (5-byte
+// entries: u8 count, then up to 2 u16 picIds). Verified bytes:
+//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
+//   entry 1: count=2, picIds = 0x0192, 0x01b1
+// Only entry 0 is reachable from the PDA notebook (rect 1) and the
+// gallery (rect 1) — both call `_InterfaceHelp(0)`.
+const uint16 kHelpPics[][2] = {
+	{ 0x0063, 0x01ae },
+	{ 0x0192, 0x01b1 },
+};
+
+// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
+// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
+// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read by
+// `getBalloonInsets`:
+//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
+// (+6/+8 = balloon h / tail offset, both unused for text layout).
+struct BalloonInsets { uint16 x; uint16 y; uint16 w; };
+const BalloonInsets kBalloonInsetTable[] = {
+	{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+	{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
+	{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+	{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
+	{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+	{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
+	{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+	{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
+	{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+	{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
+	{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+	{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
+	{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+	{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
+	{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+};
+
 void EEMEngine::doHelp() {
 	// `_KDHelp` reads two hint TextBlock offsets from `_KDTextIndex`:
 	//   word @ +0xe : first-time hint
@@ -82,15 +118,8 @@ void EEMEngine::doInterfaceHelp(uint num) {
 	// `_Rect_Move_Mask(0, 0, ...)`, and waits for click / key. ESC ends
 	// the cycle; any other input advances to the next pic.
 	//
-	// Verified from Ghidra HelpData bytes:
-	//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
-	//   entry 1: count=2, picIds = 0x0192, 0x01b1
-	// Only entry 0 is reachable from the PDA notebook (rect 1) and the
-	// gallery (rect 1) — both call `_InterfaceHelp(0)`.
-	static const uint16 kHelpPics[][2] = {
-		{ 0x0063, 0x01ae },
-		{ 0x0192, 0x01b1 },
-	};
+	// `kHelpPics` lives at file scope above; see comment there for the
+	// HelpData decoding.
 	if (num >= ARRAYSIZE(kHelpPics))
 		return;
 
@@ -175,34 +204,13 @@ void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
 
 bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
 								  uint16 &yInset, uint16 &textW) const {
-	// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
-	// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
-	// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read here:
-	//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
-	// (+6/+8 = balloon h / tail offset, both unused for text layout).
-	static const struct { uint16 x, y, w; } kTable[] = {
-		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-		{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-		{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-		{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-		{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-		{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-		{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-		{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-		{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
-	};
+	// `kBalloonInsetTable` lives at file scope above; see comment there.
 	const uint idx = bubNum & 0x7F;
-	if (idx >= ARRAYSIZE(kTable))
+	if (idx >= ARRAYSIZE(kBalloonInsetTable))
 		return false;
-	xInset = kTable[idx].x;
-	yInset = kTable[idx].y;
-	textW  = kTable[idx].w;
+	xInset = kBalloonInsetTable[idx].x;
+	yInset = kBalloonInsetTable[idx].y;
+	textW  = kBalloonInsetTable[idx].w;
 	return true;
 }
 
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index 15fc9981d46..91886e837fb 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -80,7 +80,7 @@ void DBDArchive::close() {
  * Read one 12-byte frame header + payload at the current stream position.
  * Shared between picture and animation loaders since the layout is the same.
  */
-static bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out) {
+bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out) {
 	out.flags             = stream.readUint16LE();
 	const uint16 height   = stream.readUint16LE();
 	const uint16 width    = stream.readUint16LE();
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index df3bc56cbd3..4b641dc441c 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -33,8 +33,6 @@
 
 namespace EEM {
 
-namespace {
-
 // Masked blit a Picture into a ManagedSurface. Pixels equal to `transp`
 // (the high byte of `pic.flags`, per `_Rect_Move_Mask @ 1000:03fc`) are
 // skipped. Used by `enterSiteAnim` for both skateboard + KD slide-in
@@ -60,6 +58,31 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 	}
 }
 
+// Mask-aware blit from a Picture into a `Graphics::Surface` (the
+// locked framebuffer). Same pixel-mask semantics as `blitFrame`.
+// Used by hotspot/NPC rendering inside `SiteScreen::renderHotspots`
+// and `renderStaticDrops`.
+void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
+					   int x, int y) {
+	if (!screen)
+		return;
+	const byte transp = (byte)(p.flags >> 8);
+	for (int row = 0; row < p.surface.h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= screen->h)
+			continue;
+		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+		byte *dst = (byte *)screen->getBasePtr(0, dstY);
+		for (int col = 0; col < p.surface.w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= screen->w)
+				continue;
+			if (src[col] != transp)
+				dst[dstX] = src[col];
+		}
+	}
+}
+
 // Rotate one VGA palette range by one slot. Mirrors `_ColorCycle @
 // 172b:2015` — used by both the per-site Loop-1 ColorCycle entries and
 // the always-on hotspot marching-ants range 0xF9..0xFE.
@@ -83,7 +106,68 @@ void cyclePaletteRange(uint8 start, uint8 end) {
 	g_system->getPaletteManager()->setPalette(buf, start, count);
 }
 
-} // anonymous namespace
+// Per-speaker partner-position table verified against `_WaitAnims @
+// 29be:021c`. 12 bytes per entry, indexed by `siteData[+8]`. Layout:
+//   +0..1 anim Jake, +2..3 anim Jenny,
+//   +4..5 x    Jake, +6..7 x    Jenny,
+//   +8..9 y    Jake, +10..11 y    Jenny.
+// Seven valid entries — anything past entry 6 in the binary is
+// `_SiteButtons` rect data that follows the table in memory.
+const uint16 kWaitAnims[7][6] = {
+	{ 0x00, 0x0a, 0x06, 0x06, 0x50, 0x50 }, // 0
+	{ 0x03, 0x0c, 0x06, 0x06, 0x50, 0x50 }, // 1
+	{ 0x01, 0x0b, 0x06, 0x06, 0x50, 0x50 }, // 2
+	{ 0x04, 0x0d, 0x06, 0x06, 0x50, 0x50 }, // 3
+	{ 0x02, 0x10, 0x06, 0x06, 0x50, 0x50 }, // 4
+	{ 0x05, 0x05, 0x06, 0x06, 0x50, 0x50 }, // 5
+	{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
+};
+
+// `_DoKDAnim` lookup table. Six valid kdAnimNum entries (0..5)
+// verified from `29be:0228`. Layout per entry: { animJake, animJenny,
+// xJake, xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
+const uint16 kKdAnimTable[6][6] = {
+	{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
+	{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
+	{ 0x04, 0x0d, 6, 6, 80, 80 }, // 2
+	{ 0x02, 0x10, 6, 6, 80, 80 }, // 3 — same as gallery
+	{ 0x05, 0x05, 6, 6, 80, 80 }, // 4 — same anim both partners
+	{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
+};
+
+// Sequence-script lookup. Entries copied verbatim from
+// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
+// Each script is a u16[] of frame indices terminated by 0x80; we
+// don't yet handle 0x81 jumps (none of the kdAnim sequences use
+// them — verified). seqnum == animId for these calls (per
+// `_PlayAnimation` 172b:1f5d push order).
+struct KdScript {
+	uint16 seqnum;
+	uint8 len;
+	uint8 frames[20];  // long enough for any kdAnim script
+};
+const KdScript kKdScripts[] = {
+	// seqnum 1 (29be:188a) — head bob
+	{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+	// seqnum 2 (29be:18aa) — short blip then long pause
+	{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
+	// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
+	{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
+	// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
+	{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+	// seqnum 5 (29be:1910) — held idle with a single peak
+	{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
+	// seqnum 6 (29be:192c) — empty (immediate END)
+	{ 0x06,  0, { 0 } },
+	// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
+	{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
+	// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
+	{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
+	// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
+	{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
+	// seqnum 0x10 (29be:1956) — Jenny short anim
+	{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
+};
 
 void SiteScreen::enter(uint siteNum) {
 	if (!_mystery || !_mystery->isLoaded()) {
@@ -490,28 +574,6 @@ void SiteScreen::enterSiteAnim() {
 	}
 }
 
-// Mask-aware blit from a Picture into a Graphics::Surface.
-static void blitMaskedSurface(Graphics::Surface *screen,
-							  const Picture &p, int x, int y) {
-	if (!screen)
-		return;
-	const byte transp = (byte)(p.flags >> 8);
-	for (int row = 0; row < p.surface.h; row++) {
-		const int dstY = y + row;
-		if (dstY < 0 || dstY >= screen->h)
-			continue;
-		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-		byte *dst = (byte *)screen->getBasePtr(0, dstY);
-		for (int col = 0; col < p.surface.w; col++) {
-			const int dstX = x + col;
-			if (dstX < 0 || dstX >= screen->w)
-				continue;
-			if (src[col] != transp)
-				dst[dstX] = src[col];
-		}
-	}
-}
-
 void SiteScreen::renderStaticDrops(uint siteNum) {
 	// Loop 2 from `_DoSiteLoop @ 168d:03f4`:
 	//   bound: siteData[+0x4]   (verified at 168d:05c0:
@@ -671,21 +733,9 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	//   +0..1 anim Jake, +2..3 anim Jenny,
 	//   +4..5 x    Jake, +6..7 x    Jenny,
 	//   +8..9 y    Jake, +10..11 y    Jenny.
-	// Seven valid entries verified against the bytes at 29be:021c.
-	// Anything past entry 6 in the binary is `_SiteButtons` rect data
-	// that follows the table in memory — NOT continuation entries —
-	// so we cap the table here and skip rendering for siteData[+8] >= 7
-	// (which would indicate corrupt mystery data anyway).
-	static const uint16 kWaitAnims[7][6] = {
-		{ 0x00, 0x0a, 0x06, 0x06, 0x50, 0x50 }, // 0
-		{ 0x03, 0x0c, 0x06, 0x06, 0x50, 0x50 }, // 1
-		{ 0x01, 0x0b, 0x06, 0x06, 0x50, 0x50 }, // 2
-		{ 0x04, 0x0d, 0x06, 0x06, 0x50, 0x50 }, // 3
-		{ 0x02, 0x10, 0x06, 0x06, 0x50, 0x50 }, // 4
-		{ 0x05, 0x05, 0x06, 0x06, 0x50, 0x50 }, // 5
-		{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
-	};
-
+	// `kWaitAnims` lives at file scope above; we cap rendering at
+	// `speaker < 7` since anything past entry 6 is the `_SiteButtons`
+	// rect data that follows the table in the binary.
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -954,17 +1004,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 	// gesture (Jenny taking a picture, etc.) finishes before the
 	// speaker portrait + speech balloon appear.
 	//
-	// Six valid kdAnimNum entries (0..5). Verified bytes from
-	// `29be:0228`. Layout per entry: { animJake, animJenny, xJake,
-	// xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
-	static const uint16 kKdAnimTable[6][6] = {
-		{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
-		{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
-		{ 0x04, 0x0d, 6, 6, 80, 80 }, // 2
-		{ 0x02, 0x10, 6, 6, 80, 80 }, // 3 — same as gallery
-		{ 0x05, 0x05, 6, 6, 80, 80 }, // 4 — same anim both partners
-		{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
-	};
+	// `kKdAnimTable` and `kKdScripts` live at file scope above.
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
 
@@ -979,45 +1019,12 @@ void EEMEngine::playKdAnim(uint16 num) {
 		return;
 	}
 
-	// Sequence-script lookup. Entries copied verbatim from
-	// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
-	// Each script is a u16[] of frame indices terminated by 0x80; we
-	// don't yet handle 0x81 jumps (none of the kdAnim sequences use
-	// them — verified). seqnum == animId for these calls (per
-	// `_PlayAnimation` 172b:1f5d push order).
-	struct Script {
-		uint16 seqnum;
-		uint8 len;
-		uint8 frames[20];  // long enough for any kdAnim script
-	};
-	static const Script kScripts[] = {
-		// seqnum 1 (29be:188a) — head bob
-		{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-		// seqnum 2 (29be:18aa) — short blip then long pause
-		{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
-		// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
-		{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
-		// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
-		{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-		// seqnum 5 (29be:1910) — held idle with a single peak
-		{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
-		// seqnum 6 (29be:192c) — empty (immediate END)
-		{ 0x06,  0, { 0 } },
-		// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
-		{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-		// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
-		{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
-		// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
-		{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-		// seqnum 0x10 (29be:1956) — Jenny short anim
-		{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
-	};
 	const uint8 *frames = nullptr;
 	uint frameCount = 0;
-	for (uint i = 0; i < ARRAYSIZE(kScripts); i++) {
-		if (kScripts[i].seqnum == animId) {
-			frames = kScripts[i].frames;
-			frameCount = kScripts[i].len;
+	for (uint i = 0; i < ARRAYSIZE(kKdScripts); i++) {
+		if (kKdScripts[i].seqnum == animId) {
+			frames = kKdScripts[i].frames;
+			frameCount = kKdScripts[i].len;
 			break;
 		}
 	}
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2ea76273497..7c3d181afdd 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -40,7 +40,24 @@
 
 namespace EEM {
 
-namespace {
+// Five fixed gallery slot positions verified at `29be:0x116`. Used by
+// both `_DrawGallery @ 158f:0046` (notebook gallery) and the accuse
+// portrait grid; the layout is identical so we share the table.
+struct GallerySlot { int x; int y; };
+const GallerySlot kGallerySlots[5] = {
+	{  83,  14 }, // 0
+	{ 155,  14 }, // 1
+	{ 227,  14 }, // 2
+	{ 119,  90 }, // 3
+	{ 191,  90 }  // 4
+};
+
+// `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ `29be:1064`:
+//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
+//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
+const uint16 kDigitBalloons[10] = {
+	0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
+};
 
 // Return the next non-empty slot in `slotRects` starting from `from`,
 // stepping by `dir` (+1 or -1) with wraparound. Used by the accuse
@@ -215,8 +232,6 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 	g_system->updateScreen();
 }
 
-} // anonymous namespace
-
 void EEMEngine::doNewPlayer() {
 	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
 	// 0x104 + character peek pic 0x107, then shows "Please type your
@@ -1259,16 +1274,8 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 								  Common::Array<int> &slotSuspect) {
 	// Gallery redraw — formerly the `drawFrame` lambda inside `doGallery`.
 	// Mirrors `_DrawGallery @ 158f:0046`: PIC 0x3f frame + partner sprite
-	// at (5, 0x50) + suspect portraits in their `_NewOrder` slots.
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, // 0
-		{ 155,  14 }, // 1
-		{ 227,  14 }, // 2
-		{ 119,  90 }, // 3
-		{ 191,  90 }  // 4
-	};
-
+	// at (5, 0x50) + suspect portraits in their `_NewOrder` slots. Slot
+	// positions live in `kGallerySlots` in this file's anon namespace.
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
@@ -1322,7 +1329,7 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 		const uint8 phys = _mystery._newOrder[i];
 		if (phys >= 5)
 			continue;
-		const Slot &s = kGallerySlots[phys];
+		const GallerySlot &s = kGallerySlots[phys];
 
 		const bool discovered = _mystery._inGallery[phys] != 0;
 		if (discovered) {
@@ -1878,9 +1885,7 @@ uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
 	// reusing the digit-2 slot.
 	if (firstChar < '0' || firstChar > '9')
 		return 0x17;
-	static const uint16 kDigitBalloons[10] = {
-		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
-	};
+	// `kDigitBalloons` lives at file scope above.
 	return kDigitBalloons[firstChar - '0'];
 }
 
@@ -2265,14 +2270,9 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 								   Common::Array<int> &slotSuspect) {
 	// Accuse-gallery redraw — formerly the `drawGallery` lambda inside
 	// `doAccuse`. Mirrors `_DoAccuseGallery @ 1df2:0a31` portrait grid:
-	// PIC 0x3f backdrop, suspect portraits at the 5 fixed slots, and a
-	// 1-px outline (palette index 0xFE) around the highlighted slot.
-	struct Slot { int x; int y; };
-	static const Slot kGallerySlots[5] = {
-		{  83,  14 }, { 155,  14 }, { 227,  14 },
-		{ 119,  90 }, { 191,  90 }
-	};
-
+	// PIC 0x3f backdrop, suspect portraits at the 5 fixed slots
+	// (`kGallerySlots` in this file's anon namespace), and a 1-px
+	// outline (palette index 0xFE) around the highlighted slot.
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -2300,7 +2300,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		// `_InGallery[phys]` flag is 0 — that's the original gate.
 		if (_mystery._inGallery[phys] == 0)
 			continue;
-		const Slot &s = kGallerySlots[phys];
+		const GallerySlot &s = kGallerySlots[phys];
 
 		const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
 		if (picId == 0)


Commit: d44daf096d7f970c7ed8f165b5f98edcd9974878
    https://github.com/scummvm/scummvm/commit/d44daf096d7f970c7ed8f165b5f98edcd9974878
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:41+02:00

Commit Message:
EEM: implemented midi music

Changed paths:
  A engines/eem/music.cpp
  A engines/eem/music.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/module.mk
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 86ddd0a9096..0895b869273 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -35,6 +35,7 @@
 
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/music.h"
 #include "eem/site.h"
 
 #include "common/config-manager.h"
@@ -94,6 +95,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 }
 
 EEMEngine::~EEMEngine() {
+	delete _music;
 }
 
 Common::Error EEMEngine::run() {
@@ -110,6 +112,10 @@ Common::Error EEMEngine::run() {
 	if (!_font.load(Common::Path("FONT.FNT")))
 		warning("FONT.FNT failed to load; text will not render");
 
+	// MIDI music player. Mirrors `_InitMIDI @ 20a2:013a`. Constructed
+	// here (after `initGraphics` so the OSystem's timer/mixer is up).
+	_music = new MusicPlayer();
+
 	// _InitMouse @ 152d:018b in the original — install our 11x16 arrow,
 	// using palette index 0 as the transparency key. The cursor is left
 	// hidden through the opening anims and switched on at NewPlayer /
@@ -156,18 +162,34 @@ Common::Error EEMEngine::run() {
 		}
 	}
 
-	// Reproduces _DoOpeningAnims @ 2520:082a (sans audio):
+	// Reproduces _DoOpeningAnims @ 2520:082a:
 	//   EA Kids logo (PIC) -> HighScore Productions logo (PIC) ->
-	//   Storm Software logo (BOLT.ANM) -> 20 character-intro animations
-	//   (ANIM01.A .. ANIM20.A) -> TITLE.ANM. Click / any key skips a
-	//   single clip; ESC skips the rest of the chain (waitForInput /
-	//   playAnm raise `_skipIntro` so each subsequent step bails out).
+	//   Storm Software logo (BOLT.ANM) -> [music starts] -> 20
+	//   character-intro animations (ANIM01.A..ANIM20.A) -> [music
+	//   restarts] -> TITLE.ANM. Click / any key skips a single clip;
+	//   ESC skips the rest of the chain (waitForInput / playAnm raise
+	//   `_skipIntro` so each subsequent step bails out).
+	//
+	// Music timing (verified at 2520:0883 and 2520:0918):
+	//   - The three logos and `_InitMysterySounds(0x3c)` all run BEFORE
+	//     any `_MIDIPlayFile` call — those segments are voice-only.
+	//   - Theme starts with `_LoopMIDI = 0x7fff` right before the
+	//     ANIM01..ANIM20 loop (2520:0883).
+	//   - After the loop the original calls `_CleanMysterySounds` and
+	//     then `_MIDIPlayFile("theme.xmi")` again with `_LoopMIDI =
+	//     0xffff` (2520:0918) to restart the theme for TITLE.ANM.
+	//   - `_StopMIDI()` runs on keypress at the title screen
+	//     (2520:094c).
 	_skipIntro = false;
 	showEAKidsLogo();
 	if (!shouldQuit() && !_skipIntro)
 		showHighScoreLogo();
 	if (!shouldQuit() && !_skipIntro)
 		playAnm(Common::Path("BOLT.ANM"));
+	// Theme begins HERE — after the three silent logos, before the
+	// character-intro reel.
+	if (!shouldQuit() && !_skipIntro && _music)
+		_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 	for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
 		Common::String name = Common::String::format("ANIM%02d.A", i);
 		playAnm(Common::Path(name));
@@ -176,6 +198,10 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro && i != 20)
 			waitForInput(2000);
 	}
+	// Restart the theme for TITLE.ANM — matches the second
+	// `_MIDIPlayFile("theme.xmi")` call at 2520:0918.
+	if (!shouldQuit() && !_skipIntro && _music)
+		_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 	if (!shouldQuit() && !_skipIntro)
 		playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
 	_skipIntro = false;
@@ -187,6 +213,12 @@ Common::Error EEMEngine::run() {
 	// the interactive screens (matches `_MouseCursor = 1` at the tail
 	// of `_NewPlayer`).
 	CursorMan.showMouse(true);
+
+	// Stop the title music — the original `_NewPlayer / _DoChoosePartner`
+	// screens have no music until the briefing's `_PlayInSequence` /
+	// per-mystery `_StartTravelMusic` kicks in.
+	if (_music)
+		_music->stop();
 	if (!shouldQuit())
 		doNewPlayer();
 	if (!shouldQuit())
@@ -446,6 +478,30 @@ void EEMEngine::doSiteLoop() {
 	screen.run();
 }
 
+void EEMEngine::startTravelMusic() {
+	// Mirrors `_StartTravelMusic @ 20a2:0595`:
+	//
+	//   for (num = _SiteNumber; num > 4; num -= 5) {}
+	//   if (_MIDIAvailable && _MusicEnabled) {
+	//       if (_IsMIDIPlaying()) _StopMIDI();
+	//       _MIDIPlay(num);
+	//   }
+	//
+	// Five travel tracks: MUS00000.XMI .. MUS00004.XMI, picked by
+	// `_SiteNumber % 5`. The original always loops travel music (the
+	// `_LoopMIDI` global isn't reset between site changes).
+	if (!_music || !_mystery.isLoaded())
+		return;
+	const uint num = _mystery._siteNumber % 5;
+	_music->playMus(num, /*loop=*/true);
+}
+
+void EEMEngine::syncSoundSettings() {
+	Engine::syncSoundSettings();
+	if (_music)
+		_music->syncVolume();
+}
+
 bool EEMEngine::hasFeature(EngineFeature f) const {
 	// We support saving any time but loading only at startup (via the
 	// `--save-slot=N` resume path or a slot picked from the launcher).
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 35bf5100ef4..e2f3e0c2ce4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -39,6 +39,8 @@
 
 namespace EEM {
 
+class MusicPlayer;
+
 /**
  * Screen IDs used by the original ScreenDriver dispatch table at 1a35:0e5e.
  * The table holds 14 (id, handler) entries; the loop iterates until it finds
@@ -243,6 +245,21 @@ private:
 	/// minus the live ANI sequence playback.
 	void doInitClues();
 
+public:
+	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
+	/// based on `_mystery._siteNumber % 5` and starts it (looping). The
+	/// site loop calls this each time `enter(siteNum)` runs so the
+	/// music changes as the player travels between sites.
+	void startTravelMusic();
+
+	/// Forwarded from `Engine::syncSoundSettings`. Re-pulls the user's
+	/// `music_volume` slider into the MIDI player's `_masterVolume`,
+	/// otherwise the AdLib output stays at whatever the slider was at
+	/// the moment `_music` was constructed (and the live launcher
+	/// changes to the volume slider have no effect).
+	void syncSoundSettings() override;
+private:
+
 	Common::String _playerName;  ///< Substituted into 0x80 placeholders
 
 	/// Per-mystery solved state. 0 = unsolved, 1 = solved, 2 = solved
@@ -284,6 +301,12 @@ private:
 	/// accuse contexts use their own composed backdrops). See
 	/// `setPartnerEraseBg`.
 	Graphics::ManagedSurface _partnerEraseBg;
+
+	/// XMIDI music player. Mirrors the original `MIDI.C` family
+	/// (`_MIDIPlayFile`, `_MIDIPlay`, `_StopMIDI`, `_StartTravelMusic`
+	/// at 20a2:00e2-05c9). Constructed lazily during `run()` once the
+	/// MIDI driver / timer system is up.
+	MusicPlayer *_music = nullptr;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index 5583335ffd2..ce7975e1edc 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	font.o \
 	graphics.o \
 	metaengine.o \
+	music.o \
 	mystery.o \
 	resource.o \
 	site.o \
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
new file mode 100644
index 00000000000..a1a40adb60e
--- /dev/null
+++ b/engines/eem/music.cpp
@@ -0,0 +1,174 @@
+/* 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 "audio/midiparser.h"
+#include "audio/miles.h"
+
+#include "common/debug.h"
+#include "common/file.h"
+#include "common/textconsole.h"
+
+#include "eem/detection.h"
+#include "eem/music.h"
+
+namespace EEM {
+
+MusicPlayer::MusicPlayer() {
+	// Mirrors `_InitMIDI @ 20a2:013a` which used `_AIL_register_driver`
+	// to walk the .ADV files (ADLIB.ADV, SBFM.ADV, MT32MPU.ADV, etc.)
+	// and pick a backend. We honour the launcher's "Music driver"
+	// setting and only force AdLib / MT-32 paths through Miles when the
+	// detected device matches.
+	const MidiDriver::DeviceHandle dev =
+		MidiDriver::detectDevice(MDT_MIDI | MDT_ADLIB);
+	const MusicType musicType = MidiDriver::getMusicType(dev);
+
+	switch (musicType) {
+	case MT_ADLIB:
+		// `_MIDIPlayFile` (20a2:024c) opens "SAMPLE.AD" (29be:14d6) and
+		// installs every patch the sequence requests via
+		// `_AIL_install_timbre`. ScummVM's Miles AdLib driver does the
+		// same: it reads SAMPLE.AD on construction and serves timbres
+		// out of that bank, which is what makes the notes match the
+		// 1993 release. SAMPLE.OPL would be the OPL3 variant; the game
+		// only ships SAMPLE.AD, so an empty path falls back to OPL2.
+		_milesAudioMode = true;
+		_driver = Audio::MidiDriver_Miles_AdLib_create(
+			Common::Path("SAMPLE.AD"), Common::Path());
+		break;
+	case MT_MT32:
+		// `MT32MPU.ADV` was the original MT-32 driver; ScummVM has no
+		// Miles MT-32 instrument bank for EEM, so we use the standard
+		// MT-32 driver and let the XMIDI's own program changes drive
+		// the patch selection.
+		_milesAudioMode = true;
+		_driver = Audio::MidiDriver_Miles_MT32_create(Common::Path());
+		break;
+	default:
+		_milesAudioMode = false;
+		createDriver(MDT_MIDI | MDT_ADLIB);
+		break;
+	}
+
+	if (_driver) {
+		const int ret = _driver->open();
+		if (ret != 0) {
+			warning("MusicPlayer: MidiDriver::open() failed (%d)", ret);
+			delete _driver;
+			_driver = nullptr;
+		} else {
+			// No GM/MT-32 reset for AdLib (Miles AdLib handles its own
+			// state); for MT-32/GM the original would've sent its own
+			// initialisation patches via `_AIL_install_timbre`, but we
+			// don't have those banks for non-AdLib devices.
+			if (musicType != MT_ADLIB) {
+				if (musicType == MT_MT32 || _nativeMT32)
+					_driver->sendMT32Reset();
+				else
+					_driver->sendGMReset();
+			}
+			_driver->setTimerCallback(this, &timerCallback);
+		}
+	} else {
+		debugC(1, kDebugSound, "MusicPlayer: no MIDI driver — music disabled");
+	}
+}
+
+void MusicPlayer::send(uint32 b) {
+	// Miles drivers (both AdLib and MT-32) implement their own per-
+	// source-channel mixing and timbre installation, so just forward
+	// the raw event. Going through `MidiPlayer::send` would re-wrap
+	// CC 7 against `_masterVolume` AND remap the source channel via
+	// `sendToChannel`/`allocateChannel`, both of which the Miles driver
+	// already handles internally (and break the timbre selection if
+	// double-applied).
+	if (_milesAudioMode) {
+		_driver->send(b);
+		return;
+	}
+	Audio::MidiPlayer::send(b);
+}
+
+void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
+	if (!_driver)
+		return;
+
+	Common::StackLock lock(_mutex);
+	stop();
+
+	// Mirrors `_MIDIPlayFile`'s `_fopen` + `_fread` (20a2:024c-029e).
+	Common::File f;
+	if (!f.open(xmiPath)) {
+		warning("MusicPlayer: %s missing", xmiPath.toString().c_str());
+		return;
+	}
+	const uint32 size = f.size();
+	if (size == 0) {
+		warning("MusicPlayer: %s is empty", xmiPath.toString().c_str());
+		return;
+	}
+	_xmiData.resize(size);
+	if (f.read(_xmiData.data(), size) != size) {
+		warning("MusicPlayer: short read on %s",
+				xmiPath.toString().c_str());
+		_xmiData.clear();
+		return;
+	}
+
+	_parser = MidiParser::createParser_XMIDI(nullptr, nullptr, 0);
+	_parser->setMidiDriver(this);
+	_parser->setTimerRate(_driver->getBaseTempo());
+	_parser->property(MidiParser::mpCenterPitchWheelOnUnload, 1);
+	_parser->property(MidiParser::mpSendSustainOffOnNotesOff, 1);
+
+	if (!_parser->loadMusic(_xmiData.data(), _xmiData.size())) {
+		warning("MusicPlayer: XMIDI parser rejected %s",
+				xmiPath.toString().c_str());
+		delete _parser;
+		_parser = nullptr;
+		_xmiData.clear();
+		return;
+	}
+
+	// Mirrors `_LoopMIDI = 0xFFFF` (the count register the original
+	// engine uses for indefinite looping in `_DoOpeningAnims`).
+	_isLooping = loop;
+	_parser->property(MidiParser::mpAutoLoop, loop ? 1 : 0);
+	_parser->setTrack(0);
+
+	// Pull the launcher's music_volume slider into `_masterVolume` so
+	// the non-Miles `Audio::MidiPlayer::send` path scales correctly.
+	// (Miles drivers do their own volume handling on the Multisource
+	// path, but they also honour `MidiDriver::syncSoundSettings` which
+	// `Engine::syncSoundSettings` triggers.)
+	syncVolume();
+	_isPlaying = true;
+	debugC(1, kDebugSound, "MusicPlayer: playing %s (%u bytes, loop=%d, miles=%d)",
+		   xmiPath.toString().c_str(), size, loop, _milesAudioMode);
+}
+
+void MusicPlayer::playMus(uint num, bool loop) {
+	// Format string verified at `29be:1525` ("mus%05d.xmi").
+	const Common::String name = Common::String::format("MUS%05u.XMI", num);
+	playFile(Common::Path(name), loop);
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/music.h b/engines/eem/music.h
new file mode 100644
index 00000000000..f02830cf5cf
--- /dev/null
+++ b/engines/eem/music.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 EEM_MUSIC_H
+#define EEM_MUSIC_H
+
+#include "audio/midiplayer.h"
+
+#include "common/array.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+
+namespace EEM {
+
+/**
+ * MIDI music player. Mirrors the original `MIDI.C` source file in
+ * `EEMCD.EXE` (the `_MIDIPlayFile / _MIDIPlay / _StopMIDI /
+ * _IsMIDIPlaying / _StartTravelMusic` family at `20a2:00e2-05c9`).
+ *
+ * The original engine uses Miles Audio Interface Library (AIL):
+ *
+ *   - `_InitMIDI @ 20a2:013a` calls `_AIL_register_driver` against
+ *     `ADLIB.ADV` / `SBFM.ADV` / `MT32MPU.ADV` and reserves a timbre
+ *     cache via `_AIL_define_timbre_cache`.
+ *   - `_MIDIPlayFile @ 20a2:024c` opens the .XMI, calls
+ *     `_AIL_register_sequence`, then loops over the sequence's
+ *     `_AIL_timbre_request` results. For every (bank, patch) pair the
+ *     driver asks for, it pulls the AdLib instrument definition from
+ *     **`SAMPLE.AD`** (string at `29be:14d6`) via `_load_global_timbre`
+ *     and installs it through `_AIL_install_timbre`. Only after every
+ *     patch is loaded does it call `_AIL_start_sequence`.
+ *
+ * Without those custom timbres, ScummVM's generic AdLib synth falls
+ * back to its built-in default timbre table — same notes, very
+ * different timbres. ScummVM ships a Miles AdLib driver
+ * (`Audio::MidiDriver_Miles_AdLib_create`) that loads `SAMPLE.AD` and
+ * implements the same install-on-demand workflow, so we use it for
+ * AdLib output. MT-32 / GM fall back to the generic driver via
+ * `Audio::MidiPlayer::createDriver`.
+ *
+ * Available music files in the game directory:
+ *   - THEME.XMI   — opening anims (looping) + title screen
+ *   - MUS00000.XMI..MUS00004.XMI — per-site travel music
+ *     (`_StartTravelMusic` picks one via `_SiteNumber % 5`)
+ *   - MUS00005.XMI — winner ending (`_DisplayCorrect` @ 1df2:0789)
+ *   - MUS00006.XMI — loser ending (`_DisplayAlibi` @ 1df2:018a)
+ */
+class MusicPlayer : public Audio::MidiPlayer {
+public:
+	MusicPlayer();
+
+	/// Mirrors `_MIDIPlayFile @ 20a2:024c`. Reads the .XMI from the game
+	/// directory and starts playing. `loop=true` mirrors the
+	/// `_LoopMIDI = 0xFFFF` writes inside `_DoOpeningAnims` (theme music).
+	void playFile(const Common::Path &xmiPath, bool loop = false);
+
+	/// Mirrors `_MIDIPlay(num) @ 20a2:047d`. Composes the filename
+	/// "MUS%05u.XMI" and plays it. Used by `_StartTravelMusic`,
+	/// `_DisplayCorrect` (winner), `_DisplayAlibi` (loser).
+	void playMus(uint num, bool loop = false);
+
+	// In Miles AdLib mode the driver allocates its own AdLib voice
+	// pool and consumes the XMIDI's source-channel byte directly, so we
+	// must NOT route through `Audio::MidiPlayer::sendToChannel` (which
+	// remaps every source channel through `allocateChannel()` and
+	// breaks the timbre selection / volume scaling the Miles driver
+	// performs internally). Same workaround Toltecs / SAGA use.
+	void send(uint32 b) override;
+
+private:
+	bool _milesAudioMode = false;
+	Common::Array<byte> _xmiData;
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 4b641dc441c..ea50d7f56c5 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -191,6 +191,11 @@ void SiteScreen::enter(uint siteNum) {
 	debugC(1, kDebugSite, "Entering site %u (%u hotspots)",
 		   siteNum, _mystery->hotspotCount(siteNum));
 
+	// `_DoTravel @ 168d:02da` calls `_StartTravelMusic` after the
+	// destination is set. We do the same here so the music swaps as
+	// the player moves between sites.
+	_vm->startTravelMusic();
+
 	// Palette: original `_BuildBackground` calls `GetPalette(sitenum + 1)`
 	// where sitenum is the global SITES.DBD index (= the per-mystery
 	// `sitepic` field), not the per-mystery site index.
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7c3d181afdd..fe306ef463d 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -32,6 +32,7 @@
 
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/music.h"
 
 // EEM — UI screens (NOTE.C, GALLERY.C, ACCUSE.C, MAP.C, CHOOSE.C combined).
 // Each function is a self-contained modal `EEMEngine::doX()` reachable from
@@ -2250,6 +2251,12 @@ void EEMEngine::doAccuse() {
 		if (mn < sizeof(_mysteriesSolved)) {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 		}
+
+		// `_DisplayCorrect @ 1df2:073c` calls `_MIDIPlay(5)` (1df2:0789)
+		// before `_DifferenceAnimation("scrapbk.ani")` to swap from the
+		// travel music to the winner cue.
+		if (_music)
+			_music->playMus(5, /*loop=*/false);
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
 		// Auto-save into slot 0 (the engine's quicksave slot).


Commit: ffc4f5135bce80fbc259c39850fa74c3f8d13187
    https://github.com/scummvm/scummvm/commit/ffc4f5135bce80fbc259c39850fa74c3f8d13187
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:41+02:00

Commit Message:
EEM: implemented digitalized sound

Changed paths:
  A engines/eem/audio.cpp
  A engines/eem/audio.h
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/module.mk
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
new file mode 100644
index 00000000000..fab34529b71
--- /dev/null
+++ b/engines/eem/audio.cpp
@@ -0,0 +1,345 @@
+/* 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 "audio/decoders/raw.h"
+#include "audio/decoders/voc.h"
+
+#include "common/compression/dcl.h"
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/events.h"
+#include "common/memstream.h"
+#include "common/substream.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+
+#include "eem/audio.h"
+#include "eem/detection.h"
+#include "eem/eem.h"
+
+namespace EEM {
+
+AudioPlayer::AudioPlayer(EEMEngine *vm) :
+	_vm(vm), _mixer(g_system->getMixer()) {
+}
+
+AudioPlayer::~AudioPlayer() {
+	stopAll();
+}
+
+void AudioPlayer::stopAll() {
+	stopVoice();
+	stopSpool();
+	cleanMysterySounds();
+}
+
+// VOC playback --------------------------------------------------------
+
+void AudioPlayer::playVoc(const Common::Path &vocPath) {
+	stopVoice();
+
+	// Mirrors `_LoadSoundName`'s `_fopen` (1ff1:02ac).
+	Common::File *f = new Common::File();
+	if (!f->open(vocPath)) {
+		warning("AudioPlayer: %s missing", vocPath.toString().c_str());
+		delete f;
+		return;
+	}
+
+	Audio::SeekableAudioStream *stream =
+		Audio::makeVOCStream(f, Audio::FLAG_UNSIGNED, DisposeAfterUse::YES);
+	if (!stream) {
+		warning("AudioPlayer: %s is not a valid VOC",
+				vocPath.toString().c_str());
+		return;
+	}
+
+	// `_PlayVoice` (1ff1:023e) goes through `_AIL_play_VOC_file` on the
+	// digital channel — we route through `kSpeechSoundType` so the
+	// launcher's "Speech volume" slider applies.
+	_mixer->playStream(Audio::Mixer::kSpeechSoundType, &_voiceHandle,
+					   stream, -1, Audio::Mixer::kMaxChannelVolume,
+					   0, DisposeAfterUse::YES);
+	debugC(1, kDebugSound, "AudioPlayer: playVoc(%s)",
+		   vocPath.toString().c_str());
+}
+
+bool AudioPlayer::isVoicePlaying() const {
+	return _mixer->isSoundHandleActive(_voiceHandle);
+}
+
+void AudioPlayer::waitForVoiceDone(uint32 maxMs) {
+	// Mirrors the wait loop at `_WaitForVoiceDone @ 1ff1:0221` — pumps
+	// events (so animations + abort-on-click still work) while waiting
+	// for the AIL voice channel to drain.
+	const uint32 startMs = g_system->getMillis();
+	while (isVoicePlaying() && !_vm->shouldQuit() &&
+		   g_system->getMillis() - startMs < maxMs) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT ||
+				event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				event.type == Common::EVENT_LBUTTONDOWN ||
+				event.type == Common::EVENT_KEYDOWN) {
+				stopVoice();
+				return;
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+}
+
+void AudioPlayer::stopVoice() {
+	if (_mixer->isSoundHandleActive(_voiceHandle))
+		_mixer->stopHandle(_voiceHandle);
+}
+
+// Spool sound ---------------------------------------------------------
+
+bool AudioPlayer::readSdxIndex(const Common::Path &sdxPath) {
+	// Mirrors `_ReadLong(&MysterySounds, size, sdx)` at 202f:0647 —
+	// reads the entire .SDX into memory; each 12-byte entry is
+	// (u32 offset, u32 compressed_size, u32 uncompressed_size).
+	Common::File f;
+	if (!f.open(sdxPath)) {
+		warning("AudioPlayer: %s missing", sdxPath.toString().c_str());
+		return false;
+	}
+	const uint32 size = f.size();
+	if (size == 0 || (size % 12) != 0) {
+		warning("AudioPlayer: %s has invalid size %u",
+				sdxPath.toString().c_str(), size);
+		return false;
+	}
+	const uint count = size / 12;
+	_sdxIndex.resize(count);
+	for (uint i = 0; i < count; i++) {
+		_sdxIndex[i].offset           = f.readUint32LE();
+		_sdxIndex[i].compressedSize   = f.readUint32LE();
+		_sdxIndex[i].uncompressedSize = f.readUint32LE();
+	}
+	return true;
+}
+
+bool AudioPlayer::initMysterySounds(uint mysteryNum) {
+	// Mirrors `_InitMysterySounds @ 202f:05cb` — calls
+	// `_CleanMysterySounds` first, then sprintf-opens `m%u.sdx` (string
+	// at 29be:144f) and `m%u.sdb` (29be:145b).
+	cleanMysterySounds();
+
+	const Common::String sdxName = Common::String::format("M%u.SDX", mysteryNum);
+	const Common::String sdbName = Common::String::format("M%u.SDB", mysteryNum);
+	const Common::Path sdxPath(sdxName);
+	const Common::Path sdbPath(sdbName);
+
+	if (!readSdxIndex(sdxPath)) {
+		_sdxIndex.clear();
+		return false;
+	}
+	_sdbPath = sdbPath;
+	_currentMystery = (int)mysteryNum;
+	debugC(1, kDebugSound, "AudioPlayer: mystery %u — %u sounds",
+		   mysteryNum, (uint)_sdxIndex.size());
+	return true;
+}
+
+void AudioPlayer::cleanMysterySounds() {
+	stopSpool();
+	_sdxIndex.clear();
+	_sdbPath = Common::Path();
+	_currentMystery = -1;
+}
+
+void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
+								Audio::SoundHandle &handle,
+								Audio::Mixer::SoundType type) {
+	// `Audio::makeRawStream` takes ownership and `free()`s the buffer
+	// on stream destruction — so the caller must `malloc` (not `new`)
+	// the PCM buffer.
+	Audio::SeekableAudioStream *stream =
+		Audio::makeRawStream(pcm, size, sampleRate, Audio::FLAG_UNSIGNED,
+							 DisposeAfterUse::YES);
+	if (!stream) {
+		free(pcm);
+		warning("AudioPlayer: makeRawStream failed");
+		return;
+	}
+	_mixer->playStream(type, &handle, stream, -1,
+					   Audio::Mixer::kMaxChannelVolume, 0,
+					   DisposeAfterUse::YES);
+}
+
+void AudioPlayer::spoolSound(uint num) {
+	if (_currentMystery < 0 || num >= _sdxIndex.size()) {
+		warning("AudioPlayer: spoolSound(%u) — invalid index (%d, %u)",
+				num, _currentMystery, (uint)_sdxIndex.size());
+		return;
+	}
+	const SoundEntry &entry = _sdxIndex[num];
+
+	stopSpool();
+
+	Common::File sdb;
+	if (!sdb.open(_sdbPath)) {
+		warning("AudioPlayer: %s missing", _sdbPath.toString().c_str());
+		return;
+	}
+	if (!sdb.seek(entry.offset)) {
+		warning("AudioPlayer: %s seek to %u failed",
+				_sdbPath.toString().c_str(), entry.offset);
+		return;
+	}
+
+	// Mirrors the two `_fgetc(in)` reads at 202f:02da-e1: byte 0 =
+	// Sound Blaster Time Constant, byte 1 = total AIL playback blocks.
+	// Convert TC -> sample rate via the standard SB formula. The block
+	// count is only used internally by the AIL DDS pipeline; ScummVM's
+	// mixer doesn't need it.
+	const byte tc          = sdb.readByte();
+	(void)sdb.readByte(); // total blocks — unused outside AIL
+
+	// SB Time Constant: rate = 1000000 / (256 - tc). e.g. tc=0xD2 →
+	// 22 kHz. Guard against the degenerate tc=0xFF (would divide by 1
+	// → 1 MHz, well above what the mixer can resample sanely).
+	const uint sampleRate = (tc < 0xFF)
+		? (uint)(1000000u / (256u - tc))
+		: 44100u;
+
+	byte *pcm = nullptr;
+	uint32 audioSize = 0;
+
+	if (entry.compressedSize == entry.uncompressedSize) {
+		// `_UncompressedSound @ 202f:03e6` — already raw PCM. The
+		// `_SpoolSound` equality check at 202f:06e6 is `comp == uncomp`;
+		// in that case `len = uncompressed_size` bytes follow the
+		// 2-byte header. Original reads in 16 KB chunks; we slurp the
+		// lot at once.
+		audioSize = entry.uncompressedSize;
+		pcm = (byte *)malloc(audioSize);
+		if (!pcm) {
+			warning("AudioPlayer: spoolSound %u oom (%u bytes)",
+					num, audioSize);
+			return;
+		}
+		if (sdb.read(pcm, audioSize) != audioSize) {
+			warning("AudioPlayer: short read on uncompressed sound %u", num);
+			free(pcm);
+			return;
+		}
+	} else {
+		// `_DeCompressSound @ 202f:02ad` → `EXPLODE @ 25c6:0d01`
+		// (PKWARE DCL "Implode") with READDISKSOUND/WRITESOUND
+		// callbacks. EXPLODE drives both ends via its OWN end-of-stream
+		// marker (length token 519); the SDX `compressed_size` /
+		// `uncompressed_size` are loose hints, NOT exact lengths —
+		// 202f:0332 even computes `destSize - 2` and never reads it.
+		// ScummVM's fixed-size `decompressDCL` overload errors when
+		// the actual output exceeds our pre-allocated buffer (which
+		// we saw on every M0 clue voice). Use the dynamic-sized
+		// overload instead — it lets the DCL stream terminate at its
+		// own marker. Source is bounded to the rest of the SDB so
+		// the bit reader can't fall off the end.
+		const uint32 streamStart = (uint32)sdb.pos();
+		Common::SeekableSubReadStream sub(&sdb, streamStart,
+										   (uint32)sdb.size(),
+										   DisposeAfterUse::NO);
+		Common::SeekableReadStream *out = Common::decompressDCL(&sub);
+		if (!out) {
+			warning("AudioPlayer: DCL decompression failed on sound %u "
+					"(comp=%u, uncomp=%u)",
+					num, entry.compressedSize, entry.uncompressedSize);
+			return;
+		}
+		audioSize = (uint32)out->size();
+		pcm = (byte *)malloc(audioSize);
+		if (!pcm) {
+			warning("AudioPlayer: spoolSound %u oom after DCL (%u bytes)",
+					num, audioSize);
+			delete out;
+			return;
+		}
+		out->read(pcm, audioSize);
+		delete out;
+	}
+
+	debugC(1, kDebugSound,
+		   "AudioPlayer: spoolSound(%u) tc=0x%02x rate=%u size=%u %s",
+		   num, tc, sampleRate, audioSize,
+		   entry.compressedSize == entry.uncompressedSize ? "raw" : "DCL");
+
+	// `_AIL_start_digital_playback` at 202f:040c — we route through
+	// `kSFXSoundType` so the launcher's "SFX volume" slider applies
+	// (this is the same slider the original would've targeted via
+	// `_AIL_set_digital_master_volume`). Voice clips on the spool path
+	// are gameplay SFX, not the speech-only VOC stream.
+	playPcmBuffer(pcm, audioSize, sampleRate, _spoolHandle,
+				  Audio::Mixer::kSFXSoundType);
+}
+
+bool AudioPlayer::isSpoolPlaying() const {
+	return _mixer->isSoundHandleActive(_spoolHandle);
+}
+
+void AudioPlayer::waitForSpoolDone(uint32 maxMs) {
+	const uint32 startMs = g_system->getMillis();
+	while (isSpoolPlaying() && !_vm->shouldQuit() &&
+		   g_system->getMillis() - startMs < maxMs) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (event.type == Common::EVENT_QUIT ||
+				event.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				event.type == Common::EVENT_LBUTTONDOWN ||
+				event.type == Common::EVENT_KEYDOWN) {
+				stopSpool();
+				return;
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+}
+
+void AudioPlayer::stopSpool() {
+	if (_mixer->isSoundHandleActive(_spoolHandle))
+		_mixer->stopHandle(_spoolHandle);
+}
+
+void AudioPlayer::sayKDDigital(const byte *kdTextIndex, uint kdspeak,
+							   uint partner) {
+	if (!kdTextIndex || _currentMystery < 0)
+		return;
+	// `_SayKDDigital @ 2404:0fbc`:
+	//   iVar1 = kdspeak * 2;
+	//   if (_Partner == 0) iVar1++;            // Jake offset
+	//   sound = *(u16 *)(KDDigitalIndex + (iVar1 + 1) * 2) - 1;
+	//   _SpoolSound(sound);
+	// KDDigitalIndex sits 18 bytes (`+ 0x12`) after KDTextIndex per
+	// `_ReadMystery` 2404:0163-0167.
+	const byte *digital = kdTextIndex + 0x12;
+	const uint slot = (kdspeak * 2) + (partner == 0 ? 1u : 0u) + 1u;
+	const uint16 raw = READ_LE_UINT16(digital + slot * 2);
+	if (raw == 0 || raw == 0xFFFF)
+		return;
+	spoolSound((uint)(raw - 1));
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/audio.h b/engines/eem/audio.h
new file mode 100644
index 00000000000..c9238b0b3f4
--- /dev/null
+++ b/engines/eem/audio.h
@@ -0,0 +1,159 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef EEM_AUDIO_H
+#define EEM_AUDIO_H
+
+#include "audio/mixer.h"
+#include "audio/audiostream.h"
+
+#include "common/array.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/scummsys.h"
+
+namespace EEM {
+
+class EEMEngine;
+
+/**
+ * Non-MIDI audio (digitised voice + sound effects). Mirrors the
+ * original `SOUND.C` / `SPOOLSND.C` source files in `EEMCD.EXE` —
+ * the AIL digital playback path used for both standalone .VOC files
+ * and the per-mystery `M%d.SDB` spool stream.
+ *
+ * Two pathways:
+ *
+ * 1. **VOC playback** — `_LoadSoundName @ 1ff1:0299` reads a Creative
+ *    Voice File into memory and `_PlayVoice @ 1ff1:023e` hands it to
+ *    `_AIL_play_VOC_file`. Used for THUNDER.VOC (Storm logo),
+ *    PHONE.VOC (briefing), JEN.VOC / JAKE.VOC (partner choose).
+ *
+ * 2. **Spool stream** — `_InitMysterySounds @ 202f:05cb` opens
+ *    `m%d.sdx` (29be:144f) and `m%d.sdb` (29be:145b) for the active
+ *    mystery. `_SpoolSound(num) @ 202f:068d` indexes into the SDX
+ *    table (12 bytes per entry: u32 file_offset, u32 compressed_size,
+ *    u32 uncompressed_size). If sizes match it streams via
+ *    `_UncompressedSound @ 202f:03e6`; otherwise it EXPLODE-decompresses
+ *    via `_DeCompressSound @ 202f:02ad`.
+ *
+ *    Each entry's data starts with 2 metadata bytes — Sound Blaster
+ *    Time Constant (`rate = 1000000 / (256 - tc)`) and the AIL block
+ *    count — followed by the (optionally) PKWARE-DCL-compressed
+ *    8-bit unsigned PCM stream. We use ScummVM's `Common::decompressDCL`
+ *    + `Audio::makeRawStream` to reach the same audio at the same
+ *    sample rate.
+ *
+ * Mystery 60 (`M60.SDB/SDX`) holds the 19 voiceovers played between
+ * ANIM01..ANIM20 in `_DoOpeningAnims` (it loads `_InitMysterySounds(0x3c)`
+ * before the loop and `_SpoolSound(uVar3 - 1)` between every clip).
+ * Mysteries 0..55 hold each case's per-clue voice plus the partner's
+ * digital lines (`_KDDigitalIndex` table within the .SD blob).
+ */
+class AudioPlayer {
+public:
+	explicit AudioPlayer(EEMEngine *vm);
+	~AudioPlayer();
+
+	// VOC playback ----------------------------------------------------
+
+	/// Mirrors `_LoadSoundName` + `_PlayVoice`. Loads the named .VOC
+	/// from the game directory and hands it to the speech mixer
+	/// channel. A new `playVoc` cancels any prior voice.
+	void playVoc(const Common::Path &vocPath);
+
+	/// Mirrors `_VoicePlaying @ 1ff1:01f9`.
+	bool isVoicePlaying() const;
+
+	/// Mirrors `_WaitForVoiceDone @ 1ff1:0221`. Blocks (with frame /
+	/// event pumping, like the rest of the engine's busy-loops) until
+	/// the voice clip finishes. Returns early if the user clicks /
+	/// presses a key — same abort behaviour the original
+	/// `_AIL_stop_digital_playback` callback installed.
+	void waitForVoiceDone(uint32 maxMs = 60000);
+
+	/// Mirrors `_StopTheVoice @ 1ff1:0283`.
+	void stopVoice();
+
+	// Mystery sound spool ---------------------------------------------
+
+	/// Mirrors `_InitMysterySounds @ 202f:05cb`. Loads `M%u.SDX` into
+	/// memory and remembers the corresponding `M%u.SDB` path.
+	bool initMysterySounds(uint mysteryNum);
+
+	/// Mirrors `_CleanMysterySounds @ 202f:05a5`.
+	void cleanMysterySounds();
+
+	/// Mirrors `_SpoolSound @ 202f:068d`. Reads + decompresses entry
+	/// `num` from the active SDB and queues it for SFX playback. The
+	/// original blocks until playback finishes — for ScummVM we let
+	/// the mixer run asynchronously and expose `waitForSpoolDone` so
+	/// callers that need the original "block-then-continue" semantics
+	/// can opt in.
+	void spoolSound(uint num);
+
+	/// Mirrors the abort-on-input wait loop inside `_UncompressedSound`
+	/// / `_DeCompressSound`. Returns when the spool clip finishes or
+	/// the user clicks / presses a key.
+	void waitForSpoolDone(uint32 maxMs = 60000);
+
+	/// Mirrors the immediate `_AIL_stop_digital_playback` exit.
+	void stopSpool();
+
+	bool isSpoolPlaying() const;
+
+	/// Mirrors `_SayKDDigital(kdspeak) @ 2404:0fbc`. Each mystery
+	/// embeds a `KDDigitalIndex` table immediately after the 18-byte
+	/// `KDTextIndex` header (set up by `_ReadMystery @ 2404:008f`:
+	/// `_KDDigitalIndex = _KDTextIndex + 0x12`). The table is two
+	/// 1-based sound indices per `kdspeak` slot — Jen at +2, Jake at
+	/// +4 from each entry's start (+1 word for the unused header
+	/// slot). Pass the mystery's `kdTextIndex()` pointer.
+	void sayKDDigital(const byte *kdTextIndex, uint kdspeak, uint partner);
+
+	/// Mirrors `_QuitSounds @ 1ff1:03c5`.
+	void stopAll();
+
+private:
+	struct SoundEntry {
+		uint32 offset;
+		uint32 compressedSize;
+		uint32 uncompressedSize;
+	};
+
+	bool readSdxIndex(const Common::Path &sdxPath);
+	void playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
+					   Audio::SoundHandle &handle,
+					   Audio::Mixer::SoundType type);
+
+	EEMEngine *_vm = nullptr;
+	Audio::Mixer *_mixer = nullptr;
+	Audio::SoundHandle _voiceHandle;
+	Audio::SoundHandle _spoolHandle;
+
+	Common::Array<SoundEntry> _sdxIndex;
+	Common::Path _sdbPath;
+	int _currentMystery = -1;
+};
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index e5fc4df255c..a3907d32ef3 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -30,6 +30,7 @@
 #include "graphics/cursorman.h"
 #include "graphics/managed_surface.h"
 
+#include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
 
@@ -228,6 +229,15 @@ void EEMEngine::doChoosePartner() {
 		g_system->updateScreen();
 		g_system->delayMillis(20);
 	}
+
+	// Mirrors the tail of `_DoChoosePartner @ 1a35:097f` — once the
+	// player commits to a partner, load and play their intro VOC
+	// (`jen.voc` for Jenny, `jake.voc` for Jake; strings at 29be:0af1 /
+	// 29be:0af9) and block on `_WaitForVoiceDone`.
+	if (_audio) {
+		_audio->playVoc(Common::Path(_partner == 0 ? "JAKE.VOC" : "JEN.VOC"));
+		_audio->waitForVoiceDone();
+	}
 }
 
 void EEMEngine::doInitClues() {
@@ -409,6 +419,15 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
+	// `_DoInitClues` plays `phone.voc` (29be:0acc) ONLY when caseType == 2
+	// (the "incoming call" briefing variant). Verified at 1a35:05a2 —
+	// the gate is `iVar1 == 2 && _VoiceAvailable`. Other case types open
+	// straight into the briefing dialogue without it.
+	if (caseType == 2 && _audio) {
+		_audio->playVoc(Common::Path("PHONE.VOC"));
+		_audio->waitForVoiceDone();
+	}
+
 	// Step 6 — case briefing dialogue.
 	displayClue(ib + 4);
 }
@@ -686,6 +705,20 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			g_system->updateScreen();
 		}
 
+		// `_DisplayClue` @ 2404:0833-085a — after the balloon is drawn,
+		// spool the per-clue voice. Each ClueEntry stores two 1-based
+		// sound indices: `+0x18` for partner=Jenny and `+0x1a` for
+		// partner=Jake (verified against 2404:0823-0834). Index 0 / -1
+		// = no audio. The original blocks until the line ends; we run
+		// async (the wait happens implicitly while the player reads).
+		if (_audio) {
+			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
+			const uint16 voiceJake  = READ_LE_UINT16(c + 0x1a);
+			const uint16 voice = (_partner == 0) ? voiceJake : voiceJenny;
+			if (voice != 0 && voice != 0xFFFF)
+				_audio->spoolSound((uint)(voice - 1));
+		}
+
 		// Wait for click/key to advance — only if we drew something.
 		// ESC skips the entire dialogue rather than just one entry.
 		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 0895b869273..c6e2552cf79 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -33,6 +33,7 @@
 #include "graphics/cursorman.h"
 #include "graphics/paletteman.h"
 
+#include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
 #include "eem/music.h"
@@ -95,6 +96,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 }
 
 EEMEngine::~EEMEngine() {
+	delete _audio;
 	delete _music;
 }
 
@@ -116,6 +118,12 @@ Common::Error EEMEngine::run() {
 	// here (after `initGraphics` so the OSystem's timer/mixer is up).
 	_music = new MusicPlayer();
 
+	// Digital audio (VOC + spool). Mirrors `_InitDrivers @ 1ff1:0368`
+	// which `_AIL_register_driver`s SBDIG.ADV / PASDIG.ADV alongside
+	// the MIDI driver.
+	_audio = new AudioPlayer(this);
+	syncSoundSettings();
+
 	// _InitMouse @ 152d:018b in the original — install our 11x16 arrow,
 	// using palette index 0 as the transparency key. The cursor is left
 	// hidden through the opening anims and switched on at NewPlayer /
@@ -184,8 +192,22 @@ Common::Error EEMEngine::run() {
 	showEAKidsLogo();
 	if (!shouldQuit() && !_skipIntro)
 		showHighScoreLogo();
-	if (!shouldQuit() && !_skipIntro)
+	// Storm Software logo: voice + animation. The original at
+	// `_ShowStormLogo @ 2520:0707` calls `_LoadSoundName("thunder.voc")`
+	// (29be:177d) and passes the buffer to `OpenDifferenceAnimation_Sound`
+	// so the thunder roar plays alongside the lightning bolt.
+	if (!shouldQuit() && !_skipIntro) {
+		if (_audio)
+			_audio->playVoc(Common::Path("THUNDER.VOC"));
 		playAnm(Common::Path("BOLT.ANM"));
+		if (_audio)
+			_audio->stopVoice();
+	}
+	// `_InitMysterySounds(0x3c)` at 2520:086a — load M60.SDX/SDB so
+	// `_SpoolSound(uVar3 - 1)` between the ANIM01..ANIM20 anims has
+	// data to draw from.
+	if (!shouldQuit() && !_skipIntro && _audio)
+		_audio->initMysterySounds(60);
 	// Theme begins HERE — after the three silent logos, before the
 	// character-intro reel.
 	if (!shouldQuit() && !_skipIntro && _music)
@@ -193,11 +215,18 @@ Common::Error EEMEngine::run() {
 	for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
 		Common::String name = Common::String::format("ANIM%02d.A", i);
 		playAnm(Common::Path(name));
-		// Between anims the original plays a voice clip via _SpoolSound;
-		// without audio we still want a beat so each scene reads.
-		if (!shouldQuit() && !_skipIntro && i != 20)
-			waitForInput(2000);
+		// `_SpoolSound(uVar3 - 1)` at 2520:08c2 — the per-character VO
+		// plays AFTER each anim except the last (`if (uVar3 != 0x14)`
+		// at 2520:08a8). Original blocks until done; we run async and
+		// wait so the next anim doesn't start before the line ends.
+		if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
+			_audio->spoolSound((uint)(i - 1));
+			_audio->waitForSpoolDone();
+		}
 	}
+	// `_CleanMysterySounds` at 2520:0903 — release M60 before the title.
+	if (_audio)
+		_audio->cleanMysterySounds();
 	// Restart the theme for TITLE.ANM — matches the second
 	// `_MIDIPlayFile("theme.xmi")` call at 2520:0918.
 	if (!shouldQuit() && !_skipIntro && _music)
@@ -590,6 +619,11 @@ Common::Error EEMEngine::loadGameState(int slot) {
 		delete in;
 		return Common::kReadingFailed;
 	}
+	// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds(_MysteryNumber)`
+	// at the tail (2404:0298) so the SDB index is in place for clue and
+	// partner-speech spool sounds.
+	if (_audio)
+		_audio->initMysterySounds(mysteryNum);
 
 	Common::Serializer s(in, nullptr);
 	s.setVersion(ver);
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index e2f3e0c2ce4..4eded42e068 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -39,6 +39,7 @@
 
 namespace EEM {
 
+class AudioPlayer;
 class MusicPlayer;
 
 /**
@@ -307,6 +308,13 @@ private:
 	/// at 20a2:00e2-05c9). Constructed lazily during `run()` once the
 	/// MIDI driver / timer system is up.
 	MusicPlayer *_music = nullptr;
+
+	/// Digitised audio (voice + SFX). Mirrors `SOUND.C` / `SPOOLSND.C`
+	/// — VOC playback (`_PlayVoice @ 1ff1:023e`) and the per-mystery
+	/// SDB spool (`_SpoolSound @ 202f:068d` / `_InitMysterySounds @
+	/// 202f:05cb`). Constructed alongside `_music` in `run()`.
+public:
+	AudioPlayer *_audio = nullptr;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index ce7975e1edc..6879b600e85 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -2,6 +2,7 @@ MODULE := engines/eem
 
 MODULE_OBJS = \
 	animation.o \
+	audio.o \
 	clues.o \
 	eem.o \
 	font.o \
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index ea50d7f56c5..e6122b5d6eb 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -26,6 +26,7 @@
 
 #include "graphics/paletteman.h"
 
+#include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
 #include "eem/mystery.h"
@@ -380,6 +381,8 @@ void SiteScreen::run() {
 				case Common::KEYCODE_r:
 					// Restart the mystery from scratch (mirrors `_ReloadMystery`).
 					if (_mystery->load(_mystery->number())) {
+						if (_vm->_audio)
+							_vm->_audio->initMysterySounds(_mystery->number());
 						cur = 0;
 						enter(cur);
 					}
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fe306ef463d..121a83d440f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -30,6 +30,7 @@
 #include "graphics/cursorman.h"
 #include "graphics/managed_surface.h"
 
+#include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
 #include "eem/music.h"
@@ -522,6 +523,8 @@ void EEMEngine::doCaseSelection() {
 		if (!_mystery.load(0, &_rng)) {
 			warning("doCaseSelection: failed to load practice mystery");
 			_mystery.clear();
+		} else if (_audio) {
+			_audio->initMysterySounds(0);
 		}
 		return;
 	}
@@ -655,6 +658,8 @@ void EEMEngine::doCaseSelection() {
 		_mystery.clear();
 		return;
 	}
+	if (_audio)
+		_audio->initMysterySounds(sel);
 	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
 		   sel, _mystery.numSites(), _mystery.numSuspects());
 }


Commit: f7b94e57211894e3e0858fc49a441e62e7f69f83
    https://github.com/scummvm/scummvm/commit/f7b94e57211894e3e0858fc49a441e62e7f69f83
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:42+02:00

Commit Message:
EEM: profile handling in save games

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index c6e2552cf79..cade4889101 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -56,9 +56,15 @@ const uint kPicHighScoreLogo   = 0x20c; ///< _ShowHScoreLogo: GetPicture(0x20c)
 const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
 
-// Save format. Used by `saveGameState` / `loadGameState`.
-const uint32 kSaveMagic = MKTAG('E', 'E', 'M', '0');
-const byte   kSaveVer   = 3;  ///< v2: _mysteriesSolved tracker; v3: player name
+// Save body version, used by the `Common::Serializer` inside
+// `saveGameStream`/`loadGameStream`. The framework's extended-save
+// header (description / thumbnail / playtime) is appended/parsed
+// separately by `Engine::saveGameState` / `MetaEngine::readSavegameHeader`,
+// so we don't need a magic word or our own metadata fields.
+//
+//   v1 — initial schema: name + mysteriesSolved + partner +
+//        optional mystery sub-state.
+const byte kSaveBodyVer = 1;
 
 // 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
 // _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
@@ -541,99 +547,176 @@ bool EEMEngine::hasFeature(EngineFeature f) const {
 }
 
 bool EEMEngine::canLoadGameStateCurrently(Common::U32String *) {
-	return false;  // Loading is startup-only.
+	// Loading mid-mystery would replace `_mystery._data` while
+	// pointers into it are alive on the stack inside `displayClue`
+	// etc. Profile picking still works via `loadProfile` from the
+	// menu screens before a mystery loads.
+	return !_mystery.isLoaded();
 }
 
 bool EEMEngine::canSaveGameStateCurrently(Common::U32String *) {
-	return _mystery.isLoaded();
+	// Profile saves (no mystery loaded) are always OK; mid-mystery
+	// snapshots only after the active case has fully initialised.
+	return true;
 }
 
-Common::Error EEMEngine::saveGameState(int slot, const Common::String &desc, bool isAutosave) {
-	Common::OutSaveFile *out = getSaveFileManager()->openForSaving(getSaveStateName(slot));
-	if (!out)
-		return Common::kCreatingFileFailed;
-
-	out->writeUint32BE(kSaveMagic);
-	out->writeByte(kSaveVer);
-
-	// Header: description + ScummVM extended save metadata are appended
-	// automatically when `EngineFeature::kSavesUseExtendedFormat` is set;
-	// our save body just carries the engine state.
-	(void)desc;
+Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
+										 bool isAutosave) {
 	(void)isAutosave;
 
-	uint16 mysteryNum = (uint16)_mystery.number();
-	out->writeUint16LE(mysteryNum);
-	out->writeByte(_partner);
-	out->write(_mysteriesSolved, sizeof(_mysteriesSolved));
-
-	// v3: persist the player name so save-slot resume restores it.
-	out->writeUint16LE((uint16)_playerName.size());
-	out->writeString(_playerName);
+	Common::Serializer s(nullptr, stream);
+	s.setVersion(kSaveBodyVer);
+
+	// Profile-level state — mirrors the original `_PlayerRecord` body
+	// at `2d5d:3f6a` (159 bytes, written by `_SavePlayerRecord @
+	// 1c33:034f`). The `_PlayerRecord` layout is:
+	//   +0x00..+0x0b : player name (12 chars, null-padded)
+	//   +0x0c..+0x1f : random ID bytes used by `_GenerateFilename`
+	//                  (29be:0dbf "C:\EEMCDSAV\%s.PLR") — irrelevant to
+	//                  ScummVM saves which key on slot, not filename.
+	//   +0x20..+0x28 : derived 8-char .PLR basename — likewise unused.
+	//   +0x2d        : voice-enable flag (`DAT_2d5d_3f97`, default 1).
+	//   +0x2f        : chain stage (`DAT_2d5d_3f99`, 1=A, 2=B, 3=C —
+	//                  `_DisplayCorrect` advances it once every case
+	//                  in the current set is solved).
+	//   +0x31..+0xa6 : `mysteriesSolved[55]` u16 (0=unsolved, 1=solved,
+	//                  2=solved on first try) — `_DisplayCorrect`
+	//                  writes 1 always, 2 when `_FirstTry != 0`.
+	//
+	// We persist the gameplay-meaningful subset (name + solved table +
+	// partner) and skip the filename-derivation bytes. The voice /
+	// chain-stage fields aren't yet wired into the C++ port; we save
+	// space for them so they slot in without a version bump.
+	s.syncString(_playerName);
+	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
+	s.syncAsByte(_partner);
+
+	// ScummVM-only extension: persist the in-progress mystery so the
+	// player can resume mid-case. The original engine has no such
+	// notion — `_LoadGame @ 2404:0dc7` simply loads a fresh mystery,
+	// it doesn't preserve site progress. The flag lets a profile save
+	// stay valid even when no mystery is loaded (e.g. fresh profile).
+	bool hasMystery = _mystery.isLoaded();
+	s.syncAsByte(hasMystery);
+	if (hasMystery) {
+		uint16 mysteryNum = (uint16)_mystery.number();
+		s.syncAsUint16LE(mysteryNum);
+		_mystery.syncState(s);
+	}
 
 	debugC(1, kDebugGeneral,
-		   "Saved slot %d: mystery=%u partner=%u name=%s autosave=%d",
-		   slot, mysteryNum, _partner, _playerName.c_str(), isAutosave ? 1 : 0);
+		   "Saved profile name=%s partner=%u mystery=%d autosave=%d",
+		   _playerName.c_str(), _partner,
+		   hasMystery ? (int)_mystery.number() : -1,
+		   isAutosave ? 1 : 0);
+	return Common::kNoError;
+}
 
-	Common::Serializer s(nullptr, out);
-	s.setVersion(kSaveVer);
-	_mystery.syncState(s);
+Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
+	Common::Serializer s(stream, nullptr);
+	s.setVersion(kSaveBodyVer);
+
+	s.syncString(_playerName);
+	if (_playerName.empty())
+		_playerName = "Detective";
+
+	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
+	s.syncAsByte(_partner);
+
+	bool hasMystery = false;
+	s.syncAsByte(hasMystery);
+	if (hasMystery) {
+		uint16 mysteryNum = 0;
+		s.syncAsUint16LE(mysteryNum);
+		if (!_mystery.load(mysteryNum, &_rng)) {
+			_mystery.clear();
+			return Common::kReadingFailed;
+		}
+		// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds` at the
+		// tail (2404:0298) so the SDB index is in place for clue and
+		// partner-speech spool sounds.
+		if (_audio)
+			_audio->initMysterySounds(mysteryNum);
+		_mystery.syncState(s);
+	} else {
+		_mystery.clear();
+	}
 
-	out->finalize();
-	delete out;
+	debugC(1, kDebugGeneral,
+		   "Loaded profile name=%s partner=%u mystery=%d",
+		   _playerName.c_str(), _partner,
+		   _mystery.isLoaded() ? (int)_mystery.number() : -1);
 	return Common::kNoError;
 }
 
-Common::Error EEMEngine::loadGameState(int slot) {
-	Common::InSaveFile *in = getSaveFileManager()->openForLoading(getSaveStateName(slot));
-	if (!in)
-		return Common::kReadingFailed;
+SaveStateList EEMEngine::listProfiles() const {
+	// Mirrors `_findfirst("*.PLR")` in `screen8_handler @ 1c33:1012`.
+	return getMetaEngine()->listSaves(_targetName.c_str());
+}
 
-	if (in->readUint32BE() != kSaveMagic) {
-		delete in;
-		return Common::kUnknownError;
-	}
-	const byte ver = in->readByte();
-	if (ver > kSaveVer) {
-		delete in;
-		return Common::kUnknownError;
-	}
+Common::Error EEMEngine::saveProfile(const Common::String &name) {
+	if (name.empty())
+		return Common::kCreatingFileFailed;
 
-	const uint16 mysteryNum = in->readUint16LE();
-	_partner = in->readByte();
-	if (ver >= 2)
-		in->read(_mysteriesSolved, sizeof(_mysteriesSolved));
-	else
-		memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
-
-	if (ver >= 3) {
-		const uint16 nameLen = in->readUint16LE();
-		Common::String name;
-		for (uint16 i = 0; i < nameLen && i < 64; i++)
-			name += (char)in->readByte();
-		_playerName = name.empty() ? Common::String("Detective") : name;
+	const SaveStateList saves = listProfiles();
+
+	// Slot lookup by description: if a save with this profile name
+	// already exists, overwrite it. Same as Wetlands' `saveProfile`.
+	int slot = -1;
+	for (auto &s : saves) {
+		if (s.getDescription() == name) {
+			slot = s.getSaveSlot();
+			break;
+		}
 	}
 
-	if (!_mystery.load(mysteryNum, &_rng)) {
-		_mystery.clear();
-		delete in;
-		return Common::kReadingFailed;
+	// New profile — pick the lowest unused slot. The MetaEngine caps
+	// us at 99 by default (`getMaximumSaveSlot`); 25 was the DOS
+	// original's limit (`screen8_handler` walks `*.PLR` up to 25
+	// entries in `local_8c[0x19][2]`).
+	if (slot < 0) {
+		const int maxSlot = getMetaEngine()->getMaximumSaveSlot();
+		Common::Array<bool> used(maxSlot + 1);
+		for (auto &s : saves) {
+			const int sl = s.getSaveSlot();
+			if (sl >= 0 && sl <= maxSlot)
+				used[sl] = true;
+		}
+		for (int i = 0; i <= maxSlot; i++) {
+			if (!used[i]) {
+				slot = i;
+				break;
+			}
+		}
+		if (slot < 0)
+			return Common::kCreatingFileFailed;
 	}
-	// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds(_MysteryNumber)`
-	// at the tail (2404:0298) so the SDB index is in place for clue and
-	// partner-speech spool sounds.
-	if (_audio)
-		_audio->initMysterySounds(mysteryNum);
 
-	Common::Serializer s(in, nullptr);
-	s.setVersion(ver);
-	_mystery.syncState(s);
+	_playerName = name;
+	debugC(1, kDebugGeneral, "saveProfile(%s) -> slot %d",
+		   name.c_str(), slot);
+	return saveGameState(slot, name, /*isAutosave=*/false);
+}
 
-	delete in;
-	debugC(1, kDebugGeneral,
-		   "Loaded slot %d: mystery=%u partner=%u name=%s",
-		   slot, mysteryNum, _partner, _playerName.c_str());
-	return Common::kNoError;
+bool EEMEngine::loadProfile(const Common::String &name) {
+	if (name.empty())
+		return false;
+
+	const SaveStateList saves = listProfiles();
+	for (auto &s : saves) {
+		if (s.getDescription() == name) {
+			const Common::Error err = loadGameState(s.getSaveSlot());
+			if (err.getCode() == Common::kNoError) {
+				debugC(1, kDebugGeneral, "loadProfile(%s) <- slot %d",
+					   name.c_str(), s.getSaveSlot());
+				return true;
+			}
+			break;
+		}
+	}
+	debugC(1, kDebugGeneral, "loadProfile(%s) — no matching slot",
+		   name.c_str());
+	return false;
 }
 
 void EEMEngine::screenDriver() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 4eded42e068..f1aa061d57d 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -29,8 +29,11 @@
 #include "common/random.h"
 #include "common/scummsys.h"
 
+#include "common/serializer.h"
+
 #include "engines/advancedDetector.h"
 #include "engines/engine.h"
+#include "engines/savestate.h"
 
 #include "eem/animation.h"
 #include "eem/font.h"
@@ -66,8 +69,37 @@ public:
 	bool hasFeature(EngineFeature f) const override;
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
-	Common::Error loadGameState(int slot) override;
-	Common::Error saveGameState(int slot, const Common::String &desc, bool isAutosave) override;
+
+	// ScummVM extended-save hooks. The base `Engine::saveGameState` /
+	// `loadGameState` write/read the framework header (description,
+	// thumbnail, playtime, version) around our body via these
+	// streams. We keep all per-profile state in the body, with a
+	// single `Common::Serializer` version so future field additions
+	// stay backward-compatible.
+	Common::Error saveGameStream(Common::WriteStream *stream,
+								  bool isAutosave = false) override;
+	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
+
+	// Per-profile save helpers. The original `_PlayerRecord` lives at
+	// `2d5d:3f6a` (159 bytes) and is written by `_SavePlayerRecord @
+	// 1c33:034f` to `C:\EEMCDSAV\<name>.PLR`. The DOS launcher screen
+	// `screen8_handler @ 1c33:1012` walks `*.PLR`, lets the player
+	// pick a profile, and calls `_LoadPlayerRecord`. We mirror the
+	// pattern by mapping each ScummVM save slot to one profile (slot
+	// description = player name) — same approach Wetlands uses.
+
+	/// Mirrors `_SavePlayerRecord @ 1c33:034f`. Saves into the slot
+	/// whose description matches @p name, or the lowest unused slot
+	/// if no match. Returns the kNoError on success.
+	Common::Error saveProfile(const Common::String &name);
+
+	/// Mirrors `_LoadPlayerRecord @ 1c33:03a6`. Returns false if no
+	/// slot has @p name as its description.
+	bool loadProfile(const Common::String &name);
+
+	/// Mirrors the `_findfirst("*.PLR")` walk inside
+	/// `screen8_handler`. Sorted by slot.
+	SaveStateList listProfiles() const;
 
 	const ADGameDescription *_gameDescription;
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 121a83d440f..c78ea11d37d 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -284,7 +284,19 @@ void EEMEngine::doNewPlayer() {
 			if (k == Common::KEYCODE_RETURN) {
 				if (name.empty())
 					name = "Detective";
-				_playerName = name;
+				// Mirrors `_NewPlayer @ 1c33:0dda` tail (1c33:0fa0+):
+				// after the name is committed, try `_LoadPlayerRecord`
+				// — if it returns 0 (no existing .PLR), zero out the
+				// per-profile state and call `_SavePlayerRecord` to
+				// create a fresh profile file. Same flow here, mapped
+				// onto ScummVM save slots via name → description.
+				if (!loadProfile(name)) {
+					_playerName = name;
+					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
+					_mystery.clear();
+					_partner = 0;
+					saveProfile(name);
+				}
 				return;
 			}
 			if (k == Common::KEYCODE_ESCAPE) {
@@ -2264,12 +2276,16 @@ void EEMEngine::doAccuse() {
 			_music->playMus(5, /*loop=*/false);
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
-		// Auto-save into slot 0 (the engine's quicksave slot).
-		const Common::String desc = Common::String::format(
-			"%s — solved mystery %u", _playerName.c_str(), mn);
-		Common::Error err = saveGameState(0, desc, true);
+		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the
+		// `_mysteriesSolved` table is updated, the original
+		// immediately persists the player record so the win sticks
+		// even if the player quits before reaching the menu. We do
+		// the same by writing back to the active profile (the slot
+		// keyed on `_playerName`) rather than clobbering slot 0 like
+		// a generic quicksave.
+		const Common::Error err = saveProfile(_playerName);
 		if (err.getCode() != Common::kNoError)
-			warning("auto-save after solve failed: %s",
+			warning("saveProfile after solve failed: %s",
 					err.getDesc().c_str());
 	} else {
 		_mystery._firstTry = false;


Commit: df22a74a072253524335ade26962be591e1c6bb5
    https://github.com/scummvm/scummvm/commit/df22a74a072253524335ade26962be591e1c6bb5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:42+02:00

Commit Message:
EEM: improved animations and added missing UI features

Changed paths:
    engines/eem/audio.cpp
    engines/eem/audio.h
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index fab34529b71..f4719af0bb1 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -54,6 +54,17 @@ void AudioPlayer::stopAll() {
 // VOC playback --------------------------------------------------------
 
 void AudioPlayer::playVoc(const Common::Path &vocPath) {
+	// Voice / digital audio is gated by the `DAT_2d5d_3f97` flag in
+	// the original — verified at every callsite (`_DoChoosePartner @
+	// 1a35:098c`, `_DisplayClue @ 2404:0845`, `_DoOpeningAnims @
+	// 2520:08a8`, etc.). Setup-screen toggle and `_NewPlayer` fresh-
+	// profile init both rewrite that flag. We pull it into the audio
+	// player so callers don't have to duplicate the check.
+	if (!_voiceEnabled) {
+		debugC(2, kDebugSound, "AudioPlayer: voice disabled, skipping %s",
+			   vocPath.toString().c_str());
+		return;
+	}
 	stopVoice();
 
 	// Mirrors `_LoadSoundName`'s `_fopen` (1ff1:02ac).
@@ -189,6 +200,11 @@ void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
 }
 
 void AudioPlayer::spoolSound(uint num) {
+	if (!_voiceEnabled) {
+		debugC(2, kDebugSound,
+			   "AudioPlayer: voice disabled, skipping spoolSound(%u)", num);
+		return;
+	}
 	if (_currentMystery < 0 || num >= _sdxIndex.size()) {
 		warning("AudioPlayer: spoolSound(%u) — invalid index (%d, %u)",
 				num, _currentMystery, (uint)_sdxIndex.size());
diff --git a/engines/eem/audio.h b/engines/eem/audio.h
index c9238b0b3f4..921e00e900b 100644
--- a/engines/eem/audio.h
+++ b/engines/eem/audio.h
@@ -73,6 +73,17 @@ public:
 	explicit AudioPlayer(EEMEngine *vm);
 	~AudioPlayer();
 
+	/// Mirrors the gate in every original audio call site
+	/// (`_DisplayClue` 2404:0845, `_DoChoosePartner` 1a35:098c,
+	/// `_DoOpeningAnims` 2520:08a8, `_DisplayCorrect` 1df2:0780, ...)
+	/// — every `_PlayVoice` / `_SpoolSound` is wrapped in
+	/// `if ((DAT_2d5d_3f97 != 0) && (_VoiceAvailable != 0))`. The
+	/// engine pulls `_voiceOn` (= `DAT_2d5d_3f97`) into here so we
+	/// can early-return at the audio boundary instead of duplicating
+	/// the gate at every call site.
+	void setVoiceEnabled(bool enabled) { _voiceEnabled = enabled; }
+	bool voiceEnabled() const { return _voiceEnabled; }
+
 	// VOC playback ----------------------------------------------------
 
 	/// Mirrors `_LoadSoundName` + `_PlayVoice`. Loads the named .VOC
@@ -152,6 +163,7 @@ private:
 	Common::Array<SoundEntry> _sdxIndex;
 	Common::Path _sdbPath;
 	int _currentMystery = -1;
+	bool _voiceEnabled = true;
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index a3907d32ef3..1ad73525d3d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -33,6 +33,7 @@
 #include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/site.h"
 
 // EEM — clue / briefing pipeline (SCRIPT.C clue parts + KD.C briefing parts).
 // Everything that drives a `ClueBlock` through the displayClue / portrait /
@@ -295,15 +296,44 @@ void EEMEngine::doInitClues() {
 		const uint frameCount = haveGame ? game.size() : 8;
 		bool skip = false;
 		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
-			// Restore BG + advance frame.
+			// Restore BG + advance frame. The original always uses
+			// Jake's anim IDs (0x17/0x18/0x19) as the SCRIPT keys
+			// even when Jenny's CELL data is loaded — verified at
+			// `_DoInitClues @ 1a35:0507`/`0541` where
+			// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), ...)`
+			// hard-codes the script index to 0x17. So we look up
+			// `partnerFrameAtTick(0x17, ...)` regardless of partner.
+			// This gives us the correct cadence — book holds on its
+			// "thinking" pose (cell 8) for 16 ticks instead of
+			// flipbook-cycling, and nancy waits 18 ticks before her
+			// late-arrival count-up.
 			if (_picsArchive.getPicture(0x52, bg))
 				blitAt(bg, 0, 0);
-			if (haveGame)
-				blitMaskedToScreen(game[frame % game.size()], 0xcd, 0x6c);
-			if (haveBook)
-				blitMaskedToScreen(book[frame % book.size()], 0, 99);
-			if (haveNancy)
-				blitMaskedToScreen(nancy[frame % nancy.size()], 0x68, 0x8b);
+			const uint32 t = frame * 100;
+			// All three briefing anims (game/book/nancy) go through
+			// the original `_NewAnimation` path so per-frame anchors
+			// apply. Use `blitAnimFrameAnchored` against a locked
+			// screen surface so the briefing partner / book / nancy
+			// translate cleanly between cells instead of pinning at
+			// the same top-left.
+			Graphics::Surface *scr = g_system->lockScreen();
+			if (!scr) {
+				skip = true;
+				break;
+			}
+			if (haveGame) {
+				const uint f = partnerFrameAtTick(0x17, (uint)game.size(), t);
+				blitAnimFrameAnchored(scr, game[f], 0xcd, 0x6c);
+			}
+			if (haveBook) {
+				const uint f = partnerFrameAtTick(0x18, (uint)book.size(), t);
+				blitAnimFrameAnchored(scr, book[f], 0, 99);
+			}
+			if (haveNancy) {
+				const uint f = partnerFrameAtTick(0x19, (uint)nancy.size(), t);
+				blitAnimFrameAnchored(scr, nancy[f], 0x68, 0x8b);
+			}
+			g_system->unlockScreen();
 			g_system->updateScreen();
 
 			// Wait 100 ms or until input.
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index cade4889101..fa895d640a6 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -61,10 +61,7 @@ const uint kPalHighScore       = 0x27;
 // header (description / thumbnail / playtime) is appended/parsed
 // separately by `Engine::saveGameState` / `MetaEngine::readSavegameHeader`,
 // so we don't need a magic word or our own metadata fields.
-//
-//   v1 — initial schema: name + mysteriesSolved + partner +
-//        optional mystery sub-state.
-const byte kSaveBodyVer = 1;
+const byte kSaveBodyVer = 3;
 
 // 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
 // _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
@@ -128,6 +125,7 @@ Common::Error EEMEngine::run() {
 	// which `_AIL_register_driver`s SBDIG.ADV / PASDIG.ADV alongside
 	// the MIDI driver.
 	_audio = new AudioPlayer(this);
+	_audio->setVoiceEnabled(_voiceOn);
 	syncSoundSettings();
 
 	// _InitMouse @ 152d:018b in the original — install our 11x16 arrow,
@@ -146,36 +144,31 @@ Common::Error EEMEngine::run() {
 
 	// If the user chose "Load" before pressing Play, the framework
 	// invokes `loadGameState` which sets up `_mystery` and `_partner`.
-	// Honour that by skipping the intros and going straight to the
-	// loaded mystery's site loop.
+	// Honour that by skipping the intros and dropping into the screen-
+	// driver loop just past the briefing — the loaded mystery already
+	// knows its current site, so we resume at MAP (the original's
+	// post-briefing state, set by handler 0 at 1a35:0e1d).
 	const int wantedSave = ConfMan.hasKey("save_slot")
 		? ConfMan.getInt("save_slot") : -1;
+	bool resumed = false;
 	if (wantedSave >= 0) {
 		const Common::Error err = loadGameState(wantedSave);
 		if (err.getCode() == Common::kNoError && _mystery.isLoaded()) {
 			debugC(1, kDebugGeneral, "Resuming from slot %d at mystery %u",
 				   wantedSave, _mystery.number());
 			CursorMan.showMouse(true);
-			doInitClues();
-			// Original screen 0 → screen 1: after the briefing the
-			// game opens the map (function at 20fe:120b → _DoBigMap)
-			// and only enters a site once the player clicks on one.
-			doBigMap();
-			if (_mystery.isLoaded())
-				doSiteLoop();
-			while (!shouldQuit()) {
-				doCaseSelection();
-				if (!_mystery.isLoaded())
-					break;
-				doInitClues();
-				doBigMap();
-				if (_mystery.isLoaded())
-					doSiteLoop();
-			}
-			return Common::kNoError;
+			_nextScreen = kScreenMap;
+			resumed = true;
 		}
 	}
 
+	// Skip the entire intro chain (logos + anims + name entry +
+	// partner pick) when resuming a saved profile — the partner is
+	// already known, the player has already named themselves, and the
+	// loaded mystery's site loop is what they want to see again.
+	if (resumed)
+		goto screen_loop;
+
 	// Reproduces _DoOpeningAnims @ 2520:082a:
 	//   EA Kids logo (PIC) -> HighScore Productions logo (PIC) ->
 	//   Storm Software logo (BOLT.ANM) -> [music starts] -> 20
@@ -254,36 +247,120 @@ Common::Error EEMEngine::run() {
 	// per-mystery `_StartTravelMusic` kicks in.
 	if (_music)
 		_music->stop();
+	// Profile pick (or fresh creation) — `screen8_handler @ 1c33:1012`.
+	// `doProfilePicker` lists existing profiles via `listProfiles()`
+	// and falls through to `doNewPlayer` if none exist or the user
+	// picks "[New Player]".
 	if (!shouldQuit())
-		doNewPlayer();
+		doProfilePicker();
 	if (!shouldQuit())
 		doChoosePartner();
-	if (!shouldQuit())
-		doCaseSelection();
-	if (!shouldQuit() && _mystery.isLoaded()) {
-		// Mark the starting site as active and display the case briefing.
-		// `_DoInitClues` @ 1a35:0411 — case briefing.
-		doInitClues();
-		// Original screen 0 → screen 1: after the briefing the game
-		// opens the map (function at 20fe:120b → `_DoBigMap`) and only
-		// enters a site once the player clicks on one.
-		doBigMap();
-		if (_mystery.isLoaded())
-			doSiteLoop();
 
-		// After a case, loop back to CaseSelection.
-		while (!shouldQuit()) {
+	// Now drop into the screen-driver state machine — same pattern as
+	// `_ScreenDriver @ 1a35:0dc1` + the per-screen handlers in the
+	// table at 1a35:0e5e. The original sets `_NextScreen` either
+	// directly (e.g. `_DisplayCorrect` writes 12 = ACTION) or via the
+	// jumptable handlers (e.g. handler 0 calls `_DoInitClues` then
+	// writes 1 = MAP). The handlers here mirror that exactly: each
+	// case body runs the screen and updates `_nextScreen` for the next
+	// iteration. Sentinel `kScreenInvalid` (0xFFFF) ends the loop —
+	// same as the original's table-end marker.
+	//
+	// Initial value `kScreenAction` matches the original flow at the
+	// tail of `_DoChoosePartner @ 1a35:099d` which sets
+	// `_NextScreen = 0xc` once the partner has been picked. (The
+	// resume path above bypasses this and seeds `kScreenMap` instead.)
+	//
+	// Mid-mystery profile resume: if the profile picker loaded a
+	// save whose `hasMystery` flag was set, `_mystery.isLoaded()` is
+	// true here and the player just re-picked their partner. Drop
+	// straight to MAP rather than ACTION so they don't have to walk
+	// back through the case picker (which would `_mystery.load()`
+	// fresh and discard their site / clue progress). The original
+	// has no equivalent — it persists only profile-level state via
+	// `_PlayerRecord`, not in-progress mysteries — so this is a
+	// ScummVM-only ergonomics improvement.
+	if (!shouldQuit() && !resumed)
+		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
+screen_loop:
+	while (!shouldQuit() && _nextScreen != kScreenInvalid) {
+		const ScreenId current = (ScreenId)_nextScreen;
+		debugC(1, kDebugGeneral, "screenDriver: id=%d", (int)current);
+
+		switch (current) {
+		case kScreenAction:
+			// Post-mystery menu. `_ActionScreen` sets _NextScreen via
+			// its action jumptable (1c33:1be1) — see `doActionScreen`.
+			doActionScreen();
+			break;
+
+		case kScreenChooseMystery:
+			// Handler 10 at 1a35:0e0e calls `_DoChooseMystery` which
+			// presets `_NextScreen = 0` (INIT_CLUES) before
+			// `_CaseSelection`. If the picker bails out without
+			// loading a mystery (no `_ReadMystery` call), drop back
+			// to ACTION instead of falling into a missing case.
 			doCaseSelection();
-			if (!_mystery.isLoaded())
-				break;
+			_nextScreen = _mystery.isLoaded() ? kScreenInitClues
+											  : kScreenAction;
+			break;
+
+		case kScreenInitClues:
+			// Handler 0 at 1a35:0e14 runs `_PreLoad` + `_DoInitClues`
+			// then writes `_NextScreen = 1` (MAP).
 			doInitClues();
-			// Original screen 0 → screen 1: after the briefing the
-			// game opens the map (function at 20fe:120b → _DoBigMap)
-			// and only enters a site once the player clicks on one.
+			_nextScreen = _mystery.isLoaded() ? kScreenMap
+											  : kScreenAction;
+			break;
+
+		case kScreenMap:
+		case kScreenMapAlt:
+			// Handler 1/2 at 1a35:0e25 calls `_DoMapScreen @
+			// 20fe:120b` which manages its own `_NextScreen` writes —
+			// 3 (a site was clicked), 6 (setup), or 0xffff (quit).
+			// Our `doBigMap` keeps the original's "click site, then
+			// enter the site loop" behaviour inline; once it returns
+			// the natural next state is SITE.
 			doBigMap();
-			if (_mystery.isLoaded())
-				doSiteLoop();
+			if (!_mystery.isLoaded())
+				_nextScreen = kScreenAction;
+			else if (_nextScreen == current)
+				_nextScreen = kScreenSite;
+			break;
+
+		case kScreenSite:
+			// Handler 3 at 1a35:0e2c calls `_DoSiteLoop @
+			// 168d:03f4`. Our `doSiteLoop` is a complete loop —
+			// notebook / gallery / accuse / map are dispatched
+			// inline within `SiteScreen::run`. The accusation tail
+			// in `doAccuse` (see ui.cpp) writes the next screen:
+			// kScreenAction on win (matches `_DisplayCorrect @
+			// 1df2:0895` writing 0xc) or kScreenSite on lose
+			// (matches `_DisplayAlibi @ 1df2:043f` snapping back
+			// via `_LastScreen`).
+			doSiteLoop();
+			if (!_mystery.isLoaded())
+				_nextScreen = kScreenAction;
+			else if (_nextScreen == current)
+				_nextScreen = kScreenInvalid;  // user quit
+			break;
+
+		case kScreenSetup:
+			// Handler 6 at 1a35:0e48 calls `_DoSetup @ 1f78:044e`.
+			// Reachable via the BigMap setup button which writes
+			// `_NextScreen = 6` (verified at 20fe:0c33). The
+			// original sets `_NextScreen = _LastScreen` on entry,
+			// then the toggle UI returns when ESC / Back is hit;
+			// `doSetup` sets `_nextScreen` itself.
+			doSetup();
+			break;
+
+		default:
+			warning("screenDriver: unhandled screen id %d", (int)current);
+			_nextScreen = kScreenInvalid;
+			break;
 		}
+		_lastScreen = current;
 	}
 
 	debugC(1, kDebugGeneral, "EEM engine exiting");
@@ -564,8 +641,14 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 										 bool isAutosave) {
 	(void)isAutosave;
 
+	// Body header: one byte version. `Common::Serializer::setVersion`
+	// alone doesn't write/read the version — we emit it explicitly so
+	// `loadGameStream` knows which fields are present. Older saves
+	// (v1) lack `_chainStage`; newer ones include it.
 	Common::Serializer s(nullptr, stream);
 	s.setVersion(kSaveBodyVer);
+	byte ver = kSaveBodyVer;
+	s.syncAsByte(ver);
 
 	// Profile-level state — mirrors the original `_PlayerRecord` body
 	// at `2d5d:3f6a` (159 bytes, written by `_SavePlayerRecord @
@@ -590,6 +673,10 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	s.syncString(_playerName);
 	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
 	s.syncAsByte(_partner);
+	// v2+: chain-stage tier (1=Junior, 2=Senior, 3=Master).
+	s.syncAsByte(_chainStage);
+	// v3+: voice on/off flag (DAT_2d5d_3f97).
+	s.syncAsByte(_voiceOn);
 
 	// ScummVM-only extension: persist the in-progress mystery so the
 	// player can resume mid-case. The original engine has no such
@@ -605,8 +692,8 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	}
 
 	debugC(1, kDebugGeneral,
-		   "Saved profile name=%s partner=%u mystery=%d autosave=%d",
-		   _playerName.c_str(), _partner,
+		   "Saved profile name=%s partner=%u stage=%u mystery=%d autosave=%d",
+		   _playerName.c_str(), _partner, _chainStage,
 		   hasMystery ? (int)_mystery.number() : -1,
 		   isAutosave ? 1 : 0);
 	return Common::kNoError;
@@ -614,7 +701,14 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 
 Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 	Common::Serializer s(stream, nullptr);
-	s.setVersion(kSaveBodyVer);
+	byte ver = 0;
+	s.syncAsByte(ver);
+	if (ver > kSaveBodyVer) {
+		warning("loadGameStream: save body version %u newer than %u — refusing",
+				ver, kSaveBodyVer);
+		return Common::kReadingFailed;
+	}
+	s.setVersion(ver);
 
 	s.syncString(_playerName);
 	if (_playerName.empty())
@@ -622,6 +716,10 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 
 	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
 	s.syncAsByte(_partner);
+	s.syncAsByte(_chainStage);
+	s.syncAsByte(_voiceOn);
+	if (_audio)
+		_audio->setVoiceEnabled(_voiceOn);
 
 	bool hasMystery = false;
 	s.syncAsByte(hasMystery);
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index f1aa061d57d..e97bd592712 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -46,14 +46,59 @@ class AudioPlayer;
 class MusicPlayer;
 
 /**
- * Screen IDs used by the original ScreenDriver dispatch table at 1a35:0e5e.
- * The table holds 14 (id, handler) entries; the loop iterates until it finds
- * a matching id and calls its handler. ID 0xFFFF is the exit sentinel.
+ * Screen IDs used by the original `_ScreenDriver` dispatch table at
+ * 1a35:0e5e (and the fallback at 1a35:0e54). 14 entries total: each
+ * one is a screen ID + a near function pointer at offset +0x1c. The
+ * driver does `JMP word ptr CS:[BX + 0x1c]`, so handlers tail-call —
+ * each one runs the screen and updates `_NextScreen` for the next
+ * trip through the dispatcher.
+ *
+ * IDs and their handlers (verified from disassembly at 1a35:0dec..0e4f):
+ *
+ *   0  INIT_CLUES  → `_PreLoad` + `_DoInitClues`, sets _NextScreen=1
+ *   1  MAP         → `_DoMapScreen` @ 20fe:120b (sets _NextScreen
+ *                    inside; 3 = a site was clicked, 6 = setup, etc.)
+ *   2  MAP         → same handler as 1 (alternate entry, used when
+ *                    `_LastScreen == 2` to swap the briefcase anim)
+ *   3  SITE        → `_DoSiteLoop` @ 168d:03f4
+ *   4  NOTEBOOK    → `_DoNotebook` @ 161e:0500
+ *   5  GALLERY     → `_DoGallery` @ 158f:065b
+ *   6  SETUP       → `_DoSetup` @ 1f78:044e
+ *   7  ACCUSE      → `_DoAccuse` @ 1df2:0bdd (win → 12, lose → last)
+ *   8  PROFILE     → `screen8_handler` @ 1c33:1012; tail sets =9
+ *   9  PARTNER     → `_DoChoosePartner` @ 1a35:0756; sets =0xc inside
+ *   10 (0xa) CHOOSE_MYSTERY → `_DoChooseMystery` + `_CaseSelection`;
+ *                    starts with _NextScreen=0 so a successful pick
+ *                    falls through to INIT_CLUES.
+ *   11 (0xb) TITLE  → set _NextScreen=8 then dispatch (TITLE.ANM is
+ *                    actually shown earlier by `_DoOpeningAnims`, this
+ *                    handler is the post-intro "fall into PROFILE"
+ *                    redirect)
+ *   12 (0xc) ACTION → `_ActionScreen` @ 1c33:195b — post-mystery menu
+ *                    ("Solve a Mystery", scrapbook, more mysteries,
+ *                    setup). Action 1 sets =10 (CHOOSE_MYSTERY).
+ *   0xFFFF SENTINEL → exit loop
+ *
+ * Screen-driver state writes verified via xrefs to `_NextScreen @
+ * 2d5d:3f26`: `_DisplayCorrect` writes 0xc (winner returns to ACTION),
+ * `_DisplayAlibi` writes `_LastScreen` (loser snaps back), and
+ * `_DoSiteLoop` writes 1/3/4 plus 0xffff on ESC.
  */
 enum ScreenId {
-	kScreenInvalid       = 0xFFFF,
-	kScreenChoosePartner = 0x09,  ///< _DoChoosePartner @ 1a35:0756 (boy/girl picker)
-	kScreenTitle         = 0x0B   ///< _ShowTitlePage @ 1a35:06b7
+	kScreenInvalid        = 0xFFFF,
+	kScreenInitClues      = 0x00,
+	kScreenMap            = 0x01,
+	kScreenMapAlt         = 0x02,
+	kScreenSite           = 0x03,
+	kScreenNotebook       = 0x04,
+	kScreenGallery        = 0x05,
+	kScreenSetup          = 0x06,
+	kScreenAccuse         = 0x07,
+	kScreenProfile        = 0x08,
+	kScreenChoosePartner  = 0x09,
+	kScreenChooseMystery  = 0x0A,
+	kScreenTitle          = 0x0B,
+	kScreenAction         = 0x0C
 };
 
 class EEMEngine : public Engine {
@@ -268,11 +313,55 @@ private:
 	// Screen handlers — port targets in screens/ later.
 	void showEAKidsLogo();
 	void showHighScoreLogo();
+
+	/// Profile selector — mirrors `screen8_handler @ 1c33:1012`.
+	/// Walks `listProfiles()`, draws the list of existing profile
+	/// names plus a "[New Player]" entry, and either calls
+	/// `loadProfile(name)` on a click or falls through to
+	/// `doNewPlayer()` if the user picks "New". When no profiles
+	/// exist, behaves identically to `doNewPlayer()` (the original
+	/// also bypasses the picker when `local_20 == 0` — see
+	/// 1c33:1170: `if (saves == 0) _NewPlayer();`).
+	void doProfilePicker();
 	void doNewPlayer();          ///< Mirrors `_NewPlayer` @ 1c33:0dda
 	void doChoosePartner();
+
+	/// Display the per-mystery ending pages from `E<num>.BIN`.
+	/// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage
+	/// @ 1df2:044c`. The file format is a 2-byte page count followed
+	/// by N pages, each `{ u16 picNum, u16 x1, u16 y1, u16 x2, u16 y2,
+	/// char text[] (null-terminated, ParseString placeholders) }`.
+	/// Blocks until the player clicks past the last page or hits ESC.
+	/// `_ShowOneScrap @ 1f78:0773` is just `_DisplayEnding(num, 1)`,
+	/// so this same call covers the post-mystery scrapbook view from
+	/// the action menu.
+	void doShowEnding(uint num);
 	void doCaseSelection();
 	void doSiteLoop();
 
+	/// Post-mystery action menu. Mirrors `_ActionScreen @ 1c33:195b` —
+	/// the screen the original returns to after `_DisplayCorrect`
+	/// (winner) or after the player explicitly leaves a case via the
+	/// PROFILE → PARTNER chain. The original offers up to 5 choices
+	/// gated on the player's chain stage (`DAT_2d5d_3f99`):
+	///   1: "Solve a Mystery" (set _NextScreen=10 — CHOOSE_MYSTERY)
+	///   3: replay the last solved case (`_ReloadMystery(0)` callsite)
+	///   5: scrapbook viewer (`_ShowOneScrap(0, 1)` callsite)
+	///   7: chain-stage advance (cmp `_3f99 == 1`)
+	///   9: chain-stage advance (cmp `_3f99 == 2` / `== 3`)
+	///   sentinel: exit / back to PARTNER
+	/// We start with just option 1 wired (the practical loop) plus
+	/// quit; the others slot in as their underlying screens land.
+	void doActionScreen();
+
+	/// Setup / preferences screen. Mirrors `_DoSetup @ 1f78:044e` —
+	/// per-profile preferences (voice on/off via `DAT_2d5d_3f97`,
+	/// partner pick via SwapColors on Kid1/Kid2 rects). Reachable
+	/// from BigMap's setup button (sets `_NextScreen = 6` per
+	/// `_DoBigMap @ 20fe:0c33`). Returns to whatever
+	/// `_lastScreen` was — typically MAP.
+	void doSetup();
+
 	/// Render the case briefing background + game/book decorations and
 	/// display the briefing ClueBlock. Mirrors `_DoInitClues` @ 1a35:0411
 	/// minus the live ANI sequence playback.
@@ -300,6 +389,28 @@ private:
 	/// original `_DisplayCorrect` flow.
 	uint8 _mysteriesSolved[55] = {};
 
+	/// Current chain/tier the player is at — mirrors `DAT_2d5d_3f99`
+	/// (`_PlayerRecord +0x2f`):
+	///   1 = Junior detective  (mysteries  1 .. 24, "A chain")
+	///   2 = Senior detective  (mysteries 25 .. 48, "B chain")
+	///   3 = Master detective  (mysteries 49 .. 54, "C chain")
+	/// Initialized to 1 in `_NewPlayer @ 1c33:0fa3` and bumped by
+	/// `_DisplayCorrect @ 1df2:0853` once every mystery in the current
+	/// tier is solved (range checks at 1df2:080d / 0824 / 0837). The
+	/// value also gates `_CaseSelection`'s book label and selection
+	/// list (1c33:0a87 onwards).
+	uint8 _chainStage = 1;
+
+	/// Voice / digital-audio enable flag. Mirrors `DAT_2d5d_3f97`
+	/// (`_PlayerRecord +0x2d`). Set to 1 by `_NewPlayer @ 1c33:0fa3`,
+	/// toggled by the SoundOn / SoundOff hot-rects in `_DoSetup @
+	/// 1f78:044e` (verified at `_SetupSettings` 1f78:0076 reading
+	/// the same byte to colour the on/off labels). Gates every
+	/// `_PlayVoice` and `_SpoolSound` call site (clue voices,
+	/// partner speech, intro VO etc. — see `_DoChoosePartner`,
+	/// `_DisplayClue`, `_SayKDDigital` xrefs).
+	bool _voiceOn = true;
+
 	Common::RandomSource _rng;
 
 	DBDArchive _picsArchive;     ///< PICS.DBD/.DBX (sprites, buttons, frame backgrounds)
@@ -347,6 +458,12 @@ private:
 	/// 202f:05cb`). Constructed alongside `_music` in `run()`.
 public:
 	AudioPlayer *_audio = nullptr;
+
+	/// Public setter for `_nextScreen` so site loop / inline screens
+	/// can drive the screen-driver state machine without making the
+	/// member itself public. Mirrors the original's direct write to
+	/// `_NextScreen @ 2d5d:3f26` from anywhere in the engine.
+	void setNextScreen(ScreenId s) { _nextScreen = s; }
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index e6122b5d6eb..42c5429d44e 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -65,6 +65,12 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 // and `renderStaticDrops`.
 void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 					   int x, int y) {
+	// Top-left semantics. Used for static drops (`_AddDrop @
+	// 172b:1a77` which calls `_Rect_Move_Mask(..., x, y, ...)` with
+	// the raw (x, y) and ignores per-frame anchors) and any other
+	// non-animated overlay. Animation rendering should go through
+	// `blitAnimFrameAnchored` instead so per-frame anchor offsets
+	// (miscflags = X, rowoff = Y) apply correctly.
 	if (!screen)
 		return;
 	const byte transp = (byte)(p.flags >> 8);
@@ -84,6 +90,40 @@ void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 	}
 }
 
+void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
+						   int anchorX, int anchorY) {
+	// `_UpdateAnimations @ 172b:09c1` blits each animation frame at
+	// `(anchor_x - puVar5[4], anchor_y - puVar5[3])` where puVar5[3]/[4]
+	// are the per-frame `rowoff` / `miscflags` values from the
+	// 16-byte PicData header. Both are SIGNED 16-bit anchor offsets
+	// — when frames have varying anchors (anim 0x14 BigMap walk-
+	// cycle has miscflags = -2 per cell, anim 0x07 has rowoff up to
+	// 61), the sprite actually translates across the screen as it
+	// cycles through cells. Without this, the partner "shakes in
+	// place" instead of walking. (Transparency still comes from
+	// `flags >> 8`, verified at the `_Rect_Move_Mask(..., *thePic >>
+	// 8)` call — NOT from miscflags as an earlier comment claimed.)
+	if (!screen)
+		return;
+	const int blitX = anchorX - (int)(int16)p.miscflags;
+	const int blitY = anchorY - (int)(int16)p.rowoff;
+	const byte transp = (byte)(p.flags >> 8);
+	for (int row = 0; row < p.surface.h; row++) {
+		const int dstY = blitY + row;
+		if (dstY < 0 || dstY >= screen->h)
+			continue;
+		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
+		byte *dst = (byte *)screen->getBasePtr(0, dstY);
+		for (int col = 0; col < p.surface.w; col++) {
+			const int dstX = blitX + col;
+			if (dstX < 0 || dstX >= screen->w)
+				continue;
+			if (src[col] != transp)
+				dst[dstX] = src[col];
+		}
+	}
+}
+
 // Rotate one VGA palette range by one slot. Mirrors `_ColorCycle @
 // 172b:2015` — used by both the per-site Loop-1 ColorCycle entries and
 // the always-on hotspot marching-ants range 0xF9..0xFE.
@@ -136,40 +176,287 @@ const uint16 kKdAnimTable[6][6] = {
 	{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
 };
 
-// Sequence-script lookup. Entries copied verbatim from
-// `_AnimationSequences @ 29be:22d4` walked through to the next 0x80.
-// Each script is a u16[] of frame indices terminated by 0x80; we
-// don't yet handle 0x81 jumps (none of the kdAnim sequences use
-// them — verified). seqnum == animId for these calls (per
-// `_PlayAnimation` 172b:1f5d push order).
-struct KdScript {
+// Animation script table. Mirrors `_AnimationSequences @ 29be:22d4`
+// (a 55-entry table of far ptrs, each pointing to a u16-frame-index
+// stream terminated by 0x80; 0x81 marks a jump that we don't see in
+// the partner subset and so don't yet implement).
+//
+// `_NewAnimation @ 172b:06e1` reads the script via
+// `_AnimationSequences[anim_id]` and stores the pointer in
+// `DAT_2d5d_3eaf[i*0xb]`. `_UpdateAnimations @ 172b:09c1` then walks
+// it one entry per `_CheckFrameRate` tick (~100 ms): the value at
+// `script[index]` is the frame to render; 0x80 resets index to 0
+// (loop). So a script like `[0,0,0,0,0,0,0,0,0,2]` renders nine ticks
+// of frame 0 then one tick of frame 2 → the natural "blink with long
+// idle hold" cadence.
+//
+// We use the same scripts for the wait anims (`renderPartner`) AND
+// the kd-clue reaction anims (`playKdAnim`), since both call
+// `_NewAnimation` in the original — only the state field differs (1
+// = looping, 4 = one-shot). seqnum == animId per `_PlayAnimation`
+// 172b:1f5d push order.
+//
+// Each entry was read directly from the EXE via Ghidra; cross-checked
+// against `_NewAnimation`'s read. Frame counts include only the
+// playable frames (the trailing 0x80 is the terminator, not a frame).
+struct AnimScript {
 	uint16 seqnum;
 	uint8 len;
-	uint8 frames[20];  // long enough for any kdAnim script
+	uint8 frames[28];  // longest partner-subset script is 26 frames (anim 2)
 };
-const KdScript kKdScripts[] = {
-	// seqnum 1 (29be:188a) — head bob
+const AnimScript kAnimScripts[] = {
+	// 0x00 (29be:185e) — Jake speaker-0 wait: nine idle, one blink, loop
+	{ 0x00, 10, { 0,0,0,0,0,0,0,0,0,2 } },
+	// 0x01 (29be:188a) — Jake PDA idle: alternating head bob with peak
 	{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-	// seqnum 2 (29be:18aa) — short blip then long pause
-	{ 0x02, 16, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0 } },
-	// seqnum 3 (29be:18e0) — Jake "lift, hold, lower" gesture
+	// 0x02 (29be:18aa) — Jake gallery: brief wave, long hold, second
+	// wave, hold (CONFIRMED 26 frames — earlier table was truncated to
+	// 16 which dropped the second wave cycle).
+	{ 0x02, 26, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0,0,0,0 } },
+	// 0x03 (29be:18e0) — Jake "lift, hold, lower" gesture
 	{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
-	// seqnum 4 (29be:18f4) — bigger gesture (camera flash-style)
+	// 0x04 (29be:18f4) — Jake bigger gesture (camera flash-style)
 	{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-	// seqnum 5 (29be:1910) — held idle with a single peak
+	// 0x05 (29be:1910) — Jake/Jenny shared (speaker 5): held idle, peak
 	{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
-	// seqnum 6 (29be:192c) — empty (immediate END)
+	// 0x06 (29be:192c) — speaker 6 partner: empty (immediate END,
+	// renders nothing — verified at 29be:192c byte 0 = 0x80)
 	{ 0x06,  0, { 0 } },
-	// seqnum 0xb (29be:188a, same as 1) — Jenny PDA idle
+	// 0x07 (29be:192e) — Jake walk-cycle (10 frames: 0..9)
+	{ 0x07, 10, { 0,1,2,3,4,5,6,7,8,9 } },
+	// 0x08 (29be:1944) — Jake stand-still with very late blink
+	{ 0x08,  8, { 0,0,0,0,0,0,0,1 } },
+	// 0x09 (29be:1956) — short blip animation
+	{ 0x09,  9, { 0,0,0,1,0,0,0,0,0 } },
+	// 0x0a — Jenny speaker-0 wait (alias of 0x00 in the binary)
+	{ 0x0a, 10, { 0,0,0,0,0,0,0,0,0,2 } },
+	// 0x0b — Jenny PDA idle (alias of 0x01)
 	{ 0x0b, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-	// seqnum 0xc (29be:18e0, same as 3) — Jenny "take a picture"
+	// 0x0c — Jenny "take a picture" (alias of 0x03)
 	{ 0x0c,  9, { 0,1,2,3,2,2,2,1,0 } },
-	// seqnum 0xd (29be:18f4, same as 4) — Jenny big gesture
+	// 0x0d — Jenny big gesture (alias of 0x04)
 	{ 0x0d, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
-	// seqnum 0x10 (29be:1956) — Jenny short anim
+	// 0x0e — alias of 0x06 (empty)
+	{ 0x0e,  0, { 0 } },
+	// 0x0f — alias of 0x09
+	{ 0x0f,  9, { 0,0,0,1,0,0,0,0,0 } },
+	// 0x10 — Jenny gallery wait (alias of 0x09 — verified at 29be:1956)
 	{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
+	// 0x11 (29be:1992) — Jenny entrance count-up: 0..7. Used by
+	// `_DoBigMap` when `_LastScreen == 2` (BigMap entrance one-shot).
+	{ 0x11,  8, { 0,1,2,3,4,5,6,7 } },
+	// 0x12 (29be:197e) — Jake entrance count-down 8..0. Used by
+	// `_DoBigMap` (entrance one-shot, partner-specific exit cell).
+	{ 0x12,  9, { 8,7,6,5,4,3,2,1,0 } },
+	// 0x13 (29be:1992, alias of 0x11) — Jake walk-cycle 0..7,
+	// looped during BigMap idle.
+	{ 0x13,  8, { 0,1,2,3,4,5,6,7 } },
+	// 0x14 (29be:196a) — BigMap idle walk-cycle 0..8 (9 cells),
+	// partner shifts feet while you pick a site.
+	{ 0x14,  9, { 0,1,2,3,4,5,6,7,8 } },
+	// 0x15 (29be:185e, alias of 0x00) — Jake CaseSelection greeter:
+	// nine idle, one blink, loop. Same blink cadence as the site
+	// loop's wait anim (animID 0x00).
+	{ 0x15, 10, { 0,0,0,0,0,0,0,0,0,2 } },
+	// 0x16 (29be:185e, alias of 0x00) — Jenny CaseSelection greeter,
+	// same blink script as 0x15.
+	{ 0x16, 10, { 0,0,0,0,0,0,0,0,0,2 } },
+	// Briefing animations — `_DoInitClues @ 1a35:0411` calls
+	// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), 1, ...)`
+	// for the game animation (always anim ID 0x17 — even Jenny's
+	// briefing reuses Jake's SCRIPT, even though the loaded ANI.DBD
+	// cells come from her partner-specific entry 0x3b). Same pattern
+	// for book (0x18 always) and nancy (0x19 always).
+	//
+	// AnimScript len was 28 — these scripts overflow that. Bump
+	// `frames[]` is fine because we just need to fit 30 frames per
+	// briefing entry. We size `frames` to 36 so all five scripts fit
+	// (longest is 0x18 at 30 frames).
+};
+static_assert(true, "see kAnimScriptsLong below for >28-frame scripts");
+
+// Scripts longer than 28 frames live here so the main `kAnimScripts`
+// table can keep its tight `frames[28]` storage (the lookup in
+// `findAnimScript` checks both arrays). Used for the briefing
+// animations whose original scripts run 30 frames each.
+struct AnimScriptLong {
+	uint16 seqnum;
+	uint8 len;
+	uint8 frames[36];
+};
+const AnimScriptLong kAnimScriptsLong[] = {
+	// 0x17 (29be:221a) — briefing game count-up 0..29 (30 frames),
+	// drives the per-tick frame walk of the game piece animation
+	// during `_DoInitClues`.
+	{ 0x17, 30, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+				  20,21,22,23,24,25,26,27,28,29 } },
+	// 0x18 (29be:2296) — briefing book: counts up to cell 8 then
+	// holds for 16 ticks (the "thinking" pose) then count up 9..15.
+	{ 0x18, 30, { 0,1,2,3,4,5,6,7,8,8,8,8,8,8,8,8,
+				  8,8,8,8,8,8,8,9,10,11,12,13,14,15 } },
+	// 0x19 (29be:2258) — briefing nancy: 18 idle ticks then
+	// count-up 1..12 (the late-arriving sidekick pose).
+	{ 0x19, 30, { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+				  1,2,3,4,5,6,7,8,9,10,11,12 } },
 };
 
+// Look up the script for `seqnum`. Returns the frame array + length,
+// or `(nullptr, 0)` if no script is known — caller falls back to
+// flipbook cycling so unknown anims still animate (just without idle
+// holds).
+struct AnimScriptRef {
+	const uint8 *frames;
+	uint8 len;
+};
+static AnimScriptRef findAnimScript(uint16 seqnum) {
+	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++) {
+		if (kAnimScripts[i].seqnum == seqnum) {
+			AnimScriptRef r;
+			r.frames = kAnimScripts[i].frames;
+			r.len = kAnimScripts[i].len;
+			return r;
+		}
+	}
+	for (uint i = 0; i < ARRAYSIZE(kAnimScriptsLong); i++) {
+		if (kAnimScriptsLong[i].seqnum == seqnum) {
+			AnimScriptRef r;
+			r.frames = kAnimScriptsLong[i].frames;
+			r.len = kAnimScriptsLong[i].len;
+			return r;
+		}
+	}
+	AnimScriptRef r;
+	r.frames = nullptr;
+	r.len = 0;
+	return r;
+}
+
+void auditPartnerAnims(EEMEngine *vm) {
+	// Cross-check every registered partner-subset script against the
+	// underlying ANI.DBD entry it references. If the script asks for
+	// a frame past the anim's actual frame count, the visible result
+	// is "missing frames" — that's the user-reported symptom we
+	// want to catch and fix here, not paper over with a clamp.
+	if (!vm)
+		return;
+	DBDArchive &ani = vm->getAni();
+
+	// Helper: audit one (id, frames, len) tuple — log a warning if
+	// the script asks for a frame the ANI.DBD entry doesn't have.
+	struct Walker {
+		static void check(DBDArchive &ani, uint16 id, const uint8 *frames, uint8 len) {
+			if (len == 0)
+				return;
+			Animation a;
+			if (!ani.loadAnimation(id, a) || a.empty()) {
+				debugC(1, kDebugSite,
+					   "auditPartnerAnims: anim 0x%02x failed to load", id);
+				return;
+			}
+			uint maxRequested = 0;
+			for (uint j = 0; j < len; j++)
+				if (frames[j] > maxRequested)
+					maxRequested = frames[j];
+			if (maxRequested >= a.size()) {
+				warning("anim 0x%02x: script wants frame %u but ANI.DBD has "
+						"only %u — frames will be clamped (verify script "
+						"reading from `_AnimationSequences[0x%02x]` against "
+						"Ghidra)",
+						id, maxRequested, (uint)a.size(), id);
+			} else {
+				debugC(2, kDebugSite,
+					   "anim 0x%02x: %u cells, script max=%u, len=%u",
+					   id, (uint)a.size(), maxRequested, len);
+			}
+		}
+	};
+
+	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++)
+		Walker::check(ani, kAnimScripts[i].seqnum,
+					  kAnimScripts[i].frames, kAnimScripts[i].len);
+	for (uint i = 0; i < ARRAYSIZE(kAnimScriptsLong); i++)
+		Walker::check(ani, kAnimScriptsLong[i].seqnum,
+					  kAnimScriptsLong[i].frames, kAnimScriptsLong[i].len);
+
+	// Per-frame anchor-offset audit. The original `_UpdateAnimations
+	// @ 172b:09c1` blits each frame at `(anchor_x - frame.miscflags,
+	// anchor_y - frame.rowoff)`. Our `blitMaskedSurface` ignores
+	// those offsets — it treats the WaitAnims (anchor_x, anchor_y)
+	// as the top-left. That's fine when every frame has
+	// `miscflags == 0 && rowoff == 0`; if any frame has a non-zero
+	// anchor, the partner sprite jumps between cells. Log the
+	// offending IDs so we know whether to plumb anchors through
+	// `partnerFrameAtTick` callers.
+	// Audit covers every animID we register a script for — the
+	// partner-subset (0x00..0x16, used by the wait anims, kd-clue
+	// reactions, BigMap, Notebook, Gallery, CaseSelection greeter)
+	// AND the briefing-subset (0x17..0x19, the
+	// game/book/nancy anims driven by `_DoInitClues @ 1a35:0411`).
+	const uint16 partnerIds[] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
+								   0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
+								   0x0d, 0x0f, 0x10, 0x11, 0x12, 0x13,
+								   0x14, 0x15, 0x16, 0x17, 0x18, 0x19 };
+	for (uint i = 0; i < ARRAYSIZE(partnerIds); i++) {
+		const uint16 id = partnerIds[i];
+		Animation a;
+		if (!ani.loadAnimation(id, a) || a.empty())
+			continue;
+		bool anyAnchor = false;
+		int rowMin = 0, rowMax = 0, miscMin = 0, miscMax = 0;
+		for (uint f = 0; f < a.size(); f++) {
+			const Picture &fr = a[f];
+			const int sRow  = (int)(int16)fr.rowoff;
+			const int sMisc = (int)(int16)fr.miscflags;
+			if (sRow != 0 || sMisc != 0) {
+				if (!anyAnchor) {
+					rowMin = rowMax = sRow;
+					miscMin = miscMax = sMisc;
+					anyAnchor = true;
+				} else {
+					rowMin = MIN(rowMin, sRow);
+					rowMax = MAX(rowMax, sRow);
+					miscMin = MIN(miscMin, sMisc);
+					miscMax = MAX(miscMax, sMisc);
+				}
+			}
+		}
+		if (anyAnchor) {
+			// `_UpdateAnimations @ 172b:09c1` reads these as signed
+			// 16-bit values via `puVar5[3]/[4]`, so log them with
+			// the sign preserved — earlier the log printed unsigned
+			// 65534 instead of -2.
+			debugC(1, kDebugSite,
+				   "anim 0x%02x: per-frame anchor (rowoff [%d..%d], "
+				   "miscflags [%d..%d]) — handled by "
+				   "`blitAnimFrameAnchored`",
+				   id, rowMin, rowMax, miscMin, miscMax);
+		}
+	}
+}
+
+// Pick the frame index to render at `tickMs` for the looping
+// animation `seqnum` whose underlying ANI.DBD entry has `numFrames`
+// frames. Mirrors the looping path of `_UpdateAnimations`: walk the
+// script one entry per ~100 ms `_CheckFrameRate` tick, wrap on the
+// 0x80 terminator. Exposed (non-static) so the BigMap, CaseSelection
+// greeter, Notebook, and Gallery render paths in `ui.cpp` can use the
+// same cadence — without this, every off-site partner rendering
+// flipbook-cycles ALL cells of the ANI entry (no idle holds, no
+// timing variations) which is the user-reported "constantly looping"
+// symptom.
+uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
+	const AnimScriptRef s = findAnimScript(seqnum);
+	const uint kFramePeriodMs = 100;
+	if (!s.frames || s.len == 0)
+		return numFrames > 0 ? (uint)((tickMs / kFramePeriodMs) % numFrames) : 0;
+	const uint scriptIdx = (uint)((tickMs / kFramePeriodMs) % s.len);
+	const uint frame     = s.frames[scriptIdx];
+	// The script can in theory request a frame that's outside the
+	// animation's actual frame count (a misencoded script). Clamp so
+	// we don't read past `anim[]` in the caller.
+	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
+}
+
 void SiteScreen::enter(uint siteNum) {
 	if (!_mystery || !_mystery->isLoaded()) {
 		warning("SiteScreen::enter: no mystery loaded");
@@ -181,6 +468,13 @@ void SiteScreen::enter(uint siteNum) {
 		return;
 	}
 
+	// Reset the wait-anim phase so the partner starts fresh from
+	// script[0] when entering. Mirrors `_DoSiteLoop @ 168d:0436`
+	// where `_NewAnimation` sets the new slot's frame index to
+	// 0xffff (= -1, becomes 0 on the first `_UpdateAnimations`
+	// tick).
+	_waitPhaseAnchor = g_system->getMillis();
+
 	// Capture whether this is the first time the player enters this
 	// site BEFORE we mark it visited — `_DoSiteLoop @ 168d:03f4`
 	// uses the same check to decide whether to play the arrival
@@ -347,8 +641,18 @@ void SiteScreen::run() {
 			case Common::EVENT_KEYDOWN:
 				switch (event.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
-					if (_vm->areYouSure())
+					if (_vm->areYouSure()) {
+						// Mirrors `_DoSiteLoop @ 168d:07b7` ESC path:
+						// `_NextScreen = 1` (back to MAP) after the
+						// areYouSure confirm. Without explicitly
+						// writing it here, the run() loop would see
+						// _nextScreen unchanged and treat it as a
+						// quit-engine signal — abandoning the case.
+						// Going to MAP keeps the case alive so the
+						// player can continue from a different site.
+						_vm->setNextScreen(kScreenMap);
 						return;
+					}
 					enter(cur);
 					break;
 				case Common::KEYCODE_m:
@@ -646,8 +950,6 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	if (!screen)
 		return;
 
-	const uint32 kFramePeriodMs = 100; // ~10 FPS, in line with `_CheckFrameRate`.
-
 	for (uint i = 0; i < numAnims; i++) {
 		const uint dropOff = 0x48 + i * 6;
 		const int16 animId = (int16)READ_LE_UINT16(site + dropOff + 0);
@@ -658,8 +960,12 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 		Animation anim;
 		if (!_vm->getAni().loadAnimation((uint)animId, anim) || anim.empty())
 			continue;
-		const uint frameIdx = (uint)((tickMs / kFramePeriodMs) % anim.size());
-		blitMaskedSurface(screen, anim[frameIdx], x, y);
+		const uint frameIdx = partnerFrameAtTick((uint16)animId,
+												  (uint)anim.size(), tickMs);
+		// Animated drops go through `_NewAnimation` in the original,
+		// so `_UpdateAnimations` applies per-frame anchor offsets —
+		// route through the anchored blitter.
+		blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
 	}
 
 	g_system->unlockScreen();
@@ -763,16 +1069,31 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
 		return;
 
-	// `_UpdateAnimations @ 172b:09c1` advances the partner's frame
-	// every `_CheckFrameRate` tick. We pick the frame from a global
-	// 100 ms clock so the partner cycles in sync with the animated
-	// drops.
-	const uint32 kFramePeriodMs = 100;
-	const uint frameIdx = (uint)((tickMs / kFramePeriodMs) % anim.size());
+	// `_UpdateAnimations @ 172b:09c1` walks the per-anim script (from
+	// `_AnimationSequences[seqnum]`) one entry per `_CheckFrameRate`
+	// tick (~100 ms): render `script[index]`, advance, wrap on 0x80.
+	// That's how the original gets long idle holds with brief blinks
+	// — naive flipbook cycling (`tick % nFrames`) loses those pauses
+	// and makes the partner constantly fidget. `partnerFrameAt` picks
+	// the right frame; if no script is registered for this anim it
+	// falls back to flipbook so unknown anims still move.
+	// Use the relative phase anchor instead of the raw `tickMs` so
+	// the wait anim resumes from script[0] after each kdAnim
+	// one-shot ends — matching the original's `_PlayAnimation @
+	// 172b:1f5d` resetting the resumed slot's frame index to
+	// 0xffff. Without this, the wait anim snaps mid-cycle every
+	// time we return from a clue display.
+	const uint32 elapsed = (tickMs >= _waitPhaseAnchor)
+							? (tickMs - _waitPhaseAnchor)
+							: tickMs;
+	const uint frameIdx = partnerFrameAtTick((uint16)animId,
+											  (uint)anim.size(), elapsed);
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
-	blitMaskedSurface(screen, anim[frameIdx], x, y);
+	// Partner sprite — anchor-aware blit so per-frame `miscflags` /
+	// `rowoff` apply (e.g. the BigMap walk-cycle's -2 px shift).
+	blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
 	g_system->unlockScreen();
 }
 
@@ -1012,7 +1333,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 	// gesture (Jenny taking a picture, etc.) finishes before the
 	// speaker portrait + speech balloon appear.
 	//
-	// `kKdAnimTable` and `kKdScripts` live at file scope above.
+	// `kKdAnimTable` and `kAnimScripts` live at file scope above.
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
 
@@ -1027,15 +1348,14 @@ void EEMEngine::playKdAnim(uint16 num) {
 		return;
 	}
 
-	const uint8 *frames = nullptr;
-	uint frameCount = 0;
-	for (uint i = 0; i < ARRAYSIZE(kKdScripts); i++) {
-		if (kKdScripts[i].seqnum == animId) {
-			frames = kKdScripts[i].frames;
-			frameCount = kKdScripts[i].len;
-			break;
-		}
-	}
+	// `_DoKDAnim` (168d:028a) calls `_PlayAnimation` with state=4 (one-
+	// shot), which `_UpdateAnimations` walks until it sees the 0x80
+	// terminator and then frees the slot. The same script the
+	// site-loop wait anim uses (looping) is what the one-shot plays
+	// through ONCE here.
+	const AnimScriptRef s = findAnimScript(animId);
+	const uint8 *frames = s.frames;
+	uint frameCount     = s.len;
 	if (frameCount == 0) {
 		// Fallback: linear playback through anim cells (better than
 		// nothing if a future kdAnim references an unscripted anim).
@@ -1082,22 +1402,13 @@ void EEMEngine::playKdAnim(uint16 num) {
 			memcpy((byte *)scratch.getBasePtr(0, row),
 				   (const byte *)bg.getBasePtr(0, row), 320);
 		}
-		const int w = MIN<int>(fr.surface.w, 320 - px);
-		const int h = MIN<int>(fr.surface.h, 200 - py);
-		for (int row = 0; row < h; row++) {
-			const int dstY = py + row;
-			if (dstY < 0)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < w; col++) {
-				const int dstX = px + col;
-				if (dstX < 0)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		// Anchor-aware: kdAnim cells (0x03/0x04/0x0c/0x0d ...) have
+		// non-zero per-frame `miscflags`/`rowoff` (anim 0x03 has
+		// rowoff up to 9, anim 0x04 has miscflags = -2). Without
+		// applying those, the camera-flash gesture pop-up appears
+		// at a fixed pixel rather than translating across cells.
+		(void)transp;  // anchored blitter recomputes from p.flags
+		blitAnimFrameAnchored(scratch.surfacePtr(), fr, px, py);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 00c1e647ea0..51129bfed74 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -27,11 +27,35 @@
 
 #include "graphics/managed_surface.h"
 
+#include "eem/resource.h"  // Picture / Animation
+
 namespace EEM {
 
 class EEMEngine;
 class Mystery;
 
+/// Pick the frame index to render at `tickMs` for animation
+/// `seqnum`. Walks the script registered in `kAnimScripts` (mirrors
+/// `_UpdateAnimations @ 172b:09c1`'s looping path) at one entry per
+/// 100 ms tick, wrapping on the script's 0x80 terminator. Falls
+/// back to flipbook (`tick % numFrames`) when no script is
+/// registered. `numFrames` is the underlying ANI.DBD entry's cell
+/// count — used both for the fallback path and to clamp script
+/// values that point past the asset.
+uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
+
+/// Anchor-aware masked blit. Mirrors the per-frame anchor offset
+/// math in `_UpdateAnimations @ 172b:09c1`:
+/// `blit_x = anchor_x - frame.miscflags`, `blit_y = anchor_y -
+/// frame.rowoff`. Use for any animation rendered through the
+/// `_NewAnimation` path in the original (partner sprites, animated
+/// drops, briefing animations) — without it, frames with non-zero
+/// per-cell anchors (e.g. anim 0x14 BigMap walk-cycle's miscflags
+/// = -2 shift) "shake in place" instead of translating across
+/// the screen as they're meant to.
+void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
+						   int anchorX, int anchorY);
+
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
 struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates
@@ -122,6 +146,22 @@ private:
 	Graphics::ManagedSurface _bgSnapshot;
 	uint32 _lastTickMs = 0;        ///< Last frame-pump tick in ms.
 
+	/// Wall-clock timestamp at which the partner's wait animation
+	/// "started" (or last restarted). The site loop renders the
+	/// wait sprite at `partnerFrameAtTick(animId, ..., now -
+	/// _waitPhaseAnchor)` so the script position is RELATIVE to
+	/// this anchor, not the global clock. Bump it on:
+	///   - entry to a new site (mirrors `_NewAnimation` setting
+	///     the slot's frame index to 0xffff at site setup, see
+	///     `_DoSiteLoop @ 168d:0436`)
+	///   - return from a one-shot kdAnim (mirrors `_PlayAnimation
+	///     @ 172b:1f5d` writing 0xffff to the resumed slot's frame
+	///     index when state=4 chains back to WaitHandle)
+	/// Without this, the wait anim "snaps" to wherever the global
+	/// clock dictates each time the partner reappears, instead of
+	/// resuming from script[0].
+	uint32 _waitPhaseAnchor = 0;
+
 	/// Per-site cached ColorCycle ranges. Up to 5 (matching the
 	/// original's 5-slot animation table).
 	struct ColorCycleRange { uint8 start, end; };
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index c78ea11d37d..55554a7ff30 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -34,6 +34,7 @@
 #include "eem/detection.h"
 #include "eem/eem.h"
 #include "eem/music.h"
+#include "eem/site.h"
 
 // EEM — UI screens (NOTE.C, GALLERY.C, ACCUSE.C, MAP.C, CHOOSE.C combined).
 // Each function is a self-contained modal `EEMEngine::doX()` reachable from
@@ -89,6 +90,7 @@ struct CaseSelectionView {
 	bool haveCaseBg;
 	const Animation *kdAnim;
 	bool haveKdAnim;
+	uint16 kdAnimId;     ///< 0x15 / 0x16 — looked up in kAnimScripts
 	int kdAnimX;
 	int kdAnimY;
 	const char *separator;
@@ -166,26 +168,24 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 	}
 
 	// KD greeter frame — masked-blit current animation cell at
-	// (0x112, 0x50). 100 ms tick matches the engine's `_CheckFrameRate`.
+	// (0x112, 0x50). 100 ms tick matches `_CheckFrameRate`. The
+	// original `_CaseSelection @ 1c33:0a87` calls `_NewAnimation(...,
+	// CONCAT22(0x15, ...), ..., seqnum=0x15, ...)` so the script
+	// key is 0x15 regardless of partner — even Jenny's CELLS (loaded
+	// via animID 0x16 = ANI.DBD slot) get driven by Jake's 0x15
+	// blink script. Both 0x15 and 0x16 are aliases of 0x00 in our
+	// table so the result is identical, but routing through 0x15
+	// matches the binary.
 	if (v.haveKdAnim) {
 		const uint32 now = g_system->getMillis();
-		const uint frameIdx = (uint)((now / 100) % v.kdAnim->size());
-		const Picture &fr = (*v.kdAnim)[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-		for (int row = 0; row < fr.surface.h; row++) {
-			const int dstY = v.kdAnimY + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < fr.surface.w; col++) {
-				const int dstX = v.kdAnimX + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		const uint frameIdx = partnerFrameAtTick(0x15,
+												  (uint)v.kdAnim->size(), now);
+		// Anchor-aware blit. Same rendering path used everywhere
+		// the partner is registered through `_NewAnimation` in the
+		// original.
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  (*v.kdAnim)[frameIdx],
+							  v.kdAnimX, v.kdAnimY);
 	}
 	if (v.vm->getFont().isLoaded()) {
 		// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
@@ -234,6 +234,138 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 	g_system->updateScreen();
 }
 
+void EEMEngine::doProfilePicker() {
+	// Mirrors `screen8_handler @ 1c33:1012`. The original walks
+	// `*.PLR` files in `C:\EEMCDSAV\` (max 25), reads the first 12
+	// bytes of each (the player-name field of `_PlayerRecord`), and
+	// hands the list to `_DoChoose`. If no profiles exist (loop hits
+	// `local_20 == 0` at 1c33:1170), it falls straight into
+	// `_NewPlayer`. Selecting an entry calls `_LoadPlayerRecord` and
+	// returns; selecting the "exit" sentinel goes back to title.
+	const SaveStateList saves = listProfiles();
+	if (saves.empty()) {
+		doNewPlayer();
+		return;
+	}
+
+	if (!_font.isLoaded()) {
+		// No font means we can't render the picker — fall through.
+		doNewPlayer();
+		return;
+	}
+
+	// Build the visible list: existing profile names + "[New Player]".
+	struct Entry {
+		Common::String label;
+		int slot;       ///< -1 means "create new"
+	};
+	Common::Array<Entry> entries;
+	for (const SaveStateDescriptor &s : saves) {
+		Entry e;
+		e.label = s.getDescription();
+		e.slot  = s.getSaveSlot();
+		entries.push_back(e);
+	}
+	Entry newEntry;
+	newEntry.label = "[New Player]";
+	newEntry.slot  = -1;
+	entries.push_back(newEntry);
+
+	int sel = 0;
+	bool done = false;
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		Picture bg;
+		if (_picsArchive.getPicture(0x104, bg)) {
+			const int w = MIN<int>(bg.surface.w, 320);
+			const int h = MIN<int>(bg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.surface.getBasePtr(0, row), w);
+		}
+		_font.drawString(&scratch, "Pick a player:", 80, 30, 220, 0xF);
+		const int kLineH = 12;
+		for (uint i = 0; i < entries.size(); i++) {
+			const byte color = ((int)i == sel) ? 0xF : 0x8;
+			_font.drawString(&scratch, entries[i].label,
+							 80, 60 + (int)i * kLineH, 220, color);
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!done && !shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		bool committed = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_playerName = "Detective";
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				switch (ev.kbd.keycode) {
+				case Common::KEYCODE_UP:
+					sel = (sel + (int)entries.size() - 1) % (int)entries.size();
+					dirty = true;
+					break;
+				case Common::KEYCODE_DOWN:
+					sel = (sel + 1) % (int)entries.size();
+					dirty = true;
+					break;
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER:
+					committed = true;
+					break;
+				case Common::KEYCODE_ESCAPE:
+					_playerName = "Detective";
+					return;
+				default:
+					break;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				const int kLineH = 12;
+				const int hit = (ev.mouse.y - 60) / kLineH;
+				if (hit >= 0 && hit < (int)entries.size()) {
+					sel = hit;
+					committed = true;
+				}
+			}
+			if (committed)
+				break;
+		}
+		if (committed) {
+			done = true;
+			break;
+		}
+		if (dirty)
+			draw();
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+
+	const Entry &e = entries[sel];
+	if (e.slot < 0) {
+		doNewPlayer();
+	} else {
+		// Mirrors `_LoadPlayerRecord` at 1c33:1281 — slot found,
+		// load it. The save body re-fills `_playerName`, partner,
+		// chain stage, mysteriesSolved.
+		if (!loadProfile(e.label)) {
+			warning("doProfilePicker: failed to load profile '%s' at slot %d",
+					e.label.c_str(), e.slot);
+			doNewPlayer();
+		}
+	}
+}
+
 void EEMEngine::doNewPlayer() {
 	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
 	// 0x104 + character peek pic 0x107, then shows "Please type your
@@ -295,6 +427,10 @@ void EEMEngine::doNewPlayer() {
 					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
 					_mystery.clear();
 					_partner = 0;
+					// `_NewPlayer @ 1c33:0fa3` writes
+					// `DAT_2d5d_3f99 = 1` — fresh profiles always
+					// start at the Junior tier.
+					_chainStage = 1;
 					saveProfile(name);
 				}
 				return;
@@ -339,6 +475,395 @@ void EEMEngine::doNewPlayer() {
 	}
 }
 
+void EEMEngine::doShowEnding(uint num) {
+	// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @
+	// 1df2:044c`. File format (verified by reading E0.BIN's bytes):
+	//   u16 pageCount
+	//   for each page:
+	//     u16 picNum
+	//     u16 x1, y1, x2, y2  (story rect — passed to WordWrap)
+	//     char text[]        (null-terminated, ParseString opcodes)
+	//
+	// The original walks pages with PrevPage / NextPage rects (29be:
+	// 0x..); we approximate with mouse-click / Enter = next, ESC =
+	// exit. ParseString substitutes `\x80` (player name) and the rest
+	// of the 0x80..0x89 placeholder family so the rendered text
+	// reads as the correct player + partner combo.
+	const Common::String fname = Common::String::format("E%u.BIN", num);
+	Common::File f;
+	if (!f.open(Common::Path(fname))) {
+		warning("doShowEnding: %s missing", fname.c_str());
+		return;
+	}
+	const uint32 size = f.size();
+	if (size < 2) {
+		warning("doShowEnding: %s too small (%u bytes)",
+				fname.c_str(), size);
+		return;
+	}
+	Common::Array<byte> buf(size);
+	if (f.read(buf.data(), size) != size) {
+		warning("doShowEnding: %s short read", fname.c_str());
+		return;
+	}
+
+	const uint16 pageCount = READ_LE_UINT16(buf.data());
+	if (pageCount == 0)
+		return;
+
+	// Walk page records. Each page header is 10 bytes; text is
+	// null-terminated and follows the header.
+	uint pageOffsets[8];   // ENDING_RANGE_MAX from `_DisplayEnding`
+	const uint kMaxPages = MIN<uint>(pageCount,
+									 (uint)(sizeof(pageOffsets) / sizeof(uint)));
+	uint cursor = 2;
+	for (uint p = 0; p < kMaxPages; p++) {
+		pageOffsets[p] = cursor;
+		if (cursor + 10 >= size)
+			break;
+		// Skip the 10-byte header and find the null terminator.
+		cursor += 10;
+		while (cursor < size && buf[cursor] != 0)
+			cursor++;
+		cursor++;  // past the null
+	}
+
+	uint pageIdx = 0;
+	bool dirty = true;
+	while (!shouldQuit() && pageIdx < kMaxPages) {
+		if (dirty) {
+			const uint off = pageOffsets[pageIdx];
+			if (off + 10 >= size)
+				break;
+			const uint16 picNum = READ_LE_UINT16(buf.data() + off);
+			const uint16 x1     = READ_LE_UINT16(buf.data() + off + 2);
+			const uint16 y1     = READ_LE_UINT16(buf.data() + off + 4);
+			const uint16 x2     = READ_LE_UINT16(buf.data() + off + 6);
+			(void)READ_LE_UINT16(buf.data() + off + 8);  // y2 (unused — WordWrap2 takes width only)
+
+			// Halve the rect coords: ending pages use 320x400 logical
+			// coords (x2=0x128=296, y2=0xa8=168 in our test file
+			// E0.BIN — both already 320x200 mode 13h). No conversion.
+			Picture bg;
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			scratch.clear();
+			if (_picsArchive.getPicture(picNum, bg)) {
+				const int w = MIN<int>(bg.surface.w, 320);
+				const int h = MIN<int>(bg.surface.h, 200);
+				for (int row = 0; row < h; row++)
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)bg.surface.getBasePtr(0, row), w);
+			}
+
+			// Story text. The bytes are a null-terminated string with
+			// `_ParseString` placeholders (0x80 = player name, 0x82
+			// = partner first name, etc.).
+			const char *raw = (const char *)buf.data() + off + 10;
+			const Common::String text = parseString(raw, _playerName, _partner);
+
+			if (_font.isLoaded() && x2 > x1) {
+				const int textW = MIN<int>((int)x2 - (int)x1, 320 - (int)x1);
+				_font.drawWordWrapped(&scratch, (int)x1, (int)y1,
+									  textW, text, 0xF);
+			}
+
+			// Page indicator at top-right ("page 1/3").
+			if (_font.isLoaded() && kMaxPages > 1) {
+				const Common::String hdr = Common::String::format(
+					"%u/%u", (unsigned)pageIdx + 1, (unsigned)kMaxPages);
+				_font.drawString(&scratch, hdr, 280, 4, 32, 0xF);
+			}
+
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+			dirty = false;
+		}
+
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				switch (ev.kbd.keycode) {
+				case Common::KEYCODE_ESCAPE:
+					return;
+				case Common::KEYCODE_LEFT:
+				case Common::KEYCODE_PAGEUP:
+					if (pageIdx > 0) {
+						pageIdx--;
+						dirty = true;
+					}
+					break;
+				case Common::KEYCODE_RIGHT:
+				case Common::KEYCODE_PAGEDOWN:
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER:
+				case Common::KEYCODE_SPACE:
+					pageIdx++;
+					dirty = true;
+					break;
+				default:
+					break;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				pageIdx++;
+				dirty = true;
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doSetup() {
+	// Mirrors `_DoSetup @ 1f78:044e`. Loads BG pic 0x40 +
+	// state-button pics 0x9b/0x9c/0x9d/0x9e. The original wires 13
+	// hot-rects (`_SetupButtons @ 29be:1218`) but only two of them
+	// drive persistent state — `Kid1`/`Kid2` (partner) and
+	// `SoundOn`/`SoundOff` (voice flag, `DAT_2d5d_3f97`). The rest
+	// are reset/help/return buttons. We render a minimal text
+	// version: two toggle lines, click to flip, ESC to leave.
+	if (!_font.isLoaded()) {
+		_nextScreen = (ScreenId)_lastScreen;
+		return;
+	}
+
+	const Common::Rect kBackBtn(120, 170, 200, 188);
+	const Common::Rect kPartnerToggle(40, 60, 280, 78);
+	const Common::Rect kVoiceToggle  (40, 90, 280, 108);
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		Picture bg;
+		if (_picsArchive.getPicture(0x40, bg)) {
+			const int w = MIN<int>(bg.surface.w, 320);
+			const int h = MIN<int>(bg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.surface.getBasePtr(0, row), w);
+		}
+		_font.drawString(&scratch, "Setup", 140, 30, 80, 0xF);
+		const Common::String partnerLine = Common::String::format(
+			"Partner: %s   (click to switch)",
+			_partner == 0 ? "Jake" : "Jenny");
+		_font.drawString(&scratch, partnerLine, 50, 64, 240, 0xF);
+		const Common::String voiceLine = Common::String::format(
+			"Voice:   %s   (click to toggle)",
+			_voiceOn ? "ON" : "OFF");
+		_font.drawString(&scratch, voiceLine, 50, 94, 240, 0xF);
+		_font.drawString(&scratch, "[ Back ]", 130, 174, 80, 0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE ||
+					ev.kbd.keycode == Common::KEYCODE_RETURN) {
+					// `_DoSetup @ 1f78:044a` writes `_NextScreen =
+					// _LastScreen` on entry. Returning means we just
+					// dispatch back to whichever screen called us.
+					_nextScreen = (ScreenId)_lastScreen;
+					if (_nextScreen == kScreenSetup ||
+						_nextScreen == kScreenInvalid)
+						_nextScreen = kScreenMap;
+					if (_voiceOn)
+						saveProfile(_playerName);
+					return;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				if (kBackBtn.contains(ev.mouse.x, ev.mouse.y)) {
+					_nextScreen = (ScreenId)_lastScreen;
+					if (_nextScreen == kScreenSetup ||
+						_nextScreen == kScreenInvalid)
+						_nextScreen = kScreenMap;
+					saveProfile(_playerName);
+					return;
+				}
+				if (kPartnerToggle.contains(ev.mouse.x, ev.mouse.y)) {
+					_partner = _partner == 0 ? 1 : 0;
+					dirty = true;
+				}
+				if (kVoiceToggle.contains(ev.mouse.x, ev.mouse.y)) {
+					_voiceOn = !_voiceOn;
+					if (_audio)
+						_audio->setVoiceEnabled(_voiceOn);
+					dirty = true;
+				}
+			}
+		}
+		if (dirty)
+			draw();
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+}
+
+void EEMEngine::doActionScreen() {
+	// Mirrors `_ActionScreen @ 1c33:195b` — the post-mystery menu the
+	// original loops back to after `_DisplayCorrect` (winner). The
+	// original draws PIC 0x122 (Cok), 0x124 (Cexit), then calls
+	// `_DoChoose(ActionNames)` and dispatches the result via the
+	// jumptable at 1c33:1be1 (verified to set `_NextScreen` for each
+	// action — entry 1 → 10 = CHOOSE_MYSTERY, entry 3 →
+	// `_ReloadMystery`, etc.).
+	//
+	// We render a minimal text version of the menu — the original's
+	// background pic + "Cok" / "Cexit" overlays land once the
+	// `_DoChoose` UI lands. For now: two practical entries — "Solve a
+	// Mystery" (the only one whose underlying screen is wired) and
+	// "Quit". Sets `_nextScreen` exactly the same way the original
+	// jumptable does:
+	//   "Solve a Mystery"   → kScreenChooseMystery (matches handler 1
+	//                          at 1c33:1add: `MOV [_NextScreen], 0xa`)
+	//   "Quit"              → kScreenInvalid (matches the sentinel
+	//                          handler at 1c33:1afa)
+	if (!_font.isLoaded()) {
+		_nextScreen = kScreenChooseMystery;
+		return;
+	}
+
+	enum ActionKind {
+		kActSolve,         // → kScreenChooseMystery (action 1, 1c33:1add)
+		kActScrapbook,     // → doShowEnding(lastSolved) (action 5, 1c33:1b13)
+		kActQuit
+	};
+	struct Entry {
+		const char *label;
+		ActionKind  kind;
+	};
+	// Locate the highest-numbered solved mystery — that's the one
+	// the player most recently completed, and what the action-menu
+	// "Look at My Books" entry replays. If none solved yet, hide
+	// the entry (matches the original's grey-out at 1c33:19f3 where
+	// `local_24[5] = 1` before the chain-stage check toggles it).
+	int lastSolved = -1;
+	for (int i = (int)sizeof(_mysteriesSolved) - 1; i >= 0; i--) {
+		if (_mysteriesSolved[i] != 0) {
+			lastSolved = i;
+			break;
+		}
+	}
+
+	Common::Array<Entry> entries;
+	Entry solveEntry; solveEntry.label = "Solve a Mystery"; solveEntry.kind = kActSolve;
+	entries.push_back(solveEntry);
+	if (lastSolved >= 0) {
+		Entry sb; sb.label = "Look at My Books"; sb.kind = kActScrapbook;
+		entries.push_back(sb);
+	}
+	Entry quitEntry; quitEntry.label = "Quit"; quitEntry.kind = kActQuit;
+	entries.push_back(quitEntry);
+	const int kCount = (int)entries.size();
+
+	int sel = 0;
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		Picture bg;
+		if (_picsArchive.getPicture(0x104, bg)) {
+			const int w = MIN<int>(bg.surface.w, 320);
+			const int h = MIN<int>(bg.surface.h, 200);
+			for (int row = 0; row < h; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)bg.surface.getBasePtr(0, row), w);
+		}
+		_font.drawString(&scratch, "What now?", 100, 30, 220, 0xF);
+		for (int i = 0; i < kCount; i++) {
+			const byte color = (i == sel) ? 0xF : 0x8;
+			_font.drawString(&scratch, entries[i].label,
+							 100, 60 + i * 14, 220, color);
+		}
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		bool committed = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				switch (ev.kbd.keycode) {
+				case Common::KEYCODE_UP:
+					sel = (sel + kCount - 1) % kCount;
+					dirty = true;
+					break;
+				case Common::KEYCODE_DOWN:
+					sel = (sel + 1) % kCount;
+					dirty = true;
+					break;
+				case Common::KEYCODE_RETURN:
+				case Common::KEYCODE_KP_ENTER:
+					committed = true;
+					break;
+				case Common::KEYCODE_ESCAPE:
+					_nextScreen = kScreenInvalid;
+					return;
+				default:
+					break;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				const int hit = (ev.mouse.y - 60) / 14;
+				if (hit >= 0 && hit < kCount) {
+					sel = hit;
+					committed = true;
+				}
+			}
+			if (committed)
+				break;
+		}
+		if (committed) {
+			switch (entries[sel].kind) {
+			case kActSolve:
+				_nextScreen = kScreenChooseMystery;
+				return;
+			case kActScrapbook:
+				if (lastSolved >= 0)
+					doShowEnding((uint)lastSolved);
+				// Fall through to redraw the menu after viewing.
+				draw();
+				continue;
+			case kActQuit:
+				_nextScreen = kScreenInvalid;
+				return;
+			}
+			return;
+		}
+		if (dirty)
+			draw();
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+	_nextScreen = kScreenInvalid;
+}
+
 void EEMEngine::doCaseSelection() {
 	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
 	// (chooser background) plus a centred "Book %d" / "Challenge Book"
@@ -416,6 +941,7 @@ void EEMEngine::doCaseSelection() {
 	v.haveCaseBg = haveCaseBg;
 	v.kdAnim = &kdAnim;
 	v.haveKdAnim = haveKdAnim;
+	v.kdAnimId = (uint16)kKdAniId;
 	v.kdAnimX = kKdAnimX;
 	v.kdAnimY = kKdAnimY;
 	v.separator = kSeparator;
@@ -548,11 +1074,25 @@ void EEMEngine::doCaseSelection() {
 	}
 
 	// "Choose A Mystery" sub-screen: pick a specific case from the
-	// 55-mystery roster. The original opens a different list here;
-	// we approximate with the tier-aware numeric chooser we used
-	// before. Default to the first unsolved mystery.
-	uint sel = 0;
-	for (uint i = 0; i <= kMaxMystery; i++) {
+	// 55-mystery roster. `_CaseSelection @ 1c33:0a87` only shows
+	// mysteries in the player's current chain stage:
+	//   stage 1 (Junior) → 1..24    (start = `iVar1 = 1`     @ 1c33:0aff)
+	//   stage 2 (Senior) → 25..48   (start = `iVar1 = 0x19`)
+	//   stage 3 (Master) → 49..54   (start = `iVar1 = 0x31`)
+	// The original passes `&DAT_2d5d_3f9b + iVar1` as the grey-mask
+	// pointer so DoChoose lights up only the tier-relevant entries.
+	// We clamp `sel` to the tier range and pre-seed it to the first
+	// unsolved case in that range.
+	uint stageLo = 1, stageHi = 0x18;
+	switch (_chainStage) {
+	case 2: stageLo = 0x19; stageHi = 0x30; break;
+	case 3: stageLo = 0x31; stageHi = 0x36; break;
+	default: break;  // stage 1 (or fallback)
+	}
+	if (stageHi > kMaxMystery)
+		stageHi = kMaxMystery;
+	uint sel = stageLo;
+	for (uint i = stageLo; i <= stageHi; i++) {
 		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
 			sel = i;
 			break;
@@ -587,13 +1127,13 @@ void EEMEngine::doCaseSelection() {
 					return;
 				}
 				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel == 0) ? kMaxMystery : sel - 1;
+					sel = (sel <= stageLo) ? stageHi : sel - 1;
 					sv.sel = sel;
 					drawCaseSubmenu(sv);
 					continue;
 				}
 				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+					sel = (sel >= stageHi) ? stageLo : sel + 1;
 					sv.sel = sel;
 					drawCaseSubmenu(sv);
 					continue;
@@ -635,31 +1175,31 @@ void EEMEngine::doCaseSelection() {
 				continue;
 			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
-				sel = (sel >= kMaxMystery) ? 0 : sel + 1;
+				sel = (sel >= stageHi) ? stageLo : sel + 1;
 				sv.sel = sel;
 				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_UP) {
-				sel = (sel == 0) ? kMaxMystery : sel - 1;
+				sel = (sel <= stageLo) ? stageHi : sel - 1;
 				sv.sel = sel;
 				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEDOWN) {
-				sel = (sel + 10 > kMaxMystery) ? kMaxMystery : sel + 10;
+				sel = (sel + 10 > stageHi) ? stageHi : sel + 10;
 				sv.sel = sel;
 				drawCaseSubmenu(sv);
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEUP) {
-				sel = (sel < 10) ? 0 : sel - 10;
+				sel = (sel < stageLo + 10) ? stageLo : sel - 10;
 				sv.sel = sel;
 				drawCaseSubmenu(sv);
 				continue;
 			}
-			if (k == Common::KEYCODE_HOME) { sel = 0; sv.sel = sel; drawCaseSubmenu(sv); continue; }
-			if (k == Common::KEYCODE_END)  { sel = kMaxMystery; sv.sel = sel; drawCaseSubmenu(sv); continue; }
+			if (k == Common::KEYCODE_HOME) { sel = stageLo; sv.sel = sel; drawCaseSubmenu(sv); continue; }
+			if (k == Common::KEYCODE_END)  { sel = stageHi; sv.sel = sel; drawCaseSubmenu(sv); continue; }
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
@@ -871,28 +1411,25 @@ void EEMEngine::drawNotebookFrame(int &page) {
 				   (const byte *)frame.surface.getBasePtr(0, row), w);
 	}
 
-	// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny.
+	// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny
+	// for CELLS, but the original `_DoNotebook @ 161e:0500` always
+	// uses script 0x01 (verified by `CONCAT22(1, ...)` in its
+	// `_NewAnimation` call at 161e:054c). Both 0x01 and 0x0b have
+	// the SAME script in `kAnimScripts` (alias), so both lookups
+	// produce identical results — but routing through 0x01
+	// matches the original verbatim.
 	const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
 	Animation partnerAni;
 	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
 		const uint32 now = g_system->getMillis();
-		const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-		const Picture &fr = partnerAni[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-		for (int row = 0; row < fr.surface.h; row++) {
-			const int dstY = 80 + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < fr.surface.w; col++) {
-				const int dstX = 5 + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		const uint frameIdx = partnerFrameAtTick(0x01,
+												  (uint)partnerAni.size(), now);
+		// Anchor-aware blit. The PDA partner (anim 0x01/0x0b) cells
+		// have miscflags = rowoff = 0 in the audit, but routing
+		// through `blitAnimFrameAnchored` is harmless and keeps the
+		// rendering path consistent with the BigMap partner.
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  partnerAni[frameIdx], 5, 80);
 	}
 
 	// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
@@ -1314,28 +1851,23 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 		}
 	}
 
-	// Partner sprite frame @ (5, 0x50).
+	// Partner sprite frame @ (5, 0x50). The original `_DoGallery @
+	// 158f:065b` registers `_NewAnimation(..., CONCAT22(2, ...), ...)`
+	// — script key 0x02 regardless of partner. Jake's 0x02 script
+	// (26 frames, brief wave + long hold + second wave) is what
+	// drives BOTH partners' cells. Earlier our port used 0x10 for
+	// Jenny, which is a 9-frame short blip — so Jenny was missing
+	// 17 frames of the wave-and-pause cadence that Jake has.
 	if (havePartner) {
 		const uint32 now = g_system->getMillis();
-		const uint frameIdx = (uint)((now / 100) % partnerAni.size());
-		const Picture &fr = partnerAni[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-		const int px = 5;
-		const int py = 0x50;
-		for (int row = 0; row < fr.surface.h; row++) {
-			const int dstY = py + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < fr.surface.w; col++) {
-				const int dstX = px + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		const uint frameIdx = partnerFrameAtTick(0x02,
+												  (uint)partnerAni.size(), now);
+		// Anchor-aware blit, consistent with site-loop / BigMap
+		// rendering paths. Anim 0x02 has rowoff = miscflags = 0
+		// per the audit but the anchored blitter is still the
+		// right semantic for an `_NewAnimation`-rendered sprite.
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  partnerAni[frameIdx], 5, 0x50);
 	}
 
 	// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1 and
@@ -1470,12 +2002,13 @@ void EEMEngine::doBigMap() {
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				// SetupButtonRect → `_NextScreen = 6` (the original's
-				// settings screen). We use it as "back to menu":
-				// abandon the current mystery and return to case
-				// selection.
+				// settings screen, mirrors `_DoBigMap @ 20fe:0c33`
+				// where it pushes `_PressButton` then writes
+				// `_NextScreen = 6`). Now wired to the actual
+				// `doSetup` handler instead of dropping the player
+				// out to the launcher.
 				if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
-					_mystery.clear();
-					_nextScreen = kScreenInvalid;
+					_nextScreen = kScreenSetup;
 					return;
 				}
 				// Click in the BigMapWindow → zoom. Original formula:
@@ -1753,30 +2286,24 @@ void EEMEngine::drawBigMapOverview() {
 		}
 	}
 
-	// Partner idle sprite at (0xfd, 0x50). Jake = anim 0x14, Jenny = 0x12.
+	// Partner idle sprite at (0xfd, 0x50). Jake = anim 0x14, Jenny = 0x12
+	// for the loaded CELLS, but the original `_DoBigMap @ 20fe:0a47`
+	// always passes `CONCAT22(0x14, ...)` to `_NewAnimation` so the
+	// SCRIPT key is 0x14 (`[0..8]` count-up) regardless of partner.
+	// Without this, Jenny was running 0x12's count-DOWN script
+	// `[8..0]` over her cells — visually backwards from the original.
 	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
 		const uint32 now = g_system->getMillis();
-		const uint frameIdx = (uint)((now / 100) % mapAnim.size());
-		const Picture &fr = mapAnim[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-		const int kMapAnimX = 0xfd;
-		const int kMapAnimY = 0x50;
-		for (int row = 0; row < fr.surface.h; row++) {
-			const int dstY = kMapAnimY + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < fr.surface.w; col++) {
-				const int dstX = kMapAnimX + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		const uint frameIdx = partnerFrameAtTick(0x14,
+												  (uint)mapAnim.size(), now);
+		// Anchor-aware: the BigMap walk-cycle has miscflags = -2 per
+		// cell, so the partner shifts left as it cycles — without the
+		// anchor adjustment the sprite "shakes in place" instead of
+		// walking forward.
+		blitAnimFrameAnchored(scratch.surfacePtr(), mapAnim[frameIdx],
+							  0xfd, 0x50);
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -1856,31 +2383,20 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	}
 
 	// Partner sprite on the detail map (drawn last to sit over the
-	// frame and the BIGMAP.PIC viewport).
+	// frame and the BIGMAP.PIC viewport). The original always passes
+	// `CONCAT22(0x13, ...)` to `_NewAnimation` (i.e. script ID 0x13)
+	// regardless of partner — verified at `_DoBigMap @ 20fe:0a47`.
+	// So we look up script 0x13 for both partners while still
+	// loading the partner-specific CELLS via `kDetailAniId`.
 	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
 	Animation detailAnim;
 	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
 		!detailAnim.empty()) {
 		const uint32 now = g_system->getMillis();
-		const uint frameIdx = (uint)((now / 100) % detailAnim.size());
-		const Picture &fr = detailAnim[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-		const int kDetailAnimX = 0x101;
-		const int kDetailAnimY = 0x50;
-		for (int row = 0; row < fr.surface.h; row++) {
-			const int dstY = kDetailAnimY + row;
-			if (dstY < 0 || dstY >= 200)
-				continue;
-			const byte *src = (const byte *)fr.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < fr.surface.w; col++) {
-				const int dstX = kDetailAnimX + col;
-				if (dstX < 0 || dstX >= 320)
-					continue;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		const uint frameIdx = partnerFrameAtTick(0x13,
+												  (uint)detailAnim.size(), now);
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  detailAnim[frameIdx], 0x101, 0x50);
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -2269,6 +2785,37 @@ void EEMEngine::doAccuse() {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 		}
 
+		// Mirrors the chain-advancement loop at `_DisplayCorrect @
+		// 1df2:0824-0850`. Skip mystery 0 (the practice case) per the
+		// `if (_MysteryNumber != 0)` guard at 1df2:080d, then check
+		// every mystery in the current tier:
+		//   stage 1 → check  1..0x18 (24 mysteries, "A chain")
+		//   stage 2 → check 0x19..0x30 (24 mysteries, "B chain")
+		//   stage 3 → check 0x31..0x36 (6 mysteries, "C chain")
+		// If every solve flag in that range is non-zero, bump the
+		// stage. Original increments unconditionally (3→4 is harmless
+		// since no further range covers it); we cap at 3 for clarity.
+		if (mn != 0) {
+			uint lo = 0, hi = 0;
+			switch (_chainStage) {
+			case 1: lo = 1;    hi = 0x18; break;
+			case 2: lo = 0x19; hi = 0x30; break;
+			case 3: lo = 0x31; hi = 0x36; break;
+			default: break;
+			}
+			bool allSolved = (hi >= lo);
+			for (uint i = lo; i <= hi && allSolved; i++) {
+				if (i >= sizeof(_mysteriesSolved) || _mysteriesSolved[i] == 0)
+					allSolved = false;
+			}
+			if (allSolved && _chainStage < 3) {
+				_chainStage++;
+				debugC(1, kDebugMystery,
+					   "chainStage advanced to %u after solving mystery %u",
+					   _chainStage, mn);
+			}
+		}
+
 		// `_DisplayCorrect @ 1df2:073c` calls `_MIDIPlay(5)` (1df2:0789)
 		// before `_DifferenceAnimation("scrapbk.ani")` to swap from the
 		// travel music to the winner cue.
@@ -2276,6 +2823,17 @@ void EEMEngine::doAccuse() {
 			_music->playMus(5, /*loop=*/false);
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
+		// `_DisplayCorrect @ 1df2:07ac` then calls `_DisplayClue`
+		// against the briefing's winning ClueBlock at
+		// `_Mystery + _MysteryIndex[0x10]` BEFORE `_ShowOneScrap`.
+		// We don't render that intermediate ClueBlock yet (it ties
+		// into the chain-A/B/C result selection); skip straight to
+		// the per-mystery ending pages — the same screen
+		// `_ShowOneScrap @ 1f78:0773` displays via
+		// `_DisplayEnding(num, 1)`. Players see the per-mystery
+		// resolution text on top of the ending pic.
+		doShowEnding(mn);
+
 		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the
 		// `_mysteriesSolved` table is updated, the original
 		// immediately persists the player record so the win sticks
@@ -2287,8 +2845,22 @@ void EEMEngine::doAccuse() {
 		if (err.getCode() != Common::kNoError)
 			warning("saveProfile after solve failed: %s",
 					err.getDesc().c_str());
+
+		// `_DisplayCorrect @ 1df2:0895` writes `_NextScreen = 0xc`
+		// — the winner returns to the post-mystery `_ActionScreen`.
+		// Free the mystery first so the loop can break out cleanly:
+		// `_DeleteSavedGame` at 1df2:0851 + `_FreeMystery` at
+		// 1df2:08a4 do the same.
+		_mystery.clear();
+		_nextScreen = kScreenAction;
 	} else {
 		_mystery._firstTry = false;
+		// `_DisplayAlibi @ 1df2:043f` writes `_NextScreen =
+		// _LastScreen` — drop back to wherever the player accused
+		// from. With no mystery state to unload, the site loop
+		// resumes naturally.
+		_nextScreen = _lastScreen != kScreenInvalid
+						? (ScreenId)_lastScreen : kScreenSite;
 	}
 }
 


Commit: 9c88ee5bc94b0f42d1f1edc32ced135c61533386
    https://github.com/scummvm/scummvm/commit/9c88ee5bc94b0f42d1f1edc32ced135c61533386
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:42+02:00

Commit Message:
EEM: fixed the map animation

Changed paths:
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index e97bd592712..af19e0c43fc 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -252,10 +252,11 @@ private:
 	void drawGalleryFrame(const byte *gd, uint8 numSuspects,
 						  Common::Array<Common::Rect> &slotRects,
 						  Common::Array<int> &slotSuspect);
-	void drawBigMapOverview();
+	void drawBigMapOverview(uint32 elapsedMs);
 	void drawBigMapDetail(int scrollX, int scrollY,
 						  const Common::Array<byte> &mapPixels,
-						  uint16 mapW, uint16 mapH);
+						  uint16 mapW, uint16 mapH,
+						  uint32 elapsedMs);
 	void drawAccuseGallery(uint8 numSuspects, const byte *gd,
 						   int highlighted,
 						   Common::Array<Common::Rect> &slotRects,
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 42c5429d44e..54d3673f501 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -457,6 +457,49 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
 }
 
+// Generic "play `unfold` once, then loop `waitSeq` forever" walker.
+// Mirrors the original's slot-script-swap idiom: the entrance script
+// runs to its 0x80 terminator, then the slot's script pointer is
+// rewritten to a looping wait sequence (e.g. `_BigMapWaitSeq @
+// 29be:1574`, `_SmallMapWaitSeq @ 29be:1548`). `partnerFrameAtTick`
+// can't model that swap on its own (it always wraps on the same
+// script), hence this helper.
+static uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
+									   const uint8 *waitSeq, uint waitSeqLen,
+									   uint numFrames, uint32 elapsedMs) {
+	const uint kFramePeriodMs = 100;
+	const uint tick = elapsedMs / kFramePeriodMs;
+	const uint frame = (tick < unfoldLen)
+		? unfold[tick]
+		: waitSeq[(tick - unfoldLen) % waitSeqLen];
+	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
+}
+
+uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
+	// Script 0x14 @ 29be:196a (count-up 0..8, 0x80) → on terminator,
+	// `_DoBigMap` swaps to `_BigMapWaitSeq` @ 29be:1574
+	// (9,9,9,9,10,9,9,9,9, 0x80) — open-map hold with a fidget.
+	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
+	static const uint8 kWaitSeq[] = { 9, 9, 9, 9, 10, 9, 9, 9, 9 };
+	return oneShotThenLoopFrameAtTick(kUnfold, ARRAYSIZE(kUnfold),
+									  kWaitSeq, ARRAYSIZE(kWaitSeq),
+									  numFrames, elapsedMs);
+}
+
+uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
+	// Script 0x13 @ 29be:1992 (count-up 0..7, 0x80) → on terminator,
+	// `_DoMapScreen @ 20fe:1390` swaps to `_SmallMapWaitSeq` @ 29be:1548
+	// (18 entries: hold cell 7 with a single cell-10 fidget) — fidget
+	// every ~1.8 s.
+	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7 };
+	static const uint8 kWaitSeq[] = {
+		7, 7, 7, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7
+	};
+	return oneShotThenLoopFrameAtTick(kUnfold, ARRAYSIZE(kUnfold),
+									  kWaitSeq, ARRAYSIZE(kWaitSeq),
+									  numFrames, elapsedMs);
+}
+
 void SiteScreen::enter(uint siteNum) {
 	if (!_mystery || !_mystery->isLoaded()) {
 		warning("SiteScreen::enter: no mystery loaded");
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 51129bfed74..33f6f23ab55 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -44,6 +44,26 @@ class Mystery;
 /// values that point past the asset.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
+/// BigMap-overview partner frame: plays the count-up 0..8 once (the
+/// "unfold the map" pose), then loops the `_BigMapWaitSeq` hold
+/// (9,9,9,9,10,9,9,9,9). Mirrors `_DoBigMap @ 20fe:09e7`'s two-phase
+/// dispatch — the slot starts on script 0x14 (count-up @ 29be:196a)
+/// and on the 0x80 terminator the slot's script pointer is rewritten
+/// to `_BigMapWaitSeq @ 29be:1574`. `partnerFrameAtTick` can't model
+/// that swap on its own (it always loops), so without this helper the
+/// unfold cycles forever instead of resting on the open-map pose.
+/// `elapsedMs` is the time since the BigMap was opened.
+uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
+
+/// BigMap-detail (zoomed view) partner frame. Same two-phase shape as
+/// `bigMapPartnerFrameAtTick`, but the original `_DoMapScreen @ 20fe:120b`
+/// uses script 0x13 (count-up 0..7 @ 29be:1992) for the unfold and
+/// swaps the slot pointer to `_SmallMapWaitSeq @ 29be:1548` (an
+/// 18-frame hold of cell 7 with a single cell-10 fidget) on the
+/// terminator (`MOV [BX+0x789f],0x1548` at 20fe:1390). `elapsedMs`
+/// is the time since the detail screen was opened.
+uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
+
 /// Anchor-aware masked blit. Mirrors the per-frame anchor offset
 /// math in `_UpdateAnimations @ 172b:09c1`:
 /// `blit_x = anchor_x - frame.miscflags`, `blit_y = anchor_y -
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 55554a7ff30..7d8666b36ca 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1980,8 +1980,15 @@ void EEMEngine::doBigMap() {
 	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
 	// ------------------------------------------------------------------
 
-	drawBigMapOverview();
-	uint32 mapLastTick = g_system->getMillis();
+	// Anchor for the partner-sprite timeline. `_DoBigMap`'s
+	// `_NewAnimation` call seeds the slot's frame index to 0xffff so
+	// the first `_UpdateAnimations` tick starts at script[0]; we mirror
+	// that by passing elapsed-since-open (zero on the first paint) into
+	// `bigMapPartnerFrameAtTick`, which plays the unfold once and then
+	// loops the wait sequence.
+	const uint32 mapStartTick = g_system->getMillis();
+	drawBigMapOverview(0);
+	uint32 mapLastTick = mapStartTick;
 
 	// Static rectangles read directly from the binary at the labelled
 	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
@@ -2032,7 +2039,7 @@ void EEMEngine::doBigMap() {
 		const uint32 now = g_system->getMillis();
 		if (now - mapLastTick >= 100) {
 			mapLastTick = now;
-			drawBigMapOverview();
+			drawBigMapOverview(now - mapStartTick);
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
@@ -2069,8 +2076,12 @@ void EEMEngine::doBigMap() {
 	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
-	drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH);
-	uint32 detailLastTick = g_system->getMillis();
+	// Anchor the detail-screen partner timeline (mirrors `_DoMapScreen`'s
+	// `_NewAnimation` seeding the slot's frame index to 0xffff). The
+	// unfold (script 0x13) plays once, then `_SmallMapWaitSeq` loops.
+	const uint32 detailStartTick = g_system->getMillis();
+	drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
+	uint32 detailLastTick = detailStartTick;
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -2201,13 +2212,14 @@ void EEMEngine::doBigMap() {
 			dirty = true;
 		}
 		if (dirty)
-			drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH);
+			drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH,
+							 now - detailStartTick);
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}
 }
 
-void EEMEngine::drawBigMapOverview() {
+void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	// Map-overview redraw — formerly the `drawOverview` lambda inside
 	// `doBigMap`. PIC 0x42 frame + per-site marker (Done / Crime / Site
 	// per `_DrawBigMapButtons @ 20fe:0877`) + the partner idle sprite.
@@ -2217,6 +2229,11 @@ void EEMEngine::drawBigMapOverview() {
 	// at (0xfd, 0x50). We don't track LastScreen finely enough so we
 	// always render the IDLE pose at (0xfd, 0x50). Idle anim ID:
 	// Jake = 0x14 (20), Jenny = 0x12 (18).
+	//
+	// `elapsedMs` is the time since `doBigMap` opened — the partner-sprite
+	// timeline anchor. `bigMapPartnerFrameAtTick` uses it to play the
+	// unfold script (0..8) once, then loop `_BigMapWaitSeq` (the open-map
+	// hold). Without that anchor the unfold would loop indefinitely.
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
@@ -2295,9 +2312,8 @@ void EEMEngine::drawBigMapOverview() {
 	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x14,
-												  (uint)mapAnim.size(), now);
+		const uint frameIdx = bigMapPartnerFrameAtTick((uint)mapAnim.size(),
+													   elapsedMs);
 		// Anchor-aware: the BigMap walk-cycle has miscflags = -2 per
 		// cell, so the partner shifts left as it cycles — without the
 		// anchor adjustment the sprite "shakes in place" instead of
@@ -2313,12 +2329,17 @@ void EEMEngine::drawBigMapOverview() {
 
 void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 								 const Common::Array<byte> &mapPixels,
-								 uint16 mapW, uint16 mapH) {
+								 uint16 mapW, uint16 mapH,
+								 uint32 elapsedMs) {
 	// Map-detail redraw — formerly the `drawDetail` lambda inside
 	// `doBigMap`. PIC 0x43 frame + a 0xe9 × 0xab BIGMAP.PIC viewport at
 	// (2, 2), stamped site buttons, and the partner sprite at (0x101,
 	// 0x50) — `_DoMapScreen @ 20fe:120b` (`_NewAnimation` at
 	// 20fe:12cd-12f0, anim 0x13 Jake / 0x11 Jenny, seqnum 0x13).
+	//
+	// `elapsedMs` is the time since the detail screen was opened —
+	// `bigMapDetailPartnerFrameAtTick` uses it to play the unfold once
+	// and then loop `_SmallMapWaitSeq`.
 	const int kMapWinW = 0xe9;
 	const int kMapWinH = 0xab;
 	const int kMapWinX = 2;
@@ -2392,9 +2413,8 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	Animation detailAnim;
 	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
 		!detailAnim.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x13,
-												  (uint)detailAnim.size(), now);
+		const uint frameIdx = bigMapDetailPartnerFrameAtTick(
+				(uint)detailAnim.size(), elapsedMs);
 		blitAnimFrameAnchored(scratch.surfacePtr(),
 							  detailAnim[frameIdx], 0x101, 0x50);
 	}


Commit: ef46d69023cf56dd56aeef76f538ba43d8fcef99
    https://github.com/scummvm/scummvm/commit/ef46d69023cf56dd56aeef76f538ba43d8fcef99
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:43+02:00

Commit Message:
EEM: fixed missing frames in partner animations

Changed paths:
    engines/eem/clues.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 1ad73525d3d..d2635e3823d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -601,8 +601,22 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		// This is what surfaces "Jenny takes a picture with a camera"
 		// (and the matching Jake gestures) during NPC searches.
 		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
-		if (kdAnimNum != -1)
+		if (kdAnimNum != -1) {
 			playKdAnim((uint16)kdAnimNum);
+			// `playKdAnim` leaves the screen on the partner-less
+			// `_partnerEraseBg`, mirroring its between-frame erase
+			// source. Re-stamp `bg` so the partner sprite captured at
+			// `displayClue` entry returns before we draw the speaker
+			// portrait + balloon. Without this the bubble lands on a
+			// partner-less screen and the partner appears invisible
+			// for the entire first iteration. Mirrors the original's
+			// `_UpdateAnimations @ 172b:09c1` swap on `0x80`: the
+			// kdAnim slot is freed and the WaitHandle (partner) slot
+			// is reactivated with frame index 0xffff, so on the next
+			// tick the partner renders at script[0] of its wait anim.
+			g_system->copyRectToScreen(bg.getPixels(), bg.pitch,
+									   0, 0, 320, 200);
+		}
 
 		const bool useP1 = (_partner == 1) &&
 			(READ_LE_UINT16(c + 10) != 0xFFFF);
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 54d3673f501..062f81f04ba 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -894,6 +894,13 @@ void SiteScreen::enterSiteAnim() {
 	// frame-by-frame anchors decrease as the animation progresses):
 	//   destX = -frame.miscflags    (on-disk byte 8 = anchor X)
 	//   destY = kKDY - frame.rowoff (on-disk byte 6 = anchor Y)
+	// Both anchors are SIGNED int16 (mirrors `blitAnimFrameAnchored` /
+	// `_UpdateAnimations @ 172b:09c1`). The original's `-pPVar8->width`
+	// negation wraps in 16-bit on DOS, so a frame with `miscflags = -2`
+	// (0xFFFE) lands at destX = +2 in the original. Without the int16
+	// re-cast our 32-bit negation produces destX = -65534 and the
+	// second-to-last frame (which has a non-zero anchor in anim 7/0xf)
+	// clips entirely off-screen, leaving a one-tick partner-less gap.
 	// Each frame waits one `_CheckFrameRate` tick — we use 80 ms which
 	// matches the original's ~12 FPS pacing.
 	Animation kd;
@@ -903,8 +910,8 @@ void SiteScreen::enterSiteAnim() {
 			 frameIdx++) {
 			const Picture &fr = kd[frameIdx];
 			const byte transp = (byte)(fr.flags >> 8);
-			const int destX = -(int)fr.miscflags;
-			const int destY = kKDY - (int)fr.rowoff;
+			const int destX = -(int)(int16)fr.miscflags;
+			const int destY = kKDY - (int)(int16)fr.rowoff;
 
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());


Commit: d7268b5a91f241aea153b8685404f40cc2b1a8a7
    https://github.com/scummvm/scummvm/commit/d7268b5a91f241aea153b8685404f40cc2b1a8a7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:43+02:00

Commit Message:
EEM: better handling of saves

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index fa895d640a6..529970dcbdf 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -749,7 +749,20 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 
 SaveStateList EEMEngine::listProfiles() const {
 	// Mirrors `_findfirst("*.PLR")` in `screen8_handler @ 1c33:1012`.
-	return getMetaEngine()->listSaves(_targetName.c_str());
+	// We disable autosave (`getAutosaveSlot()` returns -1) but a
+	// pre-existing slot-0 file from a previous run, or one written by
+	// another engine using the same target, would still show up here
+	// and pollute the picker. Filter it out so the picker treats slot
+	// 0 as if it didn't exist — matching the original which never
+	// has an autosave concept.
+	SaveStateList saves = getMetaEngine()->listSaves(_targetName.c_str());
+	for (uint i = 0; i < saves.size(); ) {
+		if (saves[i].getSaveSlot() == 0)
+			saves.remove_at(i);
+		else
+			i++;
+	}
+	return saves;
 }
 
 Common::Error EEMEngine::saveProfile(const Common::String &name) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index af19e0c43fc..97b8b162ece 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -115,6 +115,15 @@ public:
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 
+	// Disable ScummVM's periodic autosave. The original engine's
+	// `screen8_handler @ 1c33:1012` builds the profile picker by
+	// walking every `*.PLR` file in the save dir, and we mirror that
+	// via `listProfiles() → MetaEngine::listSaves`. Letting the
+	// framework write a slot-0 autosave creates a phantom profile
+	// that shows up on the picker as a real save. Returning -1 tells
+	// the framework to skip autosave entirely.
+	int getAutosaveSlot() const override { return -1; }
+
 	// ScummVM extended-save hooks. The base `Engine::saveGameState` /
 	// `loadGameState` write/read the framework header (description,
 	// thumbnail, playtime, version) around our body via these
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7d8666b36ca..c4d9c5282f4 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -242,6 +242,15 @@ void EEMEngine::doProfilePicker() {
 	// `local_20 == 0` at 1c33:1170), it falls straight into
 	// `_NewPlayer`. Selecting an entry calls `_LoadPlayerRecord` and
 	// returns; selecting the "exit" sentinel goes back to title.
+
+	// Palette reset. `screen8_handler` runs `_FadeOut(); _GetPalette(0);
+	// _GetBackground(0x104);` before the picker, so the BG always
+	// renders against SITEPALS index 0 regardless of which intro
+	// palette was active last. Without this, skipping out of an intro
+	// anim (THEME / ANIM01..20 / TITLE) leaves the previous video's
+	// palette in place and the picker draws with the wrong colours.
+	setSitePalette(0);
+
 	const SaveStateList saves = listProfiles();
 	if (saves.empty()) {
 		doNewPlayer();
@@ -274,6 +283,16 @@ void EEMEngine::doProfilePicker() {
 	int sel = 0;
 	bool done = false;
 
+	// Picker geometry: `DrawList @ 1c33:040d` is called from
+	// `screen8_handler @ 1c33:1012` with `(_TextBox + 3, DAT_29be_0d02)`.
+	// `_TextBox @ 29be:0d00` holds {x1=58, y1=35, x2=238, y2=158} so
+	// the list origin is (61, 35), 10 px per row, max 12 visible
+	// rows. The "Pick a player" caption is part of the BG (PIC 0x104)
+	// — `screen8_handler` never draws it as text — so an extra
+	// `drawString` would overlay on top of the baked-in heading.
+	const int kListX = 61;
+	const int kListY = 35;
+	const int kLineH = 10;
 	auto draw = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
@@ -286,12 +305,10 @@ void EEMEngine::doProfilePicker() {
 				memcpy((byte *)scratch.getBasePtr(0, row),
 					   (const byte *)bg.surface.getBasePtr(0, row), w);
 		}
-		_font.drawString(&scratch, "Pick a player:", 80, 30, 220, 0xF);
-		const int kLineH = 12;
 		for (uint i = 0; i < entries.size(); i++) {
 			const byte color = ((int)i == sel) ? 0xF : 0x8;
 			_font.drawString(&scratch, entries[i].label,
-							 80, 60 + (int)i * kLineH, 220, color);
+							 kListX, kListY + (int)i * kLineH, 220, color);
 		}
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
@@ -331,8 +348,7 @@ void EEMEngine::doProfilePicker() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				const int kLineH = 12;
-				const int hit = (ev.mouse.y - 60) / kLineH;
+				const int hit = (ev.mouse.y - kListY) / kLineH;
 				if (hit >= 0 && hit < (int)entries.size()) {
 					sel = hit;
 					committed = true;


Commit: e87d591b963cb98745dd48c701b119690865f74f
    https://github.com/scummvm/scummvm/commit/e87d591b963cb98745dd48c701b119690865f74f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:43+02:00

Commit Message:
EEM: remove unused screen

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 529970dcbdf..46fa0497f45 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -266,22 +266,31 @@ Common::Error EEMEngine::run() {
 	// iteration. Sentinel `kScreenInvalid` (0xFFFF) ends the loop —
 	// same as the original's table-end marker.
 	//
-	// Initial value `kScreenAction` matches the original flow at the
-	// tail of `_DoChoosePartner @ 1a35:099d` which sets
-	// `_NextScreen = 0xc` once the partner has been picked. (The
-	// resume path above bypasses this and seeds `kScreenMap` instead.)
+	// `_DoChoosePartner @ 1a35:099d` sets `_NextScreen = 0xc` (= the
+	// original `_ActionScreen` — "Choose A Mystery / Practice Mystery /
+	// See ScrapBook 1..3"). In our port that menu lives inside
+	// `doCaseSelection`, which already mirrors `_ActionScreen`'s 5-entry
+	// list (verified against `ActionNames @ 29be:0d6a`) and rolls the
+	// individual-mystery picker (`_CaseSelection @ 1c33:0a87`) into the
+	// same flow. So we route straight to `kScreenChooseMystery`; the
+	// `kScreenAction` (= 0xc) state still exists for the post-win path
+	// (`_DisplayCorrect @ 1df2:0895` writes 0xc) but its handler also
+	// dispatches `doCaseSelection`. The previous `doActionScreen` was a
+	// synthetic stub ("What now?" / "Solve a Mystery" / "Look at My
+	// Books") whose strings are nowhere in the binary — confirmed by a
+	// `search_strings` for "What" returning zero matches.
 	//
 	// Mid-mystery profile resume: if the profile picker loaded a
 	// save whose `hasMystery` flag was set, `_mystery.isLoaded()` is
 	// true here and the player just re-picked their partner. Drop
-	// straight to MAP rather than ACTION so they don't have to walk
-	// back through the case picker (which would `_mystery.load()`
-	// fresh and discard their site / clue progress). The original
-	// has no equivalent — it persists only profile-level state via
-	// `_PlayerRecord`, not in-progress mysteries — so this is a
-	// ScummVM-only ergonomics improvement.
+	// straight to MAP rather than the action menu so they don't have
+	// to walk back through the case picker (which would
+	// `_mystery.load()` fresh and discard their site / clue
+	// progress). The original has no equivalent — it persists only
+	// profile-level state via `_PlayerRecord`, not in-progress
+	// mysteries — so this is a ScummVM-only ergonomics improvement.
 	if (!shouldQuit() && !resumed)
-		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
+		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenChooseMystery;
 screen_loop:
 	while (!shouldQuit() && _nextScreen != kScreenInvalid) {
 		const ScreenId current = (ScreenId)_nextScreen;
@@ -289,20 +298,27 @@ screen_loop:
 
 		switch (current) {
 		case kScreenAction:
-			// Post-mystery menu. `_ActionScreen` sets _NextScreen via
-			// its action jumptable (1c33:1be1) — see `doActionScreen`.
-			doActionScreen();
+			// Post-mystery menu. The original's `_ActionScreen @
+			// 1c33:195b` shows the 5-entry "Choose A Mystery /
+			// Practice / ScrapBook" picker; `doCaseSelection` is
+			// our port of that exact menu (plus the individual-case
+			// sub-picker the original handles in `_CaseSelection @
+			// 1c33:0a87`). After the player picks, fall through to
+			// the same routing the `kScreenChooseMystery` case uses.
+			// Reachable from `_DisplayCorrect`'s 0xc write after a
+			// solve (see `ui.cpp` `_nextScreen = kScreenAction`).
+			doCaseSelection();
+			_nextScreen = _mystery.isLoaded() ? kScreenInitClues
+											  : kScreenInvalid;
 			break;
 
 		case kScreenChooseMystery:
 			// Handler 10 at 1a35:0e0e calls `_DoChooseMystery` which
 			// presets `_NextScreen = 0` (INIT_CLUES) before
-			// `_CaseSelection`. If the picker bails out without
-			// loading a mystery (no `_ReadMystery` call), drop back
-			// to ACTION instead of falling into a missing case.
+			// `_CaseSelection`. Same dispatch as `kScreenAction`.
 			doCaseSelection();
 			_nextScreen = _mystery.isLoaded() ? kScreenInitClues
-											  : kScreenAction;
+											  : kScreenInvalid;
 			break;
 
 		case kScreenInitClues:
@@ -310,7 +326,7 @@ screen_loop:
 			// then writes `_NextScreen = 1` (MAP).
 			doInitClues();
 			_nextScreen = _mystery.isLoaded() ? kScreenMap
-											  : kScreenAction;
+											  : kScreenChooseMystery;
 			break;
 
 		case kScreenMap:
@@ -323,7 +339,7 @@ screen_loop:
 			// the natural next state is SITE.
 			doBigMap();
 			if (!_mystery.isLoaded())
-				_nextScreen = kScreenAction;
+				_nextScreen = kScreenChooseMystery;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenSite;
 			break;
@@ -340,7 +356,7 @@ screen_loop:
 			// via `_LastScreen`).
 			doSiteLoop();
 			if (!_mystery.isLoaded())
-				_nextScreen = kScreenAction;
+				_nextScreen = kScreenChooseMystery;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenInvalid;  // user quit
 			break;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 97b8b162ece..ab7389a4b9e 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -349,21 +349,6 @@ private:
 	void doCaseSelection();
 	void doSiteLoop();
 
-	/// Post-mystery action menu. Mirrors `_ActionScreen @ 1c33:195b` —
-	/// the screen the original returns to after `_DisplayCorrect`
-	/// (winner) or after the player explicitly leaves a case via the
-	/// PROFILE → PARTNER chain. The original offers up to 5 choices
-	/// gated on the player's chain stage (`DAT_2d5d_3f99`):
-	///   1: "Solve a Mystery" (set _NextScreen=10 — CHOOSE_MYSTERY)
-	///   3: replay the last solved case (`_ReloadMystery(0)` callsite)
-	///   5: scrapbook viewer (`_ShowOneScrap(0, 1)` callsite)
-	///   7: chain-stage advance (cmp `_3f99 == 1`)
-	///   9: chain-stage advance (cmp `_3f99 == 2` / `== 3`)
-	///   sentinel: exit / back to PARTNER
-	/// We start with just option 1 wired (the practical loop) plus
-	/// quit; the others slot in as their underlying screens land.
-	void doActionScreen();
-
 	/// Setup / preferences screen. Mirrors `_DoSetup @ 1f78:044e` —
 	/// per-profile preferences (voice on/off via `DAT_2d5d_3f97`,
 	/// partner pick via SwapColors on Kid1/Kid2 rects). Reachable
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index c4d9c5282f4..f91ae57ffa7 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -732,154 +732,6 @@ void EEMEngine::doSetup() {
 	}
 }
 
-void EEMEngine::doActionScreen() {
-	// Mirrors `_ActionScreen @ 1c33:195b` — the post-mystery menu the
-	// original loops back to after `_DisplayCorrect` (winner). The
-	// original draws PIC 0x122 (Cok), 0x124 (Cexit), then calls
-	// `_DoChoose(ActionNames)` and dispatches the result via the
-	// jumptable at 1c33:1be1 (verified to set `_NextScreen` for each
-	// action — entry 1 → 10 = CHOOSE_MYSTERY, entry 3 →
-	// `_ReloadMystery`, etc.).
-	//
-	// We render a minimal text version of the menu — the original's
-	// background pic + "Cok" / "Cexit" overlays land once the
-	// `_DoChoose` UI lands. For now: two practical entries — "Solve a
-	// Mystery" (the only one whose underlying screen is wired) and
-	// "Quit". Sets `_nextScreen` exactly the same way the original
-	// jumptable does:
-	//   "Solve a Mystery"   → kScreenChooseMystery (matches handler 1
-	//                          at 1c33:1add: `MOV [_NextScreen], 0xa`)
-	//   "Quit"              → kScreenInvalid (matches the sentinel
-	//                          handler at 1c33:1afa)
-	if (!_font.isLoaded()) {
-		_nextScreen = kScreenChooseMystery;
-		return;
-	}
-
-	enum ActionKind {
-		kActSolve,         // → kScreenChooseMystery (action 1, 1c33:1add)
-		kActScrapbook,     // → doShowEnding(lastSolved) (action 5, 1c33:1b13)
-		kActQuit
-	};
-	struct Entry {
-		const char *label;
-		ActionKind  kind;
-	};
-	// Locate the highest-numbered solved mystery — that's the one
-	// the player most recently completed, and what the action-menu
-	// "Look at My Books" entry replays. If none solved yet, hide
-	// the entry (matches the original's grey-out at 1c33:19f3 where
-	// `local_24[5] = 1` before the chain-stage check toggles it).
-	int lastSolved = -1;
-	for (int i = (int)sizeof(_mysteriesSolved) - 1; i >= 0; i--) {
-		if (_mysteriesSolved[i] != 0) {
-			lastSolved = i;
-			break;
-		}
-	}
-
-	Common::Array<Entry> entries;
-	Entry solveEntry; solveEntry.label = "Solve a Mystery"; solveEntry.kind = kActSolve;
-	entries.push_back(solveEntry);
-	if (lastSolved >= 0) {
-		Entry sb; sb.label = "Look at My Books"; sb.kind = kActScrapbook;
-		entries.push_back(sb);
-	}
-	Entry quitEntry; quitEntry.label = "Quit"; quitEntry.kind = kActQuit;
-	entries.push_back(quitEntry);
-	const int kCount = (int)entries.size();
-
-	int sel = 0;
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		Picture bg;
-		if (_picsArchive.getPicture(0x104, bg)) {
-			const int w = MIN<int>(bg.surface.w, 320);
-			const int h = MIN<int>(bg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.surface.getBasePtr(0, row), w);
-		}
-		_font.drawString(&scratch, "What now?", 100, 30, 220, 0xF);
-		for (int i = 0; i < kCount; i++) {
-			const byte color = (i == sel) ? 0xF : 0x8;
-			_font.drawString(&scratch, entries[i].label,
-							 100, 60 + i * 14, 220, color);
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-	draw();
-
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool dirty = false;
-		bool committed = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				_nextScreen = kScreenInvalid;
-				return;
-			}
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				switch (ev.kbd.keycode) {
-				case Common::KEYCODE_UP:
-					sel = (sel + kCount - 1) % kCount;
-					dirty = true;
-					break;
-				case Common::KEYCODE_DOWN:
-					sel = (sel + 1) % kCount;
-					dirty = true;
-					break;
-				case Common::KEYCODE_RETURN:
-				case Common::KEYCODE_KP_ENTER:
-					committed = true;
-					break;
-				case Common::KEYCODE_ESCAPE:
-					_nextScreen = kScreenInvalid;
-					return;
-				default:
-					break;
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				const int hit = (ev.mouse.y - 60) / 14;
-				if (hit >= 0 && hit < kCount) {
-					sel = hit;
-					committed = true;
-				}
-			}
-			if (committed)
-				break;
-		}
-		if (committed) {
-			switch (entries[sel].kind) {
-			case kActSolve:
-				_nextScreen = kScreenChooseMystery;
-				return;
-			case kActScrapbook:
-				if (lastSolved >= 0)
-					doShowEnding((uint)lastSolved);
-				// Fall through to redraw the menu after viewing.
-				draw();
-				continue;
-			case kActQuit:
-				_nextScreen = kScreenInvalid;
-				return;
-			}
-			return;
-		}
-		if (dirty)
-			draw();
-		g_system->updateScreen();
-		g_system->delayMillis(15);
-	}
-	_nextScreen = kScreenInvalid;
-}
-
 void EEMEngine::doCaseSelection() {
 	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
 	// (chooser background) plus a centred "Book %d" / "Challenge Book"


Commit: 253e2f0bb20aad598b1e3948dde3439cba853301
    https://github.com/scummvm/scummvm/commit/253e2f0bb20aad598b1e3948dde3439cba853301
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:44+02:00

Commit Message:
EEM: improve the setup screen with actually triggers to the code relevant

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index f91ae57ffa7..d857dce9877 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -636,21 +636,79 @@ void EEMEngine::doShowEnding(uint num) {
 }
 
 void EEMEngine::doSetup() {
-	// Mirrors `_DoSetup @ 1f78:044e`. Loads BG pic 0x40 +
-	// state-button pics 0x9b/0x9c/0x9d/0x9e. The original wires 13
-	// hot-rects (`_SetupButtons @ 29be:1218`) but only two of them
-	// drive persistent state — `Kid1`/`Kid2` (partner) and
-	// `SoundOn`/`SoundOff` (voice flag, `DAT_2d5d_3f97`). The rest
-	// are reset/help/return buttons. We render a minimal text
-	// version: two toggle lines, click to flip, ESC to leave.
+	// Mirrors `_DoSetup @ 1f78:044e`. The setup screen is BG `PIC 0x40`
+	// (loaded once on entry) with every label baked in — "Setup",
+	// "Partner", "Sound", "Music", the "Jake"/"Jenny"/"On"/"Off"
+	// option strings, etc. — all rendered in palette key `0xFE`. The
+	// original then runs `_SetupSettings @ 1f78:000d` which uses
+	// `_SwapColors @ 172b:1d2a` to recolour those `0xFE` pixels per
+	// label rect: `0x15` for the active state, `0` for the inactive
+	// one. So nothing is drawn as text; the visible state of each
+	// toggle is purely a per-rect colour swap on top of `PIC 0x40`.
+	//
+	// Click hit-tests go through `_SetupButtons @ 29be:1218` — 13×
+	// 8-byte rects. Each click runs `HandleSetupButton @ 1f78:0158`,
+	// which dispatches via the 12-entry jumptable at `1f78:0436`.
+	// Verified handler map (decompiled at each jumptable target):
+	//   [0]  ( 20, 44, 39, 61)   Partner toggle (1f78:017a)
+	//   [1]  ( 20, 87, 39,104)   Voice toggle   (1f78:0196 → DAT_2d5d_3f97)
+	//   [2]  ( 20,127, 39,144)   back to profile (NextScreen=8)
+	//   [3]  (281, 43,299, 60)   ScrapBook 1   (_ShowScrapbook(0,1))
+	//   [4]  (281, 62,299, 79)   ScrapBook 2   gated chainStage>=2
+	//   [5]  (281, 81,299, 98)   ScrapBook 3   gated chainStage>=3
+	//   [6]  (281,108,299,125)   Save game     (_SaveGame @ 2404:0c87)
+	//   [7]  (281,127,299,144)   New Case      (NextScreen=0xa)
+	//   [8]  ( 53,153,108,183)   Done          (SI=1, exit)
+	//   [9]  (145,163,174,187)   Help          (_InterfaceHelp(1))
+	//   [10] (212,153,266,184)   Quit          (_AreYouSure → NextScreen=0xffff)
+	//   [11] ( 81, 25,238, 37)   Credits       (PIC 0x208 fullscreen)
+	//   [12] ( 11,  1,  3,  3)   debug placeholder
+	// Highlight rects (`Kid1 @ 29be:1320` / `Kid2 @ 29be:1328` /
+	// `SoundOn @ 29be:1330` / `SoundOff @ 29be:1338`) drive
+	// `_SwapColors`; they're not click targets in the original.
 	if (!_font.isLoaded()) {
 		_nextScreen = (ScreenId)_lastScreen;
 		return;
 	}
 
-	const Common::Rect kBackBtn(120, 170, 200, 188);
-	const Common::Rect kPartnerToggle(40, 60, 280, 78);
-	const Common::Rect kVoiceToggle  (40, 90, 280, 108);
+	// Original button rects (`_SetupButtons` indices wired here).
+	const Common::Rect kPartnerBtn   ( 20,  44,  39,  61); // [0]
+	const Common::Rect kVoiceBtn     ( 20,  87,  39, 104); // [1]
+	const Common::Rect kProfileBtn   ( 20, 127,  39, 144); // [2]
+	const Common::Rect kScrap1Btn    (281,  43, 299,  60); // [3]
+	const Common::Rect kScrap2Btn    (281,  62, 299,  79); // [4]
+	const Common::Rect kScrap3Btn    (281,  81, 299,  98); // [5]
+	const Common::Rect kSaveBtn      (281, 108, 299, 125); // [6]
+	const Common::Rect kNewCaseBtn   (281, 127, 299, 144); // [7]
+	const Common::Rect kDoneBtn      ( 53, 153, 108, 183); // [8]
+	const Common::Rect kHelpBtn      (145, 163, 174, 187); // [9]
+	const Common::Rect kQuitBtn      (212, 153, 266, 184); // [10]
+	const Common::Rect kCreditsBtn   ( 81,  25, 238,  37); // [11]
+	// Highlight / fallback-click rects.
+	const Common::Rect kKid1Rect     ( 99,  44, 148,  52);
+	const Common::Rect kKid2Rect     ( 99,  54, 148,  62);
+	const Common::Rect kSoundOnRect  (106,  86, 125,  94);
+	const Common::Rect kSoundOffRect (106,  96, 125, 104);
+
+	// Pixel-level color-key swap. Mirrors `_SwapColors @ 172b:1d2a`:
+	// for each pixel in `r` whose value equals `from`, replace with
+	// `to`. `0xFE` is the BG's text-key color; `0x15` is the active
+	// (bright) palette index, `0x00` the inactive one — both verified
+	// in `_SetupSettings @ 1f78:000d`.
+	auto swapColors = [](Graphics::ManagedSurface &dst,
+						 const Common::Rect &r, byte from, byte to) {
+		const int x1 = MAX<int>(0, r.left);
+		const int y1 = MAX<int>(0, r.top);
+		const int x2 = MIN<int>(320, r.right);
+		const int y2 = MIN<int>(200, r.bottom);
+		for (int y = y1; y < y2; y++) {
+			byte *row = (byte *)dst.getBasePtr(0, y);
+			for (int x = x1; x < x2; x++) {
+				if (row[x] == from)
+					row[x] = to;
+			}
+		}
+	};
 
 	auto draw = [&]() {
 		Graphics::ManagedSurface scratch(320, 200,
@@ -664,22 +722,121 @@ void EEMEngine::doSetup() {
 				memcpy((byte *)scratch.getBasePtr(0, row),
 					   (const byte *)bg.surface.getBasePtr(0, row), w);
 		}
-		_font.drawString(&scratch, "Setup", 140, 30, 80, 0xF);
-		const Common::String partnerLine = Common::String::format(
-			"Partner: %s   (click to switch)",
-			_partner == 0 ? "Jake" : "Jenny");
-		_font.drawString(&scratch, partnerLine, 50, 64, 240, 0xF);
-		const Common::String voiceLine = Common::String::format(
-			"Voice:   %s   (click to toggle)",
-			_voiceOn ? "ON" : "OFF");
-		_font.drawString(&scratch, voiceLine, 50, 94, 240, 0xF);
-		_font.drawString(&scratch, "[ Back ]", 130, 174, 80, 0xF);
+
+		const byte kKey    = 0xFE;
+		const byte kBright = 0x15;
+		const byte kDim    = 0x00;
+		swapColors(scratch, kKid1Rect, kKey,
+				   _partner == 0 ? kBright : kDim);
+		swapColors(scratch, kKid2Rect, kKey,
+				   _partner == 1 ? kBright : kDim);
+		swapColors(scratch, kSoundOnRect,  kKey,
+				   _voiceOn ? kBright : kDim);
+		swapColors(scratch, kSoundOffRect, kKey,
+				   _voiceOn ? kDim : kBright);
+
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 	};
 	draw();
 
+	// Modal "Are you sure?" yes/no prompt. Mirrors `_AreYouSure @
+	// 1a35:0a5c` — the original draws a centred message, listens for
+	// Y/Enter (confirm) or N/ESC (cancel). We render a minimal
+	// overlay with Y / N keys (and click on left/right halves) so
+	// the Quit button gives the player a chance to back out.
+	auto areYouSure = [&]() -> bool {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			for (int row = 0; row < 200; row++)
+				memcpy((byte *)scratch.getBasePtr(0, row),
+					   (const byte *)cur->getBasePtr(0, row), 320);
+			g_system->unlockScreen();
+		}
+		const Common::Rect kBox(80, 80, 240, 120);
+		scratch.fillRect(kBox, 0x00);
+		_font.drawString(&scratch, "Are you sure?", 100, 88, 200, 0xF);
+		_font.drawString(&scratch, "Y = yes   N = no", 100, 102, 200, 0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+		while (!shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+					return true;
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					const Common::KeyCode k = ev.kbd.keycode;
+					if (k == Common::KEYCODE_y ||
+						k == Common::KEYCODE_RETURN)
+						return true;
+					if (k == Common::KEYCODE_n ||
+						k == Common::KEYCODE_ESCAPE)
+						return false;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					return ev.mouse.x < 160;
+				}
+			}
+			g_system->delayMillis(15);
+		}
+		return false;
+	};
+
+	// Fullscreen-pic modal. Mirrors `_InterfaceHelp @ 1560:0205`'s
+	// per-frame loop: blit pic, wait for click/key, advance / exit.
+	// Used by Credits (single PIC 0x208) and Help (we reuse for a
+	// minimal stub since the help-pic table at `_InterfaceHelp`'s
+	// offset isn't fully decoded yet).
+	auto showFullscreenPic = [&](uint16 picId) {
+		Picture pic;
+		if (!_picsArchive.getPicture(picId, pic)) {
+			warning("doSetup: PIC %u missing", (uint)picId);
+			return;
+		}
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		const int w = MIN<int>(pic.surface.w, 320);
+		const int h = MIN<int>(pic.surface.h, 200);
+		for (int row = 0; row < h; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)pic.surface.getBasePtr(0, row), w);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+		while (!shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					ev.type == Common::EVENT_KEYDOWN ||
+					ev.type == Common::EVENT_LBUTTONDOWN)
+					return;
+			}
+			g_system->delayMillis(15);
+		}
+	};
+
+	auto leaveSetup = [&]() {
+		// `_DoSetup`'s entry writes `_NextScreen = _LastScreen`. We
+		// honor any handler that has already overridden `_nextScreen`
+		// (Credits / Save don't, but New Case / Quit do). Otherwise
+		// fall back to `_lastScreen`.
+		if (_nextScreen == kScreenSetup) {
+			_nextScreen = (ScreenId)_lastScreen;
+			if (_nextScreen == kScreenSetup ||
+				_nextScreen == kScreenInvalid)
+				_nextScreen = kScreenMap;
+		}
+		saveProfile(_playerName);
+	};
+
+	_nextScreen = kScreenSetup;  // sentinel — leaveSetup picks the real target
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
@@ -692,38 +849,131 @@ void EEMEngine::doSetup() {
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE ||
 					ev.kbd.keycode == Common::KEYCODE_RETURN) {
-					// `_DoSetup @ 1f78:044a` writes `_NextScreen =
-					// _LastScreen` on entry. Returning means we just
-					// dispatch back to whichever screen called us.
-					_nextScreen = (ScreenId)_lastScreen;
-					if (_nextScreen == kScreenSetup ||
-						_nextScreen == kScreenInvalid)
-						_nextScreen = kScreenMap;
-					if (_voiceOn)
-						saveProfile(_playerName);
+					leaveSetup();
 					return;
 				}
 			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				if (kBackBtn.contains(ev.mouse.x, ev.mouse.y)) {
-					_nextScreen = (ScreenId)_lastScreen;
-					if (_nextScreen == kScreenSetup ||
-						_nextScreen == kScreenInvalid)
-						_nextScreen = kScreenMap;
-					saveProfile(_playerName);
-					return;
-				}
-				if (kPartnerToggle.contains(ev.mouse.x, ev.mouse.y)) {
-					_partner = _partner == 0 ? 1 : 0;
+			if (ev.type != Common::EVENT_LBUTTONDOWN)
+				continue;
+			const int mx = ev.mouse.x;
+			const int my = ev.mouse.y;
+
+			// Partner toggle (button [0]) — original has no symmetric
+			// right-side button (the [3] rect is ScrapBook 1, not a
+			// partner arrow). Direct clicks on the Jake/Jenny labels
+			// are accepted as a more intuitive fallback.
+			if (kPartnerBtn.contains(mx, my)) {
+				_partner = _partner == 0 ? 1 : 0;
+				dirty = true;
+				continue;
+			}
+			if (kKid1Rect.contains(mx, my)) {
+				if (_partner != 0) { _partner = 0; dirty = true; }
+				continue;
+			}
+			if (kKid2Rect.contains(mx, my)) {
+				if (_partner != 1) { _partner = 1; dirty = true; }
+				continue;
+			}
+
+			// Voice toggle (button [1]).
+			if (kVoiceBtn.contains(mx, my)) {
+				_voiceOn = !_voiceOn;
+				if (_audio)
+					_audio->setVoiceEnabled(_voiceOn);
+				dirty = true;
+				continue;
+			}
+			if (kSoundOnRect.contains(mx, my)) {
+				if (!_voiceOn) {
+					_voiceOn = true;
+					if (_audio)
+						_audio->setVoiceEnabled(_voiceOn);
 					dirty = true;
 				}
-				if (kVoiceToggle.contains(ev.mouse.x, ev.mouse.y)) {
-					_voiceOn = !_voiceOn;
+				continue;
+			}
+			if (kSoundOffRect.contains(mx, my)) {
+				if (_voiceOn) {
+					_voiceOn = false;
 					if (_audio)
 						_audio->setVoiceEnabled(_voiceOn);
 					dirty = true;
 				}
+				continue;
+			}
+
+			// New Case (button [7]). Original handler at 1f78:01ad
+			// sets `_NextScreen = 0xa` (CHOOSE_MYSTERY) and exits the
+			// dispatch loop with SI=1.
+			if (kNewCaseBtn.contains(mx, my)) {
+				saveProfile(_playerName);
+				_nextScreen = kScreenChooseMystery;
+				return;
+			}
+
+			// Save (button [6]). Original calls `_SaveGame @
+			// 2404:0c87` and stays in the setup loop. Our save is
+			// profile-scoped (one slot per player name) — same effect.
+			if (kSaveBtn.contains(mx, my)) {
+				saveProfile(_playerName);
+				continue;
+			}
+
+			// Done (button [8]). Original handler is just `MOV SI,1;
+			// JMP exit` — `_NextScreen` stays at whatever entry set it
+			// to (= `_LastScreen`).
+			if (kDoneBtn.contains(mx, my)) {
+				leaveSetup();
+				return;
+			}
+
+			// Quit (button [10]). Original: `_AreYouSure(0)` →
+			// confirmed → `_NextScreen = 0xffff` (sentinel quit).
+			if (kQuitBtn.contains(mx, my)) {
+				if (areYouSure()) {
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				dirty = true;  // restore the BG after the prompt
+				continue;
 			}
+
+			// Help (button [9]). Original calls `_InterfaceHelp(1)`,
+			// which walks a sequence of help-pic IDs from a runtime
+			// table we haven't fully decoded. PIC 0x4F is the in-game
+			// help backdrop the briefing chain reuses; we show that
+			// as a stub until the full table is wired.
+			if (kHelpBtn.contains(mx, my)) {
+				showFullscreenPic(0x4F);
+				dirty = true;
+				continue;
+			}
+
+			// Credits (button [11]). Original handler at 1f78:025a
+			// loads PIC 0x208 and blits it fullscreen, then waits
+			// for any input.
+			if (kCreditsBtn.contains(mx, my)) {
+				showFullscreenPic(0x208);
+				// PIC 0x208 has its own palette baked into the BG
+				// dump via `_GetPicture`; the original restores via
+				// `_GetPalette` on return. Reset to setup palette
+				// (SITEPALS index 0) so the setup BG renders right.
+				setSitePalette(0);
+				dirty = true;
+				continue;
+			}
+
+			// Profile (button [2]). Goes back to the profile picker
+			// in the original (`_NextScreen = 8`). Treat the same way
+			// as Done for now — switching profiles mid-game isn't
+			// wired in our port and would discard mystery state.
+			if (kProfileBtn.contains(mx, my)) {
+				leaveSetup();
+				return;
+			}
+
+			(void)kScrap1Btn; (void)kScrap2Btn; (void)kScrap3Btn;
 		}
 		if (dirty)
 			draw();


Commit: ae83ff8a9f033760539254101d11b6718943bc25
    https://github.com/scummvm/scummvm/commit/ae83ff8a9f033760539254101d11b6718943bc25
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:44+02:00

Commit Message:
EEM: help screen in setup screen implemented

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index d857dce9877..9822cf9f53d 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -787,25 +787,40 @@ void EEMEngine::doSetup() {
 		return false;
 	};
 
-	// Fullscreen-pic modal. Mirrors `_InterfaceHelp @ 1560:0205`'s
-	// per-frame loop: blit pic, wait for click/key, advance / exit.
-	// Used by Credits (single PIC 0x208) and Help (we reuse for a
-	// minimal stub since the help-pic table at `_InterfaceHelp`'s
-	// offset isn't fully decoded yet).
-	auto showFullscreenPic = [&](uint16 picId) {
+	// Render `picId` and block until click/key. Returns the pressed
+	// keycode (KEYCODE_ESCAPE for an explicit bail, KEYCODE_INVALID
+	// for a click or any other key). When `transparent` is true,
+	// preserve the current screen behind and overlay `picId` with
+	// its transparent colour key — mirrors `_InterfaceHelp @
+	// 1560:0205` calling `_Rect_Move_Mask` with the pic's
+	// `miscflags >> 8` as the transp byte. When false, do a raw
+	// fullscreen blit — mirrors the credits handler at 1f78:0281
+	// using `_vga_fbuffvid`.
+	auto showFullscreenPic = [&](uint16 picId,
+								  bool transparent) -> Common::KeyCode {
 		Picture pic;
 		if (!_picsArchive.getPicture(picId, pic)) {
 			warning("doSetup: PIC %u missing", (uint)picId);
-			return;
+			return Common::KEYCODE_INVALID;
 		}
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		const int w = MIN<int>(pic.surface.w, 320);
-		const int h = MIN<int>(pic.surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)pic.surface.getBasePtr(0, row), w);
+		if (transparent) {
+			// Preserve the current screen so the help PIC's
+			// transparent pixels show the setup BG underneath.
+			Graphics::Surface *cur = g_system->lockScreen();
+			if (cur) {
+				for (int row = 0; row < 200; row++)
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)cur->getBasePtr(0, row), 320);
+				g_system->unlockScreen();
+			}
+			const byte transp = (byte)(pic.flags >> 8);
+			scratch.transBlitFrom(pic.surface, (uint32)transp);
+		} else {
+			scratch.clear();
+			scratch.simpleBlitFrom(pic.surface);
+		}
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -813,13 +828,16 @@ void EEMEngine::doSetup() {
 			Common::Event ev;
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
-					ev.type == Common::EVENT_KEYDOWN ||
-					ev.type == Common::EVENT_LBUTTONDOWN)
-					return;
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+					return Common::KEYCODE_ESCAPE;
+				if (ev.type == Common::EVENT_KEYDOWN)
+					return ev.kbd.keycode;
+				if (ev.type == Common::EVENT_LBUTTONDOWN)
+					return Common::KEYCODE_INVALID;
 			}
 			g_system->delayMillis(15);
 		}
+		return Common::KEYCODE_ESCAPE;
 	};
 
 	auto leaveSetup = [&]() {
@@ -939,22 +957,48 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Help (button [9]). Original calls `_InterfaceHelp(1)`,
-			// which walks a sequence of help-pic IDs from a runtime
-			// table we haven't fully decoded. PIC 0x4F is the in-game
-			// help backdrop the briefing chain reuses; we show that
-			// as a stub until the full table is wired.
+			// Help (button [9]). Original `_InterfaceHelp(1) @
+			// 1560:0205` walks the help-pic table at `29be:00c8`:
+			// each `num` slot is 5 bytes — count + two u16 PIC IDs.
+			// For num=1: count=2, pics = {0x0192, 0x01B1}. The
+			// original blits each pic with `_Rect_Move_Mask` — a
+			// MASKED blit whose transparent colour is the pic's
+			// `miscflags >> 8`, so the setup BG shows through. It
+			// also hides the cursor (`MOV [0x3a00], 0` + `_RemoveMouse`
+			// at the top of `_InterfaceHelp`, 1560:0216-021c). ESC
+			// at any point breaks out (1560:02b3 sets uVar5 = count).
 			if (kHelpBtn.contains(mx, my)) {
-				showFullscreenPic(0x4F);
+				static const uint16 kHelp1Pics[] = { 0x0192, 0x01B1 };
+				CursorMan.showMouse(false);
+				for (uint i = 0; i < ARRAYSIZE(kHelp1Pics); i++) {
+					// Re-render the setup BG before each help PIC so
+					// each one overlays a clean canvas. Without this,
+					// `showFullscreenPic`'s `lockScreen` snapshot would
+					// pick up the previous PIC and the two help cards
+					// would composite together. Mirrors the original's
+					// `_vga_fvidvid(0)` call at the tail of every
+					// `_InterfaceHelp` iteration (1560:02e5), which
+					// restores the back-buffer BG between cards.
+					draw();
+					const Common::KeyCode k =
+						showFullscreenPic(kHelp1Pics[i], /*transparent=*/true);
+					if (k == Common::KEYCODE_ESCAPE)
+						break;
+				}
+				CursorMan.showMouse(true);
 				dirty = true;
 				continue;
 			}
 
 			// Credits (button [11]). Original handler at 1f78:025a
-			// loads PIC 0x208 and blits it fullscreen, then waits
-			// for any input.
+			// loads PIC 0x208, hides the cursor (`MOV [0x3a00], 0`
+			// at 1f78:0269 + `_RemoveMouse @ 1000:542f` at 1f78:026F),
+			// blits it fullscreen via `_vga_fbuffvid` (raw copy, no
+			// mask), then waits for any input.
 			if (kCreditsBtn.contains(mx, my)) {
-				showFullscreenPic(0x208);
+				CursorMan.showMouse(false);
+				showFullscreenPic(0x208, /*transparent=*/false);
+				CursorMan.showMouse(true);
 				// PIC 0x208 has its own palette baked into the BG
 				// dump via `_GetPicture`; the original restores via
 				// `_GetPalette` on return. Reset to setup palette


Commit: 754e723072acc7bbc79c86a451dcecfa1d5d858d
    https://github.com/scummvm/scummvm/commit/754e723072acc7bbc79c86a451dcecfa1d5d858d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:44+02:00

Commit Message:
EEM: setup screen accesible in zoom map screen

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 9822cf9f53d..87ab366765e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2291,11 +2291,18 @@ void EEMEngine::doBigMap() {
 				const int kSliderRangeY = mapH - kMapWinH;
 
 				if (kSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
-					// Setup button on detail too — `_NextScreen = 6` in
-					// the original. We treat it the same way: bail back
-					// to case selection.
-					_mystery.clear();
-					_nextScreen = kScreenInvalid;
+					// `_DoMapScreen @ 20fe:1560` writes `_NextScreen
+					// = 6` (= kScreenSetup) and `INC [BP-8]` to bail
+					// out of the detail loop — verified via the byte
+					// search for `c7 06 16 79 06 00`, which finds the
+					// imm at exactly this site and `_DoBigMap @
+					// 20fe:0c33`. Same `SetupButtonRect @ 29be:15ce`
+					// rect used by both the overview and the detail
+					// (no per-screen rect duplication in the binary).
+					// The detail/zoom state is lost on return because
+					// the screen driver re-enters BigMap at stage 1 —
+					// this matches the original behaviour.
+					_nextScreen = kScreenSetup;
 					return;
 				}
 				if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {


Commit: 92c6607481ab1258822aa61cde27ff0116e09e8f
    https://github.com/scummvm/scummvm/commit/92c6607481ab1258822aa61cde27ff0116e09e8f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:45+02:00

Commit Message:
EEM: improved navigation on PDA

Changed paths:
    engines/eem/graphics.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 3955914eb98..351036153f0 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -114,9 +114,13 @@ void EEMEngine::doHelp() {
 void EEMEngine::doInterfaceHelp(uint num) {
 	// Mirrors `_InterfaceHelp(num)` @ 1560:0205. The original walks
 	// `HelpData @ 29be:00c8` (5-byte entries: u8 count, then up to 2
-	// u16 picIds), `_GetPicture`s each one, blits it fullscreen via
-	// `_Rect_Move_Mask(0, 0, ...)`, and waits for click / key. ESC ends
-	// the cycle; any other input advances to the next pic.
+	// u16 picIds), `_GetPicture`s each one, blits it via
+	// `_Rect_Move_Mask(0, 0, ...)` (a MASKED blit on top of the
+	// existing screen — transparent pixels show the caller's BG), and
+	// waits for click / key. ESC at `1560:02b3` skips to end. The
+	// function also hides the cursor at the top (`MOV [0x3a00], 0` at
+	// 1560:0216 + `_RemoveMouse @ 1000:542f` at 1560:021c) and
+	// restores it at the tail (`_DrawMouse @ 1000:5429` at 1560:02e8).
 	//
 	// `kHelpPics` lives at file scope above; see comment there for the
 	// HelpData decoding.
@@ -126,6 +130,26 @@ void EEMEngine::doInterfaceHelp(uint num) {
 	debugC(1, kDebugScript, "doInterfaceHelp(%u): showing pics 0x%x, 0x%x",
 		   num, kHelpPics[num][0], kHelpPics[num][1]);
 
+	// Snapshot the caller's screen ONCE so each help PIC overlays the
+	// same clean BG. Without this, after the first PIC is dismissed the
+	// second snapshot would include the first PIC's pixels and the two
+	// would composite together — same gotcha as the setup-screen help
+	// loop fix in `doSetup`.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	{
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			for (int row = 0; row < 200; row++)
+				memcpy((byte *)bg.getBasePtr(0, row),
+					   (const byte *)cur->getBasePtr(0, row), 320);
+			g_system->unlockScreen();
+		}
+	}
+
+	const bool wasShown = CursorMan.isVisible();
+	CursorMan.showMouse(false);
+
 	for (uint i = 0; i < 2; i++) {
 		const uint16 picId = kHelpPics[num][i];
 		Picture pic;
@@ -136,24 +160,15 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
 			   picId, pic.surface.w, pic.surface.h, pic.flags);
 
-		// Compose a 320x200 frame (cleared) and blit the help pic at (0,0)
-		// with the original's masked-blit semantics: pixels equal to the
-		// pic's sub-mode (high byte of `pic[0]`, see `_Rect_Move_Mask`
-		// param_10 at 1000:03fc) are treated as transparent and skipped.
+		// Compose a 320x200 frame from the clean BG snapshot and overlay
+		// the help pic with `transBlitFrom` — `Graphics::ManagedSurface`'s
+		// masked blit (transparent colour = the pic's `flags >> 8`,
+		// matching `_Rect_Move_Mask`'s param_10 at 1000:03fc).
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
+		scratch.simpleBlitFrom(bg);
 		const byte transp = (byte)(pic.flags >> 8);
-		const int w = MIN<int>(pic.surface.w, 320);
-		const int h = MIN<int>(pic.surface.h, 200);
-		for (int row = 0; row < h; row++) {
-			const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, row);
-			for (int col = 0; col < w; col++) {
-				if (src[col] != transp)
-					dst[col] = src[col];
-			}
-		}
+		scratch.transBlitFrom(pic.surface, (uint32)transp);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -165,6 +180,8 @@ void EEMEngine::doInterfaceHelp(uint num) {
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_QUIT ||
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					if (wasShown)
+						CursorMan.showMouse(true);
 					return;
 				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -187,6 +204,9 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		if (escape)
 			break;
 	}
+
+	if (wasShown)
+		CursorMan.showMouse(true);
 }
 
 void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 87ab366765e..9375f894a11 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1425,7 +1425,13 @@ void EEMEngine::doNotebook() {
 	const Common::Rect kBtnPagePrev(226, 174, 247, 190);  // [6] PAGE PREV
 	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7] MAP
 	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8] SITE
-	const Common::Rect kNoteArea   ( 66,  79, 267, 174);  // [10] note area
+	const Common::Rect kBtnHelp2   (267, 174, 288, 190);  // [10] extra HELP
+	// (`_NoteButtons @ 29be:0147` actually has rect [10] at
+	// (267,174,288,190) — small button on the right of the bottom
+	// bar that the original handler dispatch table at 161e:04ec
+	// routes to `_InterfaceHelp(0)` again. Earlier this rect was
+	// mis-noted as a "note area" of (66,79,267,174) — that
+	// rectangle exists nowhere in the binary's button table.)
 
 	CursorMan.showMouse(true);
 
@@ -1514,26 +1520,31 @@ void EEMEngine::doNotebook() {
 					dirty = true;
 					continue;
 				}
-				if (kNoteArea.contains(ev.mouse.x, ev.mouse.y)) {
-					// Toggle the selection on whichever clue's text
-					// the click landed in. The original calls
-					// `_InterfaceHelp` here; that's the help screen,
-					// not selection — selection is in the Accuse
-					// screen. We use the area for selection because
-					// keyboard 1..9 toggling is awkward, and the
-					// resulting `_NoteSelected` state is what
-					// `_SolvedCheck` reads.
-					for (uint i = 0; i < _notebookSlotRects.size(); i++) {
-						if (_notebookSlotRects[i].contains(ev.mouse.x,
-														   ev.mouse.y)) {
-							const uint clueId = _notebookSlotClues[i];
-							_mystery._noteSelected[clueId] ^= 1;
-							dirty = true;
-							break;
-						}
-					}
+				if (kBtnHelp2.contains(ev.mouse.x, ev.mouse.y)) {
+					// `_NoteButtons[10]` → handler 0x03f9 = same
+					// `_InterfaceHelp(0)` as button [1].
+					doInterfaceHelp(0);
+					dirty = true;
 					continue;
 				}
+				// Click on a clue's slot rect → toggle selection. The
+				// original `_DoNotebook` doesn't do this — note
+				// selection lives in the accuse screen there — but
+				// keyboard 1..9 toggling is awkward, and the resulting
+				// `_NoteSelected` state is what `_SolvedCheck` reads
+				// either way. Slot rects are the per-clue rectangles
+				// `drawNotebookFrame` publishes, so this just
+				// reproduces the visible-text-bbox click without the
+				// previous bogus outer-area gate.
+				for (uint i = 0; i < _notebookSlotRects.size(); i++) {
+					if (_notebookSlotRects[i].contains(ev.mouse.x,
+													   ev.mouse.y)) {
+						const uint clueId = _notebookSlotClues[i];
+						_mystery._noteSelected[clueId] ^= 1;
+						dirty = true;
+						break;
+					}
+				}
 			}
 		}
 		if (exitFlag)
@@ -1982,6 +1993,12 @@ void EEMEngine::doGallery() {
 			drawGalleryFrame(gd, num, slotRects, slotSuspect);
 			lastDraw = now;
 		}
+		// `g_system->updateScreen()` is what tells the framework to
+		// re-render the cursor at its current mouse position; without
+		// it here, the cursor only refreshes when `drawGalleryFrame`
+		// runs (every 100 ms) and visibly lags the mouse. Match
+		// `doNotebook`'s per-tick `updateScreen()` cadence (line 1548).
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 }


Commit: d87214f047517949fbaa941fa107d740d0cd19f6
    https://github.com/scummvm/scummvm/commit/d87214f047517949fbaa941fa107d740d0cd19f6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:45+02:00

Commit Message:
EEM: basic accusation flow in the PDA

Changed paths:
    engines/eem/graphics.cpp
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 351036153f0..0718f6c1471 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -163,12 +163,20 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		// Compose a 320x200 frame from the clean BG snapshot and overlay
 		// the help pic with `transBlitFrom` — `Graphics::ManagedSurface`'s
 		// masked blit (transparent colour = the pic's `flags >> 8`,
-		// matching `_Rect_Move_Mask`'s param_10 at 1000:03fc).
+		// matching `_Rect_Move_Mask`'s param_10 at 1000:03fc). Pass an
+		// explicit `destPos` of (0, 0) — the no-destPos overload at
+		// managed_surface.cpp:738 scales src to fill `this`'s rect,
+		// stretching the help PIC to 320x200 instead of placing it at
+		// native size. The original `_Rect_Move_Mask` passes destX=0,
+		// destY=0 with copy-width = pic[+4] (= `pic.surface.w`) and
+		// copy-height = pic[+2] (= `pic.surface.h`) — i.e. native size,
+		// not stretched.
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
 		const byte transp = (byte)(pic.flags >> 8);
-		scratch.transBlitFrom(pic.surface, (uint32)transp);
+		scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
+							  (uint32)transp);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index e3b4051a14a..39c854535d9 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -224,6 +224,31 @@ const byte *Mystery::mapEntry(uint siteNum) const {
 	return _data.data() + off;
 }
 
+bool Mystery::isGuilty(uint suspectIdx) const {
+	// `_WITCH @ 1df2:089f`: `if (GalleryData[i*0x46 + 0x02] == -1)
+	// _DisplayCorrect(); else _DisplayAlibi(...)`. Innocent suspects
+	// store their alibi-text TextBlock offset at +0x02; the guilty
+	// one stores the sentinel 0xFFFF.
+	const byte *gd = galleryData();
+	if (!gd || suspectIdx >= _numSuspects)
+		return false;
+	const uint16 off = READ_LE_UINT16(gd + suspectIdx * 0x46 + 0x02);
+	return off == 0xFFFF;
+}
+
+uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
+	const byte *gd = galleryData();
+	if (!gd || suspectIdx >= _numSuspects)
+		return 0xFFFF;
+	return READ_LE_UINT16(gd + suspectIdx * 0x46 + 0x02);
+}
+
+const byte *Mystery::solvedClueBlock() const {
+	if (!isLoaded() || _solvedOffset == 0 || _solvedOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _solvedOffset;
+}
+
 int Mystery::selectedPoints() const {
 	const byte *ni = noteIndex();
 	const uint16 cnt = noteIndexCount();
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 96da8666f1b..dadaec00a75 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -152,6 +152,23 @@ public:
 	/// True when `selectedPoints() > 99`. Mirrors `_SolvedCheck`.
 	bool solvedCheck() const { return selectedPoints() > 99; }
 
+	/// True iff suspect @p suspectIdx is the case's guilty party. The
+	/// guilty marker is `GalleryData[suspectIdx * 0x46 + 0x02] ==
+	/// 0xFFFF` — innocent suspects store their alibi text offset there;
+	/// the guilty suspect uses the sentinel. Verified at `_WITCH @
+	/// 1df2:089f` (`if (psVar1->field_0x2 == -1) _DisplayCorrect();
+	/// else _DisplayAlibi(...)`).
+	bool isGuilty(uint suspectIdx) const;
+
+	/// TextBlock offset of suspect @p suspectIdx's alibi text. Returns
+	/// 0xFFFF for the guilty suspect (no alibi).
+	uint16 alibiTextOffset(uint suspectIdx) const;
+
+	/// Pointer to the win-clueblock (`MysteryIndex[+0x10]` =
+	/// `_solvedOffset`). Mirrors `_DisplayCorrect`'s
+	/// `_DisplayClue(_Mystery + MysteryIndex[+0x10], 0)` at 1df2:0769.
+	const byte *solvedClueBlock() const;
+
 	/// Per-mystery runtime state, zeroed at load time.
 	uint8  _cluesFound[kCluesFoundCap]   = {};
 	uint8  _noteSelected[kCluesFoundCap] = {};  ///< Mirror `_NoteSelected`
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 9375f894a11..fa27ce1cf86 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -816,7 +816,12 @@ void EEMEngine::doSetup() {
 				g_system->unlockScreen();
 			}
 			const byte transp = (byte)(pic.flags >> 8);
-			scratch.transBlitFrom(pic.surface, (uint32)transp);
+			// Explicit destPos — the no-destPos overload of
+			// `transBlitFrom` (managed_surface.cpp:738) stretches
+			// src to dst dimensions, scaling the PIC to 320x200.
+			// The original `_Rect_Move_Mask` blits at native size.
+			scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
+								  (uint32)transp);
 		} else {
 			scratch.clear();
 			scratch.simpleBlitFrom(pic.surface);
@@ -2834,25 +2839,43 @@ void EEMEngine::doAccuse() {
 	if (picked < 0)
 		return;
 
-	// Real chain evaluation: sum point values of clues the player marked
-	// "selected" in the notebook. Mirrors `_SolvedCheck` @ 1df2:00ec.
-	const int points = _mystery.selectedPoints();
-	const bool guessedRight = _mystery.solvedCheck();
-	debugC(1, kDebugScript, "doAccuse: picked=%d selectedPts=%d -> %s",
-		   picked, points, guessedRight ? "correct" : "wrong");
-
-	// If the player hasn't marked any evidence yet, give them a hint
-	// rather than an instant fail. Mirrors the original "We're not ready
-	// to solve this mystery yet..." string at 29be:10f0.
-	if (points == 0 && _font.isLoaded()) {
+	// Real chain evaluation. Mirrors the original two-gate accusation:
+	//   1. `_AccuseEntry @ 1df2:0ff8` checks `_GetFoundPoints() >= 100`
+	//      — gates whether the suspect picker is even reachable. We
+	//      replicate this with `_SolvedCheck → selectedPoints > 99`,
+	//      since our port marks clues in the notebook (port deviation;
+	//      the original gates on the auto-sorted top-5 found clues
+	//      via `_GetFoundPoints @ 1df2:0098`).
+	//   2. `_WITCH @ 1df2:089f` checks `GalleryData[picked*0x46+0x02] ==
+	//      0xFFFF`. Innocent suspects store an alibi-text TextBlock
+	//      offset there; the guilty one uses the sentinel.
+	// Both must hold for `_DisplayCorrect`; otherwise it's an alibi.
+	const int points          = _mystery.selectedPoints();
+	const bool enoughEvidence = _mystery.solvedCheck();
+	const bool pickedGuilty   = _mystery.isGuilty((uint)picked);
+	const bool guessedRight   = enoughEvidence && pickedGuilty;
+	debugC(1, kDebugScript,
+		   "doAccuse: picked=%d selectedPts=%d evidence=%s guilty=%s -> %s",
+		   picked, points,
+		   enoughEvidence ? "yes" : "no",
+		   pickedGuilty ? "yes" : "no",
+		   guessedRight ? "correct" : "wrong");
+
+	// Insufficient evidence: same hint the original shows when
+	// `_AccuseEntry` returns 0 (string at `_KDTextIndex[0]`).
+	if (!enoughEvidence && _font.isLoaded()) {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		_font.drawWordWrapped(&scratch, 16, 80, 288,
-			"We're not ready to solve this mystery yet. "
-			"Let's keep investigating until we have some "
-			"more solid evidence to make our case! "
-			"(Press N in the site screen to mark clues.)",
+			points == 0
+				? "We're not ready to solve this mystery yet. "
+				  "Let's keep investigating until we have some "
+				  "more solid evidence to make our case! "
+				  "(Press N in the site screen to mark clues.)"
+				: "We don't have quite enough solid evidence yet. "
+				  "Let's review our notes and find a few more "
+				  "clues before we accuse anyone.",
 			0xF);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
@@ -2861,131 +2884,72 @@ void EEMEngine::doAccuse() {
 		return;
 	}
 
-	// Pick the ending based on the chain. For a correct accusation the
-	// original would call `_DisplayClue(_Mystery + AChain[0])`, play
-	// SCRAPBK.ANI and save progress. We load the matching `E<n>.BIN`
-	// ending text and render its pages with prev/next navigation.
-	const int endingNum = guessedRight ? picked : 0;
-	const Common::String fname = Common::String::format("E%d.BIN", endingNum);
-	Common::File f;
-	if (!f.open(Common::Path(fname))) {
-		warning("doAccuse: %s missing", fname.c_str());
-		return;
-	}
-
-	// E<n>.BIN format (verified against `_DisplayEndingPage` @ 1df2:044c):
-	//   u16 numPages
-	//   per page (10 bytes header + NUL-string):
-	//     u16 picNum
-	//     u16 x1, y1, x2, y2  (story rect)
-	//     bytes[] NUL-terminated text
-	const uint32 fileLen = f.size();
-	Common::Array<byte> blob(fileLen);
-	if (f.read(blob.data(), fileLen) != fileLen)
-		return;
-	const byte *e = blob.data();
-	const uint16 pages = READ_LE_UINT16(e);
-
-	uint pageIdx = 0;
-
-	while (!shouldQuit()) {
-		// Walk to pageIdx.
-		uint pos = 2;
-		uint cur = 0;
-		while (cur < pageIdx && pos + 10 < fileLen) {
-			const char *t = (const char *)(e + pos + 10);
-			pos += 10 + strlen(t) + 1;
-			cur++;
+	// Wrong suspect: render alibi flow. Mirrors `_DisplayAlibi @
+	// 1df2:0145` — shows the suspect's alibi text + their picture on
+	// PIC 0x3e, then returns to the accuse gallery for another try.
+	// Skips MIDI 6 (alibi music) and the per-partner voice line — the
+	// text rendering alone is enough to convey "this suspect is
+	// innocent". `_FirstTry` is cleared so a subsequent correct pick
+	// no longer counts as a first-try win (1df2:0445 -> _FirstTry=0).
+	if (!guessedRight) {
+		const uint16 alibiOff = _mystery.alibiTextOffset((uint)picked);
+		Common::String alibi;
+		if (gd && alibiOff != 0xFFFF) {
+			const char *raw = _mystery.textAt(alibiOff);
+			if (raw)
+				alibi = parseString(raw, _playerName, _partner);
 		}
-		if (pos + 10 >= fileLen)
-			break;
-
-		const uint16 picNum = READ_LE_UINT16(e + pos + 0);
-		const uint16 x1     = READ_LE_UINT16(e + pos + 2);
-		const uint16 y1     = READ_LE_UINT16(e + pos + 4);
-		const uint16 x2     = READ_LE_UINT16(e + pos + 6);
-		const uint16 y2     = READ_LE_UINT16(e + pos + 8);
-		const char *raw     = (const char *)(e + pos + 10);
-		const Common::String txt = parseString(raw, _playerName, _partner);
+		Picture alibiBg;
+		const bool haveAlibiBg =
+			_picsArchive.getPicture(0x3e, alibiBg);
+		Picture suspect;
+		const uint16 picId = gd
+			? READ_LE_UINT16(gd + (uint)picked * 0x46)
+			: 0;
+		const bool haveSuspect = picId != 0 &&
+			_picsArchive.getPicture(picId, suspect);
 
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
-
-		// Page background.
-		if (picNum != 0) {
-			Picture bg;
-			if (_picsArchive.getPicture(picNum, bg)) {
-				const int w = MIN<int>(bg.surface.w, 320);
-				const int h = MIN<int>(bg.surface.h, 200);
-				for (int row = 0; row < h; row++) {
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)bg.surface.getBasePtr(0, row), w);
-				}
-			}
+		if (haveAlibiBg) {
+			scratch.simpleBlitFrom(alibiBg.surface);
 		}
-
-		if (_font.isLoaded()) {
-			Common::String banner = "Not enough evidence";
-			if (guessedRight)
-				banner = _mystery._firstTry ? "CORRECT - FIRST TRY!" : "CORRECT!";
-			_font.drawString(&scratch, banner, 8, 4, 320, 0xF);
-			_font.drawString(&scratch, Common::String::format("Evidence: %d/100  Suspect: %d",
-									   points, picked + 1), 8, 16, 320, 0xF);
-			const int wrapW = MAX<int>(16, x2 - x1);
-			const int wrapY = MAX<int>(28, (int)y1);
-			(void)y2;
-			_font.drawWordWrapped(&scratch, x1, wrapY, wrapW, txt, 0xF);
-			_font.drawString(&scratch, Common::String::format("page %u/%u  (Left/Right or click)",
-									   pageIdx + 1, pages), 8, 188, 320, 0xF);
+		if (haveSuspect) {
+			// Original `_DisplayAlibi` blits the suspect at (0x82, py)
+			// where py varies by balloon size; we use a fixed centred
+			// position since we don't render the balloon shapes yet.
+			const byte transp = (byte)(suspect.flags >> 8);
+			scratch.transBlitFrom(suspect.surface,
+								  Common::Point(0x82, 0x40),
+								  (uint32)transp);
+		}
+		if (_font.isLoaded() && !alibi.empty()) {
+			_font.drawWordWrapped(&scratch, 16, 8, 200, alibi, 0xF);
+			_font.drawString(&scratch, "(click / ESC: back)",
+							 16, 188, 200, 0xF);
 		}
-
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
+		waitForInput(20000);
 
-		// Page navigation.
-		bool advance = false;
-		bool back    = false;
-		bool exit    = false;
-		while (!advance && !back && !exit && !shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-					exit = true; break;
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					advance = true; break;
-				}
-				if (ev.type == Common::EVENT_KEYDOWN) {
-					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-						exit = true;
-					else if (ev.kbd.keycode == Common::KEYCODE_LEFT)
-						back = true;
-					else
-						advance = true;
-					break;
-				}
-			}
-			g_system->updateScreen();
-			g_system->delayMillis(15);
-		}
-		if (exit)
-			break;
-		if (advance) {
-			if (pageIdx + 1 >= pages)
-				break;
-			pageIdx++;
-		} else if (back) {
-			if (pageIdx > 0)
-				pageIdx--;
-		}
+		_mystery._firstTry = false;
+		// `_DisplayAlibi @ 1df2:043f` writes `_NextScreen =
+		// _LastScreen` so the player drops back to wherever they
+		// accused from (typically the site loop). With no mystery
+		// state to unload, the site loop resumes naturally.
+		_nextScreen = _lastScreen != kScreenInvalid
+						? (ScreenId)_lastScreen : kScreenSite;
+		return;
 	}
 
-	// Mirror `_DisplayCorrect`'s scrap-book animation + solved tracking +
-	// auto-save (the original calls `_SavePlayerRecord` after a win).
-	if (guessedRight) {
+	// Right suspect — full win flow. Mirrors `_DisplayCorrect @
+	// 1df2:073c`: mark mystery solved, advance chain stage if the
+	// tier is complete, swap MIDI to the win cue, run SCRAPBK.ANI,
+	// show the per-mystery ending, save the profile, return to the
+	// action menu (`_NextScreen = 0xc` at 1df2:0895).
+	{
 		const uint mn = _mystery.number();
 		if (mn < sizeof(_mysteriesSolved)) {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
@@ -3059,14 +3023,6 @@ void EEMEngine::doAccuse() {
 		// 1df2:08a4 do the same.
 		_mystery.clear();
 		_nextScreen = kScreenAction;
-	} else {
-		_mystery._firstTry = false;
-		// `_DisplayAlibi @ 1df2:043f` writes `_NextScreen =
-		// _LastScreen` — drop back to wherever the player accused
-		// from. With no mystery state to unload, the site loop
-		// resumes naturally.
-		_nextScreen = _lastScreen != kScreenInvalid
-						? (ScreenId)_lastScreen : kScreenSite;
 	}
 }
 
@@ -3079,6 +3035,15 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 	// PIC 0x3f backdrop, suspect portraits at the 5 fixed slots
 	// (`kGallerySlots` in this file's anon namespace), and a 1-px
 	// outline (palette index 0xFE) around the highlighted slot.
+	//
+	// Partner sprite at (5, 0x50): the original `_DoAccuse @ 1df2:0bdd`
+	// registers `_NewAnimation(5, 0x50, partnerCells, script=2, prior=1)`
+	// (1df2:0c30) BEFORE calling `_DoAccuseGallery`, then `_DrawGallery`
+	// calls `_DrawActiveAnimations` (158f:00a3) which re-renders the
+	// slot every frame. Without an explicit blit here, our port's
+	// accuse screen comes out partner-less. Anim CELLS are 2 (Jake) /
+	// 0x10 (Jenny); SCRIPT key is 0x02 for both partners (matches the
+	// `CONCAT22(2, ...)` arg verified at 1df2:0c2e).
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -3094,6 +3059,21 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		}
 	}
 
+	// Partner sprite, drawn BEFORE portraits so the suspect grid
+	// covers it where they overlap (the gallery slots start at
+	// y=14 / y=90, partner is at y=0x50=80 — no overlap, so order
+	// is purely defensive).
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+		!partnerAni.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = partnerFrameAtTick(0x02,
+												  (uint)partnerAni.size(), now);
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  partnerAni[frameIdx], 5, 0x50);
+	}
+
 	for (uint i = 0; i < numSuspects && i < Mystery::kGalleryCap; i++) {
 		slotRects[i] = Common::Rect();
 		slotSuspect[i] = -1;


Commit: 3b1738c38bf9c7dfa7603961bec4a65af09a597d
    https://github.com/scummvm/scummvm/commit/3b1738c38bf9c7dfa7603961bec4a65af09a597d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:45+02:00

Commit Message:
EEM: basic scrapbook code

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index ab7389a4b9e..345a6f8a36a 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -346,6 +346,17 @@ private:
 	/// so this same call covers the post-mystery scrapbook view from
 	/// the action menu.
 	void doShowEnding(uint num);
+
+	/// Walk every solved mystery in tier @p stage (1=Junior, 2=Senior,
+	/// 3=Master) and display each one's ending pages in sequence.
+	/// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`: the original
+	/// computes the mystery range from `(stage - 1) * 0x18 + 1` to
+	/// `(stage - 1) * 0x18 + 0x18` and skips entries whose
+	/// `_3f9b[i] == 0` (unsolved) so the scrapbook only contains
+	/// completed cases. Used by both the setup-screen ScrapBook
+	/// buttons and the action-menu "See ScrapBook 1/2/3" entries.
+	void doShowScrapbook(uint stage);
+
 	void doCaseSelection();
 	void doSiteLoop();
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fa27ce1cf86..4d092f292cb 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -635,6 +635,39 @@ void EEMEngine::doShowEnding(uint num) {
 	}
 }
 
+void EEMEngine::doShowScrapbook(uint stage) {
+	// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`. Walk the
+	// stage's mystery range and call `_DisplayEnding` on each solved
+	// mystery. The original splits the 55 cases into three tiers:
+	//   stage 1 (Junior) → mysteries  1..0x18 (24 cases)
+	//   stage 2 (Senior) → mysteries 0x19..0x30 (24 cases)
+	//   stage 3 (Master) → mysteries 0x31..0x36 (6 cases)
+	// Each tier's range is `lo = (stage-1)*0x18 + 1`, `hi = lo + 0x17`
+	// (verified at 1f78:064b: `iVar1 = (param_1 - 1) * 0x18; uVar2 =
+	// iVar1 + 1`). The current-stage filter at 1f78:065e
+	// (`if (DAT_2d5d_3f99 == param_1)`) skips unsolved mysteries
+	// inside the player's CURRENT tier — completed tiers show every
+	// mystery regardless. We mirror that exactly.
+	if (stage < 1 || stage > 3)
+		return;
+	const uint lo = (stage - 1) * 0x18 + 1;
+	const uint hi = lo + 0x17;
+	const bool currentTier = (stage == _chainStage);
+
+	for (uint m = lo; m <= hi; m++) {
+		if (m >= sizeof(_mysteriesSolved))
+			break;
+		// Current-tier filter (1f78:0664). Completed tiers show all
+		// 24 entries; the active tier hides unsolved ones because
+		// the player hasn't earned that scrapbook page yet.
+		if (currentTier && _mysteriesSolved[m] == 0)
+			continue;
+		doShowEnding(m);
+		if (shouldQuit())
+			return;
+	}
+}
+
 void EEMEngine::doSetup() {
 	// Mirrors `_DoSetup @ 1f78:044e`. The setup screen is BG `PIC 0x40`
 	// (loaded once on entry) with every label baked in — "Setup",
@@ -1022,7 +1055,35 @@ void EEMEngine::doSetup() {
 				return;
 			}
 
-			(void)kScrap1Btn; (void)kScrap2Btn; (void)kScrap3Btn;
+			// ScrapBook 1 / 2 / 3 (buttons [3] / [4] / [5]). Original
+			// handlers at 1f78:021F (`_ShowScrapbook(0, 1)`) /
+			// 1f78:022E (gated chain >= 2 / `_ShowScrapbook(0, 2)`) /
+			// 1f78:0244 (gated chain >= 3 / `_ShowScrapbook(0, 3)`).
+			// Convert the original's `(0, stage)` invocation into our
+			// `doShowScrapbook(stage)` (we collapse the param_1=0
+			// "no-current-mystery" indirection — relevant only for
+			// the post-win callsite).
+			auto runScrapbook = [&](uint stage) {
+				CursorMan.showMouse(false);
+				doShowScrapbook(stage);
+				CursorMan.showMouse(true);
+				setSitePalette(0);
+			};
+			if (kScrap1Btn.contains(mx, my)) {
+				runScrapbook(1);
+				dirty = true;
+				continue;
+			}
+			if (kScrap2Btn.contains(mx, my) && _chainStage >= 2) {
+				runScrapbook(2);
+				dirty = true;
+				continue;
+			}
+			if (kScrap3Btn.contains(mx, my) && _chainStage >= 3) {
+				runScrapbook(3);
+				dirty = true;
+				continue;
+			}
 		}
 		if (dirty)
 			draw();
@@ -1061,9 +1122,24 @@ void EEMEngine::doCaseSelection() {
 		"         See ScrapBook 2",
 		"         See ScrapBook 3"
 	};
-	// ScrapBooks aren't implemented yet — grey them so the player can't
-	// stop on them, mirroring the original `_Greys` mask.
-	const bool kPickEnabled[kNumPicks] = { true, true, false, false, false };
+	// ScrapBook entries are gated by chain stage exactly as the
+	// original `_ActionScreen @ 1c33:195b` does at 1c33:19f3-19f7:
+	//   stage 1 + nothing solved → ScrapBook 1/2/3 all greyed
+	//   stage 1 + ≥1 solved      → ScrapBook 1 enabled, 2/3 greyed
+	//   stage 2                  → ScrapBook 1 enabled, 2 enabled, 3 greyed
+	//   stage 3                  → all three enabled
+	// (`_3f9b[i] != 0` over the tier's mystery range is the per-tier
+	// gate; we approximate "any in tier" by checking _chainStage and
+	// any solved flag.)
+	bool anySolved1 = false;
+	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
+		if (_mysteriesSolved[i]) { anySolved1 = true; break; }
+	const bool scrap1On = anySolved1 || _chainStage >= 2;
+	const bool scrap2On = _chainStage >= 2;
+	const bool scrap3On = _chainStage >= 3;
+	const bool kPickEnabled[kNumPicks] = {
+		true, true, scrap1On, scrap2On, scrap3On
+	};
 	uint pick = kPickChoose;
 
 	const char *kSeparator = "----------------------------------";
@@ -1234,8 +1310,19 @@ void EEMEngine::doCaseSelection() {
 		return;
 	}
 
-	if (pick != kPickChoose) {
-		// ScrapBooks aren't implemented; bail back to the menu loop.
+	if (pick == kPickScrap1 || pick == kPickScrap2 || pick == kPickScrap3) {
+		// `_ActionScreen` handlers at 1c33:1B13 / 1B26 / 1B40 each
+		// call `_ShowScrapbook(0, stage)` for the matching tier
+		// (verified at the action-handler jumptable bytes
+		// `01 03 05 07 09 ff` paired with handlers at 1c33:1be1).
+		// The picker here is meant to LEAVE the mystery state untouched
+		// — viewing the scrapbook never starts a new case.
+		const uint stage = (pick == kPickScrap1) ? 1
+						 : (pick == kPickScrap2) ? 2 : 3;
+		CursorMan.showMouse(false);
+		doShowScrapbook(stage);
+		CursorMan.showMouse(true);
+		setSitePalette(0);
 		_mystery.clear();
 		return;
 	}
@@ -1976,6 +2063,12 @@ void EEMEngine::doGallery() {
 									e2.type == Common::EVENT_RETURN_TO_LAUNCHER)
 									return;
 							}
+							// Per-tick `updateScreen()` so the SDL cursor
+							// follows the mouse — without it the cursor
+							// freezes on entry to the MoreInfo screen
+							// (we never repaint here, so the cursor never
+							// gets drawn at its current position).
+							g_system->updateScreen();
 							g_system->delayMillis(20);
 						}
 						// Force gallery redraw immediately so the


Commit: b4dc4f62742265b7b62335720033115b3e37eeba
    https://github.com/scummvm/scummvm/commit/b4dc4f62742265b7b62335720033115b3e37eeba
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:46+02:00

Commit Message:
EEM: added more missing animations

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 4d092f292cb..45cea187910 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1962,6 +1962,32 @@ void EEMEngine::doGallery() {
 									   (const byte *)galBg.surface.getBasePtr(0, row), bw);
 							}
 						}
+						// Partner sprite at (5, 0x50). The original
+						// `MoreInfo @ 158f:0419` calls
+						// `_RefreshGalleryBackground` (clears the
+						// portrait grid) but the partner anim slot
+						// registered by `_DoGallery` keeps painting
+						// at every `_UpdateAnimations` tick — the
+						// suspect detail pic covers the right side
+						// only (drawn at 0x94, 0xf), so the partner
+						// stays visible on the left. Without this
+						// blit the suspect-detail screen has no
+						// partner.
+						{
+							const uint partnerAnim =
+								(_partner == 0) ? 2 : 0x10;
+							Animation partnerAni;
+							if (_aniArchive.loadAnimation(partnerAnim,
+														   partnerAni) &&
+								!partnerAni.empty()) {
+								const uint32 now = g_system->getMillis();
+								const uint frameIdx =
+									partnerFrameAtTick(0x02,
+										(uint)partnerAni.size(), now);
+								blitAnimFrameAnchored(ms.surfacePtr(),
+									partnerAni[frameIdx], 5, 0x50);
+							}
+						}
 						// Full suspect picture at (0x94, 0xf).
 						Picture detail;
 						if (_picsArchive.getPicture(detailPic, detail)) {
@@ -2794,15 +2820,39 @@ void EEMEngine::doAccuse() {
 				if (haveBalloon && balloon.surface.h < 0x4e)
 					balloonY = (0x50 - balloon.surface.h) / 2;
 
+				// Render the gallery FIRST so the balloon snapshot
+				// includes the partner sprite. The original
+				// `_DoAccuseGallery @ 1df2:0a31` does this implicitly:
+				// `_NewAnimation` registered the partner slot at
+				// (5, 0x50) before reaching this point, then
+				// `_GetBackground(0x3f)` + `_DrawGallery` paint the
+				// portraits, and `_UpdateAnimations` keeps the partner
+				// visible underneath the balloon overlay. Without this,
+				// the player sees an 8-second partner-less screen
+				// while reading the hint.
+				drawAccuseGallery(num, gd, /*highlighted=*/-1,
+								  slotRects, slotSuspect);
+
 				Graphics::ManagedSurface ms(320, 200,
 					Graphics::PixelFormat::createFormatCLUT8());
 				ms.clear();
-				if (haveAccuseBg) {
-					const int bw = MIN<int>(accuseBg.surface.w, 320);
-					const int bh = MIN<int>(accuseBg.surface.h, 200);
-					for (int row = 0; row < bh; row++) {
-						memcpy((byte *)ms.getBasePtr(0, row),
-							   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+				{
+					Graphics::Surface *cur = g_system->lockScreen();
+					if (cur) {
+						for (int row = 0; row < 200; row++)
+							memcpy((byte *)ms.getBasePtr(0, row),
+								   (const byte *)cur->getBasePtr(0, row), 320);
+						g_system->unlockScreen();
+					} else if (haveAccuseBg) {
+						// Fallback: lockScreen failed somehow; at least
+						// fill from PIC 0x3f so we don't render against
+						// stale memory.
+						const int bw = MIN<int>(accuseBg.surface.w, 320);
+						const int bh = MIN<int>(accuseBg.surface.h, 200);
+						for (int row = 0; row < bh; row++) {
+							memcpy((byte *)ms.getBasePtr(0, row),
+								   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
+						}
 					}
 				}
 				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)


Commit: 256eff166c411dc493e6636e730ac726eef5f38a
    https://github.com/scummvm/scummvm/commit/256eff166c411dc493e6636e730ac726eef5f38a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:46+02:00

Commit Message:
EEM: stop music/sound in certain points of the game

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index d2635e3823d..69a805fffd8 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -336,11 +336,21 @@ void EEMEngine::doInitClues() {
 			g_system->unlockScreen();
 			g_system->updateScreen();
 
-			// Wait 100 ms or until input.
+			// Wait 100 ms or until input. ESC also stops any pending
+			// voice / spool so audio doesn't bleed past the briefing
+			// when the player skips early — without this the per-clue
+			// voice line that `displayClue` would spool keeps playing
+			// after we've moved on to the MAP.
 			const uint32 wakeup = g_system->getMillis() + 100;
 			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
 				Common::Event ev;
 				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_KEYDOWN &&
+						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						interruptAudio();
+						skip = true;
+						break;
+					}
 					if (ev.type == Common::EVENT_LBUTTONDOWN ||
 						ev.type == Common::EVENT_KEYDOWN) {
 						skip = true;
@@ -437,6 +447,12 @@ void EEMEngine::doInitClues() {
 					   !shouldQuit() && !skip) {
 					Common::Event ev;
 					while (g_system->getEventManager()->pollEvent(ev)) {
+						if (ev.type == Common::EVENT_KEYDOWN &&
+							ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+							interruptAudio();
+							skip = true;
+							break;
+						}
 						if (ev.type == Common::EVENT_LBUTTONDOWN ||
 							ev.type == Common::EVENT_KEYDOWN) {
 							skip = true;
@@ -780,6 +796,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						advance = true;
 						skipAll = true;
+						// Cut the per-clue voice line that was just
+						// spooled — without this the voice keeps
+						// playing past the dialog dismissal and
+						// bleeds into the next screen (e.g. the
+						// case-briefing voice still talking on the
+						// MAP after ESC).
+						interruptAudio();
 						break;
 					}
 					if (ev.type == Common::EVENT_LBUTTONDOWN ||
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 46fa0497f45..9cf6ab97843 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -456,6 +456,22 @@ bool EEMEngine::setAnmPalette(const Common::Path &anmPath) {
 	return true;
 }
 
+void EEMEngine::interruptAudio() {
+	// Mirrors `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @
+	// 20a2:0512` — the original calls both whenever the player aborts
+	// the opening-anim chain or dismisses the title (`_DoOpeningAnims
+	// @ 2520:082a` writes `_LoopMIDI = 0; _StopMIDI();` after the
+	// title-input loop). We expose the same combined stop on every
+	// ESC handler so currently-playing music + voice + spool actually
+	// halt instead of bleeding through into the next screen.
+	if (_audio) {
+		_audio->stopVoice();
+		_audio->stopSpool();
+	}
+	if (_music)
+		_music->stop();
+}
+
 void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLastFrame) {
 	ANMDecoder anm;
 	if (!anm.open(path)) {
@@ -483,7 +499,8 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 		// the frame-rate calibration logic from _GetSpeedRating is wired up.
 		// ESC additionally sets `_skipIntro` so the opening-anim chain in
 		// run() bails out of the whole sequence instead of advancing to
-		// the next clip.
+		// the next clip — and stops every active audio channel so the
+		// theme music / voice spool don't bleed past the abort.
 		const uint32 frameStart = g_system->getMillis();
 		bool aborted = false;
 		while (g_system->getMillis() - frameStart < frameDelayMs && !aborted) {
@@ -496,8 +513,10 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 					break;
 				}
 				if (event.type == Common::EVENT_KEYDOWN) {
-					if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						_skipIntro = true;
+						interruptAudio();
+					}
 					aborted = true;
 					break;
 				}
@@ -523,8 +542,10 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 					break;
 				}
 				if (ev.type == Common::EVENT_KEYDOWN) {
-					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						_skipIntro = true;
+						interruptAudio();
+					}
 					clicked = true;
 					break;
 				}
@@ -534,6 +555,12 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 			g_system->updateScreen();
 			g_system->delayMillis(20);
 		}
+		// `_DoOpeningAnims @ 2520:0945` writes `_LoopMIDI = 0;
+		// _StopMIDI();` once the title-input loop exits — so the
+		// theme stops the moment the player dismisses the title,
+		// regardless of whether they used ESC or clicked.
+		if (_music)
+			_music->stop();
 	}
 }
 
@@ -549,7 +576,10 @@ void EEMEngine::blitAt(const Picture &pic, int x, int y) {
 
 void EEMEngine::waitForInput(uint32 maxMs) {
 	// ESC additionally raises `_skipIntro` so the opening-anim chain
-	// can fast-forward past the rest of the sequence.
+	// can fast-forward past the rest of the sequence, and stops any
+	// active audio so the theme / voice / spool don't bleed past
+	// the abort. Mirrors the `_CleanMysterySounds` + `_StopMIDI`
+	// pair around the title wait in `_DoOpeningAnims`.
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
 		Common::Event event;
@@ -560,8 +590,10 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 				return;
 			}
 			if (event.type == Common::EVENT_KEYDOWN) {
-				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_skipIntro = true;
+					interruptAudio();
+				}
 				return;
 			}
 		}
@@ -616,12 +648,25 @@ void EEMEngine::startTravelMusic() {
 	//   }
 	//
 	// Five travel tracks: MUS00000.XMI .. MUS00004.XMI, picked by
-	// `_SiteNumber % 5`. The original always loops travel music (the
-	// `_LoopMIDI` global isn't reset between site changes).
+	// `_SiteNumber % 5`. ONE-SHOT — `_DoOpeningAnims @ 2520:0945`
+	// resets `_LoopMIDI = 0` after the title-screen wait, and
+	// `_StartTravelMusic` doesn't write to it; combined with
+	// `_DoSiteLoop @ 168d:06c0` which waits for the track to play
+	// out and then calls `_StopMIDI()` before the interactive phase
+	// begins, the original effectively plays travel music ONCE
+	// during the entrance animation only — the site investigation
+	// itself runs without music. Our previous `loop=true` made the
+	// music never end, leaving travel music droning through site
+	// investigation, accuse, gallery, etc.
 	if (!_music || !_mystery.isLoaded())
 		return;
 	const uint num = _mystery._siteNumber % 5;
-	_music->playMus(num, /*loop=*/true);
+	_music->playMus(num, /*loop=*/false);
+}
+
+void EEMEngine::stopMusic() {
+	if (_music)
+		_music->stop();
 }
 
 void EEMEngine::syncSoundSettings() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 345a6f8a36a..2d95c0102a1 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -320,6 +320,18 @@ private:
 	void playAnm(const Common::Path &path, uint frameDelayMs = 120,
 				 bool holdLastFrame = false);
 
+	/// Stop every active audio channel — voice, sound spool, and
+	/// MIDI. Mirrors the `_CleanMysterySounds @ 202f:05a5` +
+	/// `_StopMIDI @ 20a2:0512` pair the original triggers when the
+	/// player aborts the opening-anim chain or dismisses the title
+	/// (`_DoOpeningAnims @ 2520:082a` writes `_LoopMIDI = 0;
+	/// _StopMIDI();` after the title-input loop, and
+	/// `_CleanMysterySounds` is called twice — once after the loop
+	/// and once before TITLE.ANM). Called from every ESC handler in
+	/// the intro / title chain so the theme music + voice spool
+	/// don't bleed past the abort.
+	void interruptAudio();
+
 	// Screen handlers — port targets in screens/ later.
 	void showEAKidsLogo();
 	void showHighScoreLogo();
@@ -380,6 +392,12 @@ public:
 	/// music changes as the player travels between sites.
 	void startTravelMusic();
 
+	/// Stop any currently playing MIDI track. Mirrors `_StopMIDI @
+	/// 20a2:0512` — used by `_DoSiteLoop @ 168d:06c0` after the
+	/// one-shot travel track plays out and by `_DisplayCorrect /
+	/// _DisplayAlibi` between MIDI cues.
+	void stopMusic();
+
 	/// Forwarded from `Engine::syncSoundSettings`. Re-pulls the user's
 	/// `music_volume` slider into the MIDI player's `_masterVolume`,
 	/// otherwise the AdLib output stays at whatever the slider was at
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 062f81f04ba..34a354085f1 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -554,6 +554,14 @@ void SiteScreen::enter(uint siteNum) {
 		renderBackground(siteNum);
 	}
 
+	// Stop the travel music explicitly. `_DoSiteLoop @ 168d:06c0`
+	// waits for the one-shot travel track to play out and then calls
+	// `_StopMIDI()` before the interactive site phase begins —
+	// blocking the engine while it spins. We just stop now so the
+	// site investigation runs without music (matches the original
+	// silent-investigation phase).
+	_vm->stopMusic();
+
 	// Static drops (Loop 2 from `_DoSiteLoop`) — no animation, baked
 	// into the BG snapshot the run() pump uses to restore.
 	renderStaticDrops(siteNum);


Commit: fb9922f887b5af752198719e6043a983dbb5f653
    https://github.com/scummvm/scummvm/commit/fb9922f887b5af752198719e6043a983dbb5f653
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:46+02:00

Commit Message:
EEM: added missing text bubbles here and there

Changed paths:
    engines/eem/graphics.cpp
    engines/eem/mystery.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 0718f6c1471..ed8955c555b 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -30,6 +30,7 @@
 #include "graphics/cursorman.h"
 #include "graphics/managed_surface.h"
 
+#include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
 
@@ -76,38 +77,167 @@ const BalloonInsets kBalloonInsetTable[] = {
 };
 
 void EEMEngine::doHelp() {
-	// `_KDHelp` reads two hint TextBlock offsets from `_KDTextIndex`:
-	//   word @ +0xe : first-time hint
-	//   word @ +0x10: second-time hint (cycles back to first if missing)
-	// `_SawHelpHint` toggles between them.
+	// Mirrors `_KDHelp @ 1560:010a`. The original walks the first two
+	// entries of `_AChain` (the puzzle's required-clue chain — the
+	// "spine" of evidence the player must collect):
+	//
+	//   for (i = 0; i < 2; i++) {
+	//       if (_AChain[i] != -1 && _HintBlock[i] != -1 &&
+	//           _CluesFound[_AChain[i]] == 0) {
+	//           _DisplayHint(TextBlock + _HintBlock[i], i + 10);
+	//           shown++; break;
+	//       }
+	//       if (_HintBlock[i] != -1) defined++;
+	//   }
+	//   if (!shown) {
+	//       // Fall back to the generic KD hint: KDTextIndex[+0xe]
+	//       // (first time) / KDTextIndex[+0x10] (second time, toggled
+	//       // by _SawHelpHint). If neither chain entry had a hint
+	//       // defined, show the global "no hints" sentinel instead.
+	//       _DisplayHint(...);
+	//   }
+	//
+	// So this is a SMART per-puzzle hint: the partner points the
+	// player at whichever chain clue they haven't yet found, only
+	// falling back to the generic "let's keep looking" line when
+	// every chain hint has been triggered already.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
-	const byte *kd = _mystery.kdTextIndex();
+	const byte *kd  = _mystery.kdTextIndex();
+	const byte *hb  = _mystery.hintBlock();
 	if (!kd)
 		return;
 
-	const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
-	const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
-	uint16 use = _mystery._sawHelpHint && hintSecond != 0xFFFF ? hintSecond : hintFirst;
-	if (use == 0xFFFF) {
-		debugC(1, kDebugScript, "doHelp: no hint configured");
+	uint16 chosenText = 0xFFFF;
+	int    soundNum   = 0;
+	bool   anyHintDefined = false;
+
+	if (hb) {
+		for (uint i = 0; i < 2; i++) {
+			const uint16 chainClue = _mystery.aChain(i);
+			if (chainClue == 0xFFFF)
+				continue;
+			const uint16 hintOff = READ_LE_UINT16(hb + i * 2);
+			if (hintOff == 0xFFFF)
+				continue;
+			anyHintDefined = true;
+			if (chainClue < Mystery::kCluesFoundCap &&
+				_mystery._cluesFound[chainClue] == 0) {
+				chosenText = hintOff;
+				soundNum   = (int)i + 10;
+				break;
+			}
+		}
+	}
+
+	if (chosenText == 0xFFFF) {
+		// No unfound chain clue had a hint to give — fall back to the
+		// generic KD hint (or the "no hints defined" sentinel if the
+		// chain has no hints at all). Mirrors the second arm of
+		// `_KDHelp` (1560:0152-019b).
+		if (anyHintDefined) {
+			const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
+			const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
+			if (!_mystery._sawHelpHint && hintFirst != 0xFFFF) {
+				chosenText = hintFirst;
+				soundNum   = 7;
+				_mystery._sawHelpHint = true;
+			} else if (hintSecond != 0xFFFF) {
+				chosenText = hintSecond;
+				soundNum   = 8;
+			}
+		}
+		// Else: keep chosenText == 0xFFFF — original would render
+		// `NoHints` (a "There are no hints defined for this Mystery"
+		// string at 29be:00d3); we just bail.
+	}
+
+	if (chosenText == 0xFFFF) {
+		debugC(1, kDebugScript, "doHelp: no hint available");
 		return;
 	}
-	if (!_mystery._sawHelpHint && hintFirst != 0xFFFF)
-		_mystery._sawHelpHint = true;
 
-	const Common::String raw = _mystery.textAt(use);
-	const Common::String text = parseString(raw, _playerName, _partner);
+	const Common::String raw  = _mystery.textAt(chosenText);
+	Common::String text = parseString(raw, _playerName, _partner);
 
-	Graphics::ManagedSurface scratch(320, 200,
+	// Render as a speech-balloon overlay, exactly mirroring
+	// `_DisplayHint @ 1560:0009`:
+	//
+	//   _GetKDTextBalloon(text, &bub);             // first-char dispatch
+	//   _GetBalloon(bub);                           // load balloon pic
+	//   y = (h < 0x4e) ? (0x50 - h) >> 1 : 1;       // vertical centre
+	//   _AddPicBackground(balloon, 0x21, y);        // overlay on screen
+	//   _WordWrap(0x21+tbl[bub].x, y+tbl[bub].y,   // text inside balloon
+	//             tbl[bub].w, text, -1, color=0);
+	//   _SayKDDigital(snd);                          // partner voice
+	//   _Wait();
+	//
+	// The balloon BG is the caller's CURRENT screen — site / PDA /
+	// gallery — not a cleared scratch.
+	Graphics::ManagedSurface ms(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
-	_font.drawString(&scratch, "HELP", 8, 4, 320, 0xF);
-	_font.drawWordWrapped(&scratch, 8, 24, 304, text, 0xF);
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+	ms.clear();
+	{
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			for (int row = 0; row < 200; row++)
+				memcpy((byte *)ms.getBasePtr(0, row),
+					   (const byte *)cur->getBasePtr(0, row), 320);
+			g_system->unlockScreen();
+		}
+	}
+
+	// Balloon shape dispatch via `_GetKDTextBalloon @ 1df2:0105` —
+	// based on the first char of the parsed text. Digits select a
+	// specific balloon variant; non-digit defaults to `0x17`. The
+	// digit, when present, is THEN consumed from the displayed
+	// text — mirrors `_DisplayAlibi @ 1df2:0145`'s `str = pbVar7 + 1`
+	// advance after using `*str` for `bindx`. Without this the hint
+	// renders like "1Try checking the kitchen..." with a stray
+	// leading digit. `_GetKDTextBalloon` itself doesn't strip it
+	// (verified at 1df2:0105 — it just reads `*str`), so the caller
+	// has to.
+	const byte firstChar =
+		text.empty() ? (byte)0 : (byte)text[0];
+	const uint16 bubNum = getKDTextBalloon(firstChar);
+	if (firstChar >= '0' && firstChar <= '9')
+		text.deleteChar(0);
+	Picture balloon;
+	const bool haveBalloon =
+		_balloonArchive.size() > (bubNum & 0x7F) &&
+		_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+
+	const int balloonX = 0x21;
+	int balloonY = 1;
+	if (haveBalloon && balloon.surface.h < 0x4e)
+		balloonY = (0x50 - balloon.surface.h) / 2;
+
+	if (haveBalloon) {
+		const byte transp = (byte)(balloon.flags >> 8);
+		ms.transBlitFrom(balloon.surface,
+						 Common::Point(balloonX, balloonY),
+						 (uint32)transp);
+	}
+
+	// Balloon-relative text insets from the table at `29be:0875`
+	// (10 bytes per entry: x, y, max-width, ...).
+	uint16 tx = 5, ty = 4, tw = 155;
+	getBalloonInsets(bubNum, tx, ty, tw);
+	_font.drawWordWrapped(&ms, balloonX + tx, balloonY + ty, tw, text,
+						  haveBalloon ? 0 : 0xF);
+
+	g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
 							   0, 0, 320, 200);
 	g_system->updateScreen();
+
+	// `_DisplayHint @ 1560:0009` plays `_SayKDDigital(soundnum)` —
+	// partner-specific voice line keyed to which hint type fired (10
+	// = first chain hint, 11 = second, 7 / 8 = generic KD).
+	if (_audio && _mystery.kdTextIndex() && soundNum > 0)
+		_audio->sayKDDigital(_mystery.kdTextIndex(), (uint)soundNum,
+							 _partner);
+
 	waitForInput(60000);
 }
 
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 39c854535d9..6b6bfbfc56f 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -243,6 +243,16 @@ uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
 	return READ_LE_UINT16(gd + suspectIdx * 0x46 + 0x02);
 }
 
+const byte *Mystery::hintBlock() const {
+	// Header word at index 9 (`_hintOffset`) — used by `_KDHelp @
+	// 1560:010a`'s per-chain-clue hint table. Each pair-of-bytes is
+	// a TextBlock offset for the corresponding `_AChain` entry, or
+	// `0xFFFF` if no hint is defined for that chain position.
+	if (!isLoaded() || _hintOffset == 0 || _hintOffset >= _data.size())
+		return nullptr;
+	return _data.data() + _hintOffset;
+}
+
 const byte *Mystery::solvedClueBlock() const {
 	if (!isLoaded() || _solvedOffset == 0 || _solvedOffset >= _data.size())
 		return nullptr;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 34a354085f1..e444fc4b8b7 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -668,6 +668,16 @@ void SiteScreen::run() {
 				// trigger a hotspot underneath.
 				const Common::Rect kBtnNotebook(35, 111, 56, 136);
 				const Common::Rect kBtnMap     ( 7, 177, 57, 200);
+				// Partner area — port-only enhancement so the player
+				// can click the host sprite for a hint, mirroring the
+				// PDA's rect-3 / gallery's rect-3 behaviour. The
+				// original site loop's `_FindButton(&SiteButtons, 2,
+				// ...)` only checks notebook + map, but the same
+				// partner-click → `_KDHelp` shortcut is wired in
+				// `_HandleNoteButton[3]` (0x0403) and
+				// `_HandleGalleryButton[3]` (0x061e). Rect matches
+				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
+				const Common::Rect kBtnPartner ( 5,  80, 44, 110);
 				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
 					_vm->doNotebook();
 					enter(cur);
@@ -680,6 +690,11 @@ void SiteScreen::run() {
 					enter(cur);
 					break;
 				}
+				if (kBtnPartner.contains(event.mouse.x, event.mouse.y)) {
+					_vm->doHelp();
+					enter(cur);
+					break;
+				}
 				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
 				if (idx >= 0) {
 					onHotspotClicked(cur, (uint)idx);
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 45cea187910..4c6efe1b600 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2757,19 +2757,115 @@ void EEMEngine::doAccuse() {
 	if (!_mystery.isLoaded())
 		return;
 
-	// Mirrors `_DoAccuseGallery @ 1df2:0a31`:
-	//   1. Show KD's hint balloon (KDTextIndex[+8] text).
-	//   2. `_GetBackground(0x3f)` — same backdrop as PDA / gallery.
-	//   3. `_DrawGallery()` — renders portraits at the standard 5 slots
-	//      (positions verified at 29be:0x116, bottom-aligned baseline 0x48).
-	//   4. Click loop dispatching on `_NoteButtons` (same table as PDA)
-	//      with a separate `_HandleAccuseNoteButton` jump table.
+	// Mirrors `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
+	//   1. The original notes-selection screen runs first (PIC 0x1A7).
+	//      We skip it because note-selection lives in the PDA in our
+	//      port — the accuse path is reached via the SOLVE button.
+	//   2. EVIDENCE GATE: `_DoAccuse @ 1df2:0bdd` calls `_SolvedCheck`
+	//      (1df2:0c75) before `_DoAccuseGallery`. If `selectedPoints
+	//      <= 99`, the original shows a partner balloon
+	//      (`KDTextIndex[+6]` text + `_SayKDDigital(3)`) and returns
+	//      to `_LastScreen` — the suspect picker is NEVER opened.
+	//      Our previous flow opened the picker and only checked after
+	//      the player committed a suspect; that let the player accuse
+	//      without the required evidence.
+	//   3. If the gate passes:
+	//      a. KD intro balloon (`KDTextIndex[+8]` text + `_SayKDDigital(4)`).
+	//      b. `_GetBackground(0x3f)` + `_DrawGallery()` — portraits at
+	//         the 5 fixed slots (`29be:0x116`).
+	//      c. Click loop on portraits → `_WITCH(picked)` → guilty/alibi.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0)
 		return;
 
 	const byte *gd = _mystery.galleryData();
 
+	// Evidence gate. `_DoAccuse @ 1df2:0c75` runs `_SolvedCheck`
+	// before opening the suspect picker; on failure it renders a
+	// partner balloon over the CURRENT screen (the PDA in the
+	// original) and returns. We render the same hint over the
+	// caller's screen and bail back to `_lastScreen` without ever
+	// touching the gallery BG.
+	if (!_mystery.solvedCheck()) {
+		const byte *kdIdx = _mystery.kdTextIndex();
+		const int16 hintOff = kdIdx
+			? (int16)READ_LE_UINT16(kdIdx + 6)
+			: -1;
+		Common::String hint;
+		if (hintOff != -1)
+			hint = parseString(_mystery.textAt((uint16)hintOff),
+							   _playerName, _partner);
+		if (hint.empty()) {
+			// Fallback if `KDTextIndex[+6]` isn't set in this mystery.
+			hint = (_mystery.selectedPoints() == 0)
+				? "We're not ready to solve this mystery yet. "
+				  "Let's keep investigating until we have some "
+				  "more solid evidence."
+				: "We don't have quite enough evidence yet. "
+				  "Let's review our notes and find a few more "
+				  "clues before we accuse anyone.";
+		}
+
+		// Compose balloon overlay on the current screen. Mirrors the
+		// `_GetKDTextBalloon` + `_GetBalloon` + `_AddPicBackground`
+		// + `_WordWrap` sequence at 1df2:0c8d-0cd1.
+		Graphics::ManagedSurface ms(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		ms.clear();
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			for (int row = 0; row < 200; row++)
+				memcpy((byte *)ms.getBasePtr(0, row),
+					   (const byte *)cur->getBasePtr(0, row), 320);
+			g_system->unlockScreen();
+		}
+		const byte firstChar =
+			hint.empty() ? (byte)0 : (byte)hint[0];
+		const uint16 bubNum = getKDTextBalloon(firstChar);
+		// Strip the digit prefix used for balloon dispatch — it's
+		// consumed by the original at `_DisplayAlibi @ 1df2:0163`
+		// (`str = pbVar7 + 1`) and shouldn't appear in the rendered
+		// text. `_GetKDTextBalloon` itself doesn't advance past it.
+		if (firstChar >= '0' && firstChar <= '9')
+			hint.deleteChar(0);
+		Picture balloon;
+		const bool haveBalloon =
+			_balloonArchive.size() > (bubNum & 0x7F) &&
+			_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+		const int balloonX = 0x21;
+		int balloonY = 1;
+		if (haveBalloon && balloon.surface.h < 0x4e)
+			balloonY = (0x50 - balloon.surface.h) / 2;
+		if (haveBalloon) {
+			const byte transp = (byte)(balloon.flags >> 8);
+			ms.transBlitFrom(balloon.surface,
+							 Common::Point(balloonX, balloonY),
+							 (uint32)transp);
+		}
+		uint16 tx = 5, ty = 4, tw = 155;
+		getBalloonInsets(bubNum, tx, ty, tw);
+		if (_font.isLoaded()) {
+			_font.drawWordWrapped(&ms, balloonX + tx,
+								  balloonY + ty, tw, hint,
+								  haveBalloon ? 0 : 0xF);
+		}
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// `_DoAccuse @ 1df2:0cd9` plays `_SayKDDigital(3)` —
+		// partner-specific "not enough evidence" voice line.
+		if (_audio && kdIdx)
+			_audio->sayKDDigital(kdIdx, 3, _partner);
+
+		waitForInput(20000);
+		// `_DoAccuse @ 1df2:0ce5` writes `_NextScreen = _LastScreen`
+		// so the player drops back where they came from.
+		_nextScreen = _lastScreen != kScreenInvalid
+						? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
 	// Verbatim from 29be:0x116 — same five suspect slot positions as
 	// `_DrawGallery @ 158f:0046`.
 	Picture accuseBg;
@@ -2809,6 +2905,14 @@ void EEMEngine::doAccuse() {
 				const byte firstChar =
 					hint.empty() ? (byte)0 : (byte)hint[0];
 				const uint16 bubNum = getKDTextBalloon(firstChar);
+				// Strip the digit prefix used for balloon dispatch.
+				// `_DisplayAlibi @ 1df2:0163` does `str = pbVar7 + 1`
+				// after using `*str` for `bindx`. Same pattern used by
+				// `_DisplayHint`: digit picks the bubble shape AND is
+				// then consumed from the rendered text. Without this
+				// the intro balloon shows e.g. "1Ready to solve?".
+				if (firstChar >= '0' && firstChar <= '9')
+					hint.deleteChar(0);
 				Picture balloon;
 				const bool haveBalloon =
 					_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -2985,48 +3089,22 @@ void EEMEngine::doAccuse() {
 	// Real chain evaluation. Mirrors the original two-gate accusation:
 	//   1. `_AccuseEntry @ 1df2:0ff8` checks `_GetFoundPoints() >= 100`
 	//      — gates whether the suspect picker is even reachable. We
-	//      replicate this with `_SolvedCheck → selectedPoints > 99`,
-	//      since our port marks clues in the notebook (port deviation;
-	//      the original gates on the auto-sorted top-5 found clues
-	//      via `_GetFoundPoints @ 1df2:0098`).
+	//      `_SolvedCheck → selectedPoints > 99` is now gated at the
+	//      TOP of `doAccuse` — by the time we reach this point we
+	//      already know `solvedCheck()` was true (the picker wouldn't
+	//      have opened otherwise).
 	//   2. `_WITCH @ 1df2:089f` checks `GalleryData[picked*0x46+0x02] ==
 	//      0xFFFF`. Innocent suspects store an alibi-text TextBlock
 	//      offset there; the guilty one uses the sentinel.
-	// Both must hold for `_DisplayCorrect`; otherwise it's an alibi.
 	const int points          = _mystery.selectedPoints();
-	const bool enoughEvidence = _mystery.solvedCheck();
 	const bool pickedGuilty   = _mystery.isGuilty((uint)picked);
-	const bool guessedRight   = enoughEvidence && pickedGuilty;
+	const bool guessedRight   = pickedGuilty;
 	debugC(1, kDebugScript,
-		   "doAccuse: picked=%d selectedPts=%d evidence=%s guilty=%s -> %s",
+		   "doAccuse: picked=%d selectedPts=%d guilty=%s -> %s",
 		   picked, points,
-		   enoughEvidence ? "yes" : "no",
 		   pickedGuilty ? "yes" : "no",
 		   guessedRight ? "correct" : "wrong");
 
-	// Insufficient evidence: same hint the original shows when
-	// `_AccuseEntry` returns 0 (string at `_KDTextIndex[0]`).
-	if (!enoughEvidence && _font.isLoaded()) {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		_font.drawWordWrapped(&scratch, 16, 80, 288,
-			points == 0
-				? "We're not ready to solve this mystery yet. "
-				  "Let's keep investigating until we have some "
-				  "more solid evidence to make our case! "
-				  "(Press N in the site screen to mark clues.)"
-				: "We don't have quite enough solid evidence yet. "
-				  "Let's review our notes and find a few more "
-				  "clues before we accuse anyone.",
-			0xF);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-		waitForInput(15000);
-		return;
-	}
-
 	// Wrong suspect: render alibi flow. Mirrors `_DisplayAlibi @
 	// 1df2:0145` — shows the suspect's alibi text + their picture on
 	// PIC 0x3e, then returns to the accuse gallery for another try.
@@ -3130,21 +3208,31 @@ void EEMEngine::doAccuse() {
 		}
 
 		// `_DisplayCorrect @ 1df2:073c` calls `_MIDIPlay(5)` (1df2:0789)
-		// before `_DifferenceAnimation("scrapbk.ani")` to swap from the
-		// travel music to the winner cue.
+		// before `_DisplayClue` to swap from the travel music to the
+		// winner cue.
 		if (_music)
 			_music->playMus(5, /*loop=*/false);
+
+		// `_DisplayCorrect @ 1df2:07ac` calls
+		// `_DisplayClue(_Mystery + _MysteryIndex[0x10], 0)` BEFORE the
+		// scrapbook animation. That clueblock is the partner's
+		// chain-by-chain RECAP — they enumerate every required clue
+		// (`Look at this — the suspect was here at 8pm`, `... and
+		// remember the broken vase from the kitchen`, `... so it had
+		// to be X!`) and arrive at the conclusion. Without rendering
+		// it the player goes straight from suspect-pick to the
+		// scrapbook anim and misses the deduction entirely.
+		const byte *solved = _mystery.solvedClueBlock();
+		if (solved)
+			displayClue(solved);
+
+		// `_DifferenceAnimation("scrapbk.ani")` (1df2:0848) — the
+		// physical scrapbook flip animation that introduces the
+		// per-mystery ending pages.
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
-		// `_DisplayCorrect @ 1df2:07ac` then calls `_DisplayClue`
-		// against the briefing's winning ClueBlock at
-		// `_Mystery + _MysteryIndex[0x10]` BEFORE `_ShowOneScrap`.
-		// We don't render that intermediate ClueBlock yet (it ties
-		// into the chain-A/B/C result selection); skip straight to
-		// the per-mystery ending pages — the same screen
-		// `_ShowOneScrap @ 1f78:0773` displays via
-		// `_DisplayEnding(num, 1)`. Players see the per-mystery
-		// resolution text on top of the ending pic.
+		// `_ShowOneScrap @ 1f78:0773` is `_DisplayEnding(num, 1)` —
+		// the multi-page per-mystery ending narrative.
 		doShowEnding(mn);
 
 		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the


Commit: 27d73fdbfe92cb2107975cfb780c5c7ba5237b8c
    https://github.com/scummvm/scummvm/commit/27d73fdbfe92cb2107975cfb780c5c7ba5237b8c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:47+02:00

Commit Message:
EEM: dedicated code for accusation window

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 2d95c0102a1..525cf4773f7 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -188,6 +188,14 @@ public:
 	/// Mirrors `_DoAccuseGallery` @ 1df2:0a31 + `_DisplayEnding` @ 1df2:0548.
 	void doAccuse();
 
+	/// Show the accuse-notes screen (PIC 0x1A7, the red "accuse-mode"
+	/// BG with selectable clue list + "N clues" remaining counter).
+	/// Mirrors the outer loop of `_DoAccuse @ 1df2:0bdd`. Returns
+	/// true if the player committed (selected the chain-required
+	/// number of clues and clicked SOLVE), false if they exited via
+	/// ESC / back. Called from `doAccuse` before the evidence gate.
+	bool doAccuseNotes();
+
 	/// Show a host hint from `KDTextIndex`. Mirrors `_KDHelp` @ 1560:010a +
 	/// `_DisplayHint` @ 1560:0009. Cycles between the two hint slots that
 	/// the original engine tracks via `_SawHelpHint`.
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 4c6efe1b600..9956e137bd9 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1792,13 +1792,15 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		y += h + 7;
 	}
 
-	// Page indicator + selected-points counter directly on PIC.
+	// Page indicator only — the original `_DrawNotes @ 161e:01d0`
+	// has no points display in the PDA notebook (the per-clue point
+	// values are SPOILERS for the chain weighting that the engine
+	// uses internally for `_GetSelectedPoints`). Showing the running
+	// total tells the player exactly when they have enough evidence
+	// to solve, which deflates the deduction step.
 	_font.drawString(&scratch, Common::String::format("p%d/%d",
 							   page + 1, (int)pageStarts.size()),
 					 270, 4, 320, 0x5C);
-	_font.drawString(&scratch, Common::String::format("%d pts",
-							   _mystery.selectedPoints()),
-					 270, 14, 320, 0x5C);
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
@@ -2753,33 +2755,340 @@ uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
 	return kDigitBalloons[firstChar - '0'];
 }
 
+bool EEMEngine::doAccuseNotes() {
+	// Mirrors the accuse-notes screen at the head of `_DoAccuse @
+	// 1df2:0bdd`:
+	//   * BG: PIC 0x1A7 (the red "accuse-mode" backdrop).
+	//   * `_AccuseNoteRect @ 29be:1048` = (79, 27, 304, 159) holds
+	//     the rendered clue list.
+	//   * Counter at `(0xd1, 0xb)` = `(209, 11)` shows "N clue(s)"
+	//     remaining (`_UpdateSelectionCount @ 1df2:08dd`,
+	//     `_Show_String(0xb, 0xd1, ...)`).
+	//   * Expected count = `6 - DAT_2d5d_3f99`:
+	//       chainStage 1 → 5 clues, 2 → 4 clues, 3 → 3 clues.
+	//   * `_NoteUnselectedColor = 1` (red) for unselected, `0x3c`
+	//     for selected (1df2:0c2c sets it on entry).
+	//   * Click on a clue toggles its selection
+	//     (`_SearchNoteAreas` + `_SwapColors`).
+	//   * Click `_NoteButtons[2]` (rect at `(157, 174, 178, 190)`,
+	//     the original's "go to gallery" button) jumps to the
+	//     evidence check; `_HandleAccuseNoteButton(2)` returns 2
+	//     and the outer loop forces `uStack_8 = uStack_a` to
+	//     trigger `_SolvedCheck`.
+	//   * ESC sets `_NextScreen = 3` and exits.
+	if (!_mystery.isLoaded() || !_font.isLoaded())
+		return false;
+	const byte *ni = _mystery.noteIndex();
+	const uint16 niCount = _mystery.noteIndexCount();
+	if (!ni)
+		return false;
+
+	Picture accuseBg;
+	const bool haveBg = _picsArchive.getPicture(0x1a7, accuseBg);
+
+	// Required count for solving — `6 - chainStage`.
+	const uint expected = (_chainStage >= 1 && _chainStage <= 3)
+		? (uint)(6 - _chainStage)
+		: 5;
+
+	// Build the list of FOUND clue IDs (in clue-ID order; the
+	// original's `_DrawNotes(NULL, 100, ...)` walks `_CluesFound[]`
+	// the same way).
+	Common::Array<uint> found;
+	for (uint i = 0; i < niCount && i < Mystery::kCluesFoundCap; i++) {
+		if (_mystery._cluesFound[i])
+			found.push_back(i);
+	}
+
+	// `_AccuseNoteRect` (79, 27, 304, 159) — text wrap area.
+	const int rectX = 79;
+	const int rectY = 27;
+	const int rectW = 304 - 79;
+	const int rectH = 159 - 27;
+
+	// `_NoteButtons` rects (verified at `29be:0147`). `_DoAccuse`
+	// re-uses the same table as `_DoNotebook`, but only SOLVE /
+	// PAGE NEXT / PAGE PREV do anything; others sit inert.
+	// `_HandleAccuseNoteButton @ 1df2:0990` returns `DI` (initialised
+	// to 0) and only sets `DI = 2` in the `i == 4` branch (asm:
+	// `1df2:09b2: MOV DI, 0x2`). The outer loop's `iVar6 == 2` test
+	// at `1df2:0db2` is checking the HANDLER'S RETURN VALUE, not the
+	// button INDEX — earlier comment had this backwards. So the
+	// SOLVE rect is `[4]` (180, 174, 201, 190), the same icon the
+	// PDA uses to trigger the accuse flow in the first place.
+	const Common::Rect kBtnSolve   (180, 174, 201, 190); // [4] SOLVE
+	const Common::Rect kBtnPageNext(204, 174, 224, 190); // [5] PAGE NEXT
+	const Common::Rect kBtnPagePrev(226, 174, 247, 190); // [6] PAGE PREV
+	const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
+
+	// Per-page slot rects + their clue IDs (for click hit-testing).
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<uint> slotClues;
+
+	int page = 0;
+	int pageBreaks[16];
+	int numPages = 1;
+	pageBreaks[0] = 0;
+
+	auto rebuildPagination = [&]() {
+		numPages = 1;
+		pageBreaks[0] = 0;
+		const int lineH = _font.getFontHeight() + 1;
+		int y = rectY;
+		for (uint i = 0; i < found.size(); i++) {
+			const uint clueId = found[i];
+			Common::String txt;
+			if (clueId < niCount) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				txt = parseString(_mystery.textAt(textOff),
+								   _playerName, _partner);
+			}
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, rectW, wrapped);
+			const int h = (int)wrapped.size() * lineH;
+			if (y + h + 7 > rectY + rectH) {
+				if (numPages < (int)ARRAYSIZE(pageBreaks)) {
+					pageBreaks[numPages++] = (int)i;
+					y = rectY;
+				}
+			}
+			y += h + 7;
+		}
+		if (page >= numPages)
+			page = numPages - 1;
+		if (page < 0)
+			page = 0;
+	};
+
+	auto draw = [&]() {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveBg)
+			scratch.simpleBlitFrom(accuseBg.surface);
+
+		// Partner sprite at (5, 0x50). The original `_DoAccuse @
+		// 1df2:0c2c` does `_NewAnimation(5, 0x50, partnerCells,
+		// script=2, prior=1)` — same anim cells as the gallery
+		// (anim 2 for Jake / 0x10 for Jenny) with script 0x02. The
+		// `_UpdateAnimations` loop in `_DoAccuse @ 1df2:0bfa` keeps
+		// the slot painting through the entire selection screen;
+		// without an explicit blit here the player sees a partner-
+		// less accuse-mode screen.
+		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+		Animation partnerAni;
+		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+			!partnerAni.empty()) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = partnerFrameAtTick(0x02,
+													  (uint)partnerAni.size(), now);
+			blitAnimFrameAnchored(scratch.surfacePtr(),
+								  partnerAni[frameIdx], 5, 0x50);
+		}
+
+		// Clue list inside `_AccuseNoteRect`. Selected = 0x3c (yellow),
+		// unselected = 1 (red), per `_NoteUnselectedColor = 1` set at
+		// 1df2:0c25 — the red colour is what gives the screen its
+		// "accuse-mode" look together with PIC 0x1A7.
+		slotRects.clear();
+		slotClues.clear();
+		const int lineH = _font.getFontHeight() + 1;
+		const int startIdx = pageBreaks[page];
+		const int endIdx   = (page + 1 < numPages)
+			? pageBreaks[page + 1]
+			: (int)found.size();
+		int y = rectY;
+		uint selectedCount = 0;
+		for (uint i = 0; i < found.size(); i++) {
+			if (_mystery._noteSelected[found[i]])
+				selectedCount++;
+		}
+		for (int i = startIdx; i < endIdx; i++) {
+			const uint clueId = found[i];
+			Common::String txt;
+			if (clueId < niCount) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				txt = parseString(_mystery.textAt(textOff),
+								   _playerName, _partner);
+			}
+			if (txt.empty())
+				txt = Common::String::format("clue %u", clueId);
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, rectW, wrapped);
+			const int h = (int)wrapped.size() * lineH;
+			const byte color = _mystery._noteSelected[clueId] ? 0x3c : 0x01;
+			for (uint li = 0; li < wrapped.size(); li++) {
+				_font.drawString(&scratch, wrapped[li], rectX,
+								 y + (int)li * lineH, rectW, color);
+			}
+			slotRects.push_back(Common::Rect(rectX, y,
+											  rectX + rectW, y + h));
+			slotClues.push_back(clueId);
+			y += h + 7;
+		}
+
+		// Counter — `_UpdateSelectionCount(remaining)` at (0xd1, 0xb).
+		const uint remaining = (selectedCount < expected)
+			? expected - selectedCount
+			: 0;
+		const Common::String counter = Common::String::format("%u %s",
+			remaining, remaining == 1 ? "clue" : "clues");
+		_font.drawString(&scratch, counter, 209, 11, 100, 0x0F);
+
+		if (numPages > 1) {
+			_font.drawString(&scratch,
+				Common::String::format("p%d/%d", page + 1, numPages),
+				rectX, 11, 60, 0x0F);
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	rebuildPagination();
+	draw();
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return false;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return false;
+				if (ev.kbd.keycode == Common::KEYCODE_LEFT &&
+					page > 0) {
+					page--;
+					dirty = true;
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT &&
+						   page + 1 < numPages) {
+					page++;
+					dirty = true;
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				const int mx = ev.mouse.x;
+				const int my = ev.mouse.y;
+				// Page navigation — `_NoteButtons[5]` / `[6]`,
+				// dispatched in `_HandleAccuseNoteButton @
+				// 1df2:0990`. Only effective if there's another
+				// page in that direction; mirrors the
+				// `1 < _CurrentPage` guard at 1df2:09a8 and the
+				// `_NextClue != -1` guard at 1df2:099e.
+				if (kBtnPageNext.contains(mx, my)) {
+					if (page + 1 < numPages) {
+						page++;
+						dirty = true;
+					}
+					continue;
+				}
+				if (kBtnPagePrev.contains(mx, my)) {
+					if (page > 0) {
+						page--;
+						dirty = true;
+					}
+					continue;
+				}
+				// Partner click — `_NoteButtons[3]`. Original
+				// `_HandleAccuseNoteButton` doesn't dispatch this
+				// (no case for i == 3), but the rect is still in
+				// the table; we wire it to the puzzle hint so the
+				// player can ask the partner what to look for
+				// without leaving accuse mode.
+				if (kBtnPartner.contains(mx, my)) {
+					doHelp();
+					dirty = true;
+					continue;
+				}
+				if (kBtnSolve.contains(mx, my)) {
+					// Count selected.
+					uint selected = 0;
+					for (uint i = 0; i < found.size(); i++) {
+						if (_mystery._noteSelected[found[i]])
+							selected++;
+					}
+					if (selected == expected) {
+						// Commit — let the caller do the
+						// `_SolvedCheck` + suspect picker dance.
+						return true;
+					}
+					// Wrong count — `_DoAccuse` only triggers the
+					// check when `uStack_8 == uStack_a`; we just
+					// stay in the loop so the player can keep
+					// adjusting.
+					continue;
+				}
+				// Toggle clue under cursor.
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotRects[i].contains(mx, my)) {
+						const uint clueId = slotClues[i];
+						_mystery._noteSelected[clueId] ^= 1;
+						dirty = true;
+						break;
+					}
+				}
+			}
+		}
+		if (dirty)
+			draw();
+		// Per-tick redraw so the partner sprite cycles. Same
+		// 100 ms cadence as `_CheckFrameRate` + `_UpdateAnimations`
+		// in the original (1df2:0bfa).
+		static uint32 sLastTick = 0;
+		const uint32 now = g_system->getMillis();
+		if (now - sLastTick >= 100) {
+			sLastTick = now;
+			draw();
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+	return false;
+}
+
 void EEMEngine::doAccuse() {
-	if (!_mystery.isLoaded())
+	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
 	// Mirrors `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
-	//   1. The original notes-selection screen runs first (PIC 0x1A7).
-	//      We skip it because note-selection lives in the PDA in our
-	//      port — the accuse path is reached via the SOLVE button.
-	//   2. EVIDENCE GATE: `_DoAccuse @ 1df2:0bdd` calls `_SolvedCheck`
-	//      (1df2:0c75) before `_DoAccuseGallery`. If `selectedPoints
-	//      <= 99`, the original shows a partner balloon
-	//      (`KDTextIndex[+6]` text + `_SayKDDigital(3)`) and returns
-	//      to `_LastScreen` — the suspect picker is NEVER opened.
-	//      Our previous flow opened the picker and only checked after
-	//      the player committed a suspect; that let the player accuse
-	//      without the required evidence.
-	//   3. If the gate passes:
-	//      a. KD intro balloon (`KDTextIndex[+8]` text + `_SayKDDigital(4)`).
-	//      b. `_GetBackground(0x3f)` + `_DrawGallery()` — portraits at
-	//         the 5 fixed slots (`29be:0x116`).
-	//      c. Click loop on portraits → `_WITCH(picked)` → guilty/alibi.
+	//   1. ACCUSE-NOTES SCREEN (PIC 0x1A7, the red "accuse-mode" BG):
+	//      `_DrawNotes(_AccuseNoteRect, NULL, 100, _NoteSelected)`
+	//      lists every found clue inside the rect at `29be:1048` =
+	//      `(79, 27, 304, 159)`. `_UpdateSelectionCount(remaining)`
+	//      shows "N clue(s)" at `(209, 11)` (ASM: `_Show_String(0xb,
+	//      0xd1, ...)` at 1df2:0907). `_NoteUnselectedColor = 1` is
+	//      the dim red used for unselected entries; selected ones
+	//      get `0x3c`. Click toggles via `_SearchNoteAreas` +
+	//      `_SwapColors`. Expected count = `6 - DAT_2d5d_3f99`
+	//      (= 6 - chainStage):
+	//          stage 1 → 5 clues, stage 2 → 4, stage 3 → 3.
+	//      When the count matches, `_SolvedCheck` decides:
+	//          fail → KD "not enough evidence" balloon → return.
+	//          pass → `_DoAccuseGallery()` (suspect picker).
+	//   2. KD intro balloon (`KDTextIndex[+8]` + `_SayKDDigital(4)`).
+	//   3. `_GetBackground(0x3f)` + `_DrawGallery()` — portraits at
+	//      the 5 fixed slots (`29be:0x116`).
+	//   4. Click loop on portraits → `_WITCH(picked)` → guilty/alibi.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0)
 		return;
 
 	const byte *gd = _mystery.galleryData();
 
+	// ACCUSE-NOTES SCREEN — let the player commit which N clues they
+	// believe solve the case. Mirrors the click-driven selection of
+	// `_DoAccuse @ 1df2:0bdd`'s outer loop. ESC / cancel returns to
+	// the site (matches `_DoAccuse @ 1df2:0c11` writing
+	// `_NextScreen = 3` on ESC).
+	if (!doAccuseNotes()) {
+		_nextScreen = _lastScreen != kScreenInvalid
+						? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
 	// Evidence gate. `_DoAccuse @ 1df2:0c75` runs `_SolvedCheck`
 	// before opening the suspect picker; on failure it renders a
 	// partner balloon over the CURRENT screen (the PDA in the


Commit: 4b19ad71a33e7cbb764e5ada33cb4c4662a5301e
    https://github.com/scummvm/scummvm/commit/4b19ad71a33e7cbb764e5ada33cb4c4662a5301e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:47+02:00

Commit Message:
EEM: missing background when accusation resulted in the correct answer

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 9956e137bd9..962e06210e4 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3516,19 +3516,50 @@ void EEMEngine::doAccuse() {
 			}
 		}
 
-		// `_DisplayCorrect @ 1df2:073c` calls `_MIDIPlay(5)` (1df2:0789)
-		// before `_DisplayClue` to swap from the travel music to the
-		// winner cue.
+		// `_DisplayCorrect @ 1df2:073c` order:
+		//   1df2:0773  _AllBlack();
+		//   1df2:0776  _BuildBackground(5, 0x42, 0x14);  // conclusion BG
+		//   1df2:0780  _FadeIn();
+		//   1df2:0789  _MIDIPlay(5);                      // win music
+		//   1df2:07ac  _DisplayClue(MysteryIndex[+0x10]); // chain recap
+		// `_BuildBackground @ 172b:13e2` loads PIC 0x3D (the standard
+		// frame) and overlays SITES.DBD entry 5 at (0x42, 0x14), then
+		// sets palette via `_GetPalette(sitenum + 1)` = palette 6.
+		// Without this BG the chain-recap balloons render on top of
+		// the accuse-gallery BG (PIC 0x3F + suspect portraits), which
+		// is visually jarring — the conclusion is supposed to play
+		// against the dedicated "office / desk" scene.
+		Graphics::Surface *blk = g_system->lockScreen();
+		if (blk) {
+			memset(blk->getPixels(), 0, 320 * 200);
+			g_system->unlockScreen();
+		}
+		setSitePalette(6); // sitenum + 1 per `_GetPalette` call
+		Picture frame, scene;
+		if (_picsArchive.loadEntry(0x3d, frame)) {
+			g_system->copyRectToScreen(frame.surface.getPixels(),
+									   frame.surface.pitch, 0, 0,
+									   frame.surface.w, frame.surface.h);
+		}
+		if (5 < _sitesArchive.size() &&
+			_sitesArchive.loadEntry(5, scene)) {
+			const int sx = 0x42, sy = 0x14;
+			const int sw = MIN<int>(scene.surface.w, 320 - sx);
+			const int sh = MIN<int>(scene.surface.h, 200 - sy);
+			if (sw > 0 && sh > 0)
+				g_system->copyRectToScreen(scene.surface.getPixels(),
+										   scene.surface.pitch, sx, sy,
+										   sw, sh);
+		}
+		g_system->updateScreen();
+
 		if (_music)
 			_music->playMus(5, /*loop=*/false);
 
-		// `_DisplayCorrect @ 1df2:07ac` calls
-		// `_DisplayClue(_Mystery + _MysteryIndex[0x10], 0)` BEFORE the
-		// scrapbook animation. That clueblock is the partner's
-		// chain-by-chain RECAP — they enumerate every required clue
-		// (`Look at this — the suspect was here at 8pm`, `... and
-		// remember the broken vase from the kitchen`, `... so it had
-		// to be X!`) and arrive at the conclusion. Without rendering
+		// Chain-by-chain RECAP. Partner enumerates every required
+		// clue ("Look at this — the suspect was here at 8pm", "... and
+		// remember the broken vase from the kitchen", "... so it had
+		// to be X!") and arrives at the conclusion. Without rendering
 		// it the player goes straight from suspect-pick to the
 		// scrapbook anim and misses the deduction entirely.
 		const byte *solved = _mystery.solvedClueBlock();


Commit: 1e6b78b869f2d96642e1e34fd9286cda2db590ab
    https://github.com/scummvm/scummvm/commit/1e6b78b869f2d96642e1e34fd9286cda2db590ab
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:47+02:00

Commit Message:
EEM: implement wrong accusation

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 9cf6ab97843..5b419853d44 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -143,21 +143,36 @@ Common::Error EEMEngine::run() {
 	debugC(1, kDebugGeneral, "EEM engine starting");
 
 	// If the user chose "Load" before pressing Play, the framework
-	// invokes `loadGameState` which sets up `_mystery` and `_partner`.
-	// Honour that by skipping the intros and dropping into the screen-
-	// driver loop just past the briefing — the loaded mystery already
-	// knows its current site, so we resume at MAP (the original's
-	// post-briefing state, set by handler 0 at 1a35:0e1d).
+	// invokes `loadGameState` which sets up `_playerName`, `_partner`,
+	// `_mysteriesSolved`, and (optionally) `_mystery`. Honour that by
+	// skipping the intros — the player has already typed their name
+	// and picked a partner, so the title chain + profile picker +
+	// partner picker would all be redundant.
+	//
+	//   * Save HAS a mystery in progress → resume at MAP (mirrors the
+	//     original's post-briefing state, handler 0 at 1a35:0e1d).
+	//   * Save has NO mystery → drop into the case-selection screen
+	//     (`kScreenChooseMystery`) so the player can pick which case
+	//     to play. This matches what the original `_ActionScreen`
+	//     leads to — without the redundant action menu in front.
 	const int wantedSave = ConfMan.hasKey("save_slot")
 		? ConfMan.getInt("save_slot") : -1;
 	bool resumed = false;
 	if (wantedSave >= 0) {
 		const Common::Error err = loadGameState(wantedSave);
-		if (err.getCode() == Common::kNoError && _mystery.isLoaded()) {
-			debugC(1, kDebugGeneral, "Resuming from slot %d at mystery %u",
-				   wantedSave, _mystery.number());
+		if (err.getCode() == Common::kNoError) {
 			CursorMan.showMouse(true);
-			_nextScreen = kScreenMap;
+			if (_mystery.isLoaded()) {
+				debugC(1, kDebugGeneral,
+					   "Resuming from slot %d at mystery %u",
+					   wantedSave, _mystery.number());
+				_nextScreen = kScreenMap;
+			} else {
+				debugC(1, kDebugGeneral,
+					   "Resuming profile from slot %d (no mystery — "
+					   "→ case selection)", wantedSave);
+				_nextScreen = kScreenChooseMystery;
+			}
 			resumed = true;
 		}
 	}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 525cf4773f7..d58629c9061 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -170,6 +170,9 @@ public:
 	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
 	void displayClue(const byte *clueBlock);
 
+	/// Active player name (saved as the profile-save description).
+	const Common::String &playerName() const { return _playerName; }
+
 	/// Apply a single ClueEntry's side effects — notebook adds, gallery
 	/// updates, site flags. Called both by `displayClue` after a normal
 	/// click-through and when the player ESC-skips a multi-entry clue.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index e444fc4b8b7..b453150eb3c 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1374,6 +1374,12 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 			   hotIdx, clueOff);
 		const byte *clueBlock = _mystery->blobAt(clueOff);
 		if (clueBlock) {
+			// Snapshot `_cluesFound` BEFORE the clue display so we
+			// can detect if any new clue was actually collected
+			// (vs. a re-read of an already-found clue) — only worth
+			// auto-saving when the player makes progress.
+			byte before[Mystery::kCluesFoundCap];
+			memcpy(before, _mystery->_cluesFound, sizeof(before));
 			// Hand the engine our partner-less backdrop so that
 			// `_DoKDAnim` / `playKdAnim` (the camera-style reaction
 			// animation that fires when a ClueEntry has +0x3a != -1)
@@ -1384,6 +1390,26 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 			_vm->setPartnerEraseBg(&_bgSnapshot);
 			_vm->displayClue(clueBlock);
 			_vm->setPartnerEraseBg(nullptr);
+			// Auto-save when a new clue is found. The original
+			// engine has no autosave (saving is a manual SETUP
+			// button, `_SaveGame @ 2404:0c87`); we add the autosave
+			// here so the player never loses mystery progress.
+			// Detected via 0→1 transition in `_cluesFound[]` (set
+			// by `applyClueSideEffects` inside `displayClue`).
+			bool foundNewClue = false;
+			for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
+				if (!before[i] && _mystery->_cluesFound[i]) {
+					foundNewClue = true;
+					break;
+				}
+			}
+			if (foundNewClue) {
+				const Common::Error err =
+					_vm->saveProfile(_vm->playerName());
+				if (err.getCode() != Common::kNoError)
+					warning("auto-save after clue failed: %s",
+							err.getDesc().c_str());
+			}
 		}
 	}
 	// Caller (`SiteScreen::run`) re-renders the site after this returns.
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 962e06210e4..9ee920fb720 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3414,14 +3414,31 @@ void EEMEngine::doAccuse() {
 		   pickedGuilty ? "yes" : "no",
 		   guessedRight ? "correct" : "wrong");
 
-	// Wrong suspect: render alibi flow. Mirrors `_DisplayAlibi @
-	// 1df2:0145` — shows the suspect's alibi text + their picture on
-	// PIC 0x3e, then returns to the accuse gallery for another try.
-	// Skips MIDI 6 (alibi music) and the per-partner voice line — the
-	// text rendering alone is enough to convey "this suspect is
-	// innocent". `_FirstTry` is cleared so a subsequent correct pick
-	// no longer counts as a first-try win (1df2:0445 -> _FirstTry=0).
+	// Wrong suspect: full alibi flow. Mirrors `_DisplayAlibi @
+	// 1df2:0145`:
+	//   1. Plays MIDI 6 (loser sting) and waits for it to finish while
+	//      the gallery is still on screen (1df2:0184-1df2:0192).
+	//   2. Draws PIC 0x3e + the suspect's speech balloon + their
+	//      portrait at (0x82, py), where the balloon shape comes from
+	//      `AlibiBubbles[bindx]` (table @ 29be:1050) and bindx is the
+	//      digit-prefix on the alibi text (else 2). bindx<8 centres the
+	//      balloon horizontally; bindx>=8 pins it at x=0x21.
+	//   3. Plays the suspect's voice via `_SpoolSound(talk - 1)` where
+	//      `talk = (Partner == 0) ? gd[+0x6] : gd[+0x0]` (1df2:0258),
+	//      then waits for a click.
+	//   4. Overlays the partner's reaction balloon (text @
+	//      `KDTextIndex[+10]`) at (0x21, y) and plays
+	//      `_SayKDDigital(5)`.
+	//   5. Clears `_FirstTry` (1df2:0447) and returns to LastScreen.
 	if (!guessedRight) {
+		// Balloon-shape table @ 29be:1050 — 16 entries × u16.
+		static const uint16 kAlibiBubbles[16] = {
+			0x002B, 0x002C, 0x002D, 0x002E,
+			0x00AB, 0x00AC, 0x00AD, 0x00AE,
+			0x001D, 0x001E, 0x0015, 0x0016,
+			0x0017, 0x0018, 0x0019, 0x001A,
+		};
+
 		const uint16 alibiOff = _mystery.alibiTextOffset((uint)picked);
 		Common::String alibi;
 		if (gd && alibiOff != 0xFFFF) {
@@ -3429,46 +3446,230 @@ void EEMEngine::doAccuse() {
 			if (raw)
 				alibi = parseString(raw, _playerName, _partner);
 		}
+		// Digit-prefix dispatch — `_DisplayAlibi @ 1df2:0163` reads
+		// `*str` for `bindx` and advances `str = pbVar7 + 1` so the
+		// digit doesn't reach the renderer. Non-digit first chars fall
+		// through to the default bindx=2 (1df2:015e).
+		uint bindx = 2;
+		const byte firstChar = alibi.empty() ? (byte)0 : (byte)alibi[0];
+		if (firstChar >= '0' && firstChar <= '9') {
+			bindx = (uint)(firstChar - '0');
+			alibi.deleteChar(0);
+		}
+		if (bindx >= 16)
+			bindx = 2;
+		const uint16 bubNum = kAlibiBubbles[bindx];
+
 		Picture alibiBg;
-		const bool haveAlibiBg =
-			_picsArchive.getPicture(0x3e, alibiBg);
+		const bool haveAlibiBg = _picsArchive.getPicture(0x3e, alibiBg);
 		Picture suspect;
 		const uint16 picId = gd
 			? READ_LE_UINT16(gd + (uint)picked * 0x46)
 			: 0;
 		const bool haveSuspect = picId != 0 &&
 			_picsArchive.getPicture(picId, suspect);
+		Picture balloon;
+		const bool haveBalloon =
+			_balloonArchive.size() > (bubNum & 0x7F) &&
+			_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
 
-		Graphics::ManagedSurface scratch(320, 200,
+		// Position math from 1df2:01a4-1df2:0207. py is the suspect
+		// portrait's Y; defaults to 0x5a, only overridden in the
+		// bindx<8 branch when the balloon is too tall to fit.
+		int balloonX = 0x21;
+		int balloonY = 1;
+		int py = 0x5a;
+		if (bindx < 8) {
+			const int bw = haveBalloon ? balloon.surface.w : 0;
+			const int bh = haveBalloon ? balloon.surface.h : 0;
+			balloonX = (320 - bw) / 2;
+			if (bh < 0x5a) {
+				balloonY = (0x5a - bh) / 2;
+			} else {
+				balloonY = 1;
+				py = bh;
+			}
+		} else {
+			const int bh = haveBalloon ? balloon.surface.h : 0;
+			balloonX = 0x21;
+			balloonY = (bh < 0x4f) ? (0x50 - bh) / 2 : 1;
+		}
+
+		// `base` = BG + suspect + partner sprite — the persistent layer
+		// that survives across both balloon phases. The original engine
+		// keeps PIC 0x3e in the master BG buffer (16000), `_AddPicBackground`
+		// commits the suspect there, and the partner animation
+		// registered by `_DoAccuse @ 1df2:0c30` is re-blitted by every
+		// `_Repaint` via `_DrawActiveAnimations`. We don't have a
+		// slot-based animation system, so we manually keep a "base"
+		// surface and re-draw the partner frame for each phase.
+		Graphics::ManagedSurface base(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveAlibiBg) {
-			scratch.simpleBlitFrom(alibiBg.surface);
-		}
+		base.clear();
+		if (haveAlibiBg)
+			base.simpleBlitFrom(alibiBg.surface);
 		if (haveSuspect) {
-			// Original `_DisplayAlibi` blits the suspect at (0x82, py)
-			// where py varies by balloon size; we use a fixed centred
-			// position since we don't render the balloon shapes yet.
 			const byte transp = (byte)(suspect.flags >> 8);
-			scratch.transBlitFrom(suspect.surface,
-								  Common::Point(0x82, 0x40),
+			base.transBlitFrom(suspect.surface,
+							   Common::Point(0x82, py),
+							   (uint32)transp);
+		}
+		// Partner sprite at (5, 0x50). Anim cells: 2 (Jake) / 0x10
+		// (Jenny); script key 0x02 — same indices `_DoAccuse @
+		// 1df2:0c30` uses for its `_NewAnimation` call. Partner is
+		// drawn AFTER the suspect so it doesn't get clipped by the
+		// portrait if their bounding boxes graze.
+		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+		Animation partnerAni;
+		const bool havePartner =
+			_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+			!partnerAni.empty();
+
+		// Alibi-phase scratch = base + alibi balloon + alibi text +
+		// partner sprite (animation slot drawn on top per
+		// `_DrawActiveAnimations`).
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.simpleBlitFrom(base);
+		if (haveBalloon) {
+			const byte transp = (byte)(balloon.flags >> 8);
+			scratch.transBlitFrom(balloon.surface,
+								  Common::Point(balloonX, balloonY),
 								  (uint32)transp);
 		}
+		// Balloon-text inset table @ 29be:0875 — same dispatch as KD
+		// balloons. WordWrap color is 0 inside a balloon (1df2:0240).
+		uint16 tx = 5, ty = 4, tw = 155;
+		getBalloonInsets(bubNum, tx, ty, tw);
 		if (_font.isLoaded() && !alibi.empty()) {
-			_font.drawWordWrapped(&scratch, 16, 8, 200, alibi, 0xF);
-			_font.drawString(&scratch, "(click / ESC: back)",
-							 16, 188, 200, 0xF);
+			_font.drawWordWrapped(&scratch, balloonX + tx,
+								  balloonY + ty, tw, alibi,
+								  haveBalloon ? 0 : 0xF);
+		}
+		if (havePartner) {
+			const uint frameIdx = partnerFrameAtTick(0x02,
+				(uint)partnerAni.size(), g_system->getMillis());
+			blitAnimFrameAnchored(scratch.surfacePtr(),
+								  partnerAni[frameIdx], 5, 0x50);
 		}
+
+		// Step 1 — alibi music. Original blocks until MIDI 6 ends with
+		// the gallery still on screen. We poll `_music->isPlaying`;
+		// click/ESC aborts early.
+		if (_music) {
+			_music->playMus(6, /*loop=*/false);
+			const uint32 musStart = g_system->getMillis();
+			bool aborted = false;
+			while (_music->isPlaying() && !shouldQuit() && !aborted) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_QUIT ||
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+						return;
+					if (ev.type == Common::EVENT_KEYDOWN ||
+						ev.type == Common::EVENT_LBUTTONDOWN) {
+						aborted = true;
+						break;
+					}
+				}
+				// Hard cap so we never get stuck if MIDI never reports
+				// finish (some sound configurations).
+				if (g_system->getMillis() - musStart > 10000)
+					break;
+				g_system->updateScreen();
+				g_system->delayMillis(20);
+			}
+			_music->stop();
+		}
+
+		// Step 2 — flip the alibi scene to screen + play suspect voice.
+		// `talk = (Partner==0) ? gd[+0x6] : gd[+0x0]` (1df2:0252-0258);
+		// indices are 1-based so subtract 1 before SpoolSound.
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
-		waitForInput(20000);
+		if (_audio && gd) {
+			const uint16 alibiVoice =
+				READ_LE_UINT16(gd + (uint)picked * 0x46 + 0x00);
+			const uint16 jakeVoice =
+				READ_LE_UINT16(gd + (uint)picked * 0x46 + 0x06);
+			const uint16 talk =
+				(_partner == 0) ? jakeVoice : alibiVoice;
+			if (talk != 0)
+				_audio->spoolSound((uint)(talk - 1));
+		}
+		waitForInput(60000);
+
+		// Step 3 — partner reaction balloon. Mirrors 1df2:026e-1df2:02b6.
+		// Rebuild scratch from `base` (BG + suspect + partner sprite)
+		// so the alibi balloon + text don't bleed through. The
+		// original's `_Repaint` re-renders the master BG (which still
+		// has the alibi balloon committed), but the engine ALSO calls
+		// `_GetBackground(0x3e)` again before `_AddPicBackground` for
+		// each new balloon — flushing the master back to a clean state.
+		// We achieve the same end result by restoring `base` here.
+		// `_SayKDDigital(5)` auto-cancels the still-playing alibi voice
+		// (spoolSound calls stopSpool internally) so no explicit stop.
+		const byte *reactIdx = _mystery.kdTextIndex();
+		if (reactIdx) {
+			const int16 reactOff = (int16)READ_LE_UINT16(reactIdx + 10);
+			Common::String react;
+			if (reactOff != -1) {
+				const char *raw = _mystery.textAt((uint16)reactOff);
+				if (raw)
+					react = parseString(raw, _playerName, _partner);
+			}
+			if (!react.empty()) {
+				const byte rChar = (byte)react[0];
+				const uint16 rBub = getKDTextBalloon(rChar);
+				if (rChar >= '0' && rChar <= '9')
+					react.deleteChar(0);
+				Picture rBalloon;
+				const bool haveR =
+					_balloonArchive.size() > (rBub & 0x7F) &&
+					_balloonArchive.loadEntry(rBub & 0x7F, rBalloon);
+				const int rX = 0x21;
+				int rY = 1;
+				if (haveR && rBalloon.surface.h < 0x4e)
+					rY = (0x50 - rBalloon.surface.h) / 2;
+
+				// Reset to a clean BG + suspect, then layer the new
+				// balloon and the (refreshed) partner frame on top.
+				scratch.simpleBlitFrom(base);
+				if (haveR) {
+					const byte transp = (byte)(rBalloon.flags >> 8);
+					scratch.transBlitFrom(rBalloon.surface,
+										   Common::Point(rX, rY),
+										   (uint32)transp);
+				}
+				uint16 rtx = 5, rty = 4, rtw = 155;
+				getBalloonInsets(rBub, rtx, rty, rtw);
+				if (_font.isLoaded()) {
+					_font.drawWordWrapped(&scratch, rX + rtx,
+										  rY + rty, rtw, react,
+										  haveR ? 0 : 0xF);
+				}
+				if (havePartner) {
+					const uint frameIdx = partnerFrameAtTick(0x02,
+						(uint)partnerAni.size(),
+						g_system->getMillis());
+					blitAnimFrameAnchored(scratch.surfacePtr(),
+										  partnerAni[frameIdx],
+										  5, 0x50);
+				}
+				g_system->copyRectToScreen(scratch.getPixels(),
+					scratch.pitch, 0, 0, 320, 200);
+				g_system->updateScreen();
+				if (_audio)
+					_audio->sayKDDigital(reactIdx, 5, _partner);
+			}
+		}
+		waitForInput(60000);
 
 		_mystery._firstTry = false;
 		// `_DisplayAlibi @ 1df2:043f` writes `_NextScreen =
-		// _LastScreen` so the player drops back to wherever they
-		// accused from (typically the site loop). With no mystery
-		// state to unload, the site loop resumes naturally.
+		// _LastScreen`. The original returns the player to the caller
+		// (PDA / site / map) for another try.
 		_nextScreen = _lastScreen != kScreenInvalid
 						? (ScreenId)_lastScreen : kScreenSite;
 		return;


Commit: ddb7949f39880d791315b1b621db0b8437bdaa54
    https://github.com/scummvm/scummvm/commit/ddb7949f39880d791315b1b621db0b8437bdaa54
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:48+02:00

Commit Message:
EEM: fixed some issues in the win screen/newspaper

Changed paths:
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index d58629c9061..875d62f608c 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -368,7 +368,19 @@ private:
 	/// `_ShowOneScrap @ 1f78:0773` is just `_DisplayEnding(num, 1)`,
 	/// so this same call covers the post-mystery scrapbook view from
 	/// the action menu.
-	void doShowEnding(uint num);
+	///
+	/// `firstPage=true` opens at page 0; `false` opens at the last
+	/// page (used by `doShowScrapbook` when navigating backwards
+	/// between mysteries — mirrors the `local_8 = 0` write at
+	/// `_ShowScrapbook @ 1f78:067e`).
+	///
+	/// Returns the direction the user wants the caller to navigate:
+	///   -1 → previous mystery (LEFT pressed on the first page or
+	///        click in `PrevPageRect` while on first page),
+	///    0 → exit the scrapbook (ESC or click outside both rects),
+	///   +1 → next mystery (RIGHT/SPACE/Enter/click on last page).
+	/// Mirrors `_DisplayEnding`'s `[BP-0x18]` return at 1df2:0723.
+	int doShowEnding(uint num, bool firstPage = true);
 
 	/// Walk every solved mystery in tier @p stage (1=Junior, 2=Senior,
 	/// 3=Master) and display each one's ending pages in sequence.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index b453150eb3c..4131e6dfafb 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -813,6 +813,15 @@ void SiteScreen::run() {
 		if (exitRequested)
 			return;
 
+		// `doAccuse` on a win clears the mystery (so the screen driver
+		// can route to the post-mystery menu). Notebook / Gallery /
+		// hotspot paths route through this same loop, so a transitive
+		// `doAccuse` may have wiped `_mystery` underneath us — exit
+		// immediately rather than tick another frame against stale BG
+		// snapshots / hotspot tables.
+		if (!_mystery || !_mystery->isLoaded())
+			return;
+
 		// Per-tick frame pump (mirrors `_CheckFrameRate` +
 		// `_UpdateAnimations` at the top of `_DoSiteLoop`'s main loop).
 		// Restore the static BG snapshot, redraw animated NPCs +
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 9ee920fb720..07430ce1012 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -491,7 +491,7 @@ void EEMEngine::doNewPlayer() {
 	}
 }
 
-void EEMEngine::doShowEnding(uint num) {
+int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @
 	// 1df2:044c`. File format (verified by reading E0.BIN's bytes):
 	//   u16 pageCount
@@ -500,32 +500,63 @@ void EEMEngine::doShowEnding(uint num) {
 	//     u16 x1, y1, x2, y2  (story rect — passed to WordWrap)
 	//     char text[]        (null-terminated, ParseString opcodes)
 	//
-	// The original walks pages with PrevPage / NextPage rects (29be:
-	// 0x..); we approximate with mouse-click / Enter = next, ESC =
-	// exit. ParseString substitutes `\x80` (player name) and the rest
-	// of the 0x80..0x89 placeholder family so the rendered text
-	// reads as the correct player + partner combo.
+	// The original swaps the font: `_FreeFont(); _LoadFont("tiny.fnt")`
+	// at 1df2:055f-1df2:0563, calls `_GetPalette(0)` (site palette 0),
+	// then for each page `_GetBackground(picNum)` +
+	// `_WordWrap2(x1, y1, x2-x1, text, fontColor=0, dropColor=-1)`. The
+	// fontColor=0 draws in palette index 0 (the newspaper's body-text
+	// colour), with no drop shadow. Verified at the call site asm
+	// 1df2:04cf-1df2:04f4 (Ghidra mis-paired the two trailing args).
+	//
+	// Page navigation mirrors the original key/click handlers
+	// (1df2:0689 / 1df2:06a0): LEFT decrements pageIdx, RIGHT (or
+	// SPACE / Enter / click) increments it. Hitting the boundary
+	// (LEFT on page 0, RIGHT on last page) sets `[BP-0x18]` to -1 / 1
+	// respectively and exits — that return value is what
+	// `_ShowScrapbook` uses to walk forward / backward through
+	// solved mysteries (see 1f78:0664-1f78:069c). ESC and clicks
+	// outside both PrevPage / NextPage rects exit with `[BP-0x18]=0`.
+	//
+	// `firstPage=false` opens the ending at the LAST page (used by
+	// `doShowScrapbook` after a "previous mystery" navigation —
+	// matches `local_8 = 0` written before the back-step at
+	// 1f78:067e).
 	const Common::String fname = Common::String::format("E%u.BIN", num);
 	Common::File f;
 	if (!f.open(Common::Path(fname))) {
 		warning("doShowEnding: %s missing", fname.c_str());
-		return;
+		return 0;
 	}
 	const uint32 size = f.size();
 	if (size < 2) {
 		warning("doShowEnding: %s too small (%u bytes)",
 				fname.c_str(), size);
-		return;
+		return 0;
 	}
 	Common::Array<byte> buf(size);
 	if (f.read(buf.data(), size) != size) {
 		warning("doShowEnding: %s short read", fname.c_str());
-		return;
+		return 0;
 	}
 
 	const uint16 pageCount = READ_LE_UINT16(buf.data());
 	if (pageCount == 0)
-		return;
+		return 0;
+
+	// Mirrors 1df2:0558-1df2:056a — `_FreeFont(); _LoadFont(tiny.fnt)`.
+	// The newspaper layout uses TINY.FNT (smaller glyphs) so the body
+	// copy fits in the columns. `_LoadFont(font.fnt)` is restored at
+	// 1df2:0625 after the page loop.
+	EEMFont tinyFont;
+	const bool haveTinyFont = tinyFont.load(Common::Path("TINY.FNT"));
+	if (!haveTinyFont)
+		warning("doShowEnding: TINY.FNT failed to load — falling back");
+
+	// Mirrors 1df2:055f `_GetPalette(0)` — site palette 0 is the
+	// shared "newspaper" CLUT for ending pages. The newspaper body
+	// text in particular is palette index 0 (= newspaper black) so we
+	// MUST switch palettes before rendering.
+	setSitePalette(0);
 
 	// Walk page records. Each page header is 10 bytes; text is
 	// null-terminated and follows the header.
@@ -544,9 +575,11 @@ void EEMEngine::doShowEnding(uint num) {
 		cursor++;  // past the null
 	}
 
-	uint pageIdx = 0;
+	uint pageIdx = firstPage ? 0 : (kMaxPages - 1);
+	int direction = 0;     // -1 / 0 / +1, see header doc.
+	bool exitLoop = false;
 	bool dirty = true;
-	while (!shouldQuit() && pageIdx < kMaxPages) {
+	while (!shouldQuit() && !exitLoop) {
 		if (dirty) {
 			const uint off = pageOffsets[pageIdx];
 			if (off + 10 >= size)
@@ -557,9 +590,6 @@ void EEMEngine::doShowEnding(uint num) {
 			const uint16 x2     = READ_LE_UINT16(buf.data() + off + 6);
 			(void)READ_LE_UINT16(buf.data() + off + 8);  // y2 (unused — WordWrap2 takes width only)
 
-			// Halve the rect coords: ending pages use 320x400 logical
-			// coords (x2=0x128=296, y2=0xa8=168 in our test file
-			// E0.BIN — both already 320x200 mode 13h). No conversion.
 			Picture bg;
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
@@ -578,13 +608,20 @@ void EEMEngine::doShowEnding(uint num) {
 			const char *raw = (const char *)buf.data() + off + 10;
 			const Common::String text = parseString(raw, _playerName, _partner);
 
-			if (_font.isLoaded() && x2 > x1) {
+			// Use TINY.FNT (`_LoadFont(@29be:10a5)` at 1df2:055f-0563)
+			// and color 0 (`_WordWrap2(...,0,-1)` per asm at 1df2:04cf,
+			// not 0xF as Ghidra's decompile output suggests). Falls
+			// back to the main font if TINY.FNT failed to load.
+			const EEMFont &renderFont = haveTinyFont ? tinyFont : _font;
+			if (renderFont.isLoaded() && x2 > x1) {
 				const int textW = MIN<int>((int)x2 - (int)x1, 320 - (int)x1);
-				_font.drawWordWrapped(&scratch, (int)x1, (int)y1,
-									  textW, text, 0xF);
+				renderFont.drawWordWrapped(&scratch, (int)x1, (int)y1,
+										   textW, text, 0);
 			}
 
-			// Page indicator at top-right ("page 1/3").
+			// Page indicator at top-right ("page 1/3"). Stays in the
+			// main font + color 0xF so it doesn't blend into the
+			// newspaper masthead.
 			if (_font.isLoaded() && kMaxPages > 1) {
 				const Common::String hdr = Common::String::format(
 					"%u/%u", (unsigned)pageIdx + 1, (unsigned)kMaxPages);
@@ -600,14 +637,25 @@ void EEMEngine::doShowEnding(uint num) {
 		Common::Event ev;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				return 0;
+			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				switch (ev.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
-					return;
+					// ESC is the ONLY way out of the newspaper view.
+					// Departs from the original `_DisplayEnding`, which
+					// also exits on boundary arrow keys / clicks
+					// (1df2:0689 / 1df2:06a0); the boundary-exit path is
+					// what fed `[BP-0x18]` to `_ShowScrapbook` for
+					// per-mystery scrapbook navigation. We don't expose
+					// that — clicking ESC closes the scrapbook entirely.
+					direction = 0;
+					exitLoop = true;
+					break;
 				case Common::KEYCODE_LEFT:
 				case Common::KEYCODE_PAGEUP:
+					// Clamp at page 0 — never exit on LEFT.
 					if (pageIdx > 0) {
 						pageIdx--;
 						dirty = true;
@@ -618,21 +666,45 @@ void EEMEngine::doShowEnding(uint num) {
 				case Common::KEYCODE_RETURN:
 				case Common::KEYCODE_KP_ENTER:
 				case Common::KEYCODE_SPACE:
-					pageIdx++;
-					dirty = true;
+				case Common::KEYCODE_TAB:
+					// Clamp at last page — never exit on RIGHT either.
+					if (pageIdx + 1 < kMaxPages) {
+						pageIdx++;
+						dirty = true;
+					}
 					break;
 				default:
 					break;
 				}
+				if (exitLoop)
+					break;
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				pageIdx++;
-				dirty = true;
+				// Mouse clicks shift pages too — never exit on click.
+				// Use the original PrevPage/NextPage rect split for
+				// click direction (29be:1078 / 29be:1080); clicks
+				// outside both rects fall through to next-page so the
+				// player still gets some feedback.
+				const Common::Rect kPrevPageRect(0, 0, 27, 200);
+				if (kPrevPageRect.contains(ev.mouse.x, ev.mouse.y)) {
+					if (pageIdx > 0) {
+						pageIdx--;
+						dirty = true;
+					}
+				} else {
+					if (pageIdx + 1 < kMaxPages) {
+						pageIdx++;
+						dirty = true;
+					}
+				}
+				if (exitLoop)
+					break;
 			}
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
+	return direction;
 }
 
 void EEMEngine::doShowScrapbook(uint stage) {
@@ -654,7 +726,14 @@ void EEMEngine::doShowScrapbook(uint stage) {
 	const uint hi = lo + 0x17;
 	const bool currentTier = (stage == _chainStage);
 
-	for (uint m = lo; m <= hi; m++) {
+	// `doShowEnding` only reports `direction = 0` now (ESC), so we
+	// can't use the original 1f78:067e/0698 forward-backward walk.
+	// Instead iterate every solved mystery in the tier linearly:
+	// each ESC closes the current newspaper and moves us to the
+	// next solved entry. Departs from `_ShowScrapbook @ 1f78:0642`
+	// (which let the player browse via the boundary keys), but
+	// matches the user-requested "ESC is the only exit" rule.
+	for (uint m = lo; m <= hi && !shouldQuit(); m++) {
 		if (m >= sizeof(_mysteriesSolved))
 			break;
 		// Current-tier filter (1f78:0664). Completed tiers show all
@@ -662,9 +741,7 @@ void EEMEngine::doShowScrapbook(uint stage) {
 		// the player hasn't earned that scrapbook page yet.
 		if (currentTier && _mysteriesSolved[m] == 0)
 			continue;
-		doShowEnding(m);
-		if (shouldQuit())
-			return;
+		(void)doShowEnding(m, /*firstPage=*/true);
 	}
 }
 
@@ -3752,6 +3829,30 @@ void EEMEngine::doAccuse() {
 										   scene.surface.pitch, sx, sy,
 										   sw, sh);
 		}
+
+		// Partner sprite at (5, 0x50). The original `_DoAccuse @
+		// 1df2:0c30` registered the partner anim BEFORE entering the
+		// gallery; that slot stays active across `_DisplayCorrect`'s
+		// `_BuildBackground` (which calls `_Repaint` →
+		// `_DrawActiveAnimations` and re-blits the partner over the
+		// fresh BG). We don't have a slot system, so manually stamp
+		// the resting frame here. `displayClue` snapshots the screen
+		// on entry, so the partner ends up baked into its BG and is
+		// preserved across every clue iteration.
+		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+		Animation partnerAni;
+		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+			!partnerAni.empty()) {
+			Graphics::Surface *screen = g_system->lockScreen();
+			if (screen) {
+				const uint frameIdx = partnerFrameAtTick(0x02,
+					(uint)partnerAni.size(),
+					g_system->getMillis());
+				blitAnimFrameAnchored(screen, partnerAni[frameIdx],
+									  5, 0x50);
+				g_system->unlockScreen();
+			}
+		}
 		g_system->updateScreen();
 
 		if (_music)


Commit: 3761f15c6e38792ba88b59f34f36e1eb5b6042c7
    https://github.com/scummvm/scummvm/commit/3761f15c6e38792ba88b59f34f36e1eb5b6042c7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:48+02:00

Commit Message:
EEM: fixed missing screen updates

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5b419853d44..8386f08be13 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -536,6 +536,12 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 					break;
 				}
 			}
+			// Refresh ScummVM's cursor overlay every tick — without
+			// this the cursor only redraws when the next frame is
+			// blitted (every `frameDelayMs` ms, ~8 Hz at 120 ms),
+			// which the user perceives as choppy / unresponsive
+			// during long animations like SCRAPBK.ANI.
+			g_system->updateScreen();
 			g_system->delayMillis(5);
 		}
 		if (aborted)
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 4131e6dfafb..5869b827d53 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1523,7 +1523,11 @@ void EEMEngine::playKdAnim(uint16 num) {
 
 		// One frame per `_CheckFrameRate` tick. The original calibrates
 		// this to ~10 fps; 100 ms matches what the rest of the engine
-		// uses for partner / NPC frame cycling.
+		// uses for partner / NPC frame cycling. Pump `updateScreen`
+		// inside the inner wait so ScummVM's cursor overlay refreshes
+		// at the same rate as the wait granularity (10 ms ≈ 100 Hz)
+		// rather than only on the per-frame redraw — without this the
+		// cursor only refreshes once every 100 ms during the anim.
 		const uint32 wakeup = g_system->getMillis() + 100;
 		while (g_system->getMillis() < wakeup && !shouldQuit()) {
 			Common::Event ev;
@@ -1535,6 +1539,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 					return;
 			}
+			g_system->updateScreen();
 			g_system->delayMillis(10);
 		}
 	}


Commit: f9a5f920da384227cedd99d5f7b924dd5e3abbe7
    https://github.com/scummvm/scummvm/commit/f9a5f920da384227cedd99d5f7b924dd5e3abbe7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:48+02:00

Commit Message:
EEM: correctly load game when a mystery is solved

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 07430ce1012..1d0dc8091d9 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1238,6 +1238,14 @@ void EEMEngine::doCaseSelection() {
 	// Reassert here in case anything between hid it.
 	CursorMan.showMouse(true);
 
+	// Reassert site palette 0 (the case-selection / chooser CLUT). In
+	// the normal flow `doProfilePicker` (or the post-screen reset paths
+	// at lines 1402 / 1147 / 1121) leaves us on palette 0 already, but
+	// the launcher-resume path jumps straight here from `_AllBlack`
+	// (palette = all-zero) — without this the BG renders into a black
+	// CLUT and the player sees an empty screen.
+	setSitePalette(0);
+
 	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
 	Picture caseBg;
 	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
@@ -3880,21 +3888,23 @@ void EEMEngine::doAccuse() {
 		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the
 		// `_mysteriesSolved` table is updated, the original
 		// immediately persists the player record so the win sticks
-		// even if the player quits before reaching the menu. We do
-		// the same by writing back to the active profile (the slot
-		// keyed on `_playerName`) rather than clobbering slot 0 like
-		// a generic quicksave.
+		// even if the player quits before reaching the menu.
+		//
+		// Order matters: `_mystery.clear()` BEFORE `saveProfile` so the
+		// save records `hasMystery=false`. Otherwise the next load of
+		// this profile sees the just-won mystery still loaded and the
+		// screen driver routes to its map (forcing the player to
+		// replay the win flow). Mirrors `_DisplayCorrect @ 1df2:0851`
+		// (`_DeleteSavedGame` removes the in-progress save before
+		// `_SavePlayerRecord` writes the post-win profile).
+		_mystery.clear();
 		const Common::Error err = saveProfile(_playerName);
 		if (err.getCode() != Common::kNoError)
 			warning("saveProfile after solve failed: %s",
 					err.getDesc().c_str());
 
-		// `_DisplayCorrect @ 1df2:0895` writes `_NextScreen = 0xc`
-		// — the winner returns to the post-mystery `_ActionScreen`.
-		// Free the mystery first so the loop can break out cleanly:
-		// `_DeleteSavedGame` at 1df2:0851 + `_FreeMystery` at
-		// 1df2:08a4 do the same.
-		_mystery.clear();
+		// `_DisplayCorrect @ 1df2:0895` writes `_NextScreen = 0xc` —
+		// the winner returns to the post-mystery `_ActionScreen`.
 		_nextScreen = kScreenAction;
 	}
 }


Commit: 1131e4825f98fc8f307bc9dd2c0dd43506747398
    https://github.com/scummvm/scummvm/commit/1131e4825f98fc8f307bc9dd2c0dd43506747398
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:49+02:00

Commit Message:
EEM: implemented screen to load mysteries from different books

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 1d0dc8091d9..1737c1d60ac 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -99,17 +99,65 @@ struct CaseSelectionView {
 	uint pick;
 };
 
+// Mystery list shown in the "Choose A Mystery" sub-screen. Mirrors
+// `_DoChooseMystery @ 1a35:02b7`: opens BOOK%u.NME (CRLF-separated
+// ASCII strings, last entry is whitespace = sentinel), reads up to 25
+// lines × 40 bytes each, hands the array to `_CaseSelection`. We
+// preserve the trailing whitespace line as the original sentinel
+// since `_DoChoose @ 1c33:0514` walks until `*piVar3 == 0 && piVar3[1]
+// == 0` — but for our renderer we just keep the names array.
+Common::StringArray loadBookNames(uint book) {
+	Common::StringArray names;
+	const Common::String fname = Common::String::format("BOOK%u.NME", book);
+	Common::File f;
+	if (!f.open(Common::Path(fname))) {
+		warning("loadBookNames: %s missing", fname.c_str());
+		return names;
+	}
+	while (!f.eos()) {
+		Common::String line = f.readLine();
+		if (f.eos() && line.empty())
+			break;
+		// `_fgets` in the original reads CRLF terminators with the line;
+		// `Common::File::readLine` strips them, so `line` here is the
+		// text only. Trim trailing whitespace so the sentinel "        "
+		// last entry doesn't render as a blank scrollable row.
+		while (!line.empty() &&
+			   (line.lastChar() == ' ' || line.lastChar() == '\t' ||
+				line.lastChar() == '\r'))
+			line.deleteLastChar();
+		if (line.empty())
+			continue;
+		names.push_back(line);
+	}
+	return names;
+}
+
 // Per-mystery sub-chooser ("Choose A Mystery") view.
+//
+// `names` are the entries from BOOK%d.NME (in display order — index 0
+// = first case in the tier, mystery number = `tierLo + index`).
+// `solvedFlags` is a parallel bool array indicating which entries are
+// already solved (greyed and unselectable in `_DoChoose`).
+// `topRow` is the scroll position; up to 12 entries are visible.
+// `selRow` is the highlighted row (0-based within the names array).
 struct CaseSubmenuView {
 	EEMEngine *vm;
 	const Picture *caseBg;
 	bool haveCaseBg;
-	const byte *mysteriesSolved;
-	uint mysteriesSolvedSize;
-	uint sel;
-	uint maxMystery;
+	const Common::StringArray *names;
+	const Common::Array<bool> *solvedFlags;
+	uint topRow;
+	uint selRow;
+	uint book;            ///< 1..3 — for the "Book N" / "Challenge Book" title
 };
 
+// Mirrors `_DoChoose`'s `DrawList @ 1c33:040d`. 12 visible rows × 10 px
+// at (61, 35); colour palette: 0x13 = highlighted (selected), 0x1B =
+// greyed (already solved), 0x5C = default. We approximate with the
+// closest indices of site palette 0 — 0xF (white) / 0x7 (medium grey)
+// / 0x8 (dark grey) — since we don't decode the original CLUT byte
+// ramp.
 void drawCaseSubmenu(const CaseSubmenuView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -121,34 +169,70 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 			memcpy((byte *)scratch.getBasePtr(0, row),
 				   (const byte *)v.caseBg->surface.getBasePtr(0, row), w);
 	}
-	if (v.vm->getFont().isLoaded()) {
-		const int kListX  = 61;
-		const int kListW  = 238 - kListX;
-		const int kListY0 = 35;
-		const int kLineH  = 10;
-		const int kVisible = 12;
-		int top = (int)v.sel - kVisible / 2;
-		if (top < 0)
-			top = 0;
-		if (top + kVisible > (int)v.maxMystery + 1)
-			top = (int)v.maxMystery + 1 - kVisible;
-		for (int r = 0; r < kVisible; r++) {
-			const int idx = top + r;
-			if (idx > (int)v.maxMystery)
-				break;
-			char marker = ' ';
-			if ((uint)idx < v.mysteriesSolvedSize) {
-				if (v.mysteriesSolved[idx] == 2)
-					marker = '*';
-				else if (v.mysteriesSolved[idx] == 1)
-					marker = '+';
-			}
-			const char arrow = ((uint)idx == v.sel) ? '>' : ' ';
-			v.vm->getFont().drawString(&scratch,
-				Common::String::format("%c %c Mystery %d", arrow, marker, idx),
-				kListX, kListY0 + r * kLineH, kListW, 0xF);
+	if (!v.vm->getFont().isLoaded() || !v.names)
+		return;
+
+	// Top centred title. `_CaseSelection @ 1c33:0aa3` formats "Book %d"
+	// for tiers 1/2 and "Challenge Book" (sprintf with no arg) for
+	// tier 3. `_Show_String(0xc, (0xba - width)/2 + 0x3c, …, 0x10)`
+	// places it horizontally centred over the panel.
+	const Common::String title = (v.book == 3)
+		? Common::String("Challenge Book")
+		: Common::String::format("Book %u", v.book);
+	const int titleW = v.vm->getFont().getStringWidth(title);
+	const int titleX = (0xba - titleW) / 2 + 0x3c;
+	v.vm->getFont().drawString(&scratch, title, titleX, 12, 320, 0xF);
+
+	const int kListX  = 61;
+	const int kListW  = 238 - kListX;
+	const int kListY0 = 35;
+	const int kLineH  = 10;
+	const int kVisible = 12;
+	const uint count = (uint)v.names->size();
+
+	for (int r = 0; r < kVisible; r++) {
+		const uint idx = v.topRow + (uint)r;
+		if (idx >= count)
+			break;
+		const Common::String &name = (*v.names)[idx];
+		byte color = 0xF;  // default
+		if (idx == v.selRow) {
+			color = 0xF;   // highlighted
+		} else if (v.solvedFlags && idx < v.solvedFlags->size() &&
+				   (*v.solvedFlags)[idx]) {
+			color = 0x8;   // greyed (already solved)
+		} else {
+			color = 0x7;   // normal
 		}
+		v.vm->getFont().drawString(&scratch, name,
+			kListX, kListY0 + r * kLineH, kListW, color);
+	}
+
+	// Selection arrow at the left edge of the highlighted row — the
+	// original highlights via colour change but adding an arrow makes
+	// the keyboard-driven path obvious.
+	if (v.selRow >= v.topRow && v.selRow < v.topRow + (uint)kVisible) {
+		const int r = (int)(v.selRow - v.topRow);
+		v.vm->getFont().drawString(&scratch, ">",
+			kListX - 6, kListY0 + r * kLineH, 6, 0xF);
 	}
+
+	// Scrollbar thumb. `DrawThumb @ 1c33:????` renders a thumb at
+	// (240, 45..146) proportional to scroll position. We draw a
+	// 1-px outlined block to indicate the same range.
+	if (count > (uint)kVisible) {
+		const int trackY0 = 45;
+		const int trackH  = 146 - 45;
+		const int thumbH  = MAX<int>(8, (trackH * kVisible) / (int)count);
+		const int travel  = trackH - thumbH;
+		const int pos = (int)v.topRow * travel /
+						MAX<int>(1, (int)count - kVisible);
+		const Common::Rect thumb(240, trackY0 + pos,
+								  250, trackY0 + pos + thumbH);
+		scratch.fillRect(thumb, 0x8);
+		scratch.frameRect(thumb, 0xF);
+	}
+
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
 	g_system->updateScreen();
@@ -1413,52 +1497,94 @@ void EEMEngine::doCaseSelection() {
 	}
 
 	// "Choose A Mystery" sub-screen: pick a specific case from the
-	// 55-mystery roster. `_CaseSelection @ 1c33:0a87` only shows
-	// mysteries in the player's current chain stage:
-	//   stage 1 (Junior) → 1..24    (start = `iVar1 = 1`     @ 1c33:0aff)
-	//   stage 2 (Senior) → 25..48   (start = `iVar1 = 0x19`)
-	//   stage 3 (Master) → 49..54   (start = `iVar1 = 0x31`)
-	// The original passes `&DAT_2d5d_3f9b + iVar1` as the grey-mask
-	// pointer so DoChoose lights up only the tier-relevant entries.
-	// We clamp `sel` to the tier range and pre-seed it to the first
-	// unsolved case in that range.
+	// chain-stage's roster. Mirrors `_DoChooseMystery @ 1a35:02b7` +
+	// `_CaseSelection @ 1c33:0a87`:
+	//   stage 1 (Junior, BOOK1.NME) → mysteries  1..24
+	//   stage 2 (Senior, BOOK2.NME) → mysteries 25..48
+	//   stage 3 (Master, BOOK3.NME) → mysteries 49..54
+	// `_DoChooseMystery` opens BOOK<stage>.NME and reads up to 25
+	// CRLF-terminated lines into a 25-entry FAR-pointer array passed
+	// to `_CaseSelection`. The grey mask `_Greys = &mysteriesSolved +
+	// stageLo` (1c33:0b22) makes already-solved entries unselectable.
 	uint stageLo = 1, stageHi = 0x18;
+	uint book = 1;
 	switch (_chainStage) {
-	case 2: stageLo = 0x19; stageHi = 0x30; break;
-	case 3: stageLo = 0x31; stageHi = 0x36; break;
+	case 2: stageLo = 0x19; stageHi = 0x30; book = 2; break;
+	case 3: stageLo = 0x31; stageHi = 0x36; book = 3; break;
 	default: break;  // stage 1 (or fallback)
 	}
 	if (stageHi > kMaxMystery)
 		stageHi = kMaxMystery;
-	uint sel = stageLo;
-	for (uint i = stageLo; i <= stageHi; i++) {
-		if (i < sizeof(_mysteriesSolved) && !_mysteriesSolved[i]) {
-			sel = i;
-			break;
-		}
+
+	const Common::StringArray names = loadBookNames(book);
+	if (names.empty()) {
+		warning("doCaseSelection: BOOK%u.NME failed to load — bailing",
+				book);
+		_mystery.clear();
+		return;
 	}
+	const uint listLen = MIN<uint>((uint)names.size(), stageHi - stageLo + 1);
+
+	// Per-row solved flags. `_DoChoose @ 1c33:0521` skips solved entries
+	// when seeding the initial selection (`while *_Greys[select] != 0`)
+	// and again per-click via the same mask check.
+	Common::Array<bool> solvedFlags;
+	solvedFlags.resize(listLen);
+	for (uint i = 0; i < listLen; i++) {
+		const uint mn = stageLo + i;
+		solvedFlags[i] =
+			mn < sizeof(_mysteriesSolved) && _mysteriesSolved[mn] != 0;
+	}
+
+	// Seed the selection at the first unsolved entry — same as
+	// `_DoChoose`'s `while (*Greys[select] != 0) select++;` loop at
+	// 1c33:0524.
+	uint selRow = 0;
+	while (selRow < listLen && solvedFlags[selRow])
+		selRow++;
+	if (selRow >= listLen)
+		selRow = 0;  // every case solved — let player re-pick
+	uint topRow = 0;
+	const uint kVisible = 12;
+	if (selRow >= kVisible) {
+		topRow = selRow - kVisible / 2;
+		if (topRow + kVisible > listLen)
+			topRow = listLen > kVisible ? listLen - kVisible : 0;
+	}
+
+	auto clampTopRow = [&](uint &t) {
+		if (listLen <= kVisible) {
+			t = 0;
+			return;
+		}
+		const uint maxTop = listLen - kVisible;
+		if (t > maxTop)
+			t = maxTop;
+	};
 
 	CaseSubmenuView sv;
 	sv.vm = this;
 	sv.caseBg = &caseBg;
 	sv.haveCaseBg = haveCaseBg;
-	sv.mysteriesSolved = _mysteriesSolved;
-	sv.mysteriesSolvedSize = sizeof(_mysteriesSolved);
-	sv.sel = sel;
-	sv.maxMystery = kMaxMystery;
+	sv.names = &names;
+	sv.solvedFlags = &solvedFlags;
+	sv.topRow = topRow;
+	sv.selRow = selRow;
+	sv.book = book;
 
 	drawCaseSubmenu(sv);
 	bool confirmed = false;
 	while (!confirmed && !shouldQuit()) {
 		Common::Event ev;
+		bool dirty = false;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Same `_DoChoose` rectangles as the top-level menu.
 				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					confirmed = true;
+					if (selRow < listLen && !solvedFlags[selRow])
+						confirmed = true;
 					break;
 				}
 				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -1466,35 +1592,32 @@ void EEMEngine::doCaseSelection() {
 					return;
 				}
 				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel <= stageLo) ? stageHi : sel - 1;
-					sv.sel = sel;
-					drawCaseSubmenu(sv);
+					if (topRow > 0) { topRow--; dirty = true; }
 					continue;
 				}
 				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					sel = (sel >= stageHi) ? stageLo : sel + 1;
-					sv.sel = sel;
-					drawCaseSubmenu(sv);
+					topRow++;
+					clampTopRow(topRow);
+					dirty = true;
 					continue;
 				}
 				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Pick the row under the cursor.
+					// Pick the row under the cursor — mirrors
+					// 1c33:0635 `i = (MouseY - DAT_29be_0d02) / 10;`.
 					const int kLineH = 10;
-					const int kVisible = 12;
-					int top = (int)sel - kVisible / 2;
-					if (top < 0)
-						top = 0;
-					if (top + kVisible > (int)kMaxMystery + 1)
-						top = (int)kMaxMystery + 1 - kVisible;
-					const int row = (ev.mouse.y - kListRect.top) / kLineH;
-					const int idx = top + row;
-					if (idx >= 0 && idx <= (int)kMaxMystery) {
-						sel = (uint)idx;
-						sv.sel = sel;
-						drawCaseSubmenu(sv);
-					}
+					const int row = (ev.mouse.y - 35) / kLineH;
+					if (row < 0 || row >= (int)kVisible)
+						continue;
+					const uint idx = topRow + (uint)row;
+					if (idx >= listLen)
+						continue;
+					if (solvedFlags[idx])
+						continue;  // greyed entries ignore clicks
+					selRow = idx;
+					dirty = true;
 					continue;
 				}
+				continue;
 			}
 			if (ev.type != Common::EVENT_KEYDOWN)
 				continue;
@@ -1503,56 +1626,80 @@ void EEMEngine::doCaseSelection() {
 				_mystery.clear();
 				return;
 			}
-			if (k == Common::KEYCODE_RETURN) {
-				confirmed = true;
+			if (k == Common::KEYCODE_RETURN ||
+				k == Common::KEYCODE_KP_ENTER) {
+				if (selRow < listLen && !solvedFlags[selRow])
+					confirmed = true;
 				break;
 			}
-			if (k >= Common::KEYCODE_0 && k <= Common::KEYCODE_9) {
-				sel = (uint)(k - Common::KEYCODE_0);
-				sv.sel = sel;
-				drawCaseSubmenu(sv);
-				continue;
-			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_TAB) {
-				sel = (sel >= stageHi) ? stageLo : sel + 1;
-				sv.sel = sel;
-				drawCaseSubmenu(sv);
+				if (selRow + 1 < listLen) {
+					selRow++;
+					if (selRow >= topRow + kVisible) {
+						topRow = selRow - kVisible + 1;
+						clampTopRow(topRow);
+					}
+					dirty = true;
+				}
 				continue;
 			}
 			if (k == Common::KEYCODE_UP) {
-				sel = (sel <= stageLo) ? stageHi : sel - 1;
-				sv.sel = sel;
-				drawCaseSubmenu(sv);
+				if (selRow > 0) {
+					selRow--;
+					if (selRow < topRow)
+						topRow = selRow;
+					dirty = true;
+				}
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEDOWN) {
-				sel = (sel + 10 > stageHi) ? stageHi : sel + 10;
-				sv.sel = sel;
-				drawCaseSubmenu(sv);
+				selRow = MIN<uint>(selRow + kVisible, listLen - 1);
+				if (selRow >= topRow + kVisible) {
+					topRow = selRow - kVisible + 1;
+					clampTopRow(topRow);
+				}
+				dirty = true;
 				continue;
 			}
 			if (k == Common::KEYCODE_PAGEUP) {
-				sel = (sel < stageLo + 10) ? stageLo : sel - 10;
-				sv.sel = sel;
-				drawCaseSubmenu(sv);
+				selRow = (selRow >= kVisible) ? selRow - kVisible : 0;
+				if (selRow < topRow)
+					topRow = selRow;
+				dirty = true;
 				continue;
 			}
-			if (k == Common::KEYCODE_HOME) { sel = stageLo; sv.sel = sel; drawCaseSubmenu(sv); continue; }
-			if (k == Common::KEYCODE_END)  { sel = stageHi; sv.sel = sel; drawCaseSubmenu(sv); continue; }
+			if (k == Common::KEYCODE_HOME) {
+				selRow = 0;
+				topRow = 0;
+				dirty = true;
+				continue;
+			}
+			if (k == Common::KEYCODE_END) {
+				selRow = listLen - 1;
+				topRow = listLen > kVisible ? listLen - kVisible : 0;
+				dirty = true;
+				continue;
+			}
+		}
+		if (dirty) {
+			sv.topRow = topRow;
+			sv.selRow = selRow;
+			drawCaseSubmenu(sv);
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 
-	if (!_mystery.load(sel, &_rng)) {
-		warning("doCaseSelection: failed to load mystery %u", sel);
+	const uint mn = stageLo + selRow;
+	if (!_mystery.load(mn, &_rng)) {
+		warning("doCaseSelection: failed to load mystery %u", mn);
 		_mystery.clear();
 		return;
 	}
 	if (_audio)
-		_audio->initMysterySounds(sel);
+		_audio->initMysterySounds(mn);
 	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
-		   sel, _mystery.numSites(), _mystery.numSuspects());
+		   mn, _mystery.numSites(), _mystery.numSuspects());
 }
 
 void EEMEngine::doNotebook() {


Commit: c3f24c4280f65bc095c473e6363785e09e9076f9
    https://github.com/scummvm/scummvm/commit/c3f24c4280f65bc095c473e6363785e09e9076f9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:49+02:00

Commit Message:
EEM: unlock different scrapbooks until the end

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 1737c1d60ac..1622c1cf8cb 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1283,25 +1283,44 @@ void EEMEngine::doCaseSelection() {
 		"         See ScrapBook 2",
 		"         See ScrapBook 3"
 	};
-	// ScrapBook entries are gated by chain stage exactly as the
-	// original `_ActionScreen @ 1c33:195b` does at 1c33:19f3-19f7:
-	//   stage 1 + nothing solved → ScrapBook 1/2/3 all greyed
-	//   stage 1 + ≥1 solved      → ScrapBook 1 enabled, 2/3 greyed
-	//   stage 2                  → ScrapBook 1 enabled, 2 enabled, 3 greyed
-	//   stage 3                  → all three enabled
-	// (`_3f9b[i] != 0` over the tier's mystery range is the per-tier
-	// gate; we approximate "any in tier" by checking _chainStage and
-	// any solved flag.)
+	// Menu entry gating per `_ActionScreen @ 1c33:195b` — the asm at
+	// 1c33:19d1-1a70 sets greys[] based on chain stage AND per-tier
+	// solve count:
+	//   stage 1 → grey ScrapBook 2/3; grey ScrapBook 1 if no tier-1 solves
+	//   stage 2 → grey Practice + ScrapBook 3; grey ScrapBook 2 if no tier-2 solves
+	//   stage 3 → grey Practice; grey ScrapBook 3 if no tier-3 solves
+	//   stage 4 → grey Choose + Practice (post-completion read-only state)
+	// In other words: each tier's ScrapBook unlocks as soon as you've
+	// solved your first case in that tier. Practice Mystery is only
+	// available at stage 1. Choose A Mystery is greyed once every case
+	// in every tier is solved (stage 4).
 	bool anySolved1 = false;
 	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
 		if (_mysteriesSolved[i]) { anySolved1 = true; break; }
-	const bool scrap1On = anySolved1 || _chainStage >= 2;
-	const bool scrap2On = _chainStage >= 2;
-	const bool scrap3On = _chainStage >= 3;
+	bool anySolved2 = false;
+	for (uint i = 0x19; i <= 0x30 && i < sizeof(_mysteriesSolved); i++)
+		if (_mysteriesSolved[i]) { anySolved2 = true; break; }
+	bool anySolved3 = false;
+	for (uint i = 0x31; i <= 0x36 && i < sizeof(_mysteriesSolved); i++)
+		if (_mysteriesSolved[i]) { anySolved3 = true; break; }
+
+	const bool chooseOn   = _chainStage < 4;
+	const bool practiceOn = _chainStage <= 1;
+	const bool scrap1On =
+		_chainStage >= 2 || (_chainStage == 1 && anySolved1);
+	const bool scrap2On =
+		_chainStage >= 3 || (_chainStage == 2 && anySolved2);
+	const bool scrap3On =
+		_chainStage >= 4 || (_chainStage == 3 && anySolved3);
 	const bool kPickEnabled[kNumPicks] = {
-		true, true, scrap1On, scrap2On, scrap3On
+		chooseOn, practiceOn, scrap1On, scrap2On, scrap3On
 	};
-	uint pick = kPickChoose;
+	// Seed selection on the first enabled entry — at stage 4 the
+	// `Choose A Mystery` default is greyed, so we land on ScrapBook 1.
+	uint pick = 0;
+	for (uint i = 0; i < kNumPicks; i++) {
+		if (kPickEnabled[i]) { pick = i; break; }
+	}
 
 	const char *kSeparator = "----------------------------------";
 
@@ -1383,7 +1402,11 @@ void EEMEngine::doCaseSelection() {
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				// OK / EXIT / HELP buttons (rectangles from `_DoChoose`).
 				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					confirmed = true;
+					// Greyed entries can't be confirmed (mirrors
+					// `_DoChoose @ 1c33:0635` — clicks on a `_Greys[i]
+					// != 0` row are ignored before `select` is set).
+					if (kPickEnabled[pick])
+						confirmed = true;
 					break;
 				}
 				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -1420,8 +1443,10 @@ void EEMEngine::doCaseSelection() {
 				confirmed = true;
 				break;
 			}
-			if (k == Common::KEYCODE_RETURN) {
-				confirmed = true;
+			if (k == Common::KEYCODE_RETURN ||
+				k == Common::KEYCODE_KP_ENTER) {
+				if (kPickEnabled[pick])
+					confirmed = true;
 				break;
 			}
 			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
@@ -3941,7 +3966,13 @@ void EEMEngine::doAccuse() {
 				if (i >= sizeof(_mysteriesSolved) || _mysteriesSolved[i] == 0)
 					allSolved = false;
 			}
-			if (allSolved && _chainStage < 3) {
+			// `_DisplayCorrect @ 1df2:0852` increments unconditionally
+			// when every case in the current tier is solved — including
+			// past stage 3 (so a stage-4 endgame state exists in the
+			// `.PLR` save format). `_ActionScreen @ 1c33:19d1` gates
+			// the menu on `if (3 < _chainStage)` to grey
+			// Choose-A-Mystery and Practice once everything's solved.
+			if (allSolved && _chainStage < 4) {
 				_chainStage++;
 				debugC(1, kDebugMystery,
 					   "chainStage advanced to %u after solving mystery %u",


Commit: ab8cd8380c3799ffec83050126e667d367d65994
    https://github.com/scummvm/scummvm/commit/ab8cd8380c3799ffec83050126e667d367d65994
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:49+02:00

Commit Message:
EEM: fixed animation issue

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 69a805fffd8..cd9362494e9 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -434,12 +434,18 @@ void EEMEngine::doInitClues() {
 					blitMaskedToScreen(book[frame % book.size()], 0, 99);
 				if (haveNancy)
 					blitMaskedToScreen(nancy[frame % nancy.size()], 0x68, 0x8b);
-				// Anchor: original blits at `(sx - frame.width,
-				// sy - frame.rowoff)`. `frame.rowoff` is the y-anchor
-				// in our PicData. We use width/height directly since
-				// loadAnimation places anchor at (0, 0).
-				const int dstX = (int)0xcd - (int)fr.surface.w;
-				const int dstY = (int)seqY - (int)fr.rowoff;
+				// Anchor: `_PlayInSequence @ 172b:2d35-2d50` does
+				//   dstX = sx - cell[+0x8]     ; miscflags (signed)
+				//   dstY = sy - cell[+0x6]     ; rowoff   (signed)
+				// Ghidra's C decompile mis-labels `cell[+0x8]` as
+				// `width`; the actual asm is `SUB AX, ES:[BX+0x8]`,
+				// which is the per-frame X anchor offset stored in our
+				// `Picture::miscflags` field. Using `fr.surface.w`
+				// shifted every frame by the cell width and made the
+				// briefing partner appear duplicated next to the BG-
+				// baked figure (mystery 1 office briefing).
+				const int dstX = (int)0xcd - (int)(int16)fr.miscflags;
+				const int dstY = (int)seqY - (int)(int16)fr.rowoff;
 				blitMaskedToScreen(fr, dstX, dstY);
 				g_system->updateScreen();
 				const uint32 wakeup = g_system->getMillis() + 100;


Commit: 62063295f02fce3abad575c1b7ae25c1518322e8
    https://github.com/scummvm/scummvm/commit/62063295f02fce3abad575c1b7ae25c1518322e8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:50+02:00

Commit Message:
EEM: unseen hotspot effect

Changed paths:
    engines/eem/site.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 5869b827d53..4ebb6389f0d 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -541,6 +541,29 @@ void SiteScreen::enter(uint siteNum) {
 	const uint16 sitepic = sd ? READ_LE_UINT16(sd) : 0;
 	_vm->setSitePaletteForSite(sitepic);
 
+	// SITEPALS ships with palette indices 0xF9..0xFE all set to a
+	// uniform yellow (`3F 3E 00` = R=63 G=62 B=0 for every site palette
+	// in the file), so the original `_DrawRect`'s cycling colour
+	// pattern + `_ColorCycle(0xF9, 0xFE)` produced a uniformly-coloured
+	// outline with no visible movement — the "marching ants" pattern
+	// was a placeholder that never lit up. Override those entries with
+	// a 6-step yellow ramp here so the existing per-tick rotation
+	// (`applyColorCycles → cyclePaletteRange(0xF9, 0xFE)`) creates a
+	// pulsing glow on unsearched hotspots. Done after `setSitePalette`
+	// so the per-site palette load doesn't clobber the ramp.
+	{
+		// 6-step yellow glow: dark → bright → dim → ...
+		static const byte kAntsGlow[6 * 3] = {
+			0x40, 0x40, 0x00, // F9 — dim
+			0x80, 0x80, 0x00, // FA
+			0xC0, 0xC0, 0x00, // FB
+			0xFF, 0xFF, 0x40, // FC — peak
+			0xC0, 0xC0, 0x00, // FD
+			0x80, 0x80, 0x00, // FE
+		};
+		g_system->getPaletteManager()->setPalette(kAntsGlow, 0xF9, 6);
+	}
+
 	renderBackground(siteNum);
 
 	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` whenever
@@ -1271,16 +1294,24 @@ void SiteScreen::renderHotspots(uint siteNum) {
 
 	// Mirrors `_DrawSearchButtons @ 2404:0a8f`:
 	//   for each hotspot:
-	//     if `_Sawit(theSite, loc)` (= _SaveBuffer[hotspot[+0xa]] != 0)
-	//       _DrawRect(rect)        // outline in cycling colors 0xF9..0xFE
-	//     else
-	//       _DrawSolidRect(rect)   // outline in solid white 0xFF
-	// `_DrawRect`'s cycling colors produce a "marching ants" effect that
-	// makes already-searched hotspots visually distinct without hiding
-	// them. We approximate the cycling by rotating the start color via
-	// the global tick.
+	//     if `_Sawit(theSite, loc) == 0` (NOT seen yet):
+	//       `_DrawRect(rect)`       — outline in cycling colors
+	//                                 0xF9..0xFE; `_ColorCycle(0xF9,
+	//                                 0xFE)` rotates them every tick →
+	//                                 "marching ants" glow that draws
+	//                                 the player's eye to unsearched
+	//                                 spots.
+	//     else (seen):
+	//       `_DrawSolidRect(rect)` — outline in solid colour 0xFF.
+	// (Verified at the actual asm `2404:0af6 OR AX,AX; 2404:0af8 JZ` —
+	// the C-level decompile mis-reordered the if/else branches.)
+	//
+	// We don't need per-pixel colour cycling here because palette
+	// `0xF9..0xFE` is already rotated by `applyColorCycles` each tick;
+	// drawing the outline with any single colour in that range will
+	// pulse on its own. We pick a phased start so adjacent hotspots
+	// don't all glow in lock-step.
 	const uint32 tickMs = g_system->getMillis();
-	const byte cyclePhase = (byte)((tickMs / 80) & 0x07);  // 0..7
 
 	for (uint i = 0; i < count; i++) {
 		const byte *r = spots + i * 14;
@@ -1293,39 +1324,44 @@ void SiteScreen::renderHotspots(uint siteNum) {
 								MIN<int>(screen->h, y2));
 		const bool seen = (i < Mystery::kHotSpotsCap)
 						   && _mystery->_hotSpotsSeen[i];
-		if (!seen) {
-			// `_DrawSolidRect` — solid white outline (color 0xFF).
+		if (seen) {
+			// `_DrawSolidRect` (172b:0506) — outline in palette
+			// index 0xFF (a fixed, non-cycling colour) so already-
+			// found hotspots visually retreat into the BG.
 			screen->frameRect(rect, 0xFF);
 		} else {
-			// `_DrawRect` — cycling colors 0xF9..0xFE on each pixel of
-			// the outline. We approximate per-pixel cycling with a
-			// per-rect phase shift so the rects look animated. Start
-			// color is rotated via the global clock.
-			const byte palette[6] = { 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE };
-			byte color = palette[cyclePhase % 6];
+			// `_DrawRect` (172b:03e2) — outline in palette indices
+			// 0xF9..0xFE which `_ColorCycle(0xF9, 0xFE)` rotates
+			// every tick. Walk all four edges incrementing the
+			// colour per pixel, exactly like the original.
+			byte color = (byte)(0xF9 + ((i + (tickMs / 80)) & 0x07) % 6);
+			auto bumpColor = [&]() {
+				const byte next = (byte)(color + 1);
+				color = (next > 0xFE) ? (byte)0xF9 : next;
+			};
 			// Top edge
 			for (int x = rect.left; x < rect.right; x++) {
 				if (x >= 0 && x < screen->w && rect.top >= 0 && rect.top < screen->h)
 					*(byte *)screen->getBasePtr(x, rect.top) = color;
-				color = palette[(color - 0xF9 + 1) % 6];
+				bumpColor();
 			}
 			// Right edge
 			for (int y = rect.top; y < rect.bottom; y++) {
 				if (rect.right - 1 >= 0 && rect.right - 1 < screen->w && y >= 0 && y < screen->h)
 					*(byte *)screen->getBasePtr(rect.right - 1, y) = color;
-				color = palette[(color - 0xF9 + 1) % 6];
+				bumpColor();
 			}
 			// Bottom edge
 			for (int x = rect.right - 1; x >= rect.left; x--) {
 				if (x >= 0 && x < screen->w && rect.bottom - 1 >= 0 && rect.bottom - 1 < screen->h)
 					*(byte *)screen->getBasePtr(x, rect.bottom - 1) = color;
-				color = palette[(color - 0xF9 + 1) % 6];
+				bumpColor();
 			}
 			// Left edge
 			for (int y = rect.bottom - 1; y >= rect.top; y--) {
 				if (rect.left >= 0 && rect.left < screen->w && y >= 0 && y < screen->h)
 					*(byte *)screen->getBasePtr(rect.left, y) = color;
-				color = palette[(color - 0xF9 + 1) % 6];
+				bumpColor();
 			}
 		}
 	}


Commit: 8fa2ec9202bfa34f4d52fb99bfca3aa87e795ae0
    https://github.com/scummvm/scummvm/commit/8fa2ec9202bfa34f4d52fb99bfca3aa87e795ae0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:50+02:00

Commit Message:
EEM: make sure sound effect played are correct (or don't play any)

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index cd9362494e9..3b460988ce2 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -773,16 +773,28 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 
 		// `_DisplayClue` @ 2404:0833-085a — after the balloon is drawn,
 		// spool the per-clue voice. Each ClueEntry stores two 1-based
-		// sound indices: `+0x18` for partner=Jenny and `+0x1a` for
-		// partner=Jake (verified against 2404:0823-0834). Index 0 / -1
-		// = no audio. The original blocks until the line ends; we run
-		// async (the wait happens implicitly while the player reads).
+		// sound indices: `+0x18` (Jenny voice) and `+0x1a` (Jake voice).
+		//
+		// Critical gate (verified at 2404:0833):
+		//   if (clue[+0x18] != 0 && voiceOn && voiceAvail) {
+		//       iVar6 = clue[+0x18];           // Jenny default
+		//       if (Partner == 0) iVar6 = clue[+0x1a];  // Jake override
+		//       _SpoolSound(iVar6 - 1);
+		//   }
+		// The condition gates on the JENNY slot regardless of partner.
+		// Some hotspot ClueBlocks define `+0x1a` (Jake voice) but leave
+		// `+0x18` at 0 — for those, the original engine plays nothing
+		// and the entry is text-only. Our previous code gated on the
+		// partner-selected slot and ended up firing unrelated voices
+		// (e.g., a "no audio" entry triggering Jake's spoolSound).
 		if (_audio) {
 			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
-			const uint16 voiceJake  = READ_LE_UINT16(c + 0x1a);
-			const uint16 voice = (_partner == 0) ? voiceJake : voiceJenny;
-			if (voice != 0 && voice != 0xFFFF)
-				_audio->spoolSound((uint)(voice - 1));
+			if (voiceJenny != 0 && voiceJenny != 0xFFFF) {
+				const uint16 voiceJake = READ_LE_UINT16(c + 0x1a);
+				const uint16 voice = (_partner == 0) ? voiceJake : voiceJenny;
+				if (voice != 0 && voice != 0xFFFF)
+					_audio->spoolSound((uint)(voice - 1));
+			}
 		}
 
 		// Wait for click/key to advance — only if we drew something.


Commit: 15962d464e03a707fda1ccd69f5fca471567028a
    https://github.com/scummvm/scummvm/commit/15962d464e03a707fda1ccd69f5fca471567028a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:50+02:00

Commit Message:
EEM: removed debug keys

Changed paths:
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 4ebb6389f0d..f7a605baf4e 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -728,104 +728,20 @@ void SiteScreen::run() {
 			}
 
 			case Common::EVENT_KEYDOWN:
-				switch (event.kbd.keycode) {
-				case Common::KEYCODE_ESCAPE:
+				// `_DoSiteLoop @ 168d:07e1` only dispatches on the
+				// 6-entry table at `168d:09d5` (TAB / ENTER / arrow
+				// keys for hotspot cursor cycling) plus ESC handled
+				// separately at 168d:07a9. We don't implement the
+				// hotspot cursor cycling — clicks are the primary
+				// interaction — so the only keyboard binding kept
+				// here is ESC (matches `_ESCHit` → "Are you sure?"
+				// → MAP).
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					if (_vm->areYouSure()) {
-						// Mirrors `_DoSiteLoop @ 168d:07b7` ESC path:
-						// `_NextScreen = 1` (back to MAP) after the
-						// areYouSure confirm. Without explicitly
-						// writing it here, the run() loop would see
-						// _nextScreen unchanged and treat it as a
-						// quit-engine signal — abandoning the case.
-						// Going to MAP keeps the case alive so the
-						// player can continue from a different site.
 						_vm->setNextScreen(kScreenMap);
 						return;
 					}
 					enter(cur);
-					break;
-				case Common::KEYCODE_m:
-					_vm->doBigMap();
-					// Either way the map covered the site — re-render.
-					if (_mystery->_siteNumber < _mystery->numSites())
-						cur = _mystery->_siteNumber;
-					enter(cur);
-					break;
-				case Common::KEYCODE_n:
-					_vm->doNotebook();
-					enter(cur);
-					break;
-				case Common::KEYCODE_g:
-					_vm->doGallery();
-					enter(cur);
-					break;
-				case Common::KEYCODE_a:
-					_vm->doAccuse();
-					exitRequested = true;
-					break;
-				case Common::KEYCODE_h:
-					_vm->doHelp();
-					enter(cur);
-					break;
-				case Common::KEYCODE_v:
-					_showHotspots = !_showHotspots;
-					enter(cur);
-					break;
-				case Common::KEYCODE_r:
-					// Restart the mystery from scratch (mirrors `_ReloadMystery`).
-					if (_mystery->load(_mystery->number())) {
-						if (_vm->_audio)
-							_vm->_audio->initMysterySounds(_mystery->number());
-						cur = 0;
-						enter(cur);
-					}
-					break;
-				case Common::KEYCODE_QUESTION:
-				case Common::KEYCODE_F1: {
-					if (_vm->getFont().isLoaded()) {
-						Graphics::ManagedSurface help(320, 200,
-							Graphics::PixelFormat::createFormatCLUT8());
-						help.clear();
-						const EEMFont &fnt = _vm->getFont();
-						int y = 8;
-						const char *lines[] = {
-							"EAGLE EYE MYSTERIES — keys",
-							"",
-							"  click   search a hotspot",
-							"  V       toggle hotspot outlines",
-							"  M       map (travel between sites)",
-							"  N       notebook (mark evidence with 1..9)",
-							"  G       gallery (suspect portraits)",
-							"  H       hint from the case host",
-							"  A       accuse a suspect",
-							"  R       restart current mystery",
-							"  Tab     next site (cycle)",
-							"  F5      save / load (ScummVM dialog)",
-							"  ? / F1  this help",
-							"  Esc     quit (with confirm)",
-							"",
-							"Notebook: select evidence with 1..9.",
-							"Selected-points > 99 wins the case."
-						};
-						for (uint i = 0; i < sizeof(lines)/sizeof(lines[0]); i++) {
-							fnt.drawString(&help, lines[i], 8, y, 320, 0xF);
-							y += fnt.getFontHeight() + 1;
-						}
-						g_system->copyRectToScreen(help.getPixels(),
-							help.pitch, 0, 0, 320, 200);
-						g_system->updateScreen();
-						_vm->waitForInput(60000);
-						enter(cur);
-					}
-					break;
-				}
-				case Common::KEYCODE_TAB:
-					_mystery->_lastSite = cur;
-					cur = (cur + 1) % _mystery->numSites();
-					enter(cur);
-					break;
-				default:
-					break;
 				}
 				break;
 
@@ -1251,34 +1167,6 @@ void SiteScreen::renderBackground(uint siteNum) {
 }
 
 void SiteScreen::renderHotspots(uint siteNum) {
-	// HUD overlay: site number, found clues, selected points. Drawn at
-	// the BOTTOM of the screen so the scene's top row stays visible —
-	// 320x200 mode 13h has a small bottom strip that the original engine
-	// uses for tool buttons; we repurpose it for the HUD.
-	if (_vm->getFont().isLoaded()) {
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (screen) {
-			uint cluesFound = 0;
-			for (uint i = 0; i < Mystery::kCluesFoundCap; i++)
-				if (_mystery->_cluesFound[i])
-					cluesFound++;
-			Common::String hud = Common::String::format(
-				"Site %u  Clues %u  Pts %d   M N G H A R V Tab ?",
-				siteNum, cluesFound, _mystery->selectedPoints());
-			const int hudY = 192;
-			screen->fillRect(Common::Rect(0, hudY, 320, 200), 0);
-			Graphics::ManagedSurface mgr(320, 9,
-				Graphics::PixelFormat::createFormatCLUT8());
-			mgr.clear();
-			_vm->getFont().drawString(&mgr, hud, 4, 0, 320, 0x0F);
-			for (int row = 0; row < 8; row++) {
-				memcpy((byte *)screen->getBasePtr(0, hudY + row),
-					   (const byte *)mgr.getBasePtr(0, row), 320);
-			}
-			g_system->unlockScreen();
-		}
-	}
-
 	// Hotspot outlines (`_DrawSearchButtons`): toggle via V.
 	if (!_showHotspots)
 		return;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 1622c1cf8cb..e9db217e7be 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1817,9 +1817,6 @@ void EEMEngine::doNotebook() {
 						   ev.kbd.keycode == Common::KEYCODE_TAB) {
 					page++;
 					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_h) {
-					doHelp();
-					dirty = true;
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {


Commit: 7a9ad68ef6ea6bf7c9ca8772f293802bc0305c42
    https://github.com/scummvm/scummvm/commit/7a9ad68ef6ea6bf7c9ca8772f293802bc0305c42
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:51+02:00

Commit Message:
EEM: use blit API

Changed paths:
    engines/eem/graphics.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index ed8955c555b..33a3536c454 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -181,9 +181,7 @@ void EEMEngine::doHelp() {
 	{
 		Graphics::Surface *cur = g_system->lockScreen();
 		if (cur) {
-			for (int row = 0; row < 200; row++)
-				memcpy((byte *)ms.getBasePtr(0, row),
-					   (const byte *)cur->getBasePtr(0, row), 320);
+			ms.simpleBlitFrom(*cur);
 			g_system->unlockScreen();
 		}
 	}
@@ -270,9 +268,7 @@ void EEMEngine::doInterfaceHelp(uint num) {
 	{
 		Graphics::Surface *cur = g_system->lockScreen();
 		if (cur) {
-			for (int row = 0; row < 200; row++)
-				memcpy((byte *)bg.getBasePtr(0, row),
-					   (const byte *)cur->getBasePtr(0, row), 320);
+			bg.simpleBlitFrom(*cur);
 			g_system->unlockScreen();
 		}
 	}
@@ -351,10 +347,7 @@ void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
 	if (bg && bg->w == 320 && bg->h == 200) {
 		_partnerEraseBg.create(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)_partnerEraseBg.getBasePtr(0, row),
-				   (const byte *)bg->getBasePtr(0, row), 320);
-		}
+		_partnerEraseBg.simpleBlitFrom(*bg);
 	} else {
 		_partnerEraseBg.free();
 	}
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index f7a605baf4e..35adeb52f90 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -41,22 +41,7 @@ namespace EEM {
 // to the screen each tick.
 void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 			   int x, int y, byte transp) {
-	const int w = p.surface.w;
-	const int h = p.surface.h;
-	for (int row = 0; row < h; row++) {
-		const int dstY = y + row;
-		if (dstY < 0 || dstY >= 200)
-			continue;
-		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-		byte *out = (byte *)dst.getBasePtr(0, dstY);
-		for (int col = 0; col < w; col++) {
-			const int dstX = x + col;
-			if (dstX < 0 || dstX >= 320)
-				continue;
-			if (src[col] != transp)
-				out[dstX] = src[col];
-		}
-	}
+	dst.transBlitFrom(p.surface, Common::Point(x, y), (uint32)transp);
 }
 
 // Mask-aware blit from a Picture into a `Graphics::Surface` (the
@@ -809,10 +794,7 @@ void SiteScreen::enterSiteAnim() {
 		return;
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
-	for (int row = 0; row < 200; row++) {
-		memcpy((byte *)bg.getBasePtr(0, row),
-			   (const byte *)screen->getBasePtr(0, row), 320);
-	}
+	bg.simpleBlitFrom(*screen);
 	g_system->unlockScreen();
 
 	// Phase 1 — skateboard scroll. `_GetAnimation(6 | 0xe)`.
@@ -833,10 +815,7 @@ void SiteScreen::enterSiteAnim() {
 		while (x + spriteW > 0 && !_vm->shouldQuit()) {
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.getBasePtr(0, row), 320);
-			}
+			scratch.simpleBlitFrom(bg);
 			blitFrame(scratch, skate[frameIdx], x, y, transp);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, 320, 200);
@@ -886,10 +865,7 @@ void SiteScreen::enterSiteAnim() {
 
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.getBasePtr(0, row), 320);
-			}
+			scratch.simpleBlitFrom(bg);
 			blitFrame(scratch, fr, destX, destY, transp);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, 320, 200);
@@ -1043,10 +1019,7 @@ void SiteScreen::captureBgSnapshot() {
 		_snapshotSite = -1;
 		return;
 	}
-	for (int row = 0; row < 200; row++) {
-		memcpy((byte *)_bgSnapshot.getBasePtr(0, row),
-			   (const byte *)screen->getBasePtr(0, row), 320);
-	}
+	_bgSnapshot.simpleBlitFrom(*screen);
 	g_system->unlockScreen();
 }
 
@@ -1405,18 +1378,12 @@ void EEMEngine::playKdAnim(uint16 num) {
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)bg.getBasePtr(0, row),
-				   (const byte *)_partnerEraseBg.getBasePtr(0, row), 320);
-		}
+		bg.simpleBlitFrom(_partnerEraseBg);
 	} else {
 		Graphics::Surface *screen = g_system->lockScreen();
 		if (!screen)
 			return;
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)bg.getBasePtr(0, row),
-				   (const byte *)screen->getBasePtr(0, row), 320);
-		}
+		bg.simpleBlitFrom(*screen);
 		g_system->unlockScreen();
 	}
 
@@ -1430,10 +1397,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 		// Restore BG, then masked-blit the next frame.
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)bg.getBasePtr(0, row), 320);
-		}
+		scratch.simpleBlitFrom(bg);
 		// Anchor-aware: kdAnim cells (0x03/0x04/0x0c/0x0d ...) have
 		// non-zero per-frame `miscflags`/`rowoff` (anim 0x03 has
 		// rowoff up to 9, anim 0x04 has miscflags = -2). Without
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index e9db217e7be..86dbe5af528 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -162,13 +162,8 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
-	if (v.haveCaseBg) {
-		const int w = MIN<int>(v.caseBg->surface.w, 320);
-		const int h = MIN<int>(v.caseBg->surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)v.caseBg->surface.getBasePtr(0, row), w);
-	}
+	if (v.haveCaseBg)
+		scratch.simpleBlitFrom(v.caseBg->surface);
 	if (!v.vm->getFont().isLoaded() || !v.names)
 		return;
 
@@ -242,14 +237,8 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
-	if (v.haveCaseBg) {
-		const int w = MIN<int>(v.caseBg->surface.w, 320);
-		const int h = MIN<int>(v.caseBg->surface.h, 200);
-		for (int row = 0; row < h; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)v.caseBg->surface.getBasePtr(0, row), w);
-		}
-	}
+	if (v.haveCaseBg)
+		scratch.simpleBlitFrom(v.caseBg->surface);
 
 	// KD greeter frame — masked-blit current animation cell at
 	// (0x112, 0x50). 100 ms tick matches `_CheckFrameRate`. The
@@ -382,13 +371,8 @@ void EEMEngine::doProfilePicker() {
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		Picture bg;
-		if (_picsArchive.getPicture(0x104, bg)) {
-			const int w = MIN<int>(bg.surface.w, 320);
-			const int h = MIN<int>(bg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.surface.getBasePtr(0, row), w);
-		}
+		if (_picsArchive.getPicture(0x104, bg))
+			scratch.simpleBlitFrom(bg.surface);
 		for (uint i = 0; i < entries.size(); i++) {
 			const byte color = ((int)i == sel) ? 0xF : 0x8;
 			_font.drawString(&scratch, entries[i].label,
@@ -490,13 +474,8 @@ void EEMEngine::doNewPlayer() {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
-	if (haveBG) {
-		const int w = MIN<int>(bg.surface.w, 320);
-		const int h = MIN<int>(bg.surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)bg.surface.getBasePtr(0, row), w);
-	}
+	if (haveBG)
+		scratch.simpleBlitFrom(bg.surface);
 	_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
 	_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
@@ -556,13 +535,8 @@ void EEMEngine::doNewPlayer() {
 			// Re-render with the updated `name`. Same body as the
 			// initial render above — only `name + "_"` changes.
 			scratch.clear();
-			if (haveBG) {
-				const int w = MIN<int>(bg.surface.w, 320);
-				const int h = MIN<int>(bg.surface.h, 200);
-				for (int row = 0; row < h; row++)
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)bg.surface.getBasePtr(0, row), w);
-			}
+			if (haveBG)
+				scratch.simpleBlitFrom(bg.surface);
 			_font.drawString(&scratch, "Please type your name:",
 							 80, 40, 240, 0xF);
 			_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
@@ -678,13 +652,8 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.clear();
-			if (_picsArchive.getPicture(picNum, bg)) {
-				const int w = MIN<int>(bg.surface.w, 320);
-				const int h = MIN<int>(bg.surface.h, 200);
-				for (int row = 0; row < h; row++)
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)bg.surface.getBasePtr(0, row), w);
-			}
+			if (_picsArchive.getPicture(picNum, bg))
+				scratch.simpleBlitFrom(bg.surface);
 
 			// Story text. The bytes are a null-terminated string with
 			// `_ParseString` placeholders (0x80 = player name, 0x82
@@ -909,13 +878,8 @@ void EEMEngine::doSetup() {
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		Picture bg;
-		if (_picsArchive.getPicture(0x40, bg)) {
-			const int w = MIN<int>(bg.surface.w, 320);
-			const int h = MIN<int>(bg.surface.h, 200);
-			for (int row = 0; row < h; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)bg.surface.getBasePtr(0, row), w);
-		}
+		if (_picsArchive.getPicture(0x40, bg))
+			scratch.simpleBlitFrom(bg.surface);
 
 		const byte kKey    = 0xFE;
 		const byte kBright = 0x15;
@@ -1922,13 +1886,8 @@ void EEMEngine::drawNotebookFrame(int &page) {
 
 	// PIC 0x3f frame.
 	Picture frame;
-	if (_picsArchive.getPicture(0x3f, frame)) {
-		const int w = MIN<int>(frame.surface.w, 320);
-		const int h = MIN<int>(frame.surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)frame.surface.getBasePtr(0, row), w);
-	}
+	if (_picsArchive.getPicture(0x3f, frame))
+		scratch.simpleBlitFrom(frame.surface);
 
 	// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny
 	// for CELLS, but the original `_DoNotebook @ 161e:0500` always
@@ -2805,13 +2764,8 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	scratch.clear();
 
 	Picture frame;
-	if (_picsArchive.getPicture(0x42, frame)) {
-		const int w = MIN<int>(frame.surface.w, 320);
-		const int h = MIN<int>(frame.surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)frame.surface.getBasePtr(0, row), w);
-	}
+	if (_picsArchive.getPicture(0x42, frame))
+		scratch.simpleBlitFrom(frame.surface);
 
 	// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
 	// once at boot via `_GetPicture` (1-based IDs):
@@ -2916,13 +2870,8 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	scratch.clear();
 
 	Picture frame;
-	if (_picsArchive.getPicture(0x43, frame)) {
-		const int w = MIN<int>(frame.surface.w, 320);
-		const int h = MIN<int>(frame.surface.h, 200);
-		for (int row = 0; row < h; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)frame.surface.getBasePtr(0, row), w);
-	}
+	if (_picsArchive.getPicture(0x43, frame))
+		scratch.simpleBlitFrom(frame.surface);
 
 	const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
 	const int copyH = MIN<int>(mapH - scrollY, kMapWinH);


Commit: 0ccaaeb7c40435f712a55d22b502c6e3c52fae1e
    https://github.com/scummvm/scummvm/commit/0ccaaeb7c40435f712a55d22b502c6e3c52fae1e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:51+02:00

Commit Message:
EEM: initial support for floppy version

Changed paths:
    engines/eem/audio.cpp
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/music.cpp
    engines/eem/music.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/resource.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index f4719af0bb1..4c2e2a4bb15 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -205,9 +205,17 @@ void AudioPlayer::spoolSound(uint num) {
 			   "AudioPlayer: voice disabled, skipping spoolSound(%u)", num);
 		return;
 	}
-	if (_currentMystery < 0 || num >= _sdxIndex.size()) {
-		warning("AudioPlayer: spoolSound(%u) — invalid index (%d, %u)",
-				num, _currentMystery, (uint)_sdxIndex.size());
+	if (_currentMystery < 0) {
+		// No SDB/SDX bundle is loaded — floppy install (no `M*.SDB`)
+		// or pre-mystery state. Silently no-op; per-voice VOC playback
+		// for floppy lives elsewhere (TODO).
+		debugC(2, kDebugSound,
+			   "AudioPlayer: spoolSound(%u) skipped (no mystery sounds)", num);
+		return;
+	}
+	if (num >= _sdxIndex.size()) {
+		warning("AudioPlayer: spoolSound(%u) — index out of range (%u)",
+				num, (uint)_sdxIndex.size());
 		return;
 	}
 	const SoundEntry &entry = _sdxIndex[num];
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 3b460988ce2..1c893ae553b 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -236,7 +236,21 @@ void EEMEngine::doChoosePartner() {
 	// (`jen.voc` for Jenny, `jake.voc` for Jake; strings at 29be:0af1 /
 	// 29be:0af9) and block on `_WaitForVoiceDone`.
 	if (_audio) {
-		_audio->playVoc(Common::Path(_partner == 0 ? "JAKE.VOC" : "JEN.VOC"));
+		// CD: standalone clips `JAKE.VOC` / `JEN.VOC`. Floppy uses
+		// per-partner-and-event voice tables at `2608:0F0E` (Jake) /
+		// `2608:0F76` (Jenny), each 25 entries × FAR ptr to a VOC
+		// filename. After a partner pick, `FUN_19bb_0858 @ 19bb:0858`
+		// calls `FUN_1f4e_0305(0x14)` which loads voice slot 20 from
+		// the table for the chosen partner:
+		//   Jake  slot 20 → `2608:116B = "m-0113sl.voc"`
+		//   Jenny slot 20 → `2608:12AE = "f-0140sl.voc"`
+		Common::String voc;
+		if (isFloppy()) {
+			voc = (_partner == 0) ? "M-0113SL.VOC" : "F-0140SL.VOC";
+		} else {
+			voc = (_partner == 0) ? "JAKE.VOC" : "JEN.VOC";
+		}
+		_audio->playVoc(Common::Path(voc));
 		_audio->waitForVoiceDone();
 	}
 }
@@ -266,11 +280,28 @@ void EEMEngine::doInitClues() {
 	if (!ib)
 		return;
 
-	const uint16 startSite = READ_LE_UINT16(ib + 2);
-	if (startSite < Mystery::kVisitedSiteCap)
-		_mystery._onSites[startSite] = 1;
-	_mystery._siteNumber = startSite;
-	_mystery._lastSite = startSite;
+	// CD InitBlock starts with `u16 caseType; u16 startSite; <clue block>`.
+	// Floppy InitBlock starts with `u8 caseType; u8 nSubjects; subjects[];
+	// u8 nDialog; dialog_records[]` — no embedded `startSite`. Verified in
+	// `FUN_19bb_042f` (floppy briefing) where `cVar1 = *(buffer +
+	// initOffset)` reads caseType as a single byte and the dialog loop
+	// uses `local_e = byte[initOffset + 1 + nSubjects + 1]`.
+	const bool floppy = isFloppy();
+	const uint16 caseType = floppy ? (uint16)ib[0] : READ_LE_UINT16(ib);
+
+	if (!floppy) {
+		const uint16 startSite = READ_LE_UINT16(ib + 2);
+		if (startSite < Mystery::kVisitedSiteCap)
+			_mystery._onSites[startSite] = 1;
+		_mystery._siteNumber = startSite;
+		_mystery._lastSite = startSite;
+	} else {
+		// Floppy doesn't store a startSite in the InitBlock; default to 0
+		// so the first BigMap entry is reachable.
+		_mystery._onSites[0] = 1;
+		_mystery._siteNumber = 0;
+		_mystery._lastSite = 0;
+	}
 
 	setSitePalette(0x22);
 	Picture bg;
@@ -283,7 +314,6 @@ void EEMEngine::doInitClues() {
 	const bool haveGame  = _aniArchive.loadAnimation(gameAni, game) && !game.empty();
 	const bool haveBook  = _aniArchive.loadAnimation(bookAni, book) && !book.empty();
 
-	const uint16 caseType = READ_LE_UINT16(ib);
 	const bool haveNancy = (caseType == 1)
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
@@ -476,12 +506,24 @@ void EEMEngine::doInitClues() {
 	// the gate is `iVar1 == 2 && _VoiceAvailable`. Other case types open
 	// straight into the briefing dialogue without it.
 	if (caseType == 2 && _audio) {
-		_audio->playVoc(Common::Path("PHONE.VOC"));
+		// Floppy ships `PHONESL.VOC` instead of CD's `PHONE.VOC` (the
+		// `_LoadSoundName` call site at 2608:1107-110c hands the
+		// floppy filename to the same loader).
+		_audio->playVoc(Common::Path(isFloppy() ? "PHONESL.VOC" : "PHONE.VOC"));
 		_audio->waitForVoiceDone();
 	}
 
-	// Step 6 — case briefing dialogue.
-	displayClue(ib + 4);
+	// Step 6 — case briefing dialogue. CD InitBlock has the clue block
+	// at +4 (after `u16 caseType; u16 startSite`); floppy uses an
+	// entirely different format — `u8 caseType; u8 nSubjects;
+	// subjects[nSubjects]; u8 nDialog; dialog_records[nDialog]` —
+	// dispatched via `FUN_22dc_05c8 @ 22dc:05c8` per record (a
+	// different on-screen format from the CD clue blocks we render
+	// here). Skip the briefing display on floppy until the dialog-record
+	// renderer is ported; the briefing animations + composited final
+	// frames remain on screen until the user clicks past the BigMap.
+	if (!floppy)
+		displayClue(ib + 4);
 }
 
 /// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 27371f0c29e..402a5a89cef 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -42,6 +42,16 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO1(GUIO_NONE)
 	},
+	{
+		"eem",
+		"Floppy",
+		AD_ENTRY2s("EEM.EXE",   "692a5e6e7f4516d6e40c1f80cbc1b2cc", 109542,
+				   "PICS.DBD",  "26b97e8586f798ea90440e88d3d527cd", 959160),
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_NO_FLAGS,
+		GUIO1(GUIO_NONE)
+	},
 
 	AD_TABLE_END_MARKER
 };
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 8386f08be13..7ecc68da136 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -96,6 +96,13 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	: Engine(syst), _gameDescription(gameDesc), _rng("eem"),
 	  _playerName("Detective"),
 	  _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle), _partner(0) {
+	// `ADGameDescription::extra` is set by the matching entry in
+	// `gameDescriptions[]` ("CD" or "Floppy"). Keep variant detection
+	// purely string-based so a future re-release with a different
+	// `extra` tag falls back to CD-style asset paths.
+	_variant = (gameDesc && gameDesc->extra &&
+				Common::String(gameDesc->extra).contains("Floppy"))
+				 ? kVariantFloppy : kVariantCD;
 }
 
 EEMEngine::~EEMEngine() {
@@ -119,7 +126,7 @@ Common::Error EEMEngine::run() {
 
 	// MIDI music player. Mirrors `_InitMIDI @ 20a2:013a`. Constructed
 	// here (after `initGraphics` so the OSystem's timer/mixer is up).
-	_music = new MusicPlayer();
+	_music = new MusicPlayer(isFloppy());
 
 	// Digital audio (VOC + spool). Mirrors `_InitDrivers @ 1ff1:0368`
 	// which `_AIL_register_driver`s SBDIG.ADV / PASDIG.ADV alongside
@@ -203,50 +210,70 @@ Common::Error EEMEngine::run() {
 	//   - `_StopMIDI()` runs on keypress at the title screen
 	//     (2520:094c).
 	_skipIntro = false;
-	showEAKidsLogo();
-	if (!shouldQuit() && !_skipIntro)
-		showHighScoreLogo();
-	// Storm Software logo: voice + animation. The original at
-	// `_ShowStormLogo @ 2520:0707` calls `_LoadSoundName("thunder.voc")`
-	// (29be:177d) and passes the buffer to `OpenDifferenceAnimation_Sound`
-	// so the thunder roar plays alongside the lightning bolt.
-	if (!shouldQuit() && !_skipIntro) {
-		if (_audio)
-			_audio->playVoc(Common::Path("THUNDER.VOC"));
-		playAnm(Common::Path("BOLT.ANM"));
-		if (_audio)
-			_audio->stopVoice();
-	}
-	// `_InitMysterySounds(0x3c)` at 2520:086a — load M60.SDX/SDB so
-	// `_SpoolSound(uVar3 - 1)` between the ANIM01..ANIM20 anims has
-	// data to draw from.
-	if (!shouldQuit() && !_skipIntro && _audio)
-		_audio->initMysterySounds(60);
-	// Theme begins HERE — after the three silent logos, before the
-	// character-intro reel.
-	if (!shouldQuit() && !_skipIntro && _music)
-		_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
-	for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
-		Common::String name = Common::String::format("ANIM%02d.A", i);
-		playAnm(Common::Path(name));
-		// `_SpoolSound(uVar3 - 1)` at 2520:08c2 — the per-character VO
-		// plays AFTER each anim except the last (`if (uVar3 != 0x14)`
-		// at 2520:08a8). Original blocks until done; we run async and
-		// wait so the next anim doesn't start before the line ends.
-		if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
-			_audio->spoolSound((uint)(i - 1));
-			_audio->waitForSpoolDone();
+	if (isFloppy()) {
+		// Floppy opening — strings verified via Ghidra of `EEM.EXE`
+		// floppy at `2608:1513` ("movie.anm"), `2608:14F2` ("title.anm"),
+		// `2608:151D` ("theme.xmi"). The floppy ships only those three
+		// intro assets (no `BOLT.ANM`, no `ANIM01..20.A` reel, no
+		// `THUNDER.VOC`). Order: `MOVIE.ANM` plays as the intro
+		// cinematic with theme music, then `TITLE.ANM` holds the title
+		// screen until the player clicks.
+		if (!shouldQuit() && !_skipIntro && _music)
+			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+		if (!shouldQuit() && !_skipIntro)
+			playAnm(Common::Path("MOVIE.ANM"), 120,
+					/*holdLastFrame=*/false);
+		if (!shouldQuit() && !_skipIntro)
+			playAnm(Common::Path("TITLE.ANM"), 120,
+					/*holdLastFrame=*/true);
+	} else {
+		showEAKidsLogo();
+		if (!shouldQuit() && !_skipIntro)
+			showHighScoreLogo();
+		// Storm Software logo: voice + animation. The original at
+		// `_ShowStormLogo @ 2520:0707` calls `_LoadSoundName(
+		// "thunder.voc")` (29be:177d) and passes the buffer to
+		// `OpenDifferenceAnimation_Sound` so the thunder roar plays
+		// alongside the lightning bolt.
+		if (!shouldQuit() && !_skipIntro) {
+			if (_audio)
+				_audio->playVoc(Common::Path("THUNDER.VOC"));
+			playAnm(Common::Path("BOLT.ANM"));
+			if (_audio)
+				_audio->stopVoice();
 		}
+		// `_InitMysterySounds(0x3c)` at 2520:086a — load M60.SDX/SDB
+		// so `_SpoolSound(uVar3 - 1)` between the ANIM01..ANIM20 anims
+		// has data to draw from.
+		if (!shouldQuit() && !_skipIntro && _audio)
+			_audio->initMysterySounds(60);
+		// Theme begins HERE — after the three silent logos, before
+		// the character-intro reel.
+		if (!shouldQuit() && !_skipIntro && _music)
+			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+		for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
+			Common::String name = Common::String::format("ANIM%02d.A", i);
+			playAnm(Common::Path(name));
+			// `_SpoolSound(uVar3 - 1)` at 2520:08c2 — per-character
+			// VO after each anim except the last (`if (uVar3 != 0x14)`
+			// at 2520:08a8). Original blocks until done; we run async
+			// and wait so the next anim doesn't start prematurely.
+			if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
+				_audio->spoolSound((uint)(i - 1));
+				_audio->waitForSpoolDone();
+			}
+		}
+		// `_CleanMysterySounds` at 2520:0903 — release M60 before the
+		// title.
+		if (_audio)
+			_audio->cleanMysterySounds();
+		// Restart the theme for TITLE.ANM — matches the second
+		// `_MIDIPlayFile("theme.xmi")` call at 2520:0918.
+		if (!shouldQuit() && !_skipIntro && _music)
+			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+		if (!shouldQuit() && !_skipIntro)
+			playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
 	}
-	// `_CleanMysterySounds` at 2520:0903 — release M60 before the title.
-	if (_audio)
-		_audio->cleanMysterySounds();
-	// Restart the theme for TITLE.ANM — matches the second
-	// `_MIDIPlayFile("theme.xmi")` call at 2520:0918.
-	if (!shouldQuit() && !_skipIntro && _music)
-		_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
-	if (!shouldQuit() && !_skipIntro)
-		playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
 	_skipIntro = false;
 
 	// After the title chain, the original goes Title (B) -> screen 8
@@ -814,8 +841,12 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 		}
 		// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds` at the
 		// tail (2404:0298) so the SDB index is in place for clue and
-		// partner-speech spool sounds.
-		if (_audio)
+		// partner-speech spool sounds. Floppy ships individual
+		// `M-XXXX.VOC` files instead of the bundled SDB / SDX archive,
+		// so skip the init there to avoid spamming "missing" warnings;
+		// `spoolSound` then silently no-ops via the `_currentMystery <
+		// 0` guard until the per-voice VOC mapping is wired up.
+		if (_audio && !isFloppy())
 			_audio->initMysterySounds(mysteryNum);
 		_mystery.syncState(s);
 	} else {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 875d62f608c..1e54e852c45 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -101,6 +101,18 @@ enum ScreenId {
 	kScreenAction         = 0x0C
 };
 
+/// Distribution variant. Selected at engine init from the
+/// `ADGameDescription::extra` field set by `gameDescriptions[]` in
+/// `detection.cpp`. Used to gate filename selection (TRAVEL-N.XMI
+/// vs MUS%05u.XMI, FANFARE2.XMI vs MUS00005.XMI, PHONESL.VOC vs
+/// PHONE.VOC), opening-anim flow (MOVIE.ANM vs ANIM01..20.A) and
+/// per-variant sound effects (DING.VOC / NEWSCAN.VOC only ship with
+/// the floppy release).
+enum Variant {
+	kVariantCD     = 0,
+	kVariantFloppy = 1,
+};
+
 class EEMEngine : public Engine {
 public:
 	EEMEngine(OSystem *syst, const ADGameDescription *gameDesc);
@@ -110,6 +122,8 @@ public:
 
 	const char *getGameId() const;
 	Common::Platform getPlatform() const;
+	Variant getVariant() const { return _variant; }
+	bool isFloppy() const { return _variant == kVariantFloppy; }
 
 	bool hasFeature(EngineFeature f) const override;
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
@@ -156,6 +170,7 @@ public:
 	SaveStateList listProfiles() const;
 
 	const ADGameDescription *_gameDescription;
+	Variant _variant = kVariantCD;
 
 	DBDArchive &getPics()    { return _picsArchive; }
 	DBDArchive &getAni()     { return _aniArchive; }
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
index a1a40adb60e..80842663a09 100644
--- a/engines/eem/music.cpp
+++ b/engines/eem/music.cpp
@@ -31,7 +31,7 @@
 
 namespace EEM {
 
-MusicPlayer::MusicPlayer() {
+MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 	// Mirrors `_InitMIDI @ 20a2:013a` which used `_AIL_register_driver`
 	// to walk the .ADV files (ADLIB.ADV, SBFM.ADV, MT32MPU.ADV, etc.)
 	// and pick a backend. We honour the launcher's "Music driver"
@@ -166,7 +166,31 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 }
 
 void MusicPlayer::playMus(uint num, bool loop) {
-	// Format string verified at `29be:1525` ("mus%05d.xmi").
+	// CD format string verified at `29be:1525` ("mus%05d.xmi").
+	// Floppy maps the same numeric slots to its own filenames:
+	//   0..4 → travel music. The floppy table at 2608:1399-13cd holds
+	//          5 entries (Travel-6, Travel-4, Travel-7, Travel-1,
+	//          Travel-8) used by `_StartTravelMusic` via
+	//          `siteNumber % 5`.
+	//   5    → FANFARE2.XMI (winner). String at 2608:0c64.
+	//   6    → no equivalent in floppy install (the loser sting in
+	//          `_DisplayAlibi` is CD-only); skip.
+	if (_isFloppy) {
+		static const char *const kTravelTracks[5] = {
+			"Travel-6.XMI", "Travel-4.XMI", "Travel-7.XMI",
+			"Travel-1.XMI", "Travel-8.XMI",
+		};
+		Common::String name;
+		if (num < 5) {
+			name = kTravelTracks[num];
+		} else if (num == 5) {
+			name = "FANFARE2.XMI";
+		} else {
+			return; // num == 6 (loser sting): not present on floppy
+		}
+		playFile(Common::Path(name), loop);
+		return;
+	}
 	const Common::String name = Common::String::format("MUS%05u.XMI", num);
 	playFile(Common::Path(name), loop);
 }
diff --git a/engines/eem/music.h b/engines/eem/music.h
index f02830cf5cf..50dedeb9eb7 100644
--- a/engines/eem/music.h
+++ b/engines/eem/music.h
@@ -65,7 +65,7 @@ namespace EEM {
  */
 class MusicPlayer : public Audio::MidiPlayer {
 public:
-	MusicPlayer();
+	explicit MusicPlayer(bool isFloppy = false);
 
 	/// Mirrors `_MIDIPlayFile @ 20a2:024c`. Reads the .XMI from the game
 	/// directory and starts playing. `loop=true` mirrors the
@@ -73,7 +73,8 @@ public:
 	void playFile(const Common::Path &xmiPath, bool loop = false);
 
 	/// Mirrors `_MIDIPlay(num) @ 20a2:047d`. Composes the filename
-	/// "MUS%05u.XMI" and plays it. Used by `_StartTravelMusic`,
+	/// "MUS%05u.XMI" (CD) or maps to TRAVEL-N.XMI / FANFARE2.XMI
+	/// (floppy) and plays it. Used by `_StartTravelMusic`,
 	/// `_DisplayCorrect` (winner), `_DisplayAlibi` (loser).
 	void playMus(uint num, bool loop = false);
 
@@ -87,6 +88,7 @@ public:
 
 private:
 	bool _milesAudioMode = false;
+	const bool _isFloppy;
 	Common::Array<byte> _xmiData;
 };
 
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 6b6bfbfc56f..c94b7aa9180 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -44,6 +44,10 @@ void Mystery::clear() {
 	_solvedOffset = _hintOffset = 0;
 	_numSites = 0;
 	_numSuspects = _numCONSITEs = _numCOFFSITEs = 0;
+	_isFloppy = false;
+	_floppySuspectsOff = _floppyHintBlockOff = _floppyNoteIndexOff = 0;
+	_floppyGalleryOff = _floppyTextOff = _floppyKDTextOff = 0;
+	_floppySolvedOff = 0;
 	memset(_aChain, 0, sizeof(_aChain));
 	memset(_bChain, 0, sizeof(_bChain));
 	memset(_cChain, 0, sizeof(_cChain));
@@ -86,6 +90,118 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_data = staging;
 	_number = num;
 
+	// Floppy `M*.BIN` uses a completely different header layout from
+	// the CD release. Verified via Ghidra of `EEM.EXE` floppy:
+	//
+	//   `_ReadMystery_Floppy @ 22dc:0178` parses M0..M54 into pointers
+	//    held in a global table at 28da:3c87+. The header offsets are:
+	//
+	//     header[+0..+1]   ???
+	//     header[+2..+3]   ???
+	//     header[+4..+5]   pointer → SUSPECTS section
+	//                      (count byte, then 0xb-byte entries; entry[+4] =
+	//                       pic ID, entry[+10] = recolor flag)
+	//                      (`_FloppySuspectsPtr` @ 28da:3c8b)
+	//     header[+6..+7]   pointer → ???       (28da:3c9f)
+	//     header[+8..+9]   pointer → NOTES section
+	//                      (7-byte entries indexed by clue ID; used by
+	//                       floppy `_DrawNotes` @ 15e0:01e8)
+	//                      (`DAT_28da_3c9b`)
+	//     header[+0xa..+b] pointer → GALLERY-PORTRAITS section
+	//                      (count byte, then variable-length entries
+	//                       `5 + name_len` bytes; entry[+0..+1] = u16
+	//                       picID, entry[+4] = name length)
+	//                      (`_FloppyGalleryPtr` @ 28da:3c87, count =
+	//                       `_FloppyNumSuspects` @ 28da:004b)
+	//     header[+0xc..+d] pointer → TEXT block (alibi text base; used in
+	//                       floppy `_DisplayAlibi` @ 1d40:00df)
+	//     header[+0x10..1] pointer → KDTextIndex (`_FloppyKDTextIndexPtr`
+	//                       @ 28da:3c93)
+	//     header[+0x12..3] pointer → ???       (28da:3c8f)
+	//
+	//   There is NO fixed-offset numSites / numSuspects / numCONSITEs
+	//   field — counts are stored as the FIRST byte of each section.
+	//   The CD release refactored this into a flat header at fixed
+	//   offsets; our `Mystery::load` here parses the CD layout.
+	//
+	// Detect the variant from the first u16: CD M0 starts with `0x003e`
+	// (initOffset = 62), floppy M0 starts with `0x2286` (a section
+	// pointer near end-of-file). When the first u16 is too high to be
+	// a CD `_initOffset`, parse as floppy.
+	if (readU16(0) > 0x100) {
+		_isFloppy = true;
+		// Section-pointer header verified via Ghidra of floppy
+		// `_ReadMystery_Floppy @ 22dc:0178`,
+		// `_DoSiteLoop_Floppy @ 1652:03a3`,
+		// and `FUN_1fed_07ed` (BigMap site iteration):
+		//
+		//   header[+4]   → SITES section
+		//                  count byte + 0xb-byte entries; entry[+4] =
+		//                  pic ID for BigMap marker, [+6..7] = u16 X,
+		//                  [+8..9] = u16 Y, [+10] = recolor flag.
+		//   header[+6]   → SITE INDEX (array of u16 offsets to per-site
+		//                  data structs; site[+0]=picOff,
+		//                  site[+2]=clueBlockOff, site[+8]=speakerInfo)
+		//   header[+8]   → NOTES (7-byte entries / clue ID)
+		//   header[+0xa] → SUSPECTS / GALLERY portraits
+		//   header[+0xc] → TEXT block base
+		//   header[+0x10] → KDTextIndex
+		//   header[+0x12] → SOLVED CLUE CHAIN
+		_floppySuspectsOff  = readU16(0x04);  // SITES
+		_floppyHintBlockOff = readU16(0x06);  // SITE INDEX
+		_floppyNoteIndexOff = readU16(0x08);
+		_floppyGalleryOff   = readU16(0x0a);  // SUSPECTS
+		_floppyTextOff      = readU16(0x0c);
+		_floppyKDTextOff    = readU16(0x10);
+		_floppySolvedOff    = readU16(0x12);
+
+		// header[+0] (the first u16) holds the InitBlock byte offset on
+		// floppy too — verified at `FUN_19bb_042f` where `*DAT_28da_3ca5`
+		// (deref'd as int *) reads the first u16 of the buffer and uses
+		// it as `cVar1 = *(buffer + initOffset)` (caseType byte).
+		_initOffset = readU16(0x00);
+
+		// Counts: first byte of each section. Verified at
+		// `FUN_1fed_07ed` (`uVar3 = *_FloppySuspectsPtr` then iterates)
+		// and `FUN_154e_0045` (`DAT_28da_004b = *DAT_28da_3c87`).
+		const byte *sitesSec = (_floppySuspectsOff < _data.size())
+								? _data.data() + _floppySuspectsOff : nullptr;
+		const byte *susSec   = (_floppyGalleryOff < _data.size())
+								? _data.data() + _floppyGalleryOff : nullptr;
+		_numSites    = sitesSec ? *sitesSec : 0;
+		_numSuspects = susSec   ? *susSec   : 0;
+		_numCONSITEs = 0;
+		_numCOFFSITEs = 0;
+
+		// Point CD-shaped accessor offsets at the floppy equivalents
+		// so existing accessors return the right base for floppy:
+		//   siteIndexEntry() → floppy site index (header[+6])
+		//   noteIndex()       → floppy notes (header[+8])
+		//   galleryData()     → floppy suspects (header[+0xa])
+		//   textAt()          → floppy text block (header[+0xc])
+		//   kdTextIndex()     → floppy KDTextIndex (header[+0x10])
+		//   solvedClueBlock() → floppy solved chain (header[+0x12])
+		// Per-section LAYOUTS still differ from CD, so consumers
+		// walking entries need `isFloppy()` branches.
+		_siteIndexOffset = _floppyHintBlockOff;
+		_noteOffset      = _floppyNoteIndexOff;
+		_galleryOffset   = _floppyGalleryOff;
+		_textOffset      = _floppyTextOff;
+		_kdTextOffset    = _floppyKDTextOff;
+		_solvedOffset    = _floppySolvedOff;
+		_hintOffset      = _floppyHintBlockOff;
+
+		debugC(1, kDebugMystery,
+			   "Mystery::load(%u) floppy: sites=0x%04x siteIdx=0x%04x "
+			   "notes=0x%04x suspects=0x%04x text=0x%04x kd=0x%04x "
+			   "solved=0x%04x  numSites=%u numSuspects=%u",
+			   num, _floppySuspectsOff, _floppyHintBlockOff,
+			   _floppyNoteIndexOff, _floppyGalleryOff, _floppyTextOff,
+			   _floppyKDTextOff, _floppySolvedOff,
+			   _numSites, _numSuspects);
+		return true;
+	}
+
 	// Header is 16-bit-word indexed (matches `int *piVar1 = __Mystery; piVar1[N]`).
 	_initOffset      = readU16(0  * 2);
 	_mapOffset       = readU16(2  * 2);
@@ -102,6 +218,16 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_numCONSITEs = (uint8)readU16(14 * 2);
 	_numCOFFSITEs = (uint8)readU16(15 * 2);
 
+	// Defensive clamp. The floppy mystery file format uses a different
+	// header layout (verified by comparing M0.BIN: CD has `numSites =
+	// readU16(0x14) = 3`; floppy has `readU16(0x14) = 0x1925`,
+	// obviously not a site count). Without a clamp, downstream loops
+	// over `_onSites` / `_visitedSite` (capacity 20) blow past the
+	// array end. Until the floppy format is fully supported, cap at
+	// the array capacity so the engine fails gracefully.
+	if (_numSites > kVisitedSiteCap)
+		_numSites = kVisitedSiteCap;
+
 	for (uint i = 0; i < kChainLen; i++) {
 		_aChain[i] = readU16((16 + i) * 2);
 		_bChain[i] = readU16((21 + i) * 2);
@@ -218,6 +344,18 @@ const byte *Mystery::kdTextIndex() const {
 const byte *Mystery::mapEntry(uint siteNum) const {
 	if (!isLoaded() || siteNum >= _numSites)
 		return nullptr;
+	if (_isFloppy) {
+		// Floppy SITES section: byte[0] = count, then 11-byte entries.
+		// Verified at `FUN_1fed_07ed` (BigMap site iteration) where
+		// `pcVar2 = _FloppySuspectsPtr` (header[+4]) and the loop reads
+		// `*(int *)(pcVar2 + i*0xb + 7)` (X) and `*(int *)(pcVar2 + i*0xb
+		// + 9)` (Y) — the +7/+9 offsets are 1-based because pcVar2[0]
+		// holds the count, so entry stride 11 starts at byte 1.
+		const uint off = _floppySuspectsOff + 1 + siteNum * 11;
+		if (off + 11 > _data.size())
+			return nullptr;
+		return _data.data() + off;
+	}
 	const uint off = _mapOffset + siteNum * 14;
 	if (off + 14 > _data.size())
 		return nullptr;
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index dadaec00a75..4b23fa4ea27 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -209,6 +209,20 @@ private:
 	uint16 _bChain[kChainLen] = {};
 	uint16 _cChain[kChainLen] = {};
 
+	// Floppy variant uses a completely different header (see comment
+	// in `Mystery::load`). When `_isFloppy` is true, the CD-shaped
+	// `_initOffset / _siteIndexOffset / etc.` fields are unset and the
+	// floppy section pointers below are populated from the floppy
+	// header offsets verified at `_ReadMystery_Floppy @ 22dc:0178`.
+	bool   _isFloppy = false;
+	uint16 _floppySuspectsOff = 0;   ///< header[+4]  → suspects
+	uint16 _floppyHintBlockOff = 0;  ///< header[+6]  → hint→clue table
+	uint16 _floppyNoteIndexOff = 0;  ///< header[+8]  → notes (7B/clue)
+	uint16 _floppyGalleryOff = 0;    ///< header[+0xa] → gallery portraits
+	uint16 _floppyTextOff = 0;       ///< header[+0xc] → text block
+	uint16 _floppyKDTextOff = 0;     ///< header[+0x10] → KDTextIndex
+	uint16 _floppySolvedOff = 0;     ///< header[+0x12] → solved clue chain
+
 	uint16 readU16(uint offset) const;
 };
 
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index 91886e837fb..a08ce19f85f 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -115,7 +115,15 @@ bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out
 
 bool DBDArchive::loadEntry(uint num, Picture &out) {
 	if (num >= _index.size()) {
-		warning("DBDArchive::loadEntry: %u out of range (max %u)", num, _index.size());
+		// Out-of-range picture IDs are non-fatal — every caller already
+		// checks the return value (e.g., `haveDone`/`haveCrime` in
+		// `drawBigMapOverview`). The floppy's PICS.DBD ships fewer
+		// entries than the CD (e.g., the BigMap done-marker `0x20D` is
+		// CD-only), so this fires routinely on floppy and shouldn't
+		// be a `warning`.
+		debugC(2, kDebugGfx,
+			   "DBDArchive::loadEntry: %u out of range (max %u)",
+			   num, (uint)_index.size());
 		return false;
 	}
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 86dbe5af528..460217ce9d0 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1462,7 +1462,7 @@ void EEMEngine::doCaseSelection() {
 		if (!_mystery.load(0, &_rng)) {
 			warning("doCaseSelection: failed to load practice mystery");
 			_mystery.clear();
-		} else if (_audio) {
+		} else if (_audio && !isFloppy()) {
 			_audio->initMysterySounds(0);
 		}
 		return;
@@ -1685,7 +1685,7 @@ void EEMEngine::doCaseSelection() {
 		_mystery.clear();
 		return;
 	}
-	if (_audio)
+	if (_audio && !isFloppy())
 		_audio->initMysterySounds(mn);
 	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
 		   mn, _mystery.numSites(), _mystery.numSuspects());
@@ -2699,8 +2699,11 @@ void EEMEngine::doBigMap() {
 						   ev.mouse.y < kMapWinY + kMapWinH) {
 					// Hit-test the per-site button at its actual bbox
 					// (`_StampButtons` records the rect at SmallMap +8/+0xa
-					// with the button PIC's width/height).
-					for (uint i = 0; i < _mystery.numSites(); i++) {
+					// with the button PIC's width/height). Floppy entries
+					// have a different shape so skip the SmallMap hit-test
+					// (we still get clicks via the BigMap overview path).
+					const bool fmap = _mystery.isLoaded() && isFloppy();
+					for (uint i = 0; !fmap && i < _mystery.numSites(); i++) {
 						if (!_mystery._onSites[i] &&
 							i != _mystery._siteNumber)
 							continue;
@@ -2785,9 +2788,20 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 		const byte *entry = _mystery.mapEntry(i);
 		if (!entry)
 			continue;
-		const uint16 mx    = READ_LE_UINT16(entry + 0x4);
-		const uint16 my    = READ_LE_UINT16(entry + 0x6);
-		const uint16 crime = READ_LE_UINT16(entry + 0xc);
+		// CD entries are 14 bytes: X at +4, Y at +6, crime at +12.
+		// Floppy entries are 11 bytes: X at +6, Y at +8, recolor at +10.
+		// Floppy layout verified at `FUN_1fed_07ed` (BigMap iteration):
+		//   `*(int *)(pcVar2 + i*0xb + 7)` (= entry+6, X u16)
+		//   `*(int *)(pcVar2 + i*0xb + 9)` (= entry+8, Y u16)
+		//   `pcVar2[i*0xb + 0xb]` (= entry+10, recolor flag — non-zero
+		//   selects the crime-marker PIC over the regular site marker).
+		const bool floppy  = _mystery.isLoaded() && isFloppy();
+		const uint16 mx    = floppy ? READ_LE_UINT16(entry + 0x6)
+									: READ_LE_UINT16(entry + 0x4);
+		const uint16 my    = floppy ? READ_LE_UINT16(entry + 0x8)
+									: READ_LE_UINT16(entry + 0x6);
+		const uint16 crime = floppy ? (uint16)entry[0xa]
+									: READ_LE_UINT16(entry + 0xc);
 		const bool   done_ = (i < Mystery::kVisitedSiteCap)
 							  && _mystery._visitedSite[i];
 
@@ -2885,7 +2899,12 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	//   button = _GetButton(MapData[+0])
 	//   destX  = MapData[+8]
 	//   destY  = MapData[+0xa]
-	for (uint i = 0; i < _mystery.numSites(); i++) {
+	// Floppy mystery 0 ships no SmallMap detail layer, but mapEntry() still
+	// returns the floppy SITES row which has a different shape (no
+	// per-button PIC ID, X/Y at +6/+8) — skip the per-site stamp on floppy
+	// to avoid stamping garbage button IDs from the wrong field offsets.
+	const bool floppyMap = _mystery.isLoaded() && isFloppy();
+	for (uint i = 0; !floppyMap && i < _mystery.numSites(); i++) {
 		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 			continue;
 		const byte *entry = _mystery.mapEntry(i);


Commit: a55adf77b7cd802486f60a28d8171a6dfd7407aa
    https://github.com/scummvm/scummvm/commit/a55adf77b7cd802486f60a28d8171a6dfd7407aa
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:51+02:00

Commit Message:
EEM: implement conversations for the floppy version

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/mystery.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 1c893ae553b..e765bff0107 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -296,9 +296,15 @@ void EEMEngine::doInitClues() {
 		_mystery._siteNumber = startSite;
 		_mystery._lastSite = startSite;
 	} else {
-		// Floppy doesn't store a startSite in the InitBlock; default to 0
-		// so the first BigMap entry is reachable.
-		_mystery._onSites[0] = 1;
+		// Floppy InitBlock has no startSite. The floppy BigMap iterator
+		// `FUN_1fed_07ed` walks every site in the SITES section
+		// unconditionally (no `_OnSites` gate) — verified at the floppy
+		// `_DoMapScreen @ 1fed:1060` which stamps every site marker
+		// before the interaction loop. Mark every loaded site as visible
+		// so our `_onSites`-gated overview stamps the same set.
+		const uint sites = _mystery.numSites();
+		for (uint s = 0; s < sites && s < Mystery::kVisitedSiteCap; s++)
+			_mystery._onSites[s] = 1;
 		_mystery._siteNumber = 0;
 		_mystery._lastSite = 0;
 	}
@@ -514,15 +520,14 @@ void EEMEngine::doInitClues() {
 	}
 
 	// Step 6 — case briefing dialogue. CD InitBlock has the clue block
-	// at +4 (after `u16 caseType; u16 startSite`); floppy uses an
-	// entirely different format — `u8 caseType; u8 nSubjects;
-	// subjects[nSubjects]; u8 nDialog; dialog_records[nDialog]` —
-	// dispatched via `FUN_22dc_05c8 @ 22dc:05c8` per record (a
-	// different on-screen format from the CD clue blocks we render
-	// here). Skip the briefing display on floppy until the dialog-record
-	// renderer is ported; the briefing animations + composited final
-	// frames remain on screen until the user clicks past the BigMap.
-	if (!floppy)
+	// at +4 (after `u16 caseType; u16 startSite`); floppy uses
+	// `u8 caseType; u8 nSubjects; subjects[nSubjects]; u8 nDialog;
+	// dialog_records[nDialog]` (each record `11 + textCount` bytes),
+	// dispatched via `FUN_22dc_05c8 @ 22dc:05c8`. We render dialog
+	// records ourselves on floppy.
+	if (floppy)
+		displayFloppyBriefing(ib);
+	else
 		displayClue(ib + 4);
 }
 
@@ -891,6 +896,193 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	}
 }
 
+void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
+	// Floppy briefing — mirrors the dialog loop at the tail of
+	// `FUN_19bb_042f @ 19bb:042f`:
+	//   nSubjects = ib[1]; nDialog = ib[2 + nSubjects];
+	//   records   = ib + 3 + nSubjects;
+	// Each record is dispatched through `FUN_22dc_05c8 @ 22dc:05c8`,
+	// which reads:
+	//   u16 picID    @ +0     (character portrait, 0 = skip pic)
+	//   u16 picX     @ +2
+	//   u8  picY     @ +4
+	//   u8  balloon  @ +5     (low 7 bits = balloon idx, +0x80 = mirror)
+	//   u16 ballX    @ +6
+	//   u8  ballY    @ +8
+	//   u8  sound    @ +9     (high bit = play voice, low 7 bits = slot)
+	//   u8  textCount@ +10
+	//   u8  textIdx[]@ +11    (1 byte per — low 7 bits = NOTES idx)
+	// Text offsets in NOTES are ABSOLUTE byte offsets into the mystery
+	// buffer (verified by note 0 of M0.BIN at file offset 0xd0 holding
+	// "Hello, ..., I'm ... Eagle!"), so we read text via
+	// `mystery.blobAt(noteEntry[+2..3])` for Jake / `+4..5` for Jenny.
+	if (!initBlock || !isFloppy() || !_font.isLoaded())
+		return;
+
+	const uint8 nSubjects = initBlock[1];
+	const uint8 nDialog   = initBlock[2 + nSubjects];
+	const byte *rec       = initBlock + 3 + nSubjects;
+
+	const byte *notes   = _mystery.noteIndex();
+	const byte *bufBase = _mystery.blobAt(0);
+	if (!notes || !bufBase)
+		return;
+
+	// Snapshot the current screen so each record restores the
+	// briefing background between bubbles.
+	Graphics::ManagedSurface bg(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)bg.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+		}
+	}
+
+	for (uint i = 0; i < nDialog && !shouldQuit(); i++) {
+		const uint16 picID    = READ_LE_UINT16(rec + 0);
+		const uint16 picX     = READ_LE_UINT16(rec + 2);
+		const uint8  picY     = rec[4];
+		const uint8  balByte  = rec[5];
+		const uint16 ballX    = READ_LE_UINT16(rec + 6);
+		const uint8  ballY    = rec[8];
+		const uint8  textCount= rec[10];
+
+		// Build the full text by concatenating each note's per-partner
+		// text string (parsed for `%s`-style placeholders).
+		Common::String raw;
+		for (uint t = 0; t < textCount; t++) {
+			const uint8  idx     = rec[11 + t] & 0x7f;
+			const uint16 textOff = (_partner == 0)
+				? READ_LE_UINT16(notes + idx * 7 + 2)
+				: READ_LE_UINT16(notes + idx * 7 + 4);
+			const char *line = (const char *)(bufBase + textOff);
+			if (t > 0)
+				raw += ' ';
+			raw += line;
+		}
+		const Common::String text = parseString(raw, _playerName, _partner);
+
+		// Restore briefing BG before drawing this bubble.
+		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+
+		// Optional character portrait.
+		if (picID != 0 && picID != 0xFFFF) {
+			Picture pic;
+			if (_picsArchive.getPicture(picID, pic))
+				blitAt(pic, picX, picY);
+		}
+
+		// Compose balloon + text on a scratch surface.
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		{
+			Graphics::Surface *screen = g_system->lockScreen();
+			if (screen) {
+				for (int row = 0; row < 200; row++) {
+					memcpy((byte *)scratch.getBasePtr(0, row),
+						   (const byte *)screen->getBasePtr(0, row), 320);
+				}
+				g_system->unlockScreen();
+			}
+		}
+
+		Picture balloon;
+		const uint16 balloonId  = balByte & 0x7F;
+		const bool   flipBall   = (balByte & 0x80) != 0;
+		const bool   haveBalloon = balByte != 0xFF &&
+			_balloonArchive.size() > balloonId &&
+			_balloonArchive.loadEntry(balloonId, balloon);
+		uint16 textWidth = 142;
+		uint16 textXIns  = 6;
+		uint16 textYIns  = 4;
+		if (haveBalloon) {
+			const int bw = MIN<int>(balloon.surface.w, 320 - ballX);
+			const int bh = MIN<int>(balloon.surface.h, 200 - ballY);
+			const byte transp = (byte)(balloon.flags >> 8);
+			for (int row = 0; row < bh; row++) {
+				const byte *src =
+					(const byte *)balloon.surface.getBasePtr(0, row);
+				byte *dst =
+					(byte *)scratch.getBasePtr(ballX, ballY + row);
+				for (int col = 0; col < bw; col++) {
+					const int srcCol = flipBall
+						? (balloon.surface.w - 1 - col) : col;
+					const byte px = src[srcCol];
+					if (px != transp)
+						dst[col] = px;
+				}
+			}
+			getBalloonInsets(balloonId, textXIns, textYIns, textWidth);
+		}
+
+		const int textX = ballX + textXIns;
+		const int textY = ballY + textYIns;
+		_font.drawWordWrapped(&scratch, textX, textY,
+			MAX<int>(8, (int)textWidth), text, 0);
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Drain pending events from the previous bubble (or upstream
+		// animation skip) so a single Enter press doesn't burst-advance
+		// through multiple bubbles. Without this, key-repeat or stacked
+		// KEYUP/KEYDOWN events from one keystroke could roll past
+		// several records before the user could even read them.
+		{
+			Common::Event drain;
+			while (g_system->getEventManager()->pollEvent(drain)) {}
+		}
+		// Minimum visible time per bubble — guards against accidental
+		// rapid-fire advances from accumulated input.
+		const uint32 minVisibleMs = 250;
+		const uint32 startedAt = g_system->getMillis();
+
+		// Wait for click / key. ESC skips the rest of the briefing.
+		// Inside the loop we ignore input until at least `minVisibleMs`
+		// has elapsed since the bubble was drawn — combined with the
+		// pre-loop event drain, this prevents one keystroke (or its
+		// auto-repeat tail) from advancing several bubbles back to back.
+		bool advance = false;
+		bool skipAll = false;
+		while (!advance && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					advance = true;
+					break;
+				}
+				if (g_system->getMillis() - startedAt < minVisibleMs)
+					continue;
+				if (ev.type == Common::EVENT_KEYDOWN &&
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					advance = true;
+					skipAll = true;
+					interruptAudio();
+					break;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN) {
+					advance = true;
+					break;
+				}
+			}
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+		if (skipAll)
+			return;
+
+		rec += 11 + textCount;
+	}
+}
+
 bool EEMEngine::areYouSure() {
 	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
 	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 7ecc68da136..23a34e6201f 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -53,8 +53,10 @@ const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720
 // Picture / palette IDs from the original code (1-based picture IDs).
 const uint kPicEAKidsLogo      = 0x54;  ///< _ShowEAKids: GetPicture(0x54)
 const uint kPicHighScoreLogo   = 0x20c; ///< _ShowHScoreLogo: GetPicture(0x20c)
+const uint kPicStormLogo       = 0x20b; ///< Floppy storm-logo still: PIC 0x20b
 const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
+const uint kPalStormLogo       = 0x26;  ///< Floppy `FUN_23d2_0605` palette idx
 
 // Save body version, used by the `Common::Serializer` inside
 // `saveGameStream`/`loadGameStream`. The framework's extended-save
@@ -211,15 +213,35 @@ Common::Error EEMEngine::run() {
 	//     (2520:094c).
 	_skipIntro = false;
 	if (isFloppy()) {
-		// Floppy opening — strings verified via Ghidra of `EEM.EXE`
-		// floppy at `2608:1513` ("movie.anm"), `2608:14F2` ("title.anm"),
-		// `2608:151D` ("theme.xmi"). The floppy ships only those three
-		// intro assets (no `BOLT.ANM`, no `ANIM01..20.A` reel, no
-		// `THUNDER.VOC`). Order: `MOVIE.ANM` plays as the intro
-		// cinematic with theme music, then `TITLE.ANM` holds the title
-		// screen until the player clicks.
+		// Floppy opening — driven by `FUN_23d2_039c @ 23d2:039c`:
+		//   FUN_23d2_0170()  — clear palette
+		//   FUN_23d2_004b()  — set up timer
+		//   FUN_23d2_050c()  — show PIC 0x54 (EA Kids logo, palette 0x25)
+		//   FUN_23d2_06c6()  — show PIC 0x20c (High Score logo, palette 0x27)
+		//   FUN_23d2_0605()  — show PIC 0x20b (Storm Software, palette
+		//                      0x26) AND play voice slot 25 (thunder.voc
+		//                      — verified via the Jake voice table at
+		//                      `2608:0f0e` slot 25 → `2608:11ac` =
+		//                      "thunder.voc").
+		//   _MIDIPlayFile("theme.xmi", loop=1)
+		//   _PlayANM(idx=0) — CHAT.ANM (filename table at `2608:14fe`
+		//                     index 0 → "chat.anm" at `2608:150a`).
+		//   _PlayANM(idx=1) — MOVIE.ANM (table index 1 → `2608:1513`).
+		// `TITLE.ANM` is shown later by screen-`0xb` handler `@
+		// 19bb:0ebc` once the intro driver returns. The thunder VOC
+		// alongside the storm logo is the "intro voice" the user heard
+		// missing — without it the lightning-bolt logo plays silently.
+		if (!shouldQuit() && !_skipIntro)
+			showEAKidsLogo();
+		if (!shouldQuit() && !_skipIntro)
+			showHighScoreLogo();
+		if (!shouldQuit() && !_skipIntro)
+			showFloppyStormLogo();
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+		if (!shouldQuit() && !_skipIntro)
+			playAnm(Common::Path("CHAT.ANM"), 120,
+					/*holdLastFrame=*/false);
 		if (!shouldQuit() && !_skipIntro)
 			playAnm(Common::Path("MOVIE.ANM"), 120,
 					/*holdLastFrame=*/false);
@@ -678,6 +700,29 @@ void EEMEngine::showHighScoreLogo() {
 	waitForInput(2500);
 }
 
+void EEMEngine::showFloppyStormLogo() {
+	// Floppy storm-logo splash — `FUN_23d2_0605 @ 23d2:0605`:
+	//   GetPicture(0x20b); BlitToVGA;
+	//   if (sound) { LoadVOC(slot 25 = "thunder.voc"); PlayVOC(...); }
+	//   GetPalette(0x26); FadeIn; wait 50 ticks; FadeOut.
+	// CD plays `BOLT.ANM` at this slot with `THUNDER.VOC` overlaid; the
+	// floppy uses a static still + the same VOC. Without the VOC the
+	// lightning logo plays silently — the user noticed.
+	Picture pic;
+	if (!_picsArchive.getPicture(kPicStormLogo, pic)) {
+		warning("Storm logo (%u) load failed", kPicStormLogo);
+		return;
+	}
+	blitAt(pic, 0, 0);
+	setSitePalette(kPalStormLogo);
+	g_system->updateScreen();
+	if (_audio)
+		_audio->playVoc(Common::Path("THUNDER.VOC"));
+	waitForInput(2500);
+	if (_audio)
+		_audio->stopVoice();
+}
+
 void EEMEngine::doSiteLoop() {
 	// Mirrors the per-mystery site loop. SiteScreen::run() handles
 	// hotspot clicks plus M (map), N (notebook), G (gallery), A (accuse),
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 1e54e852c45..74046faec8d 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -361,6 +361,7 @@ private:
 	// Screen handlers — port targets in screens/ later.
 	void showEAKidsLogo();
 	void showHighScoreLogo();
+	void showFloppyStormLogo();
 
 	/// Profile selector — mirrors `screen8_handler @ 1c33:1012`.
 	/// Walks `listProfiles()`, draws the list of existing profile
@@ -423,6 +424,21 @@ private:
 	/// minus the live ANI sequence playback.
 	void doInitClues();
 
+	/// Floppy variant of the briefing dialog renderer. Walks the dialog
+	/// record list at the tail of the floppy InitBlock (per
+	/// `FUN_19bb_042f` and `FUN_22dc_05c8 @ 22dc:05c8`) and renders one
+	/// speech balloon per record. Each record is `11 + textCount` bytes:
+	///   +0..1  picID (character portrait, 0 = none)
+	///   +2..3  picX
+	///   +4     picY
+	///   +5     balloonId | (mirror_flag << 7)
+	///   +6..7  balloonX
+	///   +8     balloonY
+	///   +9     soundFlag (high bit) | sound slot (low 7 bits)
+	///   +10    textCount
+	///   +11..  text indices (1 byte each, low 7 bits = NOTES idx)
+	void displayFloppyBriefing(const byte *initBlock);
+
 public:
 	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
 	/// based on `_mystery._siteNumber % 5` and starts it (looping). The
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index c94b7aa9180..437a8f62499 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -266,8 +266,12 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 const byte *Mystery::siteIndexEntry(uint siteNum) const {
 	if (!isLoaded() || siteNum >= _numSites)
 		return nullptr;
-	const uint off = _siteIndexOffset + siteNum * 6;
-	if (off + 6 > _data.size())
+	// Floppy site index uses 2-byte (u16) entries — verified at
+	// `_DoSiteLoop_Floppy @ 1652:03d2` reading `*(int *)
+	// ((int)_FloppySiteIndexPtr + siteNum * 2)`. CD uses 6-byte rows.
+	const uint stride = _isFloppy ? 2 : 6;
+	const uint off = _siteIndexOffset + siteNum * stride;
+	if (off + stride > _data.size())
 		return nullptr;
 	return _data.data() + off;
 }
@@ -286,6 +290,14 @@ const byte *Mystery::hotspots(uint siteNum) const {
 	const byte *idx = siteIndexEntry(siteNum);
 	if (!idx)
 		return nullptr;
+	// Floppy site index is only 2 bytes per entry; `idx + 4` would
+	// read into the NEXT entry's offset, returning garbage. The
+	// floppy hotspot list lives inside the site-data sub-blob (see
+	// `_DoSiteLoop_Floppy @ 1652:03a3` walking 5-byte drop entries
+	// from `*site_data + 2`); don't fake a CD-style result here —
+	// SiteScreen guards floppy paths separately.
+	if (_isFloppy)
+		return nullptr;
 	const uint16 hotspotOff = READ_LE_UINT16(idx + 4);
 	if (hotspotOff >= _data.size())
 		return nullptr;
@@ -293,6 +305,8 @@ const byte *Mystery::hotspots(uint siteNum) const {
 }
 
 uint16 Mystery::hotspotCount(uint siteNum) const {
+	if (_isFloppy)
+		return 0;
 	const byte *site = siteData(siteNum);
 	if (!site || (size_t)(site - _data.data()) + 8 > _data.size())
 		return 0;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 35adeb52f90..80610200252 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -522,8 +522,25 @@ void SiteScreen::enter(uint siteNum) {
 	// Palette: original `_BuildBackground` calls `GetPalette(sitenum + 1)`
 	// where sitenum is the global SITES.DBD index (= the per-mystery
 	// `sitepic` field), not the per-mystery site index.
+	//
+	// Floppy site_data layout differs (per `_DoSiteLoop_Floppy @
+	// 1652:03f4`): the FIRST u16 is the *offset* to a drops sub-struct
+	// whose byte 0 is the small SITES.DBD picID. Without this branch
+	// we'd dereference the offset directly and call setSitePalette
+	// with a value in the thousands — the user reported `index 9275
+	// out of range` for site 2.
 	const byte *sd = _mystery->siteData(siteNum);
-	const uint16 sitepic = sd ? READ_LE_UINT16(sd) : 0;
+	uint16 sitepic = 0;
+	if (sd) {
+		if (_vm->isFloppy()) {
+			const uint16 dropsOff = READ_LE_UINT16(sd);
+			const byte *drops = _mystery->blobAt(dropsOff);
+			if (drops)
+				sitepic = (uint16)drops[0];
+		} else {
+			sitepic = READ_LE_UINT16(sd);
+		}
+	}
 	_vm->setSitePaletteForSite(sitepic);
 
 	// SITEPALS ships with palette indices 0xF9..0xFE all set to a
@@ -980,6 +997,16 @@ void SiteScreen::scanColorCycles(uint siteNum) {
 	_colorCycles.clear();
 	if (!_mystery)
 		return;
+	// Floppy site data has a different layout (per `_DoSiteLoop_Floppy
+	// @ 1652:03a3`: site index is 2-byte u16 entries, site data starts
+	// with an offset to a sub-structure with picID + 5-byte hotspot
+	// entries — there's no `[+0xa]` anim count at the same place). The
+	// CD-shaped offsets read garbage and run past the buffer end. Until
+	// the floppy color-cycle layout is reverse-engineered, skip the
+	// scan: the floppy still does palette F9..FE rotation in its own
+	// driver, but our hotspot palette override works without it.
+	if (_vm && _vm->isFloppy())
+		return;
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -1044,6 +1071,15 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	// `kWaitAnims` lives at file scope above; we cap rendering at
 	// `speaker < 7` since anything past entry 6 is the `_SiteButtons`
 	// rect data that follows the table in the binary.
+	// Floppy site data has a different shape: site_data+8 is a u16
+	// OFFSET to a 10-byte speakerInfo struct (verified at
+	// `_DoSiteLoop_Floppy @ 1652:042b` reading `*(undefined2 *)
+	// (DAT_28da_0172) = anim_id_jake` etc.), not an index into the
+	// `kWaitAnims` table the CD uses. Skip the partner render until
+	// the floppy speakerInfo is wired up — without this guard we
+	// dereference garbage entries past the table end.
+	if (_vm && _vm->isFloppy())
+		return;
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -1118,8 +1154,25 @@ void SiteScreen::renderBackground(uint siteNum) {
 	// — the dbi entry stride is 10 bytes, no -1 adjustment). Our
 	// previous `loadEntry(sitepic - 1)` was off by one, which is why
 	// the tutorial mystery rendered scenes from neighbouring cases.
+	// Floppy site_data stores the picID one indirection deeper:
+	// `*site_data` (u16) → drops sub-struct, `drops[0]` (byte) is the
+	// SITES.DBD entry. CD uses the u16 directly. Verified at
+	// `FUN_16e2_12fd @ 16e2:12fd` (called as
+	// `FUN_16e2_12fd(*local_12, 0x42, 0x14)` from
+	// `_DoSiteLoop_Floppy`, where `*local_12` is the byte at
+	// drops_struct+0 via the `(undefined1 *)` cast).
 	const byte *site = _mystery->siteData(siteNum);
-	const uint16 sitepic = site ? READ_LE_UINT16(site) : 0;
+	uint16 sitepic = 0;
+	if (site) {
+		if (_vm->isFloppy()) {
+			const uint16 dropsOff = READ_LE_UINT16(site);
+			const byte *drops = _mystery->blobAt(dropsOff);
+			if (drops)
+				sitepic = (uint16)drops[0];
+		} else {
+			sitepic = READ_LE_UINT16(site);
+		}
+	}
 	Picture scene;
 	bool haveScene = false;
 	if (sitepic < _vm->getSites().size())
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 460217ce9d0..7e7e9de005f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2699,24 +2699,45 @@ void EEMEngine::doBigMap() {
 						   ev.mouse.y < kMapWinY + kMapWinH) {
 					// Hit-test the per-site button at its actual bbox
 					// (`_StampButtons` records the rect at SmallMap +8/+0xa
-					// with the button PIC's width/height). Floppy entries
-					// have a different shape so skip the SmallMap hit-test
-					// (we still get clicks via the BigMap overview path).
+					// with the button PIC's width/height).
 					const bool fmap = _mystery.isLoaded() && isFloppy();
-					for (uint i = 0; !fmap && i < _mystery.numSites(); i++) {
+					for (uint i = 0; i < _mystery.numSites(); i++) {
 						if (!_mystery._onSites[i] &&
 							i != _mystery._siteNumber)
 							continue;
 						const byte *entry = _mystery.mapEntry(i);
 						if (!entry)
 							continue;
-						const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
-						const uint16 mx       = READ_LE_UINT16(entry + 0x8);
-						const uint16 my       = READ_LE_UINT16(entry + 0xa);
+						uint16 mx;
+						uint16 my;
+						uint16 buttonId = 0;
+						if (fmap) {
+							// Floppy detail view: click rect on
+							// BIGMAP.PIC at (+0, +2), per
+							// `FUN_1fed_0c3e`'s write to
+							// DAT_28da_3aee/DAT_28da_3af0.
+							mx = READ_LE_UINT16(entry + 0x0);
+							my = READ_LE_UINT16(entry + 0x2);
+						} else {
+							buttonId = READ_LE_UINT16(entry + 0x0);
+							mx       = READ_LE_UINT16(entry + 0x8);
+							my       = READ_LE_UINT16(entry + 0xa);
+						}
 						Picture button;
 						int bw = 16;
 						int bh = 16;
-						if (_buttonArchive.loadEntry(buttonId, button)) {
+						if (fmap) {
+							// Floppy uses the global site-marker PIC for
+							// every site; the recolor flag at +10 picks
+							// the crime variant. Sized off whichever PIC
+							// is loaded successfully.
+							Picture m;
+							const uint pic = (entry[0xa] != 0) ? 0xc6 : 0xc5;
+							if (_picsArchive.getPicture(pic, m)) {
+								bw = m.surface.w;
+								bh = m.surface.h;
+							}
+						} else if (_buttonArchive.loadEntry(buttonId, button)) {
 							bw = button.surface.w;
 							bh = button.surface.h;
 						}
@@ -2899,24 +2920,50 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	//   button = _GetButton(MapData[+0])
 	//   destX  = MapData[+8]
 	//   destY  = MapData[+0xa]
-	// Floppy mystery 0 ships no SmallMap detail layer, but mapEntry() still
-	// returns the floppy SITES row which has a different shape (no
-	// per-button PIC ID, X/Y at +6/+8) — skip the per-site stamp on floppy
-	// to avoid stamping garbage button IDs from the wrong field offsets.
+	// Floppy SITES rows are 11 bytes — no per-button PIC ID. Use the
+	// regular site / crime marker PICs (0xc5 / 0xc6) keyed off the
+	// recolor flag at +10, same logic as the overview stamp.
 	const bool floppyMap = _mystery.isLoaded() && isFloppy();
-	for (uint i = 0; !floppyMap && i < _mystery.numSites(); i++) {
+	Picture floppySiteM;
+	Picture floppyCrimeM;
+	const bool haveFloppySite  = floppyMap && _picsArchive.getPicture(0xc5, floppySiteM);
+	const bool haveFloppyCrime = floppyMap && _picsArchive.getPicture(0xc6, floppyCrimeM);
+	for (uint i = 0; i < _mystery.numSites(); i++) {
 		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 			continue;
 		const byte *entry = _mystery.mapEntry(i);
 		if (!entry)
 			continue;
-		const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
-		const uint16 mx       = READ_LE_UINT16(entry + 0x8);
-		const uint16 my       = READ_LE_UINT16(entry + 0xa);
-
+		uint16 mx;
+		uint16 my;
 		Picture button;
-		if (!_buttonArchive.loadEntry(buttonId, button))
-			continue;
+		if (floppyMap) {
+			// Floppy SITES rows carry TWO position pairs:
+			//   (+0, +2) = position on BIGMAP.PIC (the zoomed view).
+			//              Used by `FUN_1fed_0c3e @ 1fed:0c3e` for
+			//              suspect-portrait stamping AND as the
+			//              click bbox on the zoomed map.
+			//   (+6, +8) = position on the overview PIC 0x42.
+			//              Used by `FUN_1fed_07ed @ 1fed:07ed`.
+			// The detail view scrolls BIGMAP.PIC, so we need the
+			// (+0, +2) coords here. Recolor flag at +10 still
+			// selects crime vs site marker.
+			mx = READ_LE_UINT16(entry + 0x0);
+			my = READ_LE_UINT16(entry + 0x2);
+			const bool useCrime = entry[0xa] != 0;
+			if (useCrime && haveFloppyCrime)
+				button = floppyCrimeM;
+			else if (haveFloppySite)
+				button = floppySiteM;
+			else
+				continue;
+		} else {
+			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);
+			mx                    = READ_LE_UINT16(entry + 0x8);
+			my                    = READ_LE_UINT16(entry + 0xa);
+			if (!_buttonArchive.loadEntry(buttonId, button))
+				continue;
+		}
 		const int sx = (int)mx - scrollX + kMapWinX;
 		const int sy = (int)my - scrollY + kMapWinY;
 		const byte transp = (byte)(button.flags >> 8);


Commit: 3ba1ac49ac1ddc11fa16766af84344b5dec53a16
    https://github.com/scummvm/scummvm/commit/3ba1ac49ac1ddc11fa16766af84344b5dec53a16
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:52+02:00

Commit Message:
EEM: npc conversations for the floppy version

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index e765bff0107..8e4cd5798d1 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -896,13 +896,9 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	}
 }
 
-void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
-	// Floppy briefing — mirrors the dialog loop at the tail of
-	// `FUN_19bb_042f @ 19bb:042f`:
-	//   nSubjects = ib[1]; nDialog = ib[2 + nSubjects];
-	//   records   = ib + 3 + nSubjects;
-	// Each record is dispatched through `FUN_22dc_05c8 @ 22dc:05c8`,
-	// which reads:
+void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
+	// Render `count` consecutive floppy dialog records starting at
+	// `rec`. Per `FUN_22dc_05c8 @ 22dc:05c8`, each record is:
 	//   u16 picID    @ +0     (character portrait, 0 = skip pic)
 	//   u16 picX     @ +2
 	//   u8  picY     @ +4
@@ -916,13 +912,9 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 	// buffer (verified by note 0 of M0.BIN at file offset 0xd0 holding
 	// "Hello, ..., I'm ... Eagle!"), so we read text via
 	// `mystery.blobAt(noteEntry[+2..3])` for Jake / `+4..5` for Jenny.
-	if (!initBlock || !isFloppy() || !_font.isLoaded())
+	if (!rec || !isFloppy() || !_font.isLoaded() || count == 0)
 		return;
 
-	const uint8 nSubjects = initBlock[1];
-	const uint8 nDialog   = initBlock[2 + nSubjects];
-	const byte *rec       = initBlock + 3 + nSubjects;
-
 	const byte *notes   = _mystery.noteIndex();
 	const byte *bufBase = _mystery.blobAt(0);
 	if (!notes || !bufBase)
@@ -943,7 +935,7 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 		}
 	}
 
-	for (uint i = 0; i < nDialog && !shouldQuit(); i++) {
+	for (uint i = 0; i < count && !shouldQuit(); i++) {
 		const uint16 picID    = READ_LE_UINT16(rec + 0);
 		const uint16 picX     = READ_LE_UINT16(rec + 2);
 		const uint8  picY     = rec[4];
@@ -953,17 +945,35 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 		const uint8  textCount= rec[10];
 
 		// Build the full text by concatenating each note's per-partner
-		// text string (parsed for `%s`-style placeholders).
+		// text string (parsed for `%s`-style placeholders). Bounds-
+		// check every offset against the mystery blob; if any byte
+		// looks out of range we just stop appending — corrupt offsets
+		// from a misparsed record would otherwise dereference past the
+		// buffer and SIGBUS inside `strlen`.
+		const uint32 dsz       = _mystery.dataSize();
+		const uint32 notesBase = (uint32)(notes - bufBase);
 		Common::String raw;
 		for (uint t = 0; t < textCount; t++) {
-			const uint8  idx     = rec[11 + t] & 0x7f;
+			const uint8  idx        = rec[11 + t] & 0x7f;
+			const uint32 noteAbs    = notesBase + (uint32)idx * 7;
+			if (noteAbs + 6 > dsz)
+				break;
 			const uint16 textOff = (_partner == 0)
 				? READ_LE_UINT16(notes + idx * 7 + 2)
 				: READ_LE_UINT16(notes + idx * 7 + 4);
+			if (textOff >= dsz)
+				break;
 			const char *line = (const char *)(bufBase + textOff);
+			// Find the NUL terminator within the blob — refuse to
+			// strlen past the end.
+			uint32 lineLen = 0;
+			while (textOff + lineLen < dsz && line[lineLen] != 0)
+				lineLen++;
+			if (textOff + lineLen >= dsz)
+				break;
 			if (t > 0)
 				raw += ' ';
-			raw += line;
+			raw += Common::String(line, lineLen);
 		}
 		const Common::String text = parseString(raw, _playerName, _partner);
 
@@ -1083,6 +1093,105 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 	}
 }
 
+void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
+	// Floppy briefing — `FUN_19bb_042f @ 19bb:042f` walks
+	// `nDialog = ib[2 + nSubjects]` records starting at
+	// `ib + 3 + nSubjects`. Each record is rendered identically to a
+	// hotspot dialog record (same `FUN_22dc_05c8` callee), so we
+	// share `displayFloppyDialogRecords`.
+	if (!initBlock || !isFloppy())
+		return;
+	const uint8 nSubjects = initBlock[1];
+	const uint8 nDialog   = initBlock[2 + nSubjects];
+	const byte *rec       = initBlock + 3 + nSubjects;
+	displayFloppyDialogRecords(rec, nDialog);
+}
+
+void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
+	// Floppy hotspot click — mirrors `FUN_22dc_0b80 @ 22dc:0b80` +
+	// `FUN_1652_00e6 @ 1652:00e6` + `FUN_1652_006c @ 1652:006c`.
+	// Each site stores a per-hotspot dialog list at
+	// `site_data[+6..7]`. The list is laid out as:
+	//   for each hotspot in order:
+	//     main record (11 + textCount bytes)
+	//     u8 contFlags  (low 7 bits = continuation count, high bit
+	//                    used by FUN_1652_00e6 to drive the partner
+	//                    pose — irrelevant for text rendering)
+	//     contCount × { record (11 + textCount bytes) }
+	// We walk past `hotIdx` hotspots, then dispatch the matched main
+	// record + its continuation chain through the same renderer
+	// `displayFloppyBriefing` uses.
+	if (!_mystery.isLoaded() || !isFloppy())
+		return;
+	const byte *site = _mystery.siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 dlgListOff = READ_LE_UINT16(site + 6);
+	const byte *bufBase = _mystery.blobAt(0);
+	if (!bufBase || dlgListOff == 0 || dlgListOff >= _mystery.dataSize())
+		return;
+	uint32 off = dlgListOff;
+	for (uint h = 0; h < hotIdx; h++) {
+		// Skip main record.
+		const byte *rec = bufBase + off;
+		off += 11 + rec[10];
+		// Read continuation count and skip those records.
+		const uint contCount = bufBase[off] & 0x7F;
+		off += 1;
+		for (uint c = 0; c < contCount; c++) {
+			const byte *cr = bufBase + off;
+			off += 11 + cr[10];
+		}
+	}
+	if (off >= _mystery.dataSize())
+		return;
+	// Layout per hotspot is:
+	//   main record (11 + textCount bytes)
+	//   1 byte: continuation count (high bit = partner-pose flag,
+	//           low 7 bits = number of follow-up records)
+	//   continuation records (each 11 + textCount bytes, tightly packed)
+	// `displayFloppyDialogRecords` walks tightly so we have to call it
+	// twice — once for the main record, once for the continuations —
+	// otherwise the second iteration treats the cont-count byte as a
+	// record header and runs off the buffer.
+	//
+	// Each `displayFloppyDialogRecords` call snapshots the screen to
+	// know what to redraw between bubbles. If we just call it twice
+	// back-to-back the second snapshot includes the first call's last
+	// bubble, so the second bubble draws on top of the first one (the
+	// "background isn't redrawn" glitch the user reported). Capture
+	// the clean site BG here and restore it between the two calls so
+	// every record snapshots a bubble-free background.
+	Graphics::ManagedSurface siteBG(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			siteBG.simpleBlitFrom(*screen);
+			g_system->unlockScreen();
+		}
+	}
+	const byte *mainRec = bufBase + off;
+	displayFloppyDialogRecords(mainRec, 1);
+	const uint mainLen = 11u + (uint)mainRec[10];
+	if (off + mainLen >= _mystery.dataSize())
+		return;
+	const uint contCount = (uint)(bufBase[off + mainLen] & 0x7F);
+	if (contCount == 0)
+		return;
+	const uint32 contOff = off + mainLen + 1;
+	if (contOff >= _mystery.dataSize())
+		return;
+	// Wipe the main bubble before the continuation chain snapshots the
+	// screen — otherwise the first continuation bubble treats the
+	// post-main-bubble image as its background and the main balloon
+	// pixels persist behind every following balloon.
+	g_system->copyRectToScreen(siteBG.getPixels(), siteBG.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+	displayFloppyDialogRecords(bufBase + contOff, contCount);
+}
+
 bool EEMEngine::areYouSure() {
 	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
 	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 74046faec8d..0b926e5655b 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -185,6 +185,13 @@ public:
 	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
 	void displayClue(const byte *clueBlock);
 
+	/// Floppy hotspot click — locate the dialog records for the
+	/// clicked hotspot in the site's per-hotspot dialog list at
+	/// `site_data[+6]` and dispatch them through
+	/// `displayFloppyDialogRecords`. Mirrors `FUN_22dc_0b80 +
+	/// FUN_1652_00e6 + FUN_1652_006c`.
+	void displayFloppyHotspotDialog(uint siteNum, uint hotIdx);
+
 	/// Active player name (saved as the profile-save description).
 	const Common::String &playerName() const { return _playerName; }
 
@@ -439,6 +446,12 @@ private:
 	///   +11..  text indices (1 byte each, low 7 bits = NOTES idx)
 	void displayFloppyBriefing(const byte *initBlock);
 
+	/// Render `count` consecutive floppy dialog records starting at
+	/// `rec`. Shared between briefing and hotspot click handlers since
+	/// the original engine uses the same `FUN_22dc_05c8 @ 22dc:05c8`
+	/// renderer in both contexts.
+	void displayFloppyDialogRecords(const byte *rec, uint count);
+
 public:
 	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
 	/// based on `_mystery._siteNumber % 5` and starts it (looping). The
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 437a8f62499..98f48a1d78e 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -287,17 +287,25 @@ const byte *Mystery::siteData(uint siteNum) const {
 }
 
 const byte *Mystery::hotspots(uint siteNum) const {
+	if (_isFloppy) {
+		// Floppy: hotspot table sits inside the per-site sub-blob.
+		// `site_data[+4..5]` is a u16 file offset to a header byte
+		// (count) + N×8-byte rectangles (x1, y1, x2, y2 as u16s) —
+		// verified at `FUN_22dc_0b80 @ 22dc:0b80` (the click hit-test
+		// loop reads `*(byte *)(buf + site_data[+4])` for the count
+		// then `FUN_14c9_0039(... buf + site_data[+4] + 1 + i*8)`
+		// for each rectangle).
+		const byte *site = siteData(siteNum);
+		if (!site || (size_t)(site - _data.data()) + 6 > _data.size())
+			return nullptr;
+		const uint16 hotspotOff = READ_LE_UINT16(site + 4);
+		if (hotspotOff == 0 || hotspotOff + 1 > _data.size())
+			return nullptr;
+		return _data.data() + hotspotOff + 1;
+	}
 	const byte *idx = siteIndexEntry(siteNum);
 	if (!idx)
 		return nullptr;
-	// Floppy site index is only 2 bytes per entry; `idx + 4` would
-	// read into the NEXT entry's offset, returning garbage. The
-	// floppy hotspot list lives inside the site-data sub-blob (see
-	// `_DoSiteLoop_Floppy @ 1652:03a3` walking 5-byte drop entries
-	// from `*site_data + 2`); don't fake a CD-style result here —
-	// SiteScreen guards floppy paths separately.
-	if (_isFloppy)
-		return nullptr;
 	const uint16 hotspotOff = READ_LE_UINT16(idx + 4);
 	if (hotspotOff >= _data.size())
 		return nullptr;
@@ -305,8 +313,15 @@ const byte *Mystery::hotspots(uint siteNum) const {
 }
 
 uint16 Mystery::hotspotCount(uint siteNum) const {
-	if (_isFloppy)
-		return 0;
+	if (_isFloppy) {
+		const byte *site = siteData(siteNum);
+		if (!site || (size_t)(site - _data.data()) + 6 > _data.size())
+			return 0;
+		const uint16 hotspotOff = READ_LE_UINT16(site + 4);
+		if (hotspotOff == 0 || hotspotOff >= _data.size())
+			return 0;
+		return (uint16)_data[hotspotOff];
+	}
 	const byte *site = siteData(siteNum);
 	if (!site || (size_t)(site - _data.data()) + 8 > _data.size())
 		return 0;
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 4b23fa4ea27..251320604aa 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -141,6 +141,9 @@ public:
 		return offset < _data.size() ? _data.data() + offset : nullptr;
 	}
 
+	/// Total mystery blob size in bytes (for bounds checks).
+	uint32 dataSize() const { return (uint32)_data.size(); }
+
 	/// Synchronize the per-mystery runtime state for save/load. The fixed
 	/// arrays serialize first, then the booleans and counters.
 	void syncState(Common::Serializer &s);
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 80610200252..1add3abbd4c 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -588,8 +588,13 @@ void SiteScreen::enter(uint siteNum) {
 	_vm->stopMusic();
 
 	// Static drops (Loop 2 from `_DoSiteLoop`) — no animation, baked
-	// into the BG snapshot the run() pump uses to restore.
-	renderStaticDrops(siteNum);
+	// into the BG snapshot the run() pump uses to restore. Floppy
+	// stores them in a different shape (drops sub-struct after the
+	// site_data offset), so dispatch on `isFloppy()`.
+	if (_vm->isFloppy())
+		renderFloppyDrops(siteNum);
+	else
+		renderStaticDrops(siteNum);
 
 	// Snapshot the static layers so per-tick animation re-blits don't
 	// have to re-load PIC 0x43, the SITES.DBD scene, or each
@@ -643,7 +648,10 @@ void SiteScreen::enter(uint siteNum) {
 		// balloon residues; refresh the site so the player returns to
 		// a clean state. Re-build the snapshot too.
 		renderBackground(siteNum);
-		renderStaticDrops(siteNum);
+		if (_vm->isFloppy())
+			renderFloppyDrops(siteNum);
+		else
+			renderStaticDrops(siteNum);
 		captureBgSnapshot();
 		_snapshotSite = (int)siteNum;
 		const uint32 nowAfter = g_system->getMillis();
@@ -938,6 +946,53 @@ void SiteScreen::renderStaticDrops(uint siteNum) {
 	g_system->unlockScreen();
 }
 
+void SiteScreen::renderFloppyDrops(uint siteNum) {
+	// Floppy drops live inside the drops sub-struct pointed to by
+	// `*site_data` (u16 offset). Verified from the call site in
+	// `_DoSiteLoop_Floppy @ 1652:0418`:
+	//   FUN_16e2_18eb(
+	//     *(u16 *)(local_1a + i*5),          // arg1 = u16 picID @ +0..1
+	//     *(u16 *)(local_1a + i*5 + 2),      // arg2 = u16 X     @ +2..3
+	//     local_1a[i*5 + 4]                  // arg3 = u8  Y     @ +4
+	//   );
+	// Inside `FUN_16e2_18eb @ 16e2:18eb`, `arg1 - 1` indexes the
+	// PICS.DBX table at `2608:4537` (loaded from `PICS.DBX` by
+	// `FUN_16e2_0149 @ 16e2:0149`); `arg2/arg3` become destX/destY.
+	// drops_struct[0] = BG picID (rendered separately by
+	// `renderBackground`), drops_struct[1] = drop count.
+	if (!_mystery)
+		return;
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return;
+	const uint16 dropsOff = READ_LE_UINT16(site);
+	const byte *drops = _mystery->blobAt(dropsOff);
+	if (!drops)
+		return;
+	const uint8 count = drops[1];
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+
+	for (uint i = 0; i < count; i++) {
+		const byte *e = drops + 2 + i * 5;
+		const uint16 picID = READ_LE_UINT16(e + 0);
+		const int16  x     = (int16)READ_LE_UINT16(e + 2);
+		const int16  y     = (int16)e[4];
+		if (picID == 0)
+			continue;
+		// `getPicture(num)` already does `loadEntry(num - 1)` (see
+		// `resource.h:100`), matching the `picID - 1` index the
+		// original passes to PICS.DBD.
+		Picture pic;
+		if (!_vm->getPics().getPicture((uint)picID, pic))
+			continue;
+		blitMaskedSurface(screen, pic, x, y);
+	}
+	g_system->unlockScreen();
+}
+
 void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	// Loop 1 from `_DoSiteLoop @ 168d:03f4`:
 	//   bound: siteData[+0xa]
@@ -1071,29 +1126,46 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	// `kWaitAnims` lives at file scope above; we cap rendering at
 	// `speaker < 7` since anything past entry 6 is the `_SiteButtons`
 	// rect data that follows the table in the binary.
-	// Floppy site data has a different shape: site_data+8 is a u16
-	// OFFSET to a 10-byte speakerInfo struct (verified at
-	// `_DoSiteLoop_Floppy @ 1652:042b` reading `*(undefined2 *)
-	// (DAT_28da_0172) = anim_id_jake` etc.), not an index into the
-	// `kWaitAnims` table the CD uses. Skip the partner render until
-	// the floppy speakerInfo is wired up — without this guard we
-	// dereference garbage entries past the table end.
-	if (_vm && _vm->isFloppy())
-		return;
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
-	const uint16 speaker = READ_LE_UINT16(site + 8);
-	if (speaker >= ARRAYSIZE(kWaitAnims)) {
-		warning("renderPartner: site %u has speakerIdx=%u out of range",
-				siteNum, speaker);
-		return;
-	}
-
 	const uint8 partner = _vm->getPartnerIndex();
-	const uint  animId  = kWaitAnims[speaker][0 + partner];
-	const int   x       = (int)(int16)kWaitAnims[speaker][2 + partner];
-	const int   y       = (int)(int16)kWaitAnims[speaker][4 + partner];
+	uint   animId;
+	int    x;
+	int    y;
+	if (_vm->isFloppy()) {
+		// Floppy: site_data+8 is a u16 OFFSET to a 10-byte
+		// speakerInfo struct (per `_DoSiteLoop_Floppy @ 1652:042b`):
+		//   bytes 0..1  Jake anim ID  (u16)
+		//   bytes 2..3  Jake X        (u16)
+		//   byte  4     Jake Y        (u8)
+		//   bytes 5..6  Jenny anim ID (u16)
+		//   bytes 7..8  Jenny X       (u16)
+		//   byte  9     Jenny Y       (u8)
+		const uint16 spkOff = READ_LE_UINT16(site + 8);
+		const byte *spk = _mystery->blobAt(spkOff);
+		if (!spk)
+			return;
+		if (partner == 0) {
+			animId = READ_LE_UINT16(spk + 0);
+			x      = (int)READ_LE_UINT16(spk + 2);
+			y      = (int)spk[4];
+		} else {
+			animId = READ_LE_UINT16(spk + 5);
+			x      = (int)READ_LE_UINT16(spk + 7);
+			y      = (int)spk[9];
+		}
+	} else {
+		const uint16 speaker = READ_LE_UINT16(site + 8);
+		if (speaker >= ARRAYSIZE(kWaitAnims)) {
+			warning("renderPartner: site %u has speakerIdx=%u out of range",
+					siteNum, speaker);
+			return;
+		}
+		animId = kWaitAnims[speaker][0 + partner];
+		x      = (int)(int16)kWaitAnims[speaker][2 + partner];
+		y      = (int)(int16)kWaitAnims[speaker][4 + partner];
+	}
 
 	Animation anim;
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
@@ -1227,8 +1299,13 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	// don't all glow in lock-step.
 	const uint32 tickMs = g_system->getMillis();
 
+	// CD hotspot rows are 14 bytes each (rect + 6 bytes of clue
+	// metadata). Floppy stores plain 8-byte rectangles only — clue
+	// data lives in a separate dialog-record list at `site_data[+6]`,
+	// keyed by hotspot index. Verified at `FUN_22dc_0b80 @ 22dc:0b80`.
+	const uint stride = _vm && _vm->isFloppy() ? 8 : 14;
 	for (uint i = 0; i < count; i++) {
-		const byte *r = spots + i * 14;
+		const byte *r = spots + i * stride;
 		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
 		const int16 y1 = (int16)READ_LE_UINT16(r + 2);
 		const int16 x2 = (int16)READ_LE_UINT16(r + 4);
@@ -1289,8 +1366,9 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 	if (!spots)
 		return -1;
 
+	const uint stride = _vm && _vm->isFloppy() ? 8 : 14;
 	for (uint i = 0; i < count; i++) {
-		const byte *r = spots + i * 14;
+		const byte *r = spots + i * stride;
 		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
 		const int16 y1 = (int16)READ_LE_UINT16(r + 2);
 		const int16 x2 = (int16)READ_LE_UINT16(r + 4);
@@ -1304,6 +1382,21 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
+	// Floppy: hotspot rectangles are plain 8-byte rects (no clue
+	// metadata at +0xa or +8); the dialog records live in a separate
+	// per-hotspot list at `site_data[+6]`. Dispatch through the
+	// floppy-specific renderer and mark the click rectangle seen by
+	// array index (the only ordinal we have on floppy).
+	if (_vm->isFloppy()) {
+		if (hotIdx < Mystery::kHotSpotsCap)
+			_mystery->_hotSpotsSeen[hotIdx] = 1;
+		_mystery->_searchLocationNumber = (uint16)hotIdx;
+		_vm->setPartnerEraseBg(&_bgSnapshot);
+		_vm->displayFloppyHotspotDialog(siteNum, hotIdx);
+		_vm->setPartnerEraseBg(nullptr);
+		return;
+	}
+
 	// `_DoSiteLoop @ 168d:03f4` (after _DisplayClue):
 	//   _HotSpotsSeen[hotspot[+0xa] * 2] = _HotSpotComplete;
 	// The "seen" key is the hotspotIndex field (+0xa) — the 1-based
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 33f6f23ab55..23621409806 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -131,6 +131,13 @@ private:
 	/// animate so they go in the BG snapshot.
 	void renderStaticDrops(uint siteNum);
 
+	/// Floppy variant: drops live inside the drops sub-struct
+	/// (`*site_data` → drops; `drops[1]` = count; entries at
+	/// `drops + 2` are 5 bytes each: {u16 X, u16 Y, byte picID}).
+	/// PIC entries are loaded from PICS.DBD with `picID - 1`. Per
+	/// `_DoSiteLoop_Floppy @ 1652:0418` and `FUN_16e2_18eb`.
+	void renderFloppyDrops(uint siteNum);
+
 	/// Draw the per-site animated NPCs (Loop 1) at the current tick.
 	/// `_DoSiteLoop` registers each via `_NewAnimation` (siteData[+0xa]
 	/// entries at siteData[+0x48]: {animId (-1 = ColorCycle), x, y})
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7e7e9de005f..a7589a566f6 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2710,14 +2710,15 @@ void EEMEngine::doBigMap() {
 							continue;
 						uint16 mx;
 						uint16 my;
-						uint16 buttonId = 0;
+						uint16 buttonId;
 						if (fmap) {
 							// Floppy detail view: click rect on
-							// BIGMAP.PIC at (+0, +2), per
-							// `FUN_1fed_0c3e`'s write to
-							// DAT_28da_3aee/DAT_28da_3af0.
+							// BIGMAP.PIC at (+0, +2), labelled BUTTON.DBD
+							// entry ID at entry+4 (per
+							// `FUN_1fed_0c3e @ 1fed:0c3e`).
 							mx = READ_LE_UINT16(entry + 0x0);
 							my = READ_LE_UINT16(entry + 0x2);
+							buttonId = (uint16)entry[0x4];
 						} else {
 							buttonId = READ_LE_UINT16(entry + 0x0);
 							mx       = READ_LE_UINT16(entry + 0x8);
@@ -2726,18 +2727,7 @@ void EEMEngine::doBigMap() {
 						Picture button;
 						int bw = 16;
 						int bh = 16;
-						if (fmap) {
-							// Floppy uses the global site-marker PIC for
-							// every site; the recolor flag at +10 picks
-							// the crime variant. Sized off whichever PIC
-							// is loaded successfully.
-							Picture m;
-							const uint pic = (entry[0xa] != 0) ? 0xc6 : 0xc5;
-							if (_picsArchive.getPicture(pic, m)) {
-								bw = m.surface.w;
-								bh = m.surface.h;
-							}
-						} else if (_buttonArchive.loadEntry(buttonId, button)) {
+						if (_buttonArchive.loadEntry(buttonId, button)) {
 							bw = button.surface.w;
 							bh = button.surface.h;
 						}
@@ -2916,18 +2906,18 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 			   copyW);
 	}
 
-	// Stamped site buttons. `_StampButtons @ 20fe:0d2f`:
+	// Stamped site buttons. `_StampButtons @ 20fe:0d2f` (CD):
 	//   button = _GetButton(MapData[+0])
 	//   destX  = MapData[+8]
 	//   destY  = MapData[+0xa]
-	// Floppy SITES rows are 11 bytes — no per-button PIC ID. Use the
-	// regular site / crime marker PICs (0xc5 / 0xc6) keyed off the
-	// recolor flag at +10, same logic as the overview stamp.
+	// Floppy uses `FUN_1fed_0c3e @ 1fed:0c3e`: for each SITES row, the
+	// byte at entry+4 is a BUTTON.DBD entry ID (loaded via
+	// `FUN_16e2_1838 @ 16e2:1838`, which opens `button.dbd` — string
+	// at `2608:0558`). The labelled button is stamped at
+	// `(entry+0..1, entry+2..3)` on BIGMAP.PIC. These are the same
+	// per-site labelled buttons the CD uses, just keyed off a
+	// different field offset.
 	const bool floppyMap = _mystery.isLoaded() && isFloppy();
-	Picture floppySiteM;
-	Picture floppyCrimeM;
-	const bool haveFloppySite  = floppyMap && _picsArchive.getPicture(0xc5, floppySiteM);
-	const bool haveFloppyCrime = floppyMap && _picsArchive.getPicture(0xc6, floppyCrimeM);
 	for (uint i = 0; i < _mystery.numSites(); i++) {
 		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
 			continue;
@@ -2938,24 +2928,10 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 		uint16 my;
 		Picture button;
 		if (floppyMap) {
-			// Floppy SITES rows carry TWO position pairs:
-			//   (+0, +2) = position on BIGMAP.PIC (the zoomed view).
-			//              Used by `FUN_1fed_0c3e @ 1fed:0c3e` for
-			//              suspect-portrait stamping AND as the
-			//              click bbox on the zoomed map.
-			//   (+6, +8) = position on the overview PIC 0x42.
-			//              Used by `FUN_1fed_07ed @ 1fed:07ed`.
-			// The detail view scrolls BIGMAP.PIC, so we need the
-			// (+0, +2) coords here. Recolor flag at +10 still
-			// selects crime vs site marker.
 			mx = READ_LE_UINT16(entry + 0x0);
 			my = READ_LE_UINT16(entry + 0x2);
-			const bool useCrime = entry[0xa] != 0;
-			if (useCrime && haveFloppyCrime)
-				button = floppyCrimeM;
-			else if (haveFloppySite)
-				button = floppySiteM;
-			else
+			const uint16 buttonId = (uint16)entry[0x4];
+			if (!_buttonArchive.loadEntry(buttonId, button))
 				continue;
 		} else {
 			const uint16 buttonId = READ_LE_UINT16(entry + 0x0);


Commit: 20eba94c78e88e73d1a33ee7d41f1bf23186f9fd
    https://github.com/scummvm/scummvm/commit/20eba94c78e88e73d1a33ee7d41f1bf23186f9fd
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:52+02:00

Commit Message:
EEM: improved hints and audio in the floppy version

Changed paths:
    engines/eem/audio.cpp
    engines/eem/audio.h
    engines/eem/clues.cpp
    engines/eem/graphics.cpp
    engines/eem/mystery.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 4c2e2a4bb15..6ed89dcdf0c 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -199,6 +199,39 @@ void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
 					   DisposeAfterUse::YES);
 }
 
+// Floppy per-partner voice table — verified by reading the raw filename
+// pointers at `2608:0f0e` (Jake) and `2608:0f76` (Jenny) in EEM.EXE,
+// each 26 × FAR-ptr to a NUL-terminated `*.voc` filename. Indexed by
+// `_LoadSoundName_Floppy @ 1f4e:0305`. Slots match across partners:
+//   12 = PHONESL.VOC, 20 = partner intro, 25 = THUNDER.VOC.
+static const char *const kFloppyJakeVoiceTable[26] = {
+	"DING.VOC",       "M-0083SL.VOC", "M-0085SL.VOC", "NEWSCAN.VOC",
+	"M-0089SL.VOC",   "M-0091SL.VOC", "M-0092SL.VOC", "NEWSSHRT.VOC",
+	"M-0096SL.VOC",   "M-0102SL.VOC", "M-0104SL.VOC", "M-0107SL.VOC",
+	"PHONESL.VOC",    "M-0113SL.VOC", "DING.VOC",     "DING.VOC",
+	"M-0054SL.VOC",   "M-0014SL.VOC", "M-0012SL.VOC", "SQUAK2SL.VOC",
+	"M-0113SL.VOC",   "B-0006SL.VOC", "B-0003SL.VOC", "B-0004SL.VOC",
+	"M-0163SL.VOC",   "THUNDER.VOC",
+};
+static const char *const kFloppyJennyVoiceTable[26] = {
+	"DING.VOC",       "F-0194SL.VOC", "F-0191SL.VOC", "NEWSCAN.VOC",
+	"F-0187SL.VOC",   "F-0184SL.VOC", "F-0181SL.VOC", "NEWSSHRT.VOC",
+	"F-0177SL.VOC",   "F-0170SL.VOC", "F-0168SL.VOC", "F-0166SL.VOC",
+	"PHONESL.VOC",    "F-0161SL.VOC", "DING.VOC",     "DING.VOC",
+	"F-0067SL.VOC",   "F-0016SL.VOC", "F-0013SL.VOC", "SQUAK2SL.VOC",
+	"F-0140SL.VOC",   "B-0006SL.VOC", "B-0003SL.VOC", "B-0004SL.VOC",
+	"F-0165SL.VOC",   "THUNDER.VOC",
+};
+
+void AudioPlayer::playFloppyVoiceSlot(uint slot, uint partner) {
+	if (slot >= 26)
+		slot = 0;  // mirrors `_LoadSoundName_Floppy`'s `if (0x19 < slot) slot = 0;`
+	const char *name = (partner == 0)
+		? kFloppyJakeVoiceTable[slot]
+		: kFloppyJennyVoiceTable[slot];
+	playVoc(Common::Path(name));
+}
+
 void AudioPlayer::spoolSound(uint num) {
 	if (!_voiceEnabled) {
 		debugC(2, kDebugSound,
diff --git a/engines/eem/audio.h b/engines/eem/audio.h
index 921e00e900b..12667f1179d 100644
--- a/engines/eem/audio.h
+++ b/engines/eem/audio.h
@@ -104,6 +104,13 @@ public:
 	/// Mirrors `_StopTheVoice @ 1ff1:0283`.
 	void stopVoice();
 
+	/// Floppy variant: play a VOC by 0..25 slot index in the per-partner
+	/// voice table. Mirrors `_LoadSoundName_Floppy @ 1f4e:0305` which
+	/// indexes the table at `2608:0f0e` (Jake) / `2608:0f76` (Jenny).
+	/// `partner` is 0 for Jake, 1 for Jenny. Common slots: 12 =
+	/// PHONESL.VOC, 20 = partner intro voice, 25 = THUNDER.VOC.
+	void playFloppyVoiceSlot(uint slot, uint partner);
+
 	// Mystery sound spool ---------------------------------------------
 
 	/// Mirrors `_InitMysterySounds @ 202f:05cb`. Loads `M%u.SDX` into
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 8e4cd5798d1..d53fb7f57ae 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -236,21 +236,16 @@ void EEMEngine::doChoosePartner() {
 	// (`jen.voc` for Jenny, `jake.voc` for Jake; strings at 29be:0af1 /
 	// 29be:0af9) and block on `_WaitForVoiceDone`.
 	if (_audio) {
-		// CD: standalone clips `JAKE.VOC` / `JEN.VOC`. Floppy uses
-		// per-partner-and-event voice tables at `2608:0F0E` (Jake) /
-		// `2608:0F76` (Jenny), each 25 entries × FAR ptr to a VOC
-		// filename. After a partner pick, `FUN_19bb_0858 @ 19bb:0858`
-		// calls `FUN_1f4e_0305(0x14)` which loads voice slot 20 from
-		// the table for the chosen partner:
-		//   Jake  slot 20 → `2608:116B = "m-0113sl.voc"`
-		//   Jenny slot 20 → `2608:12AE = "f-0140sl.voc"`
-		Common::String voc;
 		if (isFloppy()) {
-			voc = (_partner == 0) ? "M-0113SL.VOC" : "F-0140SL.VOC";
+			// Floppy `_DoChoosePartner_Floppy @ 19bb:0a8e` calls
+			// `_LoadSoundName_Floppy(0x14)` (= slot 20) which the
+			// per-partner table at `2608:0f0e` / `2608:0f76` resolves
+			// to `m-0113sl.voc` (Jake) or `f-0140sl.voc` (Jenny).
+			_audio->playFloppyVoiceSlot(0x14, _partner);
 		} else {
-			voc = (_partner == 0) ? "JAKE.VOC" : "JEN.VOC";
+			_audio->playVoc(Common::Path(
+				(_partner == 0) ? "JAKE.VOC" : "JEN.VOC"));
 		}
-		_audio->playVoc(Common::Path(voc));
 		_audio->waitForVoiceDone();
 	}
 }
@@ -507,16 +502,25 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// `_DoInitClues` plays `phone.voc` (29be:0acc) ONLY when caseType == 2
-	// (the "incoming call" briefing variant). Verified at 1a35:05a2 —
-	// the gate is `iVar1 == 2 && _VoiceAvailable`. Other case types open
-	// straight into the briefing dialogue without it.
-	if (caseType == 2 && _audio) {
-		// Floppy ships `PHONESL.VOC` instead of CD's `PHONE.VOC` (the
-		// `_LoadSoundName` call site at 2608:1107-110c hands the
-		// floppy filename to the same loader).
-		_audio->playVoc(Common::Path(isFloppy() ? "PHONESL.VOC" : "PHONE.VOC"));
-		_audio->waitForVoiceDone();
+	// `_DoInitClues` plays a setup-voice ONLY for caseType 2/3.
+	//   CD: caseType 2 → PHONE.VOC. caseType 3 → no voice.
+	//   Floppy `_DoInitClues_Floppy @ 19bb:042f`:
+	//     caseType 2 → `_LoadSoundName_Floppy(slot 0xc)` = PHONESL.VOC
+	//     caseType 3 → `_LoadSoundName_Floppy(slot 3)`   = NEWSCAN.VOC
+	// (newspaper-scanner sting for the "TV/news anchor" briefing
+	// variant). Other case types open straight into the briefing
+	// dialogue without it.
+	if (_audio) {
+		if (caseType == 2) {
+			if (floppy)
+				_audio->playFloppyVoiceSlot(0x0c, _partner);
+			else
+				_audio->playVoc(Common::Path("PHONE.VOC"));
+			_audio->waitForVoiceDone();
+		} else if (caseType == 3 && floppy) {
+			_audio->playFloppyVoiceSlot(0x03, _partner);
+			_audio->waitForVoiceDone();
+		}
 	}
 
 	// Step 6 — case briefing dialogue. CD InitBlock has the clue block
@@ -944,17 +948,39 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 		const uint8  ballY    = rec[8];
 		const uint8  textCount= rec[10];
 
+		// Per-record voice playback. `FUN_22dc_05c8 @ 22dc:05c8`:
+		//   if ((rec[+9] & 0x80) && voiceOption && voiceAvail) {
+		//       slot = rec[+9] & 0x7f;
+		//       _LoadSoundName_Floppy(slot); _PlayVoc(...);
+		//   }
+		// The slot indexes the per-partner table at `2608:0f0e` /
+		// `2608:0f76` (= our `playFloppyVoiceSlot`). Sound is started
+		// BEFORE the bubble renders so it plays while the player reads.
+		if ((rec[9] & 0x80) != 0 && _audio) {
+			const uint slot = rec[9] & 0x7f;
+			_audio->playFloppyVoiceSlot(slot, _partner);
+		}
+
 		// Build the full text by concatenating each note's per-partner
 		// text string (parsed for `%s`-style placeholders). Bounds-
 		// check every offset against the mystery blob; if any byte
 		// looks out of range we just stop appending — corrupt offsets
 		// from a misparsed record would otherwise dereference past the
 		// buffer and SIGBUS inside `strlen`.
+		//
+		// Each text index also flags `_cluesFound[idx]` so the PDA /
+		// notebook ("DrawNotes") later renders the clue. Mirrors
+		// `FUN_22dc_05c8 @ 22dc:091a`:
+		//   ((undefined1 *)&DAT_28da_3c08)[textIdx & 0x7f] = 1;
+		// `DAT_28da_3c08` is the floppy `TextSeen` array; we reuse the
+		// `_cluesFound` flag store since both index 0..127 by note id.
 		const uint32 dsz       = _mystery.dataSize();
 		const uint32 notesBase = (uint32)(notes - bufBase);
 		Common::String raw;
 		for (uint t = 0; t < textCount; t++) {
 			const uint8  idx        = rec[11 + t] & 0x7f;
+			if (idx < Mystery::kCluesFoundCap)
+				_mystery._cluesFound[idx] = 1;
 			const uint32 noteAbs    = notesBase + (uint32)idx * 7;
 			if (noteAbs + 6 > dsz)
 				break;
@@ -975,6 +1001,23 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 				raw += ' ';
 			raw += Common::String(line, lineLen);
 		}
+
+		// Suspect/gallery side effect — `FUN_22dc_05c8 @ 22dc:08eb`:
+		//   if ((rec[+9] & 0x80) == 0 && rec[+9] != 0)
+		//       *(u16 *)(0x5d20 + table[0x2d65 + rec[+9]] * 2) = 1;
+		// Byte 9 doubles as the voice slot (when high bit set) or as a
+		// suspect identifier (low 7 bits, when high bit clear). The
+		// 0x5d20 array is the per-mystery "suspect found in gallery"
+		// flag table that `FUN_154e_0045` reads at gallery render time.
+		// We don't model the per-mystery shuffle table at 0x2d65
+		// (`_NewOrder` is set to identity in `_ReadMystery_Floppy` for
+		// our port), so byte9-1 indexes `_inGallery` directly.
+		const uint8 b9 = rec[9];
+		if ((b9 & 0x80) == 0 && b9 != 0) {
+			const uint slot = (uint)b9 - 1;
+			if (slot < Mystery::kGalleryCap)
+				_mystery._inGallery[slot] = 1;
+		}
 		const Common::String text = parseString(raw, _playerName, _partner);
 
 		// Restore briefing BG before drawing this bubble.
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 33a3536c454..5af91578ac5 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -76,7 +76,211 @@ const BalloonInsets kBalloonInsetTable[] = {
 	{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
 };
 
+// Floppy KDHelp hotspot-searched check. Mirrors
+// `FUN_22dc_096c @ 22dc:096c`: walks the per-site dialog records at
+// `site_data[+6]` to skip `hotspotIdx` hotspots, then returns the
+// `_cluesFound` flag for that hotspot's first text index.
+static bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
+								   uint hotspotIdx) {
+	const byte *site = mystery.siteData(siteIdx);
+	if (!site)
+		return false;
+	const uint16 dlgListOff = READ_LE_UINT16(site + 6);
+	const byte *bufBase = mystery.blobAt(0);
+	if (!bufBase || dlgListOff == 0 || dlgListOff >= mystery.dataSize())
+		return false;
+	uint32 off = dlgListOff;
+	for (uint h = 0; h < hotspotIdx; h++) {
+		const byte *rec = bufBase + off;
+		off += 11u + (uint)rec[10];
+		if (off >= mystery.dataSize())
+			return false;
+		const uint contCount = (uint)(bufBase[off] & 0x7F);
+		off += 1;
+		for (uint c = 0; c < contCount; c++) {
+			const byte *cr = bufBase + off;
+			off += 11u + (uint)cr[10];
+			if (off >= mystery.dataSize())
+				return false;
+		}
+	}
+	if (off + 11 >= mystery.dataSize())
+		return false;
+	const byte *mainRec = bufBase + off;
+	const uint8 textIdx = mainRec[11] & 0x7F;
+	return textIdx < EEM::Mystery::kCluesFoundCap &&
+		   mystery._cluesFound[textIdx] != 0;
+}
+
 void EEMEngine::doHelp() {
+	// Floppy uses a totally different hint mechanism — per-mystery
+	// `H<n>.BIN` data files (one per case). Format verified at
+	// `FUN_1503_0001 @ 1503:0001` (loader, format string at
+	// `2608:0154` = "h%d.bin") + `FUN_1503_01a5 @ 1503:01a5`
+	// (consumer):
+	//   byte 0 = numChainHints
+	//   numChainHints × { byte siteIdx; byte hotspotIdx; }
+	//   byte = numExtraHints
+	//   numExtraHints × { byte siteIdx; byte hotspotIdx; }
+	//   asciiz string 1  ("[balloon-digit]Let's go to <site>...")
+	//   asciiz string 2  (alternate hint)
+	//   asciiz string 3  (post-solve hint, used when score ≥ 100)
+	// Selection logic: if any chain hotspot is unsearched → string 1.
+	// Else if any extra hotspot is unsearched → string 2. Else if
+	// `selectedPoints() ≥ 100` → string 3.
+	if (isFloppy() && _mystery.isLoaded()) {
+		const Common::String filename = Common::String::format("H%u.BIN",
+															   _mystery.number());
+		Common::File hf;
+		if (!hf.open(Common::Path(filename))) {
+			warning("doHelp: cannot open %s", filename.c_str());
+			return;
+		}
+		const uint32 hsz = hf.size();
+		Common::Array<byte> hbuf;
+		hbuf.resize(hsz);
+		if (hf.read(hbuf.data(), hsz) != hsz)
+			return;
+		const byte *hd = hbuf.data();
+
+		const uint chainCount = hd[0];
+		uint off = 1;
+		uint chainEnd = off + chainCount * 2;
+		if (chainEnd >= hsz)
+			return;
+		const uint extraCount = hd[chainEnd];
+		uint extraStart = chainEnd + 1;
+		uint extraEnd = extraStart + extraCount * 2;
+		if (extraEnd >= hsz)
+			return;
+		// Three NUL-terminated strings follow.
+		const char *str1 = (const char *)(hd + extraEnd);
+		const char *str2 = nullptr;
+		const char *str3 = nullptr;
+		const char *p = str1;
+		while ((uint)((const byte *)p - hd) < hsz && *p != 0) p++;
+		if ((uint)((const byte *)p - hd) >= hsz) return;
+		str2 = p + 1;
+		p = str2;
+		while ((uint)((const byte *)p - hd) < hsz && *p != 0) p++;
+		if ((uint)((const byte *)p - hd) >= hsz) return;
+		str3 = p + 1;
+
+		const char *chosen = nullptr;
+		bool anyChainUnseen = false;
+		for (uint i = 0; i < chainCount; i++) {
+			const uint8 siteIdx    = hd[off + i * 2 + 0];
+			const uint8 hotspotIdx = hd[off + i * 2 + 1];
+			if (!floppyHotspotSearched(_mystery, siteIdx, hotspotIdx)) {
+				anyChainUnseen = true;
+				break;
+			}
+		}
+		bool anyExtraUnseen = false;
+		if (!anyChainUnseen) {
+			for (uint i = 0; i < extraCount; i++) {
+				const uint8 siteIdx    = hd[extraStart + i * 2 + 0];
+				const uint8 hotspotIdx = hd[extraStart + i * 2 + 1];
+				if (!floppyHotspotSearched(_mystery, siteIdx, hotspotIdx)) {
+					anyExtraUnseen = true;
+					break;
+				}
+			}
+		}
+		if (anyChainUnseen)
+			chosen = str1;
+		else if (anyExtraUnseen)
+			chosen = str2;
+		else if (_mystery.selectedPoints() >= 100)
+			chosen = str3;
+		if (!chosen || *chosen == 0)
+			return;
+
+		// Strip leading balloon-digit byte. `_GetKDTextBalloon @
+		// 1df2:0105` (= floppy `FUN_1d40_009f`) doesn't take the
+		// digit's *value* — it indexes the per-character table at
+		// `2608:0c14` by the literal byte, so '0'..'9' map to a
+		// non-trivial balloon-id sequence. Verified bytes at
+		// `2608:0c44` (= 0xc14 + '0'):
+		//   '0'→0x15, '1'→0x16, '2'→0x17, '3'→0x18, '4'→0x19,
+		//   '5'→0x1a, '6'→0x1c, '7'→0x1d, '8'→0x1e, '9'→0x0a.
+		// Without this map the previous (digit - '0') version asked
+		// `getBalloonInsets` for balloon 0 (text width 142) instead of
+		// the correct balloon 21 (text width 155), which is why the
+		// hint bubble rendered narrower than the original.
+		static const uint8 kFloppyDigitToBalloon[10] = {
+			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
+		};
+		uint balloonIdx = 0x17;
+		const char *txt = chosen;
+		if (*txt >= '0' && *txt <= '9') {
+			balloonIdx = kFloppyDigitToBalloon[(int)(*txt - '0')];
+			txt++;
+		}
+
+		Common::String text = parseString(Common::String(txt),
+										   _playerName, _partner);
+		Graphics::ManagedSurface ms(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		ms.clear();
+		{
+			Graphics::Surface *cur = g_system->lockScreen();
+			if (cur) {
+				ms.simpleBlitFrom(*cur);
+				g_system->unlockScreen();
+			}
+		}
+		Picture balloon;
+		const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
+			_balloonArchive.loadEntry(balloonIdx, balloon);
+		uint16 balloonY = 1;
+		if (haveBalloon) {
+			const uint h = (uint)balloon.surface.h;
+			if (h < 0x4e)
+				balloonY = (uint16)((0x50 - h) >> 1);
+			const byte transp = (byte)(balloon.flags >> 8);
+			for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
+				 row++) {
+				const byte *src =
+					(const byte *)balloon.surface.getBasePtr(0, row);
+				byte *dst = (byte *)ms.getBasePtr(0x21, balloonY + row);
+				for (int col = 0; col < balloon.surface.w && 0x21 + col < 320;
+					 col++) {
+					if (src[col] != transp)
+						dst[col] = src[col];
+				}
+			}
+		}
+		uint16 bx = 5;
+		uint16 by = 4;
+		uint16 bw = 142;
+		getBalloonInsets(balloonIdx, bx, by, bw);
+		_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
+							  MAX<int>(8, (int)bw), text, 0);
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Wait for click — KD hint dismisses on any input.
+		while (!shouldQuit()) {
+			Common::Event ev;
+			bool advance = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN) {
+					advance = true;
+					break;
+				}
+			}
+			if (advance)
+				break;
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+		return;
+	}
+
 	// Mirrors `_KDHelp @ 1560:010a`. The original walks the first two
 	// entries of `_AChain` (the puzzle's required-clue chain — the
 	// "spine" of evidence the player must collect):
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 98f48a1d78e..781d81895a0 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -359,9 +359,14 @@ uint16 Mystery::noteIndexCount() const {
 	if (!isLoaded())
 		return 0;
 	// NoteIndex runs from _noteOffset to the start of GalleryData.
+	// CD entries are 4 bytes (`u16 textOff; u16 points`); floppy
+	// entries are 7 bytes (`u16 ?; u16 jakeOff; u16 jennyOff; u8
+	// score`) — verified at `FUN_22dc_05c8 @ 22dc:0843` reading
+	// `*(int *)(notes + idx*7 + 2)` (Jake) / `+4` (Jenny).
 	if (_galleryOffset <= _noteOffset)
 		return 0;
-	return (uint16)((_galleryOffset - _noteOffset) / 4);
+	const uint stride = _isFloppy ? 7 : 4;
+	return (uint16)((_galleryOffset - _noteOffset) / stride);
 }
 
 const byte *Mystery::kdTextIndex() const {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index a7589a566f6..42321a6c626 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1934,17 +1934,41 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	int clueCursor = 0;
 	Common::Array<int> pageStarts;
 	pageStarts.push_back(0);
+	// Floppy NoteIndex entries are 7 bytes (`u16 ?; u16 jakeOff; u16
+	// jennyOff; u8 score`) with ABSOLUTE byte offsets into the mystery
+	// blob, while CD entries are 4 bytes with offsets relative to the
+	// TextBlock at header[+0xc]. Resolve the right text for the active
+	// partner / variant once per render.
+	const bool floppyNb = isFloppy();
+	const byte *bufBase = _mystery.blobAt(0);
+	const uint32 mysSz  = _mystery.dataSize();
+	auto noteText = [&](uint clueId) -> Common::String {
+		if (!ni || clueId >= niCount)
+			return Common::String();
+		if (floppyNb && bufBase) {
+			const uint stride = 7;
+			const uint16 textOff = (_partner == 0)
+				? READ_LE_UINT16(ni + clueId * stride + 2)
+				: READ_LE_UINT16(ni + clueId * stride + 4);
+			if (textOff == 0 || textOff >= mysSz)
+				return Common::String();
+			const char *p = (const char *)(bufBase + textOff);
+			uint32 len = 0;
+			while (textOff + len < mysSz && p[len] != 0)
+				len++;
+			return parseString(Common::String(p, len),
+							   _playerName, _partner);
+		}
+		const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+		return parseString(_mystery.textAt(textOff),
+						   _playerName, _partner);
+	};
 	{
 		const int lineH = _font.getFontHeight() + 1;
 		int y = kRectY;
 		while (clueCursor < (int)found.size()) {
 			const uint clueId = found[clueCursor];
-			Common::String txt;
-			if (ni && clueId < niCount) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								  _playerName, _partner);
-			}
+			Common::String txt = noteText(clueId);
 			// Measure height by wrapping the text without drawing.
 			Common::Array<Common::String> wrapped;
 			_font.wordWrapText(txt, kRectW, wrapped);
@@ -1976,12 +2000,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	int y = kRectY;
 	for (int i = startClue; i < endClue; i++) {
 		const uint clueId = found[i];
-		Common::String txt;
-		if (ni && clueId < niCount) {
-			const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-			txt = parseString(_mystery.textAt(textOff),
-							  _playerName, _partner);
-		}
+		Common::String txt = noteText(clueId);
 		if (txt.empty())
 			txt = Common::String::format("clue %u", clueId);
 		// Per `_DrawNotes @ 161e:01d0`: text uses


Commit: 4992f3c7045f98da9019e034cdab0719746cbeb8
    https://github.com/scummvm/scummvm/commit/4992f3c7045f98da9019e034cdab0719746cbeb8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:52+02:00

Commit Message:
EEM: text pagination for some conversations

Changed paths:
    engines/eem/clues.cpp
    engines/eem/font.cpp
    engines/eem/font.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index d53fb7f57ae..645d6086112 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -589,6 +589,19 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 			return out;
 		case '\r':
 			break;
+		case '^':
+			// `_WordWrap @ 1b03:0456` treats `^` as a forced line
+			// break (sets `cur_width = max_width`, forcing the next
+			// loop turn to wrap at the previous space and skip the
+			// `^` itself). Without this conversion the caret falls
+			// through the default case and renders as a literal,
+			// pushing the line past the balloon's visual edge — the
+			// "bubbles aren't large enough" symptom. Promote it to
+			// `\n` so ScummVM's `Font::wordWrapText` (which honours
+			// embedded newlines) picks the same break point the
+			// original engine did.
+			out += '\n';
+			break;
 		default:
 			out += (char)c;
 			break;
@@ -939,6 +952,39 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 		}
 	}
 
+	const uint32 dsz       = _mystery.dataSize();
+	const uint32 notesBase = (uint32)(notes - bufBase);
+
+	auto waitForClick = [&]() -> bool {
+		// Drain pending events first so a previous keystroke's tail
+		// doesn't auto-advance the new page.
+		Common::Event drain;
+		while (g_system->getEventManager()->pollEvent(drain)) {}
+		const uint32 minVisibleMs = 250;
+		const uint32 startedAt = g_system->getMillis();
+		while (!shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+					return true;  // skip
+				if (g_system->getMillis() - startedAt < minVisibleMs)
+					continue;
+				if (ev.type == Common::EVENT_KEYDOWN &&
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					interruptAudio();
+					return true;  // skip
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN)
+					return false;  // advance one page
+			}
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+		return true;
+	};
+
 	for (uint i = 0; i < count && !shouldQuit(); i++) {
 		const uint16 picID    = READ_LE_UINT16(rec + 0);
 		const uint16 picX     = READ_LE_UINT16(rec + 2);
@@ -948,102 +994,22 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 		const uint8  ballY    = rec[8];
 		const uint8  textCount= rec[10];
 
-		// Per-record voice playback. `FUN_22dc_05c8 @ 22dc:05c8`:
-		//   if ((rec[+9] & 0x80) && voiceOption && voiceAvail) {
-		//       slot = rec[+9] & 0x7f;
-		//       _LoadSoundName_Floppy(slot); _PlayVoc(...);
-		//   }
-		// The slot indexes the per-partner table at `2608:0f0e` /
-		// `2608:0f76` (= our `playFloppyVoiceSlot`). Sound is started
-		// BEFORE the bubble renders so it plays while the player reads.
+		// Per-record voice (byte 9 high bit) — see comment in original
+		// header.
 		if ((rec[9] & 0x80) != 0 && _audio) {
 			const uint slot = rec[9] & 0x7f;
 			_audio->playFloppyVoiceSlot(slot, _partner);
 		}
-
-		// Build the full text by concatenating each note's per-partner
-		// text string (parsed for `%s`-style placeholders). Bounds-
-		// check every offset against the mystery blob; if any byte
-		// looks out of range we just stop appending — corrupt offsets
-		// from a misparsed record would otherwise dereference past the
-		// buffer and SIGBUS inside `strlen`.
-		//
-		// Each text index also flags `_cluesFound[idx]` so the PDA /
-		// notebook ("DrawNotes") later renders the clue. Mirrors
-		// `FUN_22dc_05c8 @ 22dc:091a`:
-		//   ((undefined1 *)&DAT_28da_3c08)[textIdx & 0x7f] = 1;
-		// `DAT_28da_3c08` is the floppy `TextSeen` array; we reuse the
-		// `_cluesFound` flag store since both index 0..127 by note id.
-		const uint32 dsz       = _mystery.dataSize();
-		const uint32 notesBase = (uint32)(notes - bufBase);
-		Common::String raw;
-		for (uint t = 0; t < textCount; t++) {
-			const uint8  idx        = rec[11 + t] & 0x7f;
-			if (idx < Mystery::kCluesFoundCap)
-				_mystery._cluesFound[idx] = 1;
-			const uint32 noteAbs    = notesBase + (uint32)idx * 7;
-			if (noteAbs + 6 > dsz)
-				break;
-			const uint16 textOff = (_partner == 0)
-				? READ_LE_UINT16(notes + idx * 7 + 2)
-				: READ_LE_UINT16(notes + idx * 7 + 4);
-			if (textOff >= dsz)
-				break;
-			const char *line = (const char *)(bufBase + textOff);
-			// Find the NUL terminator within the blob — refuse to
-			// strlen past the end.
-			uint32 lineLen = 0;
-			while (textOff + lineLen < dsz && line[lineLen] != 0)
-				lineLen++;
-			if (textOff + lineLen >= dsz)
-				break;
-			if (t > 0)
-				raw += ' ';
-			raw += Common::String(line, lineLen);
-		}
-
-		// Suspect/gallery side effect — `FUN_22dc_05c8 @ 22dc:08eb`:
-		//   if ((rec[+9] & 0x80) == 0 && rec[+9] != 0)
-		//       *(u16 *)(0x5d20 + table[0x2d65 + rec[+9]] * 2) = 1;
-		// Byte 9 doubles as the voice slot (when high bit set) or as a
-		// suspect identifier (low 7 bits, when high bit clear). The
-		// 0x5d20 array is the per-mystery "suspect found in gallery"
-		// flag table that `FUN_154e_0045` reads at gallery render time.
-		// We don't model the per-mystery shuffle table at 0x2d65
-		// (`_NewOrder` is set to identity in `_ReadMystery_Floppy` for
-		// our port), so byte9-1 indexes `_inGallery` directly.
+		// Suspect-found side effect for byte 9 with high bit clear.
 		const uint8 b9 = rec[9];
 		if ((b9 & 0x80) == 0 && b9 != 0) {
 			const uint slot = (uint)b9 - 1;
 			if (slot < Mystery::kGalleryCap)
 				_mystery._inGallery[slot] = 1;
 		}
-		const Common::String text = parseString(raw, _playerName, _partner);
-
-		// Restore briefing BG before drawing this bubble.
-		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
-
-		// Optional character portrait.
-		if (picID != 0 && picID != 0xFFFF) {
-			Picture pic;
-			if (_picsArchive.getPicture(picID, pic))
-				blitAt(pic, picX, picY);
-		}
-
-		// Compose balloon + text on a scratch surface.
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		{
-			Graphics::Surface *screen = g_system->lockScreen();
-			if (screen) {
-				for (int row = 0; row < 200; row++) {
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)screen->getBasePtr(0, row), 320);
-				}
-				g_system->unlockScreen();
-			}
-		}
 
+		// Pre-load balloon picture + insets once per record (constant
+		// across all paginated text indices).
 		Picture balloon;
 		const uint16 balloonId  = balByte & 0x7F;
 		const bool   flipBall   = (balByte & 0x80) != 0;
@@ -1053,81 +1019,134 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 		uint16 textWidth = 142;
 		uint16 textXIns  = 6;
 		uint16 textYIns  = 4;
-		if (haveBalloon) {
-			const int bw = MIN<int>(balloon.surface.w, 320 - ballX);
-			const int bh = MIN<int>(balloon.surface.h, 200 - ballY);
-			const byte transp = (byte)(balloon.flags >> 8);
-			for (int row = 0; row < bh; row++) {
-				const byte *src =
-					(const byte *)balloon.surface.getBasePtr(0, row);
-				byte *dst =
-					(byte *)scratch.getBasePtr(ballX, ballY + row);
-				for (int col = 0; col < bw; col++) {
-					const int srcCol = flipBall
-						? (balloon.surface.w - 1 - col) : col;
-					const byte px = src[srcCol];
-					if (px != transp)
-						dst[col] = px;
-				}
-			}
+		if (haveBalloon)
 			getBalloonInsets(balloonId, textXIns, textYIns, textWidth);
-		}
-
 		const int textX = ballX + textXIns;
-		const int textY = ballY + textYIns;
-		_font.drawWordWrapped(&scratch, textX, textY,
-			MAX<int>(8, (int)textWidth), text, 0);
+		const int balloonH = haveBalloon ? balloon.surface.h : 200;
+		const int lineH    = _font.getFontHeight() + 1;
+
+		// Pagination state — `FUN_22dc_05c8`'s text-idx loop uses
+		// `local_1c` (set from the PREVIOUS text's flag bit) to decide
+		// between "fresh page" (redraw balloon, restart Y at top) and
+		// "continuation" (append below previous lines). We mirror that
+		// state machine so multi-text records render the same way the
+		// original does — without it our impl concatenates every text
+		// idx into ONE balloon and the result spills out the bottom
+		// (the user's "bubbles aren't large enough" screenshot).
+		bool firstPage  = true;
+		int  cursorY    = ballY + textYIns;
+		bool skipAll    = false;
+
+		for (uint t = 0; t < textCount && !shouldQuit() && !skipAll; t++) {
+			const uint8 idxByte = rec[11 + t];
+			const uint8 idx     = idxByte & 0x7f;
+			if (idx < Mystery::kCluesFoundCap)
+				_mystery._cluesFound[idx] = 1;
+			const uint32 noteAbs = notesBase + (uint32)idx * 7;
+			if (noteAbs + 6 > dsz)
+				break;
+			const uint16 textOff = (_partner == 0)
+				? READ_LE_UINT16(notes + idx * 7 + 2)
+				: READ_LE_UINT16(notes + idx * 7 + 4);
+			if (textOff >= dsz)
+				break;
+			const char *linePtr = (const char *)(bufBase + textOff);
+			uint32 lineLen = 0;
+			while (textOff + lineLen < dsz && linePtr[lineLen] != 0)
+				lineLen++;
+			Common::String raw(linePtr, lineLen);
+			const Common::String text =
+				parseString(raw, _playerName, _partner);
 
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
+			// Render this text page.
+			Graphics::ManagedSurface scratch(320, 200,
+				Graphics::PixelFormat::createFormatCLUT8());
+			scratch.simpleBlitFrom(*bg.surfacePtr());
+
+			if (firstPage) {
+				// Optional character portrait — only draws once per
+				// fresh page (matches the original which only redraws
+				// the balloon on `local_1c == 0`).
+				if (picID != 0 && picID != 0xFFFF) {
+					Picture pic;
+					if (_picsArchive.getPicture(picID, pic)) {
+						const byte transpC = (byte)(pic.flags >> 8);
+						const int pw = MIN<int>(pic.surface.w, 320 - picX);
+						const int ph = MIN<int>(pic.surface.h, 200 - picY);
+						for (int row = 0; row < ph; row++) {
+							const byte *src = (const byte *)
+								pic.surface.getBasePtr(0, row);
+							byte *dst = (byte *)
+								scratch.getBasePtr(picX, picY + row);
+							for (int col = 0; col < pw; col++) {
+								if (src[col] != transpC)
+									dst[col] = src[col];
+							}
+						}
+					}
+				}
+				if (haveBalloon) {
+					const int bw = MIN<int>(balloon.surface.w, 320 - ballX);
+					const int bh = MIN<int>(balloonH, 200 - ballY);
+					const byte transp = (byte)(balloon.flags >> 8);
+					for (int row = 0; row < bh; row++) {
+						const byte *src = (const byte *)
+							balloon.surface.getBasePtr(0, row);
+						byte *dst = (byte *)
+							scratch.getBasePtr(ballX, ballY + row);
+						for (int col = 0; col < bw; col++) {
+							const int srcCol = flipBall
+								? (balloon.surface.w - 1 - col)
+								: col;
+							const byte px = src[srcCol];
+							if (px != transp)
+								dst[col] = px;
+						}
+					}
+				}
+				cursorY = ballY + textYIns;
+			}
 
-		// Drain pending events from the previous bubble (or upstream
-		// animation skip) so a single Enter press doesn't burst-advance
-		// through multiple bubbles. Without this, key-repeat or stacked
-		// KEYUP/KEYDOWN events from one keystroke could roll past
-		// several records before the user could even read them.
-		{
-			Common::Event drain;
-			while (g_system->getEventManager()->pollEvent(drain)) {}
-		}
-		// Minimum visible time per bubble — guards against accidental
-		// rapid-fire advances from accumulated input.
-		const uint32 minVisibleMs = 250;
-		const uint32 startedAt = g_system->getMillis();
+			// Wrap text into lines and draw each at cursorY.
+			Common::Array<Common::String> lines;
+			_font.wordWrapText(text, MAX<int>(8, (int)textWidth), lines);
+			for (uint l = 0; l < lines.size(); l++) {
+				_font.drawString(&scratch, lines[l], textX,
+								  cursorY + (int)l * lineH,
+								  MAX<int>(8, (int)textWidth), 0);
+			}
+			cursorY += (int)lines.size() * lineH;
 
-		// Wait for click / key. ESC skips the rest of the briefing.
-		// Inside the loop we ignore input until at least `minVisibleMs`
-		// has elapsed since the bubble was drawn — combined with the
-		// pre-loop event drain, this prevents one keystroke (or its
-		// auto-repeat tail) from advancing several bubbles back to back.
-		bool advance = false;
-		bool skipAll = false;
-		while (!advance && !shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-					advance = true;
-					break;
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+
+			// Decide pagination for the NEXT text idx based on THIS
+			// text's high bit.
+			const bool textHighBit = (idxByte & 0x80) != 0;
+			const bool isLastText  = (t + 1 == textCount);
+
+			if (!isLastText) {
+				if (textHighBit) {
+					// Continuation flag — next text appends below
+					// without waiting.
+					firstPage = false;
+				} else {
+					// New page next: wait for click, then redraw
+					// balloon for the next text.
+					if (waitForClick()) {
+						skipAll = true;
+						break;
+					}
+					firstPage = true;
 				}
-				if (g_system->getMillis() - startedAt < minVisibleMs)
-					continue;
-				if (ev.type == Common::EVENT_KEYDOWN &&
-					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					advance = true;
+			} else {
+				// Last text in record — wait for the user's click
+				// before moving on (mirrors the caller's
+				// `FUN_16e2_1a7f()` after every `FUN_22dc_05c8` call).
+				if (waitForClick())
 					skipAll = true;
-					interruptAudio();
-					break;
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN ||
-					ev.type == Common::EVENT_KEYDOWN) {
-					advance = true;
-					break;
-				}
 			}
-			g_system->updateScreen();
-			g_system->delayMillis(10);
 		}
 		if (skipAll)
 			return;
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index 4d00ab79fb1..c8c42360550 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -80,6 +80,7 @@ bool EEMFont::load(const Common::Path &path) {
 	}
 	_glyphs.resize(numChars);
 	_maxHeight = _maxWidth = 0;
+	_lineHeight = 0;
 
 	for (uint i = 0; i < numChars; i++) {
 		FontGlyph &g = _glyphs[i];
@@ -99,6 +100,19 @@ bool EEMFont::load(const Common::Path &path) {
 			_maxWidth = g.widthBits;
 	}
 
+	// `_LoadFont @ 1b03:0220` sets the line stride to the FIRST
+	// glyph's height (= the space character) — `DAT_28da_30ca =
+	// DAT_28da_30ce` (the first byte of glyph 0 = its height). Tall
+	// descender glyphs ('g', 'j', 'p', 'q', 'y') hang into the next
+	// row by design, which is how the original engine keeps balloon
+	// text dense enough to fit. Using `_maxHeight` (the descender-
+	// inflated value) added 2–3 px per line and made every multi-
+	// line bubble overflow its graphic vertically — verbatim user-
+	// reported "bubbles aren't large enough" symptom.
+	_lineHeight = !_glyphs.empty() ? _glyphs[0].height : _maxHeight;
+	if (_lineHeight == 0)
+		_lineHeight = _maxHeight;
+
 	debugC(1, kDebugGfx, "Font %s loaded: %u glyphs, max %ux%u",
 		   path.toString().c_str(), numChars, _maxWidth, _maxHeight);
 	return true;
diff --git a/engines/eem/font.h b/engines/eem/font.h
index 6ccfd6d4946..ae5a0638d7b 100644
--- a/engines/eem/font.h
+++ b/engines/eem/font.h
@@ -61,7 +61,9 @@ public:
 	bool isLoaded() const { return !_glyphs.empty(); }
 
 	// --- Graphics::Font overrides ---
-	int getFontHeight() const override { return _maxHeight; }
+	int getFontHeight() const override {
+		return _lineHeight ? _lineHeight : _maxHeight;
+	}
 	int getMaxCharWidth() const override { return _maxWidth; }
 	int getCharWidth(uint32 chr) const override;
 	void drawChar(Graphics::Surface *dst, uint32 chr, int x, int y,
@@ -76,8 +78,9 @@ public:
 
 private:
 	Common::Array<FontGlyph> _glyphs;
-	uint16 _maxHeight = 0;
-	uint16 _maxWidth  = 0;
+	uint16 _maxHeight  = 0;
+	uint16 _maxWidth   = 0;
+	uint16 _lineHeight = 0;  ///< First glyph height (= original line stride)
 };
 
 } // End of namespace EEM


Commit: fe87003b43856887061690a2c8fd6e6d919bf733
    https://github.com/scummvm/scummvm/commit/fe87003b43856887061690a2c8fd6e6d919bf733
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:53+02:00

Commit Message:
EEM: draw enter indicators for conversations in floppy version

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/graphics.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 645d6086112..2034ed40127 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -913,7 +913,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	}
 }
 
-void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
+void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
+											uint lastIndicator) {
 	// Render `count` consecutive floppy dialog records starting at
 	// `rec`. Per `FUN_22dc_05c8 @ 22dc:05c8`, each record is:
 	//   u16 picID    @ +0     (character portrait, 0 = skip pic)
@@ -1117,35 +1118,68 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count) {
 			}
 			cursorY += (int)lines.size() * lineH;
 
-			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
-			g_system->updateScreen();
-
 			// Decide pagination for the NEXT text idx based on THIS
 			// text's high bit.
 			const bool textHighBit = (idxByte & 0x80) != 0;
 			const bool isLastText  = (t + 1 == textCount);
-
+			const bool isLastRec   = (i + 1 == count);
+
+			// Stamp the "click to continue" indicator (PIC 0xa0
+			// "more" arrow / PIC 0xa1 end indicator) before
+			// flipping to wait. Mirrors `_DisplayHotspotClue_Floppy
+			// @ 22dc:08aa` (mid-page) and `@ 22dc:08c0`
+			// (end-of-record). We skip drawing it on the very last
+			// click of the very last record when the caller passes
+			// `lastIndicator == 0` — that's the original `param_2 ==
+			// 0` "no indicator" case.
+			bool waitNeeded   = false;
+			bool drawArrow    = false;
+			bool useEndPic    = false;
 			if (!isLastText) {
 				if (textHighBit) {
-					// Continuation flag — next text appends below
-					// without waiting.
+					// continuation — no wait, no indicator
 					firstPage = false;
 				} else {
-					// New page next: wait for click, then redraw
-					// balloon for the next text.
-					if (waitForClick()) {
-						skipAll = true;
-						break;
-					}
-					firstPage = true;
+					waitNeeded = true;
+					drawArrow  = true;          // PIC 0xa0
+					useEndPic  = false;
 				}
 			} else {
-				// Last text in record — wait for the user's click
-				// before moving on (mirrors the caller's
-				// `FUN_16e2_1a7f()` after every `FUN_22dc_05c8` call).
-				if (waitForClick())
+				// Last text in this record.
+				waitNeeded = true;
+				if (!isLastRec) {
+					// More records follow — original passes
+					// `param_2 = 1` → PIC 0xa0.
+					drawArrow = true;
+					useEndPic = false;
+				} else {
+					// Last record. Use caller-supplied indicator.
+					if (lastIndicator == 1) {
+						drawArrow = true;
+						useEndPic = false;
+					} else if (lastIndicator == 2) {
+						drawArrow = true;
+						useEndPic = true;       // PIC 0xa1
+					}
+				}
+			}
+
+			if (drawArrow) {
+				drawFloppyBubbleIndicator(scratch, balloonId, ballX,
+										   ballY, useEndPic);
+			}
+
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, 320, 200);
+			g_system->updateScreen();
+
+			if (waitNeeded) {
+				if (waitForClick()) {
 					skipAll = true;
+					break;
+				}
+				if (!isLastText && !textHighBit)
+					firstPage = true;
 			}
 		}
 		if (skipAll)
@@ -1234,11 +1268,23 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		}
 	}
 	const byte *mainRec = bufBase + off;
-	displayFloppyDialogRecords(mainRec, 1);
 	const uint mainLen = 11u + (uint)mainRec[10];
-	if (off + mainLen >= _mystery.dataSize())
-		return;
-	const uint contCount = (uint)(bufBase[off + mainLen] & 0x7F);
+	uint contCount = 0;
+	uint contFlagsByte = 0;
+	if (off + mainLen < _mystery.dataSize()) {
+		contFlagsByte = bufBase[off + mainLen];
+		contCount = contFlagsByte & 0x7F;
+	}
+	// `_HandleHotspotClick_Floppy @ 1652:00e6` derives the main
+	// record's `param_2` from the continuation byte:
+	//   contFlagsByte == 0  → 0 (no indicator, only one record)
+	//   high bit set         → 1 (PIC 0xa0, "more" arrow)
+	//   low 7 bits non-zero  → 2 (PIC 0xa1, alternate end)
+	uint mainIndicator = 0;
+	if (contFlagsByte != 0) {
+		mainIndicator = (contFlagsByte & 0x80) ? 1 : 2;
+	}
+	displayFloppyDialogRecords(mainRec, 1, mainIndicator);
 	if (contCount == 0)
 		return;
 	const uint32 contOff = off + mainLen + 1;
@@ -1251,7 +1297,13 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 	g_system->copyRectToScreen(siteBG.getPixels(), siteBG.pitch,
 							   0, 0, 320, 200);
 	g_system->updateScreen();
-	displayFloppyDialogRecords(bufBase + contOff, contCount);
+	// Continuation chain: `_DisplayDialogContinuations_Floppy @
+	// 1652:006c` passes `param_2 + -1 != 0` (= 1 if more records
+	// follow, 0 on the last). Our renderer maps that automatically
+	// because mid-batch records always get PIC 0xa0; the last record
+	// uses our `lastIndicator` argument (= 0, no indicator on the
+	// final continuation).
+	displayFloppyDialogRecords(bufBase + contOff, contCount, 0);
 }
 
 bool EEMEngine::areYouSure() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 0b926e5655b..4be4198ad29 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -266,11 +266,24 @@ public:
 	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
 
 	/// Look up balloon-text-inset metadata. Mirrors the 52-entry table at
-	/// `29be:0875`, indexed by `(bubNum & 0x7F)`. 10 bytes per entry; only
-	/// the first 3 fields (x inset, y inset, text width) are used for
-	/// rendering. Returns false if `bubNum` is outside the table.
+	/// `29be:0875` (CD) / `2608:05f9` (floppy), indexed by
+	/// `(bubNum & 0x7F)`. 10 bytes per entry; the first 3 fields
+	/// (x inset, y inset, text width) are used for text wrap, the last
+	/// 2 (indicator dX/dY) by `drawFloppyBubbleIndicator`. Returns
+	/// false if `bubNum` is outside the table.
 	bool getBalloonInsets(uint16 bubNum, uint16 &xInset, uint16 &yInset,
 						  uint16 &textW) const;
+	bool getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
+								 uint16 &dy) const;
+
+	/// Stamp the floppy "click to continue" indicator (PIC 0xa0 for
+	/// `endIndicator==false`, PIC 0xa1 otherwise) onto @p dst at the
+	/// position derived from the balloon-inset table. Mirrors
+	/// `FUN_22dc_05c8 @ 22dc:08aa` (mid-page) and `@ 22dc:08c0`
+	/// (end-of-record).
+	void drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
+								   uint16 bubNum, int ballX, int ballY,
+								   bool endIndicator);
 
 	/// "Are you sure?" yes/no dialog. Mirrors `_AreYouSure` @ 1a35:0a5c.
 	/// Returns true if the user picked YES.
@@ -449,8 +462,13 @@ private:
 	/// Render `count` consecutive floppy dialog records starting at
 	/// `rec`. Shared between briefing and hotspot click handlers since
 	/// the original engine uses the same `FUN_22dc_05c8 @ 22dc:05c8`
-	/// renderer in both contexts.
-	void displayFloppyDialogRecords(const byte *rec, uint count);
+	/// renderer in both contexts. `lastIndicator` is the `param_2`
+	/// equivalent for the LAST record in the batch — 0 = no
+	/// indicator, 1 = PIC 0xa0 (red "more" arrow), 2 = PIC 0xa1
+	/// (alternate end indicator). Records before the last always get
+	/// PIC 0xa0.
+	void displayFloppyDialogRecords(const byte *rec, uint count,
+									 uint lastIndicator = 0);
 
 public:
 	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 5af91578ac5..4b9eae7cbf8 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -51,29 +51,38 @@ const uint16 kHelpPics[][2] = {
 	{ 0x0192, 0x01b1 },
 };
 
-// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875`.
-// Used at 1df2:0aef-0af9 (accuse hint) and `_DisplayClue` to position
-// `_WordWrap` text inside the balloon. Only +0/+2/+4 are read by
-// `getBalloonInsets`:
-//   +0..1 = text X inset, +2..3 = Y inset, +4..5 = max wrap width
-// (+6/+8 = balloon h / tail offset, both unused for text layout).
-struct BalloonInsets { uint16 x; uint16 y; uint16 w; };
+// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875` (CD)
+// / `2608:05f9` (floppy). Used by `_DisplayClue` to position
+// `_WordWrap` text inside the balloon AND to position the
+// "click to continue" indicator drawn by
+// `_DisplayHotspotClue_Floppy @ 22dc:05c8`. Layout per entry:
+//   +0..1 = text X inset, +2..3 = text Y inset
+//   +4..5 = wrap width
+//   +6..7 = "more / end" indicator X within the balloon
+//   +8..9 = "more / end" indicator Y within the balloon
+struct BalloonInsets {
+	uint16 x;
+	uint16 y;
+	uint16 w;
+	uint16 indDX;
+	uint16 indDY;
+};
 const BalloonInsets kBalloonInsetTable[] = {
-	{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-	{ 6, 4, 142 }, { 6, 4, 142 }, { 6, 4, 142 },
-	{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-	{ 6, 4, 224 }, { 6, 4, 224 }, { 6, 4, 224 },
-	{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-	{ 6, 4, 291 }, { 6, 4, 291 }, { 6, 4, 291 },
-	{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-	{ 5, 4, 155 }, { 5, 4, 155 }, { 5, 4, 155 },
-	{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-	{ 5, 4, 237 }, { 5, 4, 237 }, { 5, 4, 237 },
-	{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-	{ 3, 4, 155 }, { 3, 4, 155 }, { 3, 4, 155 },
-	{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-	{ 5, 4, 238 }, { 5, 4, 238 }, { 5, 4, 238 },
-	{ 5, 8, 158 }, { 5, 8, 176 }, { 8, 7, 142 }
+	{ 6, 4, 142, 150,  6 }, { 6, 4, 142, 150, 14 }, { 6, 4, 142, 150, 22 }, { 6, 4, 142, 150, 40 },
+	{ 6, 4, 142, 150, 50 }, { 6, 4, 142, 150, 70 }, { 6, 4, 142, 150, 86 },
+	{ 6, 4, 224, 233,  6 }, { 6, 4, 224, 233, 14 }, { 6, 4, 224, 233, 23 }, { 6, 4, 224, 233, 41 },
+	{ 6, 4, 224, 233, 59 }, { 6, 4, 224, 233, 68 }, { 6, 4, 224, 233, 77 },
+	{ 6, 4, 291, 300,  6 }, { 6, 4, 291, 300, 14 }, { 6, 4, 291, 300, 23 }, { 6, 4, 291, 300, 32 },
+	{ 6, 4, 291, 300, 50 }, { 6, 4, 291, 300, 69 }, { 6, 4, 291, 300, 77 },
+	{ 5, 4, 155, 150, 14 }, { 5, 4, 155, 150, 25 }, { 5, 4, 155, 150, 35 }, { 5, 4, 155, 150, 52 },
+	{ 5, 4, 155, 150, 61 }, { 5, 4, 155, 150, 78 }, { 5, 4, 155, 150, 81 },
+	{ 5, 4, 237, 233, 15 }, { 5, 4, 237, 233, 24 }, { 5, 4, 237, 233, 42 }, { 5, 4, 237, 233, 50 },
+	{ 5, 4, 237, 233, 69 }, { 5, 4, 237, 233, 80 }, { 5, 4, 237, 233, 80 },
+	{ 3, 4, 155, 150, 15 }, { 3, 4, 155, 150, 24 }, { 3, 4, 155, 150, 34 }, { 3, 4, 155, 150, 40 },
+	{ 3, 4, 155, 150, 61 }, { 3, 4, 155, 150, 82 }, { 3, 4, 155, 150, 88 },
+	{ 5, 4, 238, 232, 16 }, { 5, 4, 238, 232, 25 }, { 5, 4, 238, 232, 34 }, { 5, 4, 238, 232, 51 },
+	{ 5, 4, 238, 232, 66 }, { 5, 4, 238, 232, 71 }, { 5, 4, 238, 232, 95 },
+	{ 5, 8, 158, 160, 45 }, { 5, 8, 176, 176, 50 }, { 8, 7, 142, 142, 71 }
 };
 
 // Floppy KDHelp hotspot-searched check. Mirrors
@@ -569,4 +578,48 @@ bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
 	return true;
 }
 
+bool EEMEngine::getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
+										uint16 &dy) const {
+	const uint idx = bubNum & 0x7F;
+	if (idx >= ARRAYSIZE(kBalloonInsetTable))
+		return false;
+	dx = kBalloonInsetTable[idx].indDX;
+	dy = kBalloonInsetTable[idx].indDY;
+	return true;
+}
+
+void EEMEngine::drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
+										   uint16 bubNum, int ballX, int ballY,
+										   bool endIndicator) {
+	// Mirrors `_DisplayHotspotClue_Floppy @ 22dc:08c0` (end-of-record)
+	// and `@ 22dc:08aa` (mid-pagination). Both grab a pre-loaded PIC
+	// (`DAT_28da_3034 = PIC 0xa0` for "more pages",
+	//  `DAT_28da_3030 = PIC 0xa1` for "end indicator") and stamp it at
+	// `(ballX + insetTable[bubNum].indDX,
+	//   ballY + insetTable[bubNum].indDY)` via `_AddPicBackground`.
+	uint16 dx = 0;
+	uint16 dy = 0;
+	if (!getBalloonIndicatorPos(bubNum, dx, dy))
+		return;
+	const uint picId = endIndicator ? 0xa1 : 0xa0;
+	Picture pic;
+	if (!_picsArchive.getPicture(picId, pic))
+		return;
+	const int x = ballX + (int)dx;
+	const int y = ballY + (int)dy;
+	const byte transp = (byte)(pic.flags >> 8);
+	const int pw = MIN<int>(pic.surface.w, 320 - x);
+	const int ph = MIN<int>(pic.surface.h, 200 - y);
+	if (x < 0 || y < 0 || pw <= 0 || ph <= 0)
+		return;
+	for (int row = 0; row < ph; row++) {
+		const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
+		byte *out = (byte *)dst.getBasePtr(x, y + row);
+		for (int col = 0; col < pw; col++) {
+			if (src[col] != transp)
+				out[col] = src[col];
+		}
+	}
+}
+
 } // End of namespace EEM


Commit: bfdd2c33ecfb0363422d914db71b1a5ad40a3101
    https://github.com/scummvm/scummvm/commit/bfdd2c33ecfb0363422d914db71b1a5ad40a3101
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:53+02:00

Commit Message:
EEM: initial support for spanish release and fixes for the floppy version

Changed paths:
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 2034ed40127..677566620eb 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -1002,11 +1002,25 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			_audio->playFloppyVoiceSlot(slot, _partner);
 		}
 		// Suspect-found side effect for byte 9 with high bit clear.
+		// Mirrors `_DisplayHotspotClue_Floppy @ 22dc:0908..0922`:
+		//   if ((rec[9] & 0x80) == 0 && rec[9] != 0)
+		//     _InGallery_Floppy[_GalleryShuffleSeed_Floppy[rec[9]]] = 1;
+		// `_GalleryShuffleSeed_Floppy` is a byte array at DS:0x2d65 that
+		// overlaps `_NewOrder_Floppy` at DS:0x2d66 by one byte: i.e.
+		// `_GalleryShuffleSeed[i+1] == _NewOrder[i]` (verified by
+		// comparing the two writes in `_ReadMystery_Floppy @ 22dc:0178`).
+		// So in our terms the mapping is `_inGallery[_newOrder[b9 - 1]]`.
+		// Skipping `_newOrder` (as we did before) drew the right portrait
+		// only when `_newOrder` happened to be identity — randomized
+		// shuffles dropped one of the two M0 suspects.
 		const uint8 b9 = rec[9];
 		if ((b9 & 0x80) == 0 && b9 != 0) {
-			const uint slot = (uint)b9 - 1;
-			if (slot < Mystery::kGalleryCap)
-				_mystery._inGallery[slot] = 1;
+			const uint logicalIdx = (uint)b9 - 1;
+			if (logicalIdx < Mystery::kGalleryCap) {
+				const uint8 slot = _mystery._newOrder[logicalIdx];
+				if (slot < Mystery::kGalleryCap)
+					_mystery._inGallery[slot] = 1;
+			}
 		}
 
 		// Pre-load balloon picture + insets once per record (constant
@@ -1332,8 +1346,13 @@ bool EEMEngine::areYouSure() {
 			   (const byte *)saved.getBasePtr(0, row), 320);
 	scratch.fillRect(dlg, 0);
 	scratch.frameRect(dlg, 0xF);
-	_font.drawString(&scratch, "Are you sure you want to quit?", dlg.left + 8, dlg.top + 8, 320, 0xF);
-	_font.drawString(&scratch, "Y - Yes", dlg.left + 16, dlg.top + 36, 320, 0xF);
+	_font.drawString(&scratch,
+		isSpanish() ? "Estas seguro que quieres salir?"
+					: "Are you sure you want to quit?",
+		dlg.left + 8, dlg.top + 8, 320, 0xF);
+	_font.drawString(&scratch,
+		isSpanish() ? "S - Si" : "Y - Yes",
+		dlg.left + 16, dlg.top + 36, 320, 0xF);
 	_font.drawString(&scratch, "N - No", dlg.left + 100, dlg.top + 36, 320, 0xF);
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
@@ -1351,7 +1370,9 @@ bool EEMEngine::areYouSure() {
 				break;
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
+				// Spanish prompt is "S - Si" — accept both Y and S.
 				if (ev.kbd.keycode == Common::KEYCODE_y ||
+					ev.kbd.keycode == Common::KEYCODE_s ||
 					ev.kbd.keycode == Common::KEYCODE_RETURN) {
 					result = true; decided = true; break;
 				}
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 402a5a89cef..60967493537 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -52,6 +52,20 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUIO1(GUIO_NONE)
 	},
+	{
+		// Spanish floppy release — same EEM.EXE binary as the English
+		// floppy (the engine code is identical), with a localised
+		// PICS.DBD that swaps the embedded English image text for
+		// Spanish equivalents.
+		"eem",
+		"Floppy",
+		AD_ENTRY2s("EEM.EXE",   "692a5e6e7f4516d6e40c1f80cbc1b2cc", 109542,
+				   "PICS.DBD",  "199150e7d612f87477814bc5f4a1967a", 955332),
+		Common::ES_ESP,
+		Common::kPlatformDOS,
+		ADGF_NO_FLAGS,
+		GUIO1(GUIO_NONE)
+	},
 
 	AD_TABLE_END_MARKER
 };
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 23a34e6201f..03a2449d26b 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -105,6 +105,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
 				 ? kVariantFloppy : kVariantCD;
+	_language = gameDesc ? gameDesc->language : Common::EN_ANY;
 }
 
 EEMEngine::~EEMEngine() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 4be4198ad29..e78e44066c3 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -25,6 +25,7 @@
 #define EEM_EEM_H
 
 #include "common/array.h"
+#include "common/language.h"
 #include "common/platform.h"
 #include "common/random.h"
 #include "common/scummsys.h"
@@ -171,6 +172,13 @@ public:
 
 	const ADGameDescription *_gameDescription;
 	Variant _variant = kVariantCD;
+	Common::Language _language = Common::EN_ANY;
+
+	/// True for the Spanish floppy release. Used to swap hardcoded
+	/// English UI strings (menus, name prompt, notebook labels,
+	/// fallback hint copy) for their Spanish equivalents extracted
+	/// from EEM.EXE in `eem-full-game/floppy-es/`.
+	bool isSpanish() const { return _language == Common::ES_ESP; }
 
 	DBDArchive &getPics()    { return _picsArchive; }
 	DBDArchive &getAni()     { return _aniArchive; }
@@ -212,6 +220,7 @@ public:
 	/// Run the accuse flow (pick suspect, evaluate chains, show ending).
 	/// Mirrors `_DoAccuseGallery` @ 1df2:0a31 + `_DisplayEnding` @ 1df2:0548.
 	void doAccuse();
+	void doAccuseFloppy();
 
 	/// Show the accuse-notes screen (PIC 0x1A7, the red "accuse-mode"
 	/// BG with selectable clue list + "N clues" remaining counter).
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 781d81895a0..06ce1d53f7b 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -191,6 +191,30 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_solvedOffset    = _floppySolvedOff;
 		_hintOffset      = _floppyHintBlockOff;
 
+		// Per-mystery runtime state — mirror `_ReadMystery_Floppy @
+		// 22dc:0178` zeroing `_TextSeen_Floppy` / `_InGallery_Floppy` and
+		// seeding `_NewOrder_Floppy[0]` (random) + the `+1`-shift loop
+		// that fills the rest. We use the identity mapping `[0..N-1]` so
+		// dialog `byte9` (1-based logical idx) maps to `_inGallery[idx -
+		// 1]` and the gallery render iterates the same indices. Without
+		// this init the floppy load path returns with `_newOrder` left
+		// at whatever `clear()` zeroed it to, so every byte9>0 dialog
+		// resolves through `_newOrder[logicalIdx]==0` and the second
+		// suspect overwrites the first slot.
+		memset(_cluesFound, 0, sizeof(_cluesFound));
+		memset(_noteSelected, 0, sizeof(_noteSelected));
+		memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
+		memset(_inGallery, 0, sizeof(_inGallery));
+		(void)rng;
+		for (uint i = 0; i < kGalleryCap; i++)
+			_newOrder[i] = (uint8)i;
+		memset(_visitedSite, 0, sizeof(_visitedSite));
+		memset(_onSites, 0, sizeof(_onSites));
+		_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+		_firstTry = true;
+		_searchLocationNumber = _siteNumber = 0xFFFF;
+		_lastSite = 0x1B;
+
 		debugC(1, kDebugMystery,
 			   "Mystery::load(%u) floppy: sites=0x%04x siteIdx=0x%04x "
 			   "notes=0x%04x suspects=0x%04x text=0x%04x kd=0x%04x "
@@ -396,11 +420,46 @@ const byte *Mystery::mapEntry(uint siteNum) const {
 	return _data.data() + off;
 }
 
+const byte *Mystery::floppySuspectEntry(uint suspectIdx) const {
+	// Floppy gallery section: byte[0] = numSuspects, then per suspect a
+	// variable-size record of `5 + nameLen` bytes (verified at
+	// `_DrawGallery_Floppy @ 154e:00b6` advancing `iVar7 += pbVar4[4]
+	// + 5`):
+	//   u16 +0  picID (gallery portrait, BUTTON.DBD entry)
+	//   u16 +2  alibi marker (0xFFFF = guilty; else high byte = index
+	//           into TEXT_BLOCK alibi-offset table at header[+0xc])
+	//   u8  +4  name length
+	//   u8  +5..+5+nameLen-1  name string
+	if (!_isFloppy || !isLoaded())
+		return nullptr;
+	const byte *gd = _data.data() + _galleryOffset;
+	if (gd + 1 > _data.data() + _data.size())
+		return nullptr;
+	const uint8 numSus = gd[0];
+	if (suspectIdx >= numSus)
+		return nullptr;
+	const byte *p = gd + 1;
+	const byte *bufEnd = _data.data() + _data.size();
+	for (uint i = 0; i < suspectIdx; i++) {
+		if (p + 5 > bufEnd)
+			return nullptr;
+		p += 5 + (uint)p[4];
+	}
+	if (p + 5 > bufEnd)
+		return nullptr;
+	return p;
+}
+
 bool Mystery::isGuilty(uint suspectIdx) const {
-	// `_WITCH @ 1df2:089f`: `if (GalleryData[i*0x46 + 0x02] == -1)
+	// `_WITCH @ 1df2:089f` (CD): `if (GalleryData[i*0x46 + 0x02] == -1)
 	// _DisplayCorrect(); else _DisplayAlibi(...)`. Innocent suspects
 	// store their alibi-text TextBlock offset at +0x02; the guilty
-	// one stores the sentinel 0xFFFF.
+	// one stores the sentinel 0xFFFF. Floppy uses the same convention
+	// at suspect entry +2..3 but with variable-stride entries.
+	if (_isFloppy) {
+		const byte *e = floppySuspectEntry(suspectIdx);
+		return e && READ_LE_UINT16(e + 2) == 0xFFFF;
+	}
 	const byte *gd = galleryData();
 	if (!gd || suspectIdx >= _numSuspects)
 		return false;
@@ -409,6 +468,26 @@ bool Mystery::isGuilty(uint suspectIdx) const {
 }
 
 uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
+	if (_isFloppy) {
+		// Floppy alibi: u16 at suspect +2..3 carries TWO things:
+		// 0xFFFF = guilty, otherwise the HIGH BYTE indexes the
+		// TEXT_BLOCK table (header[+0xc]), each entry u16 = absolute
+		// alibi-text offset in the buffer. Verified at
+		// `_DisplayAlibi_Floppy @ 1d40:0145` reading
+		// `*(int *)(textBlock + ((byte *)entry)[3] * 2)`. The result
+		// is an ABSOLUTE offset; caller reads via `blobAt(off)`.
+		const byte *e = floppySuspectEntry(suspectIdx);
+		if (!e)
+			return 0xFFFF;
+		const uint16 alibi = READ_LE_UINT16(e + 2);
+		if (alibi == 0xFFFF)
+			return 0xFFFF;
+		const uint8 idx = (uint8)(alibi >> 8);
+		const uint32 base = _textOffset;
+		if ((uint32)idx * 2 + 2 > _data.size() - base)
+			return 0xFFFF;
+		return READ_LE_UINT16(_data.data() + base + (uint32)idx * 2);
+	}
 	const byte *gd = galleryData();
 	if (!gd || suspectIdx >= _numSuspects)
 		return 0xFFFF;
@@ -436,6 +515,38 @@ int Mystery::selectedPoints() const {
 	const uint16 cnt = noteIndexCount();
 	if (!ni || cnt == 0)
 		return 0;
+	if (_isFloppy) {
+		// Floppy `_GetSelectedPoints_Floppy @ 1d40:0c23`: collect the
+		// per-note score (note +6 byte) for every clue with TextSeen
+		// set, sort descending, sum the top 5. Mirrors
+		// `_SortNotesByScore_Floppy @ 1d40:000e` + the top-5 fold.
+		uint8 scores[Mystery::kCluesFoundCap] = {};
+		uint scoreCount = 0;
+		const uint maxIdx = MIN<uint>(cnt, kCluesFoundCap);
+		for (uint i = 0; i < maxIdx; i++) {
+			if (_cluesFound[i] == 0)
+				continue;
+			scores[scoreCount++] = ni[i * 7 + 6];
+		}
+		// Partial selection sort for top 5.
+		const uint topN = MIN<uint>(5u, scoreCount);
+		for (uint k = 0; k < topN; k++) {
+			uint best = k;
+			for (uint j = k + 1; j < scoreCount; j++) {
+				if (scores[j] > scores[best])
+					best = j;
+			}
+			if (best != k) {
+				uint8 tmp = scores[k];
+				scores[k] = scores[best];
+				scores[best] = tmp;
+			}
+		}
+		int total = 0;
+		for (uint k = 0; k < topN; k++)
+			total += scores[k];
+		return total;
+	}
 	int total = 0;
 	for (uint i = 0; i < cnt && i < kCluesFoundCap; i++) {
 		if (!_noteSelected[i])
@@ -453,6 +564,22 @@ void Mystery::syncState(Common::Serializer &s) {
 	s.syncArray(_hotSpotsSeen, kHotSpotsCap, Common::Serializer::Uint16LE);
 	s.syncArray(_inGallery,    kGalleryCap,  Common::Serializer::Uint16LE);
 	s.syncBytes(_newOrder, kGalleryCap);
+	// Save profiles created before the floppy `_newOrder` identity init
+	// was added stored all-zeros here. Loading that back over the
+	// identity set by `Mystery::load()` makes every dialog with byte9>0
+	// resolve to slot 0 (so the second suspect found at a CONSITE
+	// overwrites the first). Detect the legacy zero-fill on read and
+	// rebuild the identity mapping that the loader produced.
+	if (s.isLoading()) {
+		bool allZero = true;
+		for (uint i = 0; i < kGalleryCap; i++) {
+			if (_newOrder[i] != 0) { allZero = false; break; }
+		}
+		if (allZero) {
+			for (uint i = 0; i < kGalleryCap; i++)
+				_newOrder[i] = (uint8)i;
+		}
+	}
 	s.syncArray(_visitedSite, kVisitedSiteCap, Common::Serializer::Uint16LE);
 	s.syncArray(_onSites,     kVisitedSiteCap, Common::Serializer::Uint16LE);
 	s.syncAsByte(_sawCOFFSITEs);
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 251320604aa..82c881803cd 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -91,6 +91,13 @@ public:
 	/// First u16 of each entry is the PIC picture ID for that suspect.
 	const byte *galleryData() const;
 
+	/// Floppy variable-stride suspect record. Returns nullptr on CD or
+	/// when @p suspectIdx is out of range. Walks the gallery section
+	/// (`5 + nameLen` bytes per suspect) to land on the requested entry.
+	/// Layout: u16 picID, u16 alibiMarker (0xFFFF = guilty),
+	/// u8 nameLen, nameLen bytes of name.
+	const byte *floppySuspectEntry(uint suspectIdx) const;
+
 	/// Pointer to the NoteIndex array (4 bytes per entry: u16 textOff + u16 pts).
 	const byte *noteIndex() const;
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 42321a6c626..fc5b53f4b0a 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -55,6 +55,19 @@ const GallerySlot kGallerySlots[5] = {
 	{ 191,  90 }  // 4
 };
 
+// Floppy gallery slot positions verified at `2608:0x16c` (5 ×
+// {u16 x, u16 y}) — read by `_DrawGallery_Floppy @ 154e:0045`'s
+// `[BX + 0x16c]` (x) and `[BX + 0x16e]` (y) loads. The floppy
+// layout is shifted left ~0x30 px relative to CD: row 0 starts at
+// x=0x53 (vs 0x53→0x83 on CD) and the bottom row at x=0x77 / 0xbf.
+const GallerySlot kFloppyGallerySlots[5] = {
+	{ 0x53, 0x0e }, // 0
+	{ 0x9b, 0x0e }, // 1
+	{ 0xe3, 0x0e }, // 2
+	{ 0x77, 0x5a }, // 3
+	{ 0xbf, 0x5a }  // 4
+};
+
 // `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ `29be:1064`:
 //   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
 //   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
@@ -170,10 +183,12 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 	// Top centred title. `_CaseSelection @ 1c33:0aa3` formats "Book %d"
 	// for tiers 1/2 and "Challenge Book" (sprintf with no arg) for
 	// tier 3. `_Show_String(0xc, (0xba - width)/2 + 0x3c, …, 0x10)`
-	// places it horizontally centred over the panel.
+	// places it horizontally centred over the panel. Spanish floppy
+	// uses "Lib. %u" / "Libro de Retos" (verified in Spanish EEM.EXE).
+	const bool spanish = v.vm && v.vm->isSpanish();
 	const Common::String title = (v.book == 3)
-		? Common::String("Challenge Book")
-		: Common::String::format("Book %u", v.book);
+		? Common::String(spanish ? "Libro de Retos" : "Challenge Book")
+		: Common::String::format(spanish ? "Lib. %u" : "Book %u", v.book);
 	const int titleW = v.vm->getFont().getStringWidth(title);
 	const int titleX = (0xba - titleW) / 2 + 0x3c;
 	v.vm->getFont().drawString(&scratch, title, titleX, 12, 320, 0xF);
@@ -274,7 +289,8 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 		// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
 		// original. We don't track challenge tier yet so always
 		// show "Book 1".
-		const Common::String book = "Book 1";
+		const Common::String book = v.vm->isSpanish()
+			? Common::String("Lib. 1") : Common::String("Book 1");
 		const int titleW = v.vm->getFont().getStringWidth(book);
 		const int titleX = (0xba - titleW) / 2 + 0x3c;
 		v.vm->getFont().drawString(&scratch, book, titleX, 12, 320, 0xF);
@@ -468,6 +484,12 @@ void EEMEngine::doNewPlayer() {
 	Picture bg;
 	const bool haveBG = _picsArchive.getPicture(0x104, bg);
 
+	// Localized name-entry prompt. Spanish text is taken from the
+	// Spanish floppy EEM.EXE ("Teclea tu nombre"). The colon suffix is
+	// our own — the original DOS prompt has none.
+	const char *prompt = isSpanish()
+		? "Teclea tu nombre:" : "Please type your name:";
+
 	// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
 	// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
 	// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
@@ -476,7 +498,7 @@ void EEMEngine::doNewPlayer() {
 	scratch.clear();
 	if (haveBG)
 		scratch.simpleBlitFrom(bg.surface);
-	_font.drawString(&scratch, "Please type your name:", 80, 40, 240, 0xF);
+	_font.drawString(&scratch, prompt, 80, 40, 240, 0xF);
 	_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, 320, 200);
@@ -537,8 +559,7 @@ void EEMEngine::doNewPlayer() {
 			scratch.clear();
 			if (haveBG)
 				scratch.simpleBlitFrom(bg.surface);
-			_font.drawString(&scratch, "Please type your name:",
-							 80, 40, 240, 0xF);
+			_font.drawString(&scratch, prompt, 80, 40, 240, 0xF);
 			_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, 320, 200);
@@ -916,8 +937,12 @@ void EEMEngine::doSetup() {
 		}
 		const Common::Rect kBox(80, 80, 240, 120);
 		scratch.fillRect(kBox, 0x00);
-		_font.drawString(&scratch, "Are you sure?", 100, 88, 200, 0xF);
-		_font.drawString(&scratch, "Y = yes   N = no", 100, 102, 200, 0xF);
+		_font.drawString(&scratch,
+			isSpanish() ? "Estas seguro?" : "Are you sure?",
+			100, 88, 200, 0xF);
+		_font.drawString(&scratch,
+			isSpanish() ? "S = si   N = no" : "Y = yes   N = no",
+			100, 102, 200, 0xF);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -929,7 +954,9 @@ void EEMEngine::doSetup() {
 					return true;
 				if (ev.type == Common::EVENT_KEYDOWN) {
 					const Common::KeyCode k = ev.kbd.keycode;
+					// Spanish prompt is "S = si" — accept both Y and S.
 					if (k == Common::KEYCODE_y ||
+						k == Common::KEYCODE_s ||
 						k == Common::KEYCODE_RETURN)
 						return true;
 					if (k == Common::KEYCODE_n ||
@@ -1240,13 +1267,24 @@ void EEMEngine::doCaseSelection() {
 		kPickScrap3,
 		kNumPicks
 	};
-	const char *kPickLabel[kNumPicks] = {
+	// Localized action-menu labels. Spanish text is taken verbatim
+	// from the Spanish floppy EEM.EXE (`eem-full-game/floppy-es/`):
+	// "Elegir Misterio" / "Caso de Practica" / "Ver Recortes  1..3".
+	const char *kPickLabelEN[kNumPicks] = {
 		"         Choose A Mystery",
 		"         Practice Mystery",
 		"         See ScrapBook 1",
 		"         See ScrapBook 2",
 		"         See ScrapBook 3"
 	};
+	const char *kPickLabelES[kNumPicks] = {
+		"         Elegir Misterio ",
+		"         Caso de Practica",
+		"         Ver Recortes  1",
+		"         Ver Recortes  2",
+		"         Ver Recortes  3"
+	};
+	const char * const *kPickLabel = isSpanish() ? kPickLabelES : kPickLabelEN;
 	// Menu entry gating per `_ActionScreen @ 1c33:195b` — the asm at
 	// 1c33:19d1-1a70 sets greys[] based on chain stage AND per-tier
 	// solve count:
@@ -2002,7 +2040,8 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		const uint clueId = found[i];
 		Common::String txt = noteText(clueId);
 		if (txt.empty())
-			txt = Common::String::format("clue %u", clueId);
+			txt = Common::String::format(
+				isSpanish() ? "nota %u" : "clue %u", clueId);
 		// Per `_DrawNotes @ 161e:01d0`: text uses
 		// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
 		// (light yellow-white) for selected. Both contrast cleanly
@@ -2173,17 +2212,34 @@ void EEMEngine::doGallery() {
 						//   _AddPicBackground(pic, 0x94, 0xf);
 						//   _DrawGalleryNotes(gd + i*0x46);
 						//   loop until ESC or button click.
-						// Suspect data layout (verified against M1):
-						//   +0..1: picId (used here AND for gallery slot)
-						//   +8..9: number of clues for this suspect
-						//   +0xa..??: array of u16 clue IDs (terminated
-						//             by 0xFFFF if shorter than count).
+						// Suspect data layout differs by variant:
+						//   * CD (`158f:0419`): fixed 0x46-byte stride.
+						//     +0..1   picId
+						//     +8..9   clue count (u16)
+						//     +0xa..  array of u16 clue IDs (max 30,
+						//             terminated by 0xFFFF if short).
+						//   * Floppy (`_MoreInfo_Floppy = 154e:042b` →
+						//     `FUN_154e_0201` → `FUN_15e0_01e8`):
+						//     variable-stride.
+						//     +0..1   picId
+						//     +2..3   alibi (0xFFFF = guilty)
+						//     +4      clue count (u8)
+						//     +5..    u8 clue IDs (per the asm at
+						//             154e:020e..0282 which calls
+						//             `FUN_15e0_01e8(rect, entry+5,
+						//                            entry[4], NULL)`).
+						const bool floppyMI = isFloppy();
 						const uint suspectIdx = (uint)slotSuspect[i];
-						const byte *suspect = gd + suspectIdx * 0x46;
+						const byte *suspect = floppyMI
+							? _mystery.floppySuspectEntry(suspectIdx)
+							: gd + suspectIdx * 0x46;
+						if (!suspect)
+							break;
 						const uint16 detailPic =
 							READ_LE_UINT16(suspect + 0);
-						const uint16 clueCount =
-							READ_LE_UINT16(suspect + 8);
+						const uint clueCount = floppyMI
+							? (uint)suspect[4]
+							: READ_LE_UINT16(suspect + 8);
 
 						Graphics::ManagedSurface ms(320, 200,
 							Graphics::PixelFormat::createFormatCLUT8());
@@ -2253,18 +2309,33 @@ void EEMEngine::doGallery() {
 						int yPos = ry;
 						const int lineH = _font.getFontHeight() + 1;
 						bool drewAny = false;
-						for (uint k = 0; k < clueCount && k < 30; k++) {
-							const uint16 clueId =
-								READ_LE_UINT16(suspect + 0xa + k * 2);
-							if (clueId == 0xFFFF)
+						const uint clueMax = floppyMI ? clueCount : 30u;
+						for (uint k = 0; k < clueCount && k < clueMax; k++) {
+							const uint16 clueId = floppyMI
+								? (uint16)suspect[5 + k]
+								: READ_LE_UINT16(suspect + 0xa + k * 2);
+							if (!floppyMI && clueId == 0xFFFF)
 								break;
 							if (clueId >= Mystery::kCluesFoundCap ||
 								!_mystery._cluesFound[clueId])
 								continue;
 							if (!ni || clueId >= niCount)
 								continue;
-							const uint16 textOff =
-								READ_LE_UINT16(ni + clueId * 4);
+							// Floppy notes are 7-byte entries: u16 ?,
+							// u16 jakeOff, u16 jennyOff, u8 score. The
+							// partner-specific text offset is at +2
+							// (Jake) or +4 (Jenny) — verified at
+							// `FUN_22dc_05c8 @ 22dc:0843`. CD notes are
+							// 4 bytes: u16 textOff, u16 score.
+							uint16 textOff;
+							if (floppyMI) {
+								const uint partnerByte = (_partner == 0)
+									? 2 : 4;
+								textOff = READ_LE_UINT16(
+									ni + clueId * 7 + partnerByte);
+							} else {
+								textOff = READ_LE_UINT16(ni + clueId * 4);
+							}
 							Common::String txt =
 								parseString(_mystery.textAt(textOff),
 											_playerName, _partner);
@@ -2281,15 +2352,20 @@ void EEMEngine::doGallery() {
 						}
 						if (!drewAny && _font.isLoaded()) {
 							_font.drawString(&ms,
-								"No clues yet for this suspect.",
+								isSpanish()
+									? "Aun no hay pistas para este sospechoso."
+									: "No clues yet for this suspect.",
 								rx, ry, rw, 0x5C);
 						}
 						// Header / footer text.
 						if (_font.isLoaded()) {
-							_font.drawString(&ms, "SUSPECT FILE",
-											  rx, ry - 11, rw, 0x3C);
-							_font.drawString(&ms, "(click / ESC: back)",
-											  rx, ry + rh + 2, rw, 0x3C);
+							_font.drawString(&ms,
+								isSpanish() ? "EXPEDIENTE" : "SUSPECT FILE",
+								rx, ry - 11, rw, 0x3C);
+							_font.drawString(&ms,
+								isSpanish() ? "(clic / ESC: volver)"
+											: "(click / ESC: back)",
+								rx, ry + rh + 2, rw, 0x3C);
 						}
 						g_system->copyRectToScreen(ms.getPixels(),
 							ms.pitch, 0, 0, 320, 200);
@@ -2407,8 +2483,17 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 							  partnerAni[frameIdx], 5, 0x50);
 	}
 
-	// Portraits — `_DrawGallery @ 158f:0046` walks suspects 0..N-1 and
-	// only renders those flagged in `_InGallery[NewOrder[i]]`.
+	// Portraits — `_DrawGallery @ 158f:0046` (CD) /
+	// `_DrawGallery_Floppy @ 154e:0045` (floppy) walks suspects 0..N-1
+	// and only renders those flagged in `_InGallery[NewOrder[i]]`.
+	// Layout differs by variant:
+	//   * CD: fixed 0x46-byte stride, slot positions at `kGallerySlots`.
+	//   * Floppy: variable-stride entries (5 + entry[4] bytes per
+	//     suspect), slot positions at `kFloppyGallerySlots` (verified
+	//     at `2608:0x16c`).
+	const bool floppy = isFloppy();
+	const GallerySlot * const slots =
+		floppy ? kFloppyGallerySlots : kGallerySlots;
 	for (uint i = 0; i < numSuspects && i < Mystery::kGalleryCap; i++) {
 		slotRects[i] = Common::Rect();
 		slotSuspect[i] = -1;
@@ -2416,11 +2501,16 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 		const uint8 phys = _mystery._newOrder[i];
 		if (phys >= 5)
 			continue;
-		const GallerySlot &s = kGallerySlots[phys];
+		const GallerySlot &s = slots[phys];
 
 		const bool discovered = _mystery._inGallery[phys] != 0;
 		if (discovered) {
-			const uint16 picId = READ_LE_UINT16(gd + i * 0x46);
+			const byte *entry = floppy
+				? _mystery.floppySuspectEntry(i)
+				: gd + i * 0x46;
+			if (!entry)
+				continue;
+			const uint16 picId = READ_LE_UINT16(entry);
 			Picture portrait;
 			if (picId == 0 ||
 				!_picsArchive.getPicture(picId, portrait))
@@ -3176,7 +3266,8 @@ bool EEMEngine::doAccuseNotes() {
 								   _playerName, _partner);
 			}
 			if (txt.empty())
-				txt = Common::String::format("clue %u", clueId);
+				txt = Common::String::format(
+					isSpanish() ? "nota %u" : "clue %u", clueId);
 			Common::Array<Common::String> wrapped;
 			_font.wordWrapText(txt, rectW, wrapped);
 			const int h = (int)wrapped.size() * lineH;
@@ -3195,8 +3286,12 @@ bool EEMEngine::doAccuseNotes() {
 		const uint remaining = (selectedCount < expected)
 			? expected - selectedCount
 			: 0;
-		const Common::String counter = Common::String::format("%u %s",
-			remaining, remaining == 1 ? "clue" : "clues");
+		// Spanish floppy uses "nota/notas" (verified in Spanish EEM.EXE).
+		const char *clueWord = isSpanish()
+			? (remaining == 1 ? "nota" : "notas")
+			: (remaining == 1 ? "clue" : "clues");
+		const Common::String counter =
+			Common::String::format("%u %s", remaining, clueWord);
 		_font.drawString(&scratch, counter, 209, 11, 100, 0x0F);
 
 		if (numPages > 1) {
@@ -3317,6 +3412,22 @@ void EEMEngine::doAccuse() {
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
+	// Floppy accusation flow is structurally different from the CD's:
+	// the suspect gallery section uses variable-stride entries (5 +
+	// nameLen bytes, NOT 0x46), the score is computed from the top-5
+	// note scores in `_TextSeen` (not `_NoteSelected`), and the win
+	// path renders the solved-chain dialog records at header[+0x12]
+	// rather than a single ClueBlock. Dispatch to a dedicated handler
+	// that mirrors `_KDHelp_Floppy` + `_HandleNoteButton_Floppy` (button
+	// 4 → `FUN_1d40_11fd` "ready to solve" gate) +
+	// `FUN_1d40_0c79` (gallery picker) + `_DisplayCorrect_Floppy` /
+	// `_DisplayAlibi_Floppy`.
+	if (isFloppy()) {
+		doAccuseFloppy();
+		return;
+	}
+
+
 	// Mirrors `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
 	//   1. ACCUSE-NOTES SCREEN (PIC 0x1A7, the red "accuse-mode" BG):
 	//      `_DrawNotes(_AccuseNoteRect, NULL, 100, _NoteSelected)`
@@ -3370,13 +3481,22 @@ void EEMEngine::doAccuse() {
 							   _playerName, _partner);
 		if (hint.empty()) {
 			// Fallback if `KDTextIndex[+6]` isn't set in this mystery.
-			hint = (_mystery.selectedPoints() == 0)
-				? "We're not ready to solve this mystery yet. "
-				  "Let's keep investigating until we have some "
-				  "more solid evidence."
-				: "We don't have quite enough evidence yet. "
-				  "Let's review our notes and find a few more "
-				  "clues before we accuse anyone.";
+			// Spanish text from the Spanish floppy EEM.EXE — there's
+			// only the single "1Necesitamos buscar pistas..." string
+			// in the binary, so use it for both the zero-points and
+			// some-points cases.
+			if (isSpanish()) {
+				hint = "Necesitamos buscar pistas antes de resolver "
+					   "el misterio. Investiguemos un poco mas!";
+			} else {
+				hint = (_mystery.selectedPoints() == 0)
+					? "We're not ready to solve this mystery yet. "
+					  "Let's keep investigating until we have some "
+					  "more solid evidence."
+					: "We don't have quite enough evidence yet. "
+					  "Let's review our notes and find a few more "
+					  "clues before we accuse anyone.";
+			}
 		}
 
 		// Compose balloon overlay on the current screen. Mirrors the
@@ -4094,6 +4214,417 @@ void EEMEngine::doAccuse() {
 	}
 }
 
+void EEMEngine::doAccuseFloppy() {
+	// Floppy accuse flow — mirrors:
+	//   `_KDHelp_Floppy / FUN_1d40_11fd @ 1d40:11fd` (score gate +
+	//        partner "ready / not ready" balloon)
+	//   `FUN_1d40_0e07 @ 1d40:0e07`   (clue-selection screen — skipped
+	//        in this MVP; selectedPoints() already takes the top-5)
+	//   `FUN_1d40_0c79 @ 1d40:0c79`   (gallery picker)
+	//   `_DisplayCorrect_Floppy @ 1d40:0894`
+	//   `_DisplayAlibi_Floppy   @ 1d40:00df`
+	const byte *kdIdx     = _mystery.kdTextIndex();
+	const byte *bufBase   = _mystery.blobAt(0);
+	const uint32 mysSize  = _mystery.dataSize();
+	if (!kdIdx || !bufBase)
+		return;
+
+	// Helper: render one KDTextIndex string as a centred KD balloon
+	// over the current screen, mirroring `_DisplayHint_Floppy @
+	// 1503:00ca` (= `FUN_1d40_11fd`'s body). `kdSlot` is the index
+	// into KDTextIndex (0..N) — entries are u16 absolute text
+	// offsets.
+	auto showFloppyKDHint = [&](uint kdSlot) {
+		if ((uint)(kdSlot * 2) + 2 > (uint)(mysSize - (kdIdx - bufBase)))
+			return;
+		const uint16 textOff = READ_LE_UINT16(kdIdx + kdSlot * 2);
+		if (textOff == 0 || textOff >= mysSize)
+			return;
+		const char *p = (const char *)(bufBase + textOff);
+		uint32 lineLen = 0;
+		while (textOff + lineLen < mysSize && p[lineLen] != 0)
+			lineLen++;
+		if (lineLen == 0)
+			return;
+		Common::String raw(p, lineLen);
+		// Digit prefix → balloon variant (per `_GetKDTextBalloon_Floppy
+		// @ 1d40:009f` and the `_KDBalloonByChar_Floppy` table at
+		// `2608:0c14 + char`).
+		static const uint8 kDigitToBalloon[10] = {
+			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
+		};
+		uint balloonIdx = 0x17;
+		const char *txt = raw.c_str();
+		if (*txt >= '0' && *txt <= '9') {
+			balloonIdx = kDigitToBalloon[(int)(*txt - '0')];
+			txt++;
+		}
+		Common::String text =
+			parseString(Common::String(txt), _playerName, _partner);
+		Graphics::ManagedSurface ms(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			ms.simpleBlitFrom(*cur);
+			g_system->unlockScreen();
+		}
+		Picture balloon;
+		const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
+			_balloonArchive.loadEntry(balloonIdx, balloon);
+		uint16 balloonY = 1;
+		if (haveBalloon) {
+			const uint h = (uint)balloon.surface.h;
+			if (h < 0x4e)
+				balloonY = (uint16)((0x50 - h) >> 1);
+			const byte transp = (byte)(balloon.flags >> 8);
+			for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
+				 row++) {
+				const byte *src =
+					(const byte *)balloon.surface.getBasePtr(0, row);
+				byte *dst = (byte *)ms.getBasePtr(0x21, balloonY + row);
+				for (int col = 0; col < balloon.surface.w && 0x21 + col < 320;
+					 col++) {
+					if (src[col] != transp)
+						dst[col] = src[col];
+				}
+			}
+		}
+		uint16 bx = 5;
+		uint16 by = 4;
+		uint16 bw = 142;
+		getBalloonInsets(balloonIdx, bx, by, bw);
+		_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
+							  MAX<int>(8, (int)bw), text, 0);
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
+		g_system->updateScreen();
+		// Wait for click.
+		while (!shouldQuit()) {
+			Common::Event ev;
+			bool advance = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN) {
+					advance = true;
+					break;
+				}
+			}
+			if (advance)
+				break;
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+	};
+
+	// `FUN_1d40_11fd` score gate. Picks one of three KD strings and
+	// returns 1 only when score >= 100.
+	const int score = _mystery.selectedPoints();
+	uint kdSlot;
+	bool readyToSolve;
+	if (score < 50) {
+		kdSlot = 0;        // KDTextIndex[0] — "we've barely started"
+		readyToSolve = false;
+	} else if (score < 100) {
+		kdSlot = 1;        // KDTextIndex[1] — "getting closer"
+		readyToSolve = false;
+	} else {
+		kdSlot = 2;        // KDTextIndex[2] — "ready to solve"
+		readyToSolve = true;
+	}
+	showFloppyKDHint(kdSlot);
+	if (!readyToSolve) {
+		_nextScreen = _lastScreen != kScreenInvalid
+			? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
+	// `FUN_1d40_0c79` — gallery picker. Show "Which suspect?" KD
+	// hint at slot 4 (KDTextIndex[+8]/2 = entry index 4), then
+	// render the gallery and wait for click.
+	showFloppyKDHint(4);
+
+	// Build slot list. Floppy gallery section: byte0 = numSuspects,
+	// then variable-stride entries.
+	const uint8 num = _mystery.numSuspects();
+	if (num == 0) {
+		_nextScreen = _lastScreen != kScreenInvalid
+			? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
+	// Verbatim from `_DrawGallery_Floppy @ 154e:0050` — slot positions
+	// at `2608:016c` (5 × {u16 x, u16 y}).
+	struct GallerySlot { int x, y; };
+	static const GallerySlot kFloppySlots[5] = {
+		{ 0x53, 0x0e }, { 0x9b, 0x0e }, { 0xe3, 0x0e },
+		{ 0x77, 0x5a }, { 0xbf, 0x5a },
+	};
+
+	Picture accuseBg;
+	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
+
+	auto drawGallery = [&](int highlighted,
+						   Common::Array<Common::Rect> &rects,
+						   Common::Array<int> &suspects) {
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveAccuseBg)
+			scratch.simpleBlitFrom(accuseBg.surface);
+
+		// Partner sprite (anim 2 / 0x10) — same as CD doAccuse.
+		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+		Animation partnerAni;
+		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+			!partnerAni.empty()) {
+			const uint32 now = g_system->getMillis();
+			const uint frameIdx = partnerFrameAtTick(0x02,
+													  (uint)partnerAni.size(), now);
+			blitAnimFrameAnchored(scratch.surfacePtr(),
+								  partnerAni[frameIdx], 5, 0x50);
+		}
+
+		rects.resize(num);
+		suspects.resize(num);
+		for (uint i = 0; i < num; i++) {
+			rects[i] = Common::Rect();
+			suspects[i] = -1;
+			const uint8 phys = _mystery._newOrder[i];
+			if (phys >= 5)
+				continue;
+			if (_mystery._inGallery[phys] == 0)
+				continue;
+			const byte *e = _mystery.floppySuspectEntry(i);
+			if (!e)
+				continue;
+			const uint16 picId = READ_LE_UINT16(e + 0);
+			if (picId == 0)
+				continue;
+			Picture portrait;
+			if (!_picsArchive.getPicture(picId, portrait))
+				continue;
+			const GallerySlot &s = kFloppySlots[phys];
+			// `_DrawGallery_Floppy @ 154e:00ed` bottom-aligns to baseline
+			// 0x48 (= 72), same as CD: `placeY = s.y + (0x48 - h)`.
+			const int placeX = s.x;
+			const int placeY = s.y + (0x48 - portrait.surface.h);
+			const byte transp = (byte)(portrait.flags >> 8);
+			scratch.transBlitFrom(portrait.surface,
+								  Common::Point(placeX, placeY),
+								  (uint32)transp);
+			rects[i] = Common::Rect(placeX, placeY,
+									 placeX + portrait.surface.w,
+									 placeY + portrait.surface.h);
+			suspects[i] = (int)i;
+			if (highlighted == (int)i) {
+				scratch.frameRect(rects[i], 0xFE);
+			}
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+	};
+
+	Common::Array<Common::Rect> slotRects;
+	Common::Array<int> slotSuspect;
+	int highlighted = 0;
+	int picked = -1;
+	drawGallery(highlighted, slotRects, slotSuspect);
+
+	uint32 lastTick = g_system->getMillis();
+	while (picked < 0 && !shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return;
+				if (ev.kbd.keycode == Common::KEYCODE_TAB ||
+					ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+					highlighted = (highlighted + 1) % MAX<int>(1, (int)num);
+					drawGallery(highlighted, slotRects, slotSuspect);
+				} else if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+					highlighted = (highlighted + (int)num - 1) %
+								  MAX<int>(1, (int)num);
+					drawGallery(highlighted, slotRects, slotSuspect);
+				} else if ((ev.kbd.keycode == Common::KEYCODE_RETURN ||
+							ev.kbd.keycode == Common::KEYCODE_KP_ENTER) &&
+						   highlighted < (int)slotRects.size() &&
+						   !slotRects[highlighted].isEmpty()) {
+					picked = slotSuspect[highlighted];
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				for (uint i = 0; i < slotRects.size(); i++) {
+					if (slotSuspect[i] >= 0 &&
+						slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
+						picked = slotSuspect[i];
+						break;
+					}
+				}
+			}
+		}
+		const uint32 now = g_system->getMillis();
+		if (now - lastTick >= 100) {
+			drawGallery(highlighted, slotRects, slotSuspect);
+			lastTick = now;
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+	if (picked < 0)
+		return;
+
+	const bool guilty = _mystery.isGuilty((uint)picked);
+
+	if (guilty) {
+		// `_DisplayCorrect_Floppy @ 1d40:0894` — load PIC 5 (win BG),
+		// walk the solved-clue chain at header[+0x12]: byte0 = count,
+		// then `count` dialog records (same FUN_22dc_05c8 layout). We
+		// reuse `displayFloppyDialogRecords` for the rendering.
+		Picture winBg;
+		if (_picsArchive.getPicture(5, winBg))
+			blitAt(winBg, 0, 0);
+		setSitePalette(0x22);
+		g_system->updateScreen();
+
+		const byte *chain = _mystery.solvedClueBlock();
+		if (chain) {
+			const uint count = chain[0];
+			displayFloppyDialogRecords(chain + 1, count, 0);
+		}
+
+		// Mark mystery solved — equivalent to
+		// `((u16 *)0x5d20)... actually 0x3054[mysteryNum] = 1` which we
+		// translate to clearing the in-progress mystery + saving the
+		// profile (matches CD `doAccuse` win path).
+		if (_partner == 0)
+			_mystery._firstTry = false;
+		_mystery._solvedPuzzle = true;
+		_mystery.clear();
+		(void)saveProfile(_playerName);
+		_nextScreen = kScreenAction;
+		return;
+	}
+
+	// Innocent — `_DisplayAlibi_Floppy @ 1d40:00df`:
+	//   1. Load PIC 0x3e (alibi BG), draw at (0x42, 0x14).
+	//   2. Load suspect picID, draw portrait at (0x82, 0x5a).
+	//   3. Read alibi text via TEXT_BLOCK[suspect[+3] * 2]; render in
+	//      a balloon picked by digit-prefix dispatch.
+	//   4. Voice from per-partner table at suspect_entry[+5..]
+	//      (we approximate using slot 13/14 — Jake/Jenny "wrong"
+	//      sting; original picks per-suspect).
+	//   5. Wait for click, then KD reaction balloon
+	//      (KDTextIndex[+0x10]/2 = slot 8) + voice slot 5.
+	const byte *susp = _mystery.floppySuspectEntry((uint)picked);
+	uint16 picId = 0;
+	uint16 alibiOff = 0xFFFF;
+	if (susp) {
+		picId    = READ_LE_UINT16(susp + 0);
+		alibiOff = _mystery.alibiTextOffset((uint)picked);
+	}
+
+	Picture alibiBg;
+	const bool haveAlibiBg = _picsArchive.getPicture(0x3e, alibiBg);
+	Picture suspectPic;
+	const bool haveSuspect = picId != 0 &&
+		_picsArchive.getPicture(picId, suspectPic);
+
+	Common::String alibi;
+	if (alibiOff != 0xFFFF && alibiOff < mysSize) {
+		const char *raw = (const char *)(bufBase + alibiOff);
+		uint32 lineLen = 0;
+		while (alibiOff + lineLen < mysSize && raw[lineLen] != 0)
+			lineLen++;
+		alibi = parseString(Common::String(raw, lineLen),
+							_playerName, _partner);
+	}
+
+	// Digit-prefix → alibi balloon idx. CD has its own table at
+	// 29be:1050; floppy uses the same KD digit→balloon mapping for
+	// the alibi too (verified by inspecting `_DisplayAlibi_Floppy`'s
+	// `_GetKDTextBalloon_Floppy` call).
+	static const uint8 kDigitToBalloon[10] = {
+		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
+	};
+	uint balloonIdx = 0x17;
+	if (!alibi.empty() && alibi[0] >= '0' && alibi[0] <= '9') {
+		balloonIdx = kDigitToBalloon[(int)(alibi[0] - '0')];
+		alibi.deleteChar(0);
+	}
+
+	// Compose alibi screen.
+	Graphics::ManagedSurface scene(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scene.clear();
+	if (haveAlibiBg)
+		scene.simpleBlitFrom(alibiBg.surface);
+	if (haveSuspect) {
+		const byte transp = (byte)(suspectPic.flags >> 8);
+		scene.transBlitFrom(suspectPic.surface,
+							 Common::Point(0x82, 0x5a),
+							 (uint32)transp);
+	}
+	Picture balloon;
+	const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
+		_balloonArchive.loadEntry(balloonIdx, balloon);
+	int balloonX = 0x21;
+	int balloonY = 1;
+	if (haveBalloon) {
+		if (balloon.surface.h < 0x4e)
+			balloonY = (0x50 - balloon.surface.h) / 2;
+		const byte transp = (byte)(balloon.flags >> 8);
+		scene.transBlitFrom(balloon.surface,
+							 Common::Point(balloonX, balloonY),
+							 (uint32)transp);
+	}
+	uint16 tx = 5, ty = 4, tw = 155;
+	getBalloonInsets(balloonIdx, tx, ty, tw);
+	if (!alibi.empty()) {
+		_font.drawWordWrapped(&scene, balloonX + tx, balloonY + ty,
+							  MAX<int>(8, (int)tw), alibi, 0);
+	}
+	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+
+	// Voice — suspect alibi voice. The original picks per-suspect from
+	// the alibi voice table at `2608:0c5e + suspectIdx`; we don't have
+	// the table indexed, so play slot 13 (= F-0161SL.VOC / M-0113SL.VOC)
+	// as a generic alibi cue.
+	if (_audio)
+		_audio->playFloppyVoiceSlot(13, _partner);
+
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool advance = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				ev.type == Common::EVENT_LBUTTONDOWN ||
+				ev.type == Common::EVENT_KEYDOWN) {
+				advance = true;
+				break;
+			}
+		}
+		if (advance)
+			break;
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+
+	// Partner reaction — KDTextIndex slot 8 (= +0x10).
+	showFloppyKDHint(8);
+
+	_mystery._firstTry = false;
+	_nextScreen = _lastScreen != kScreenInvalid
+		? (ScreenId)_lastScreen : kScreenSite;
+}
+
 void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 								   int highlighted,
 								   Common::Array<Common::Rect> &slotRects,


Commit: 9c41e30159c5cdce8fdf1120bf5df3deeea5f953
    https://github.com/scummvm/scummvm/commit/9c41e30159c5cdce8fdf1120bf5df3deeea5f953
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:53+02:00

Commit Message:
EEM: make sure all the clues are there

Changed paths:
    engines/eem/clues.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 677566620eb..1420e324a7f 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -956,6 +956,29 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	const uint32 dsz       = _mystery.dataSize();
 	const uint32 notesBase = (uint32)(notes - bufBase);
 
+	// Pre-mark every text index across all records as "seen" up front.
+	// `_DisplayHotspotClue_Floppy @ 22dc:05c8` writes
+	// `_TextSeen_Floppy[idx] = 1` inside its render loop, but the
+	// original's `_WaitForClick` blocks until input — so the loop always
+	// runs to completion and every text gets marked. Our `waitForClick`
+	// honours ESC as "skip all" (sets `skipAll`/break), which matches
+	// player expectations for fast-forward but used to drop the seen-bit
+	// for every text after the ESC point. Pre-marking restores parity:
+	// ESC fast-forwards the *visual* but the notebook always reflects
+	// every clue the player would have seen if they'd clicked through.
+	{
+		const byte *r = rec;
+		for (uint i = 0; i < count; i++) {
+			const uint8 tc = r[10];
+			for (uint t = 0; t < tc; t++) {
+				const uint8 idx = r[11 + t] & 0x7f;
+				if (idx < Mystery::kCluesFoundCap)
+					_mystery._cluesFound[idx] = 1;
+			}
+			r += 11 + tc;
+		}
+	}
+
 	auto waitForClick = [&]() -> bool {
 		// Drain pending events first so a previous keystroke's tail
 		// doesn't auto-advance the new page.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 1add3abbd4c..9b07bfb5d49 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1391,9 +1391,31 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 		if (hotIdx < Mystery::kHotSpotsCap)
 			_mystery->_hotSpotsSeen[hotIdx] = 1;
 		_mystery->_searchLocationNumber = (uint16)hotIdx;
+		// Snapshot `_cluesFound` BEFORE the floppy dialog so we can
+		// auto-save only when a new note (= clue text idx) is added.
+		// The floppy clue-side-effect path lives in
+		// `displayFloppyDialogRecords` (see clues.cpp), not in
+		// `displayClue` / `applyClueSideEffects`, so the CD-side
+		// autosave below would never trigger for floppy.
+		byte before[Mystery::kCluesFoundCap];
+		memcpy(before, _mystery->_cluesFound, sizeof(before));
 		_vm->setPartnerEraseBg(&_bgSnapshot);
 		_vm->displayFloppyHotspotDialog(siteNum, hotIdx);
 		_vm->setPartnerEraseBg(nullptr);
+		bool foundNewClue = false;
+		for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
+			if (!before[i] && _mystery->_cluesFound[i]) {
+				foundNewClue = true;
+				break;
+			}
+		}
+		if (foundNewClue) {
+			const Common::Error err =
+				_vm->saveProfile(_vm->playerName());
+			if (err.getCode() != Common::kNoError)
+				warning("auto-save after floppy clue failed: %s",
+						err.getDesc().c_str());
+		}
 		return;
 	}
 


Commit: c5336a89fde423fede72417d7583c5b3aa3b124f
    https://github.com/scummvm/scummvm/commit/c5336a89fde423fede72417d7583c5b3aa3b124f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:54+02:00

Commit Message:
EEM: setup screen for floppy screen

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fc5b53f4b0a..289a90a3e06 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -820,15 +820,26 @@ void EEMEngine::doShowScrapbook(uint stage) {
 }
 
 void EEMEngine::doSetup() {
-	// Mirrors `_DoSetup @ 1f78:044e`. The setup screen is BG `PIC 0x40`
-	// (loaded once on entry) with every label baked in — "Setup",
-	// "Partner", "Sound", "Music", the "Jake"/"Jenny"/"On"/"Off"
-	// option strings, etc. — all rendered in palette key `0xFE`. The
-	// original then runs `_SetupSettings @ 1f78:000d` which uses
-	// `_SwapColors @ 172b:1d2a` to recolour those `0xFE` pixels per
-	// label rect: `0x15` for the active state, `0` for the inactive
-	// one. So nothing is drawn as text; the visible state of each
-	// toggle is purely a per-rect colour swap on top of `PIC 0x40`.
+	// Mirrors `_DoSetup @ 1f78:044e` (CD) and `_DoSetup_Floppy @
+	// 1ee2:0387` (floppy). Both variants share the same PIC 0x40 BG
+	// with text labels baked in, the same 13 button rectangles at
+	// `_SetupButtons @ 29be:1218` (CD) / `2608:0d8c` (floppy), and the
+	// same 4 highlight rectangles at `0xe94..0xeb3` (Kid1 / Kid2 /
+	// SoundOn / SoundOff). Floppy additionally pre-loads a few overlay
+	// PICs (0x9b..0x9e + 0x1fa) that the CD uses on demand, but the
+	// behaviour is identical — colour-key swap on `0xFE` to indicate
+	// active state, click dispatch via the same 12-entry handler
+	// jumptable. So a single shared handler covers both variants.
+	//
+	// The setup screen is BG `PIC 0x40` (loaded once on entry) with
+	// every label baked in — "Setup", "Partner", "Sound", "Music",
+	// the "Jake"/"Jenny"/"On"/"Off" option strings, etc. — all
+	// rendered in palette key `0xFE`. The original then runs
+	// `_SetupSettings @ 1f78:000d` which uses `_SwapColors @
+	// 172b:1d2a` to recolour those `0xFE` pixels per label rect:
+	// `0x15` for the active state, `0` for the inactive one. So
+	// nothing is drawn as text; the visible state of each toggle is
+	// purely a per-rect colour swap on top of `PIC 0x40`.
 	//
 	// Click hit-tests go through `_SetupButtons @ 29be:1218` — 13×
 	// 8-byte rects. Each click runs `HandleSetupButton @ 1f78:0158`,
@@ -2618,9 +2629,18 @@ void EEMEngine::doBigMap() {
 	uint32 mapLastTick = mapStartTick;
 
 	// Static rectangles read directly from the binary at the labelled
-	// addresses (29be:0x1596 onwards). Format is {x1, y1, x2, y2}.
+	// addresses (CD `29be:0x1596` / floppy `2608:0x13fe..0x143e`).
+	// Format is {x1, y1, x2, y2}. The floppy click table at
+	// `2608:1436` (verified at `_BigMapInteractionLoop_Floppy @
+	// 1fed:0a3a`) puts the setup button at (251, 3, 315, 42) — 1 px
+	// up and 1 px left of the CD's (252, 4, 315, 42). The floppy
+	// PIC 0x42 BG paints the visible button border at the same
+	// pixels, so use the variant-specific rect to match the
+	// hit-test region the original uses for that variant.
 	const Common::Rect kBigMapWindow   (  0,   0, 247, 192); // 29be:1596
-	const Common::Rect kSetupBtnRect   (252,   4, 315,  42); // 29be:15ce
+	const Common::Rect kSetupBtnRect   = isFloppy()
+		? Common::Rect(251, 3, 315, 42)   // 2608:1436
+		: Common::Rect(252, 4, 315, 42);  // 29be:15ce
 
 	bool wantZoom = false;
 	int zoomX = 0;
@@ -2749,7 +2769,9 @@ void EEMEngine::doBigMap() {
 				const Common::Rect kArrowXRight(224, 175, 234, 185);
 				const Common::Rect kXSlider    ( 15, 175, 221, 185);
 				const Common::Rect kYSlider    (237,  14, 247, 160);
-				const Common::Rect kSetupBtn   (252,   4, 315,  42);
+				const Common::Rect kSetupBtn = isFloppy()
+					? Common::Rect(251, 3, 315, 42)   // 2608:1436
+					: Common::Rect(252, 4, 315, 42);  // 29be:15ce
 
 				const int kArrowStep = 16;
 				const int kSliderRange = mapW - kMapWinW;


Commit: 021009a600d68e647c7195cf6bc31a590e2e32a9
    https://github.com/scummvm/scummvm/commit/021009a600d68e647c7195cf6bc31a590e2e32a9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:54+02:00

Commit Message:
EEM: correctly validate clues in floppy version

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 289a90a3e06..479c062e623 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1983,11 +1983,24 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	int clueCursor = 0;
 	Common::Array<int> pageStarts;
 	pageStarts.push_back(0);
-	// Floppy NoteIndex entries are 7 bytes (`u16 ?; u16 jakeOff; u16
-	// jennyOff; u8 score`) with ABSOLUTE byte offsets into the mystery
-	// blob, while CD entries are 4 bytes with offsets relative to the
-	// TextBlock at header[+0xc]. Resolve the right text for the active
-	// partner / variant once per render.
+	// Floppy NoteIndex entries are 7 bytes:
+	//   +0..1  clue text offset (ABSOLUTE byte offset into the
+	//          mystery blob; partner-agnostic clue statement —
+	//          this is what the notebook displays).
+	//   +2..3  Jake's spoken line offset (used by
+	//          `_DisplayHotspotClue_Floppy` when Jake narrates a
+	//          dialog record — NOT for the notebook).
+	//   +4..5  Jenny's spoken line offset (same, for Jenny).
+	//   +6     score.
+	// Verified at `_DrawNotes_Floppy / FUN_15e0_01e8`'s
+	// `*(int *)(notes + idx * 7)` access (no `+2`/`+4` shift —
+	// always reads byte +0). The earlier port used the dialog
+	// spoken line for the notebook, which showed the partner's
+	// "Hi, Jake! Hi, Jenny!" intro instead of the actual clue
+	// statement ("We received a note that says..."). CD entries
+	// are 4 bytes with offsets relative to the TextBlock at
+	// header[+0xc]. Resolve the right text for the active variant
+	// once per render.
 	const bool floppyNb = isFloppy();
 	const byte *bufBase = _mystery.blobAt(0);
 	const uint32 mysSz  = _mystery.dataSize();
@@ -1995,10 +2008,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		if (!ni || clueId >= niCount)
 			return Common::String();
 		if (floppyNb && bufBase) {
-			const uint stride = 7;
-			const uint16 textOff = (_partner == 0)
-				? READ_LE_UINT16(ni + clueId * stride + 2)
-				: READ_LE_UINT16(ni + clueId * stride + 4);
+			const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
 			if (textOff == 0 || textOff >= mysSz)
 				return Common::String();
 			const char *p = (const char *)(bufBase + textOff);
@@ -2332,24 +2342,46 @@ void EEMEngine::doGallery() {
 								continue;
 							if (!ni || clueId >= niCount)
 								continue;
-							// Floppy notes are 7-byte entries: u16 ?,
-							// u16 jakeOff, u16 jennyOff, u8 score. The
-							// partner-specific text offset is at +2
-							// (Jake) or +4 (Jenny) — verified at
-							// `FUN_22dc_05c8 @ 22dc:0843`. CD notes are
-							// 4 bytes: u16 textOff, u16 score.
-							uint16 textOff;
+							// Floppy notes are 7-byte entries:
+							//   +0..1 clue text (absolute offset)
+							//   +2..3 Jake spoken line
+							//   +4..5 Jenny spoken line
+							//   +6    score
+							// `_DrawNotes_Floppy / FUN_15e0_01e8`'s
+							// `*(int *)(notes + idx * 7)` shows the
+							// notebook always uses +0 — the partner-
+							// agnostic clue statement. The +2/+4
+							// offsets are the partner spoken lines used
+							// by `FUN_22dc_05c8` when rendering dialog
+							// records (NOT this notebook view). CD
+							// notes are 4 bytes: u16 textOff, u16
+							// score.
+							Common::String txt;
 							if (floppyMI) {
-								const uint partnerByte = (_partner == 0)
-									? 2 : 4;
-								textOff = READ_LE_UINT16(
-									ni + clueId * 7 + partnerByte);
+								const uint16 textOff =
+									READ_LE_UINT16(ni + clueId * 7);
+								const byte *bb = _mystery.blobAt(0);
+								const uint32 dsz =
+									_mystery.dataSize();
+								if (bb && textOff != 0 &&
+									textOff < dsz) {
+									const char *p =
+										(const char *)(bb + textOff);
+									uint32 len = 0;
+									while (textOff + len < dsz &&
+										   p[len] != 0)
+										len++;
+									txt = parseString(
+										Common::String(p, len),
+										_playerName, _partner);
+								}
 							} else {
-								textOff = READ_LE_UINT16(ni + clueId * 4);
+								const uint16 textOff =
+									READ_LE_UINT16(ni + clueId * 4);
+								txt = parseString(
+									_mystery.textAt(textOff),
+									_playerName, _partner);
 							}
-							Common::String txt =
-								parseString(_mystery.textAt(textOff),
-											_playerName, _partner);
 							if (txt.empty())
 								continue;
 							const byte color =
@@ -3162,6 +3194,13 @@ bool EEMEngine::doAccuseNotes() {
 	Picture accuseBg;
 	const bool haveBg = _picsArchive.getPicture(0x1a7, accuseBg);
 
+	// Reset selection on entry. `FUN_1d40_0e07 @ 1d40:0e34` (floppy)
+	// explicitly zeroes the 0x7f-byte `_NoteSelected_Floppy` array
+	// before drawing, so the player always starts with a clean board.
+	// Without this, leftover selections from a failed attempt persist
+	// and the post-selection 100-point gate gets the wrong sum.
+	memset(_mystery._noteSelected, 0, sizeof(_mystery._noteSelected));
+
 	// Required count for solving — `6 - chainStage`.
 	const uint expected = (_chainStage >= 1 && _chainStage <= 3)
 		? (uint)(6 - _chainStage)
@@ -3206,6 +3245,37 @@ bool EEMEngine::doAccuseNotes() {
 	int numPages = 1;
 	pageBreaks[0] = 0;
 
+	// Variant-aware text resolver. CD note entries are 4 bytes
+	// (u16 textOff RELATIVE to TextBlock, u16 score), so the offset
+	// is added to `_textOffset` via `Mystery::textAt`. Floppy
+	// entries are 7 bytes:
+	//   +0..1  clue text offset (ABSOLUTE byte offset into the
+	//          mystery blob — partner-agnostic clue statement;
+	//          this is what the notebook displays).
+	//   +2..3  Jake spoken-line offset (`FUN_22dc_05c8 @ 22dc:0843`).
+	//   +4..5  Jenny spoken-line offset.
+	//   +6     score (`FUN_1d40_0c48`).
+	// `_DrawNotes_Floppy / FUN_15e0_01e8` reads `*(int *)(notes +
+	// idx * 7)` — always byte +0 — for the notebook text.
+	// Reading +2/+4 here showed the partner's "Hi, Jake! Hi,
+	// Jenny!" intro instead of the actual clue statement.
+	const bool floppyNote = isFloppy();
+	const byte *bufBaseNotes = _mystery.blobAt(0);
+	auto noteText = [&](uint clueId) -> Common::String {
+		if (floppyNote) {
+			const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
+			if (textOff == 0 || textOff >= _mystery.dataSize() ||
+				!bufBaseNotes)
+				return Common::String();
+			return parseString(
+				(const char *)(bufBaseNotes + textOff),
+				_playerName, _partner);
+		}
+		const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+		return parseString(_mystery.textAt(textOff),
+						   _playerName, _partner);
+	};
+
 	auto rebuildPagination = [&]() {
 		numPages = 1;
 		pageBreaks[0] = 0;
@@ -3214,11 +3284,8 @@ bool EEMEngine::doAccuseNotes() {
 		for (uint i = 0; i < found.size(); i++) {
 			const uint clueId = found[i];
 			Common::String txt;
-			if (clueId < niCount) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								   _playerName, _partner);
-			}
+			if (clueId < niCount)
+				txt = noteText(clueId);
 			Common::Array<Common::String> wrapped;
 			_font.wordWrapText(txt, rectW, wrapped);
 			const int h = (int)wrapped.size() * lineH;
@@ -3282,11 +3349,8 @@ bool EEMEngine::doAccuseNotes() {
 		for (int i = startIdx; i < endIdx; i++) {
 			const uint clueId = found[i];
 			Common::String txt;
-			if (clueId < niCount) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								   _playerName, _partner);
-			}
+			if (clueId < niCount)
+				txt = noteText(clueId);
 			if (txt.empty())
 				txt = Common::String::format(
 					isSpanish() ? "nota %u" : "clue %u", clueId);
@@ -3408,6 +3472,40 @@ bool EEMEngine::doAccuseNotes() {
 						const uint clueId = slotClues[i];
 						_mystery._noteSelected[clueId] ^= 1;
 						dirty = true;
+
+						// Debug: dump current user-selected score so
+						// the post-selection 100-point gate behaviour
+						// is visible while picking. Mirrors the
+						// floppy `FUN_1d40_0c48` (sum of `note[+6]`
+						// across `_NoteSelected != 0`) and the CD
+						// `selectedPoints()` (sum of `note[+2]` u16
+						// across `_NoteSelected != 0`).
+						{
+							int total = 0;
+							uint selectedCount = 0;
+							const uint maxIdx = MIN<uint>(niCount,
+								Mystery::kCluesFoundCap);
+							const bool floppy = isFloppy();
+							for (uint j = 0; j < maxIdx; j++) {
+								if (!_mystery._noteSelected[j])
+									continue;
+								selectedCount++;
+								if (floppy) {
+									total += (int)ni[j * 7 + 6];
+								} else {
+									const int16 pts =
+										(int16)READ_LE_UINT16(
+											ni + j * 4 + 2);
+									total += (int)pts;
+								}
+							}
+							warning("EEM accuse: clue %u %s "
+									"(selected=%u points=%d)",
+									clueId,
+									_mystery._noteSelected[clueId]
+										? "ON" : "OFF",
+									selectedCount, total);
+						}
 						break;
 					}
 				}
@@ -4361,6 +4459,53 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
+	// Clue selection screen — `FUN_1d40_0e07 @ 1d40:0e07`. The user
+	// must pick exactly `6 - chainStage` clues from the found list,
+	// rendered on the red accuse-mode BG (PIC 0x1A7). Verified by
+	// the asm at 1d40:0e34 reading `local_c = 6 - DAT_28da_3052`,
+	// matching the CD's `_DoAccuse @ 1df2:0bdd` expected count.
+	// `doAccuseNotes()` already handles this UI for both variants
+	// (note text reading is variant-aware via `noteTextOff`); it
+	// returns true on commit, false on ESC.
+	if (!doAccuseNotes()) {
+		_nextScreen = _lastScreen != kScreenInvalid
+			? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
+	// Second score gate — based on the CLUES THE USER ACTUALLY
+	// PICKED. `_DoAccuse_Floppy @ 1d40:0e07` (after the commit
+	// branch at 1d40:0f3a) reads `local_a = FUN_1d40_0c48()` (=
+	// sum of byte +6 across selected note entries) and:
+	//   if (local_a < 100) → KDTextIndex[+6] hint (slot 3),
+	//                        bail back to last screen.
+	//   else              → call FUN_1d40_0c79 (gallery picker).
+	// Note this is distinct from the entry gate on
+	// `_GetSelectedPoints_Floppy @ 1d40:0c23` (which uses the
+	// auto-top-5 of FOUND clues — what `Mystery::selectedPoints`
+	// returns on floppy). A player can be "ready to solve" by score
+	// yet still pick the wrong subset; that's the case this gate
+	// catches.
+	int userSelectedScore = 0;
+	{
+		const byte *ni2 = _mystery.noteIndex();
+		const uint16 niCount2 = _mystery.noteIndexCount();
+		if (ni2) {
+			const uint maxIdx = MIN<uint>(niCount2,
+										   Mystery::kCluesFoundCap);
+			for (uint i = 0; i < maxIdx; i++) {
+				if (_mystery._noteSelected[i])
+					userSelectedScore += (int)ni2[i * 7 + 6];
+			}
+		}
+	}
+	if (userSelectedScore < 100) {
+		showFloppyKDHint(3);
+		_nextScreen = _lastScreen != kScreenInvalid
+			? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
+
 	// `FUN_1d40_0c79` — gallery picker. Show "Which suspect?" KD
 	// hint at slot 4 (KDTextIndex[+8]/2 = entry index 4), then
 	// render the gallery and wait for click.
@@ -4504,28 +4649,146 @@ void EEMEngine::doAccuseFloppy() {
 	const bool guilty = _mystery.isGuilty((uint)picked);
 
 	if (guilty) {
-		// `_DisplayCorrect_Floppy @ 1d40:0894` — load PIC 5 (win BG),
-		// walk the solved-clue chain at header[+0x12]: byte0 = count,
-		// then `count` dialog records (same FUN_22dc_05c8 layout). We
-		// reuse `displayFloppyDialogRecords` for the rendering.
-		Picture winBg;
-		if (_picsArchive.getPicture(5, winBg))
-			blitAt(winBg, 0, 0);
-		setSitePalette(0x22);
-		g_system->updateScreen();
+		// Win path. Mirrors `_DisplayCorrect_Floppy @ 1d40:0894`:
+		//   1d40:08a0  _BuildBackground_Floppy(5, 0x42, 0x14);
+		//                 → PIC 0x3d frame + SITES entry 5 at (0x42,
+		//                   0x14), palette = sitenum + 1 = 6.
+		//   1d40:08b1  _FadeIn();
+		//   1d40:08c0  _MIDIPlayFile("travel-2.xmi");
+		//   1d40:08d0  walk solved chain via _DisplayHotspotClue_Floppy
+		//                 + _WaitForClick per record. Mid-recap: when
+		//                 only 3 records remain, play TITLE.ANM(0)
+		//                 (transition graphic; not yet ported).
+		//   1d40:0939  ((u16 *)0x3054)[mysteryNum] =
+		//                  _firstTry ? 2 : 1;
+		//   1d40:0941  tier-promotion check (advance _chainStage when
+		//                 every mystery in the current tier is solved).
+		//   1d40:0982  _SavePlayerRecord  (= saveProfile)
+		//   1d40:0985  _DeleteMysteryFile (= mystery cleanup)
+		//   1d40:09b0  _NextScreen = 0xc.
+		const uint mn = _mystery.number();
+
+		// Conclusion BG composition. `_BuildBackground_Floppy @
+		// 16e2:12fd` blits PIC 0x3d (the desk frame) onto a cleared
+		// page, then overlays SITES.DBD entry 5 (the conclusion
+		// artwork) at (param_2, param_3) = (0x42, 0x14). Palette
+		// becomes `sitenum + 1` = 6. Same composition the CD `doAccuse`
+		// win flow performs (see ui.cpp:4145-4166), just driven from
+		// the floppy data archives.
+		{
+			Graphics::Surface *blk = g_system->lockScreen();
+			if (blk) {
+				memset(blk->getPixels(), 0, 320 * 200);
+				g_system->unlockScreen();
+			}
+			setSitePalette(6);
+			Picture frame;
+			if (_picsArchive.loadEntry(0x3d, frame)) {
+				g_system->copyRectToScreen(frame.surface.getPixels(),
+					frame.surface.pitch, 0, 0,
+					frame.surface.w, frame.surface.h);
+			}
+			Picture scene;
+			if (5 < _sitesArchive.size() &&
+				_sitesArchive.loadEntry(5, scene)) {
+				const int sx = 0x42, sy = 0x14;
+				const int sw = MIN<int>(scene.surface.w, 320 - sx);
+				const int sh = MIN<int>(scene.surface.h, 200 - sy);
+				if (sw > 0 && sh > 0)
+					g_system->copyRectToScreen(scene.surface.getPixels(),
+						scene.surface.pitch, sx, sy, sw, sh);
+			}
 
+			// Partner sprite at (5, 0x50). The original keeps its
+			// `_NewAnimation` slot active across `_DisplayCorrect`'s
+			// `_BuildBackground_Floppy`, so `_UpdateAnimations` keeps
+			// re-stamping the partner over the fresh BG. We don't have
+			// a slot system, so manually bake the resting frame into
+			// the BG before the recap kicks off — `displayFloppyDialog
+			// Records` snapshots the screen on entry and the partner
+			// stays visible across every chain record. Without this
+			// the win sequence ends up partner-less. Anim 2 (Jake) /
+			// 0x10 (Jenny), script key 0x02 — same as the gallery
+			// picker and the CD accuse path (ui.cpp:4177).
+			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+			Animation partnerAni;
+			if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+				!partnerAni.empty()) {
+				Graphics::Surface *screen = g_system->lockScreen();
+				if (screen) {
+					const uint frameIdx = partnerFrameAtTick(0x02,
+						(uint)partnerAni.size(),
+						g_system->getMillis());
+					blitAnimFrameAnchored(screen, partnerAni[frameIdx],
+										  5, 0x50);
+					g_system->unlockScreen();
+				}
+			}
+			g_system->updateScreen();
+		}
+
+		// Win music — TRAVEL-2.XMI (`2608:0c84`, played via
+		// `_MIDIPlayFile` at `_DisplayCorrect_Floppy @ 1d40:08c0`).
+		// The CD path uses internal MUS index 5; the floppy plays the
+		// XMI directly by filename. `_voiceOn` (= `DAT_28da_3050`) is
+		// the gate the original checks first.
+		if (_music && _voiceOn)
+			_music->playFile(Common::Path("travel-2.xmi"), false);
+
+		// Walk the solved-clue chain. Header[+0x12] points at a
+		// `count` byte followed by `count` dialog records (same layout
+		// as hotspot dialogs).
 		const byte *chain = _mystery.solvedClueBlock();
 		if (chain) {
 			const uint count = chain[0];
 			displayFloppyDialogRecords(chain + 1, count, 0);
 		}
 
-		// Mark mystery solved — equivalent to
-		// `((u16 *)0x5d20)... actually 0x3054[mysteryNum] = 1` which we
-		// translate to clearing the in-progress mystery + saving the
-		// profile (matches CD `doAccuse` win path).
-		if (_partner == 0)
-			_mystery._firstTry = false;
+		// Mark mystery solved with first-try bonus tracking.
+		// `_DisplayCorrect_Floppy @ 1d40:0939`:
+		//   ((u16 *)0x3054)[mysteryNum] = 1;
+		//   if (DAT_28da_35df != 0)
+		//       ((u16 *)0x3054)[iVar5] = 2;
+		// `DAT_28da_35df` is `_firstTry`; it starts at 1 on
+		// `_ReadMystery_Floppy` and is cleared to 0 by
+		// `_DisplayAlibi_Floppy` on a wrong accusation. So 2 = won on
+		// first try, 1 = won after at least one alibi.
+		if (mn < sizeof(_mysteriesSolved))
+			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
+
+		// Tier-promotion check. `_DisplayCorrect_Floppy @
+		// 1d40:0941..0978` walks the current tier's mystery range and
+		// advances `DAT_28da_3052` (= `_chainStage`) when every entry
+		// is non-zero. Skip mystery 0 (practice case) per the
+		// `if (DAT_2608_149c != 0)` guard at 1d40:093f.
+		if (mn != 0) {
+			uint lo = 0, hi = 0;
+			switch (_chainStage) {
+			case 1: lo = 1;    hi = 0x18; break;
+			case 2: lo = 0x19; hi = 0x30; break;
+			case 3: lo = 0x31; hi = 0x36; break;
+			default: break;
+			}
+			bool allSolved = (hi >= lo);
+			for (uint i = lo; i <= hi && allSolved; i++) {
+				if (i >= sizeof(_mysteriesSolved) ||
+					_mysteriesSolved[i] == 0)
+					allSolved = false;
+			}
+			if (allSolved && _chainStage < 4) {
+				_chainStage++;
+				debugC(1, kDebugMystery,
+					   "chainStage advanced to %u after solving mystery %u",
+					   _chainStage, mn);
+			}
+		}
+
+		// Persist progress before clearing the in-progress mystery.
+		// `_DisplayCorrect_Floppy @ 1d40:0982` calls
+		// `_SavePlayerRecord` then `FUN_22dc_0dbd` (which deletes the
+		// per-mystery save file via DOS int 21h). Order matters here
+		// for the same reason as the CD path — clear → save so the
+		// profile records `hasMystery=false`.
 		_mystery._solvedPuzzle = true;
 		_mystery.clear();
 		(void)saveProfile(_playerName);
@@ -4534,15 +4797,24 @@ void EEMEngine::doAccuseFloppy() {
 	}
 
 	// Innocent — `_DisplayAlibi_Floppy @ 1d40:00df`:
-	//   1. Load PIC 0x3e (alibi BG), draw at (0x42, 0x14).
-	//   2. Load suspect picID, draw portrait at (0x82, 0x5a).
-	//   3. Read alibi text via TEXT_BLOCK[suspect[+3] * 2]; render in
-	//      a balloon picked by digit-prefix dispatch.
-	//   4. Voice from per-partner table at suspect_entry[+5..]
-	//      (we approximate using slot 13/14 — Jake/Jenny "wrong"
-	//      sting; original picks per-suspect).
-	//   5. Wait for click, then KD reaction balloon
-	//      (KDTextIndex[+0x10]/2 = slot 8) + voice slot 5.
+	//   1d40:00fb  _GetBackground(0x3e);
+	//   1d40:0103  _GetPicture(suspect.picID);
+	//   1d40:010c  _AddPicBackground(susp_pic, 0x82, 0x5a);
+	//   1d40:0125  _MIDIPlayFile("fanfare2.xmi");  // alibi sting
+	//   1d40:0145  alibi-balloon table at `2608:0c0a + first_char`
+	//                  (separate from the KD digit→balloon table at
+	//                  `2608:0c14` — yes, 0xc0a, NOT 0xc14, verified
+	//                  via the `*(char *)(*local_a + 0xc0a)` access).
+	//                  Default = `DAT_2608_0c3c` = 0x2c.
+	//   1d40:01a3  balloon centred:
+	//                  bx = (0x140 - balloon.w) / 2;
+	//                  by = (0x5a  - balloon.h) / 2;
+	//   1d40:01d5  WordWrap alibi text inside balloon, _WaitForClick.
+	//   1d40:01ee  KD reaction at KDTextIndex[+10] = slot 5
+	//                  (NOT slot 8 — slot 8 is the gallery prompt).
+	//                  Balloon at (0x21, (0x50 - h) / 2).
+	//   1d40:0247  _NextScreen = _LastScreen;
+	//   1d40:024b  _firstTry = 0;
 	const byte *susp = _mystery.floppySuspectEntry((uint)picked);
 	uint16 picId = 0;
 	uint16 alibiOff = 0xFFFF;
@@ -4551,6 +4823,11 @@ void EEMEngine::doAccuseFloppy() {
 		alibiOff = _mystery.alibiTextOffset((uint)picked);
 	}
 
+	// Alibi-screen MIDI sting (FANFARE2.XMI). `_MIDIPlayFile` is
+	// gated on `_voiceOn` (= `DAT_28da_3050`) in the original.
+	if (_music && _voiceOn)
+		_music->playFile(Common::Path("fanfare2.xmi"), false);
+
 	Picture alibiBg;
 	const bool haveAlibiBg = _picsArchive.getPicture(0x3e, alibiBg);
 	Picture suspectPic;
@@ -4567,18 +4844,24 @@ void EEMEngine::doAccuseFloppy() {
 							_playerName, _partner);
 	}
 
-	// Digit-prefix → alibi balloon idx. CD has its own table at
-	// 29be:1050; floppy uses the same KD digit→balloon mapping for
-	// the alibi too (verified by inspecting `_DisplayAlibi_Floppy`'s
-	// `_GetKDTextBalloon_Floppy` call).
-	static const uint8 kDigitToBalloon[10] = {
-		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
+	// Alibi balloon dispatch. The original reads the alibi-balloon
+	// table at `2608:0c0a + first_char` — a SEPARATE table from the
+	// KD-hint table at `2608:0c14`. For digits '0'..'9' the values at
+	// `2608:0c3a..0c43` are { 0x2a, 0x2b, 0x2c, 0x2d, 0xaa, 0xab,
+	// 0xac, 0xad, 0x09, 0x0a }; default (non-digit char) is
+	// `DAT_2608_0c3c` = 0x2c. The high-bit values (0xAA..0xAD) are
+	// `_GetBalloon`'s "mirrored" flag — the low 7 bits are the
+	// balloon idx, top bit flips horizontally.
+	static const uint8 kFloppyAlibiBalloonByDigit[10] = {
+		0x2a, 0x2b, 0x2c, 0x2d, 0xaa, 0xab, 0xac, 0xad, 0x09, 0x0a
 	};
-	uint balloonIdx = 0x17;
+	uint balloonRaw = 0x2c; // default `DAT_2608_0c3c`
 	if (!alibi.empty() && alibi[0] >= '0' && alibi[0] <= '9') {
-		balloonIdx = kDigitToBalloon[(int)(alibi[0] - '0')];
+		balloonRaw = kFloppyAlibiBalloonByDigit[(int)(alibi[0] - '0')];
 		alibi.deleteChar(0);
 	}
+	const uint balloonIdx = balloonRaw & 0x7F;
+	const bool flipBalloon = (balloonRaw & 0x80) != 0;
 
 	// Compose alibi screen.
 	Graphics::ManagedSurface scene(320, 200,
@@ -4595,15 +4878,35 @@ void EEMEngine::doAccuseFloppy() {
 	Picture balloon;
 	const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
 		_balloonArchive.loadEntry(balloonIdx, balloon);
+	// Centred per `_DisplayAlibi_Floppy @ 1d40:01a0`:
+	//   uVar2 = (0x140 - balloon.w) / 2;  // x
+	//   uVar3 = (0x5a  - balloon.h) / 2;  // y (anchor in top-half)
 	int balloonX = 0x21;
 	int balloonY = 1;
 	if (haveBalloon) {
-		if (balloon.surface.h < 0x4e)
-			balloonY = (0x50 - balloon.surface.h) / 2;
+		balloonX = (320 - balloon.surface.w) / 2;
+		balloonY = (0x5a - balloon.surface.h) / 2;
+		if (balloonX < 0) balloonX = 0;
+		if (balloonY < 0) balloonY = 0;
 		const byte transp = (byte)(balloon.flags >> 8);
-		scene.transBlitFrom(balloon.surface,
-							 Common::Point(balloonX, balloonY),
-							 (uint32)transp);
+		// `_GetBalloon`'s mirror flag (high bit of the table value)
+		// flips the balloon horizontally — the original applies it
+		// inside the blit primitive. We emulate by reading the source
+		// row in reverse.
+		for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
+			 row++) {
+			const byte *src =
+				(const byte *)balloon.surface.getBasePtr(0, row);
+			byte *dst = (byte *)scene.getBasePtr(balloonX, balloonY + row);
+			for (int col = 0;
+				 col < balloon.surface.w && balloonX + col < 320; col++) {
+				const int srcCol = flipBalloon
+					? (balloon.surface.w - 1 - col) : col;
+				const byte px = src[srcCol];
+				if (px != transp)
+					dst[col] = px;
+			}
+		}
 	}
 	uint16 tx = 5, ty = 4, tw = 155;
 	getBalloonInsets(balloonIdx, tx, ty, tw);
@@ -4614,12 +4917,11 @@ void EEMEngine::doAccuseFloppy() {
 	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 
-	// Voice — suspect alibi voice. The original picks per-suspect from
-	// the alibi voice table at `2608:0c5e + suspectIdx`; we don't have
-	// the table indexed, so play slot 13 (= F-0161SL.VOC / M-0113SL.VOC)
-	// as a generic alibi cue.
-	if (_audio)
-		_audio->playFloppyVoiceSlot(13, _partner);
+	// `_DisplayAlibi_Floppy` does NOT play any per-suspect VOC — the
+	// alibi table at `2608:0c5e` (3 bytes: 0x15, 0x16, 0x17) is
+	// referenced by the post-win scrapbook (`FUN_1d40_05b7`), not the
+	// alibi screen. The MIDI sting we kicked off above carries the
+	// audio.
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -4639,8 +4941,13 @@ void EEMEngine::doAccuseFloppy() {
 		g_system->delayMillis(10);
 	}
 
-	// Partner reaction — KDTextIndex slot 8 (= +0x10).
-	showFloppyKDHint(8);
+	// Partner reaction — `KDTextIndex[+10]` is byte offset 10 = u16
+	// stride entry 5 (NOT 8). Verified at
+	// `_DisplayAlibi_Floppy @ 1d40:01ee`:
+	//   `iVar4 = MysteryOff + *(KDTextIndex + 10);`
+	// Our `showFloppyKDHint(slot)` reads `kdIdx + slot * 2`, so slot
+	// 5 is the right argument here.
+	showFloppyKDHint(5);
 
 	_mystery._firstTry = false;
 	_nextScreen = _lastScreen != kScreenInvalid


Commit: 7aa9357fb6ba37941294d886d596224b222a2cab
    https://github.com/scummvm/scummvm/commit/7aa9357fb6ba37941294d886d596224b222a2cab
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:54+02:00

Commit Message:
EEM: simplify savegame handling

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/mystery.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 03a2449d26b..5f1d15f0c15 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -58,12 +58,7 @@ const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
 const uint kPalStormLogo       = 0x26;  ///< Floppy `FUN_23d2_0605` palette idx
 
-// Save body version, used by the `Common::Serializer` inside
-// `saveGameStream`/`loadGameStream`. The framework's extended-save
-// header (description / thumbnail / playtime) is appended/parsed
-// separately by `Engine::saveGameState` / `MetaEngine::readSavegameHeader`,
-// so we don't need a magic word or our own metadata fields.
-const byte kSaveBodyVer = 3;
+const byte kSaveBodyVer = 1;
 
 // 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
 // _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
@@ -797,9 +792,8 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	(void)isAutosave;
 
 	// Body header: one byte version. `Common::Serializer::setVersion`
-	// alone doesn't write/read the version — we emit it explicitly so
-	// `loadGameStream` knows which fields are present. Older saves
-	// (v1) lack `_chainStage`; newer ones include it.
+	// alone doesn't write/read the version, so emit it explicitly and
+	// require an exact match on load.
 	Common::Serializer s(nullptr, stream);
 	s.setVersion(kSaveBodyVer);
 	byte ver = kSaveBodyVer;
@@ -821,16 +815,12 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	//                  2=solved on first try) — `_DisplayCorrect`
 	//                  writes 1 always, 2 when `_FirstTry != 0`.
 	//
-	// We persist the gameplay-meaningful subset (name + solved table +
-	// partner) and skip the filename-derivation bytes. The voice /
-	// chain-stage fields aren't yet wired into the C++ port; we save
-	// space for them so they slot in without a version bump.
+	// We persist the gameplay-meaningful subset and skip the original
+	// filename-derivation bytes.
 	s.syncString(_playerName);
 	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
 	s.syncAsByte(_partner);
-	// v2+: chain-stage tier (1=Junior, 2=Senior, 3=Master).
 	s.syncAsByte(_chainStage);
-	// v3+: voice on/off flag (DAT_2d5d_3f97).
 	s.syncAsByte(_voiceOn);
 
 	// ScummVM-only extension: persist the in-progress mystery so the
@@ -858,8 +848,8 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 	Common::Serializer s(stream, nullptr);
 	byte ver = 0;
 	s.syncAsByte(ver);
-	if (ver > kSaveBodyVer) {
-		warning("loadGameStream: save body version %u newer than %u — refusing",
+	if (ver != kSaveBodyVer) {
+		warning("loadGameStream: unsupported save body version %u (expected %u)",
 				ver, kSaveBodyVer);
 		return Common::kReadingFailed;
 	}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index e78e44066c3..44a90e4fdf4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -142,9 +142,8 @@ public:
 	// ScummVM extended-save hooks. The base `Engine::saveGameState` /
 	// `loadGameState` write/read the framework header (description,
 	// thumbnail, playtime, version) around our body via these
-	// streams. We keep all per-profile state in the body, with a
-	// single `Common::Serializer` version so future field additions
-	// stay backward-compatible.
+	// streams. We keep all per-profile state in the body and only
+	// accept the current private body layout.
 	Common::Error saveGameStream(Common::WriteStream *stream,
 								  bool isAutosave = false) override;
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 06ce1d53f7b..3b6cb6b3c98 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -564,22 +564,6 @@ void Mystery::syncState(Common::Serializer &s) {
 	s.syncArray(_hotSpotsSeen, kHotSpotsCap, Common::Serializer::Uint16LE);
 	s.syncArray(_inGallery,    kGalleryCap,  Common::Serializer::Uint16LE);
 	s.syncBytes(_newOrder, kGalleryCap);
-	// Save profiles created before the floppy `_newOrder` identity init
-	// was added stored all-zeros here. Loading that back over the
-	// identity set by `Mystery::load()` makes every dialog with byte9>0
-	// resolve to slot 0 (so the second suspect found at a CONSITE
-	// overwrites the first). Detect the legacy zero-fill on read and
-	// rebuild the identity mapping that the loader produced.
-	if (s.isLoading()) {
-		bool allZero = true;
-		for (uint i = 0; i < kGalleryCap; i++) {
-			if (_newOrder[i] != 0) { allZero = false; break; }
-		}
-		if (allZero) {
-			for (uint i = 0; i < kGalleryCap; i++)
-				_newOrder[i] = (uint8)i;
-		}
-	}
 	s.syncArray(_visitedSite, kVisitedSiteCap, Common::Serializer::Uint16LE);
 	s.syncArray(_onSites,     kVisitedSiteCap, Common::Serializer::Uint16LE);
 	s.syncAsByte(_sawCOFFSITEs);


Commit: 2b45b48e89ec1b28769d043fe3a43feb879ea9cd
    https://github.com/scummvm/scummvm/commit/2b45b48e89ec1b28769d043fe3a43feb879ea9cd
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:55+02:00

Commit Message:
EEM: fix invalid state transition in the handling of the setup screen

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5f1d15f0c15..9be01f7b182 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -392,8 +392,9 @@ screen_loop:
 		case kScreenMap:
 		case kScreenMapAlt:
 			// Handler 1/2 at 1a35:0e25 calls `_DoMapScreen @
-			// 20fe:120b` which manages its own `_NextScreen` writes —
-			// 3 (a site was clicked), 6 (setup), or 0xffff (quit).
+			// 20fe:120b` (floppy: 19bb:0ef3 -> 1fed map code),
+			// which manages its own `_NextScreen` writes — 3 (a site
+			// was clicked), 6 (setup), or 0xffff (quit).
 			// Our `doBigMap` keeps the original's "click site, then
 			// enter the site loop" behaviour inline; once it returns
 			// the natural next state is SITE.
@@ -406,14 +407,9 @@ screen_loop:
 
 		case kScreenSite:
 			// Handler 3 at 1a35:0e2c calls `_DoSiteLoop @
-			// 168d:03f4`. Our `doSiteLoop` is a complete loop —
-			// notebook / gallery / accuse / map are dispatched
-			// inline within `SiteScreen::run`. The accusation tail
-			// in `doAccuse` (see ui.cpp) writes the next screen:
-			// kScreenAction on win (matches `_DisplayCorrect @
-			// 1df2:0895` writing 0xc) or kScreenSite on lose
-			// (matches `_DisplayAlibi @ 1df2:043f` snapping back
-			// via `_LastScreen`).
+			// 168d:03f4` (floppy's equivalent dispatches through
+			// 1652). Site writes `_NextScreen` for PDA / map rather
+			// than entering those screens as nested modals.
 			doSiteLoop();
 			if (!_mystery.isLoaded())
 				_nextScreen = kScreenChooseMystery;
@@ -421,6 +417,28 @@ screen_loop:
 				_nextScreen = kScreenInvalid;  // user quit
 			break;
 
+		case kScreenNotebook:
+			// Handler 4 calls the PDA notebook screen. Its button
+			// handler writes 2 (map), 3 (site), 5 (gallery), or 7
+			// (accuse) and then returns to this dispatcher.
+			doNotebook();
+			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
+				_nextScreen = kScreenChooseMystery;
+			else if (_nextScreen == current)
+				_nextScreen = kScreenSite;
+			break;
+
+		case kScreenGallery:
+			// Handler 5 calls the suspect gallery. Like the original,
+			// ESC and the site button write 3, the map button writes
+			// 2, and the PDA button writes 4.
+			doGallery();
+			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
+				_nextScreen = kScreenChooseMystery;
+			else if (_nextScreen == current)
+				_nextScreen = kScreenSite;
+			break;
+
 		case kScreenSetup:
 			// Handler 6 at 1a35:0e48 calls `_DoSetup @ 1f78:044e`.
 			// Reachable via the BigMap setup button which writes
@@ -431,6 +449,17 @@ screen_loop:
 			doSetup();
 			break;
 
+		case kScreenAccuse:
+			// Handler 7 runs the accusation flow. A failed accusation
+			// returns to `_LastScreen`; a correct solution writes
+			// 0xc (ACTION).
+			doAccuse();
+			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
+				_nextScreen = kScreenChooseMystery;
+			else if (_nextScreen == current)
+				_nextScreen = kScreenSite;
+			break;
+
 		default:
 			warning("screenDriver: unhandled screen id %d", (int)current);
 			_nextScreen = kScreenInvalid;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 9b07bfb5d49..0938072384b 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -695,7 +695,8 @@ void SiteScreen::run() {
 				//   Button 0: (35, 111) - (56, 136)  → notebook
 				//                                       (`_NextScreen = 4`)
 				//   Button 1: (7, 177)  - (57, 200)  → map
-				//                                       (`_NextScreen = 1`)
+				//                                       (CD `_NextScreen = 1`,
+				//                                       floppy = 2)
 				// Test the buttons before falling through to hotspots so
 				// a click on the PDA / map icon doesn't accidentally
 				// trigger a hotspot underneath.
@@ -712,16 +713,14 @@ void SiteScreen::run() {
 				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
 				const Common::Rect kBtnPartner ( 5,  80, 44, 110);
 				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
-					_vm->doNotebook();
-					enter(cur);
-					break;
+					_vm->setNextScreen(kScreenNotebook);
+					return;
 				}
 				if (kBtnMap.contains(event.mouse.x, event.mouse.y)) {
-					_vm->doBigMap();
-					if (_mystery->_siteNumber < _mystery->numSites())
-						cur = _mystery->_siteNumber;
-					enter(cur);
-					break;
+					// CD writes `_NextScreen = 1`; floppy writes 2.
+					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
+													   : kScreenMap);
+					return;
 				}
 				if (kBtnPartner.contains(event.mouse.x, event.mouse.y)) {
 					_vm->doHelp();
@@ -748,7 +747,8 @@ void SiteScreen::run() {
 				// → MAP).
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					if (_vm->areYouSure()) {
-						_vm->setNextScreen(kScreenMap);
+						_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
+														   : kScreenMap);
 						return;
 					}
 					enter(cur);
@@ -762,10 +762,7 @@ void SiteScreen::run() {
 		if (exitRequested)
 			return;
 
-		// `doAccuse` on a win clears the mystery (so the screen driver
-		// can route to the post-mystery menu). Notebook / Gallery /
-		// hotspot paths route through this same loop, so a transitive
-		// `doAccuse` may have wiped `_mystery` underneath us — exit
+		// Hotspot side effects can invalidate the active mystery; exit
 		// immediately rather than tick another frame against stale BG
 		// snapshots / hotspot tables.
 		if (!_mystery || !_mystery->isLoaded())
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 479c062e623..06c0377e833 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1812,11 +1812,13 @@ void EEMEngine::doNotebook() {
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
 				exitFlag = true;
 				break;
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					_nextScreen = kScreenSite;
 					exitFlag = true;
 					break;
 				}
@@ -1838,11 +1840,12 @@ void EEMEngine::doNotebook() {
 				// rects directly. Earlier rects "win" when overlapping
 				// (matches `_FindButton`).
 				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					_nextScreen = kScreenSite;
 					exitFlag = true;
 					break;  // back to site
 				}
 				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
-					doBigMap();
+					_nextScreen = kScreenMapAlt;
 					exitFlag = true;
 					break;
 				}
@@ -1852,14 +1855,14 @@ void EEMEngine::doNotebook() {
 					continue;
 				}
 				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
-					doAccuse();
+					_nextScreen = kScreenAccuse;
 					exitFlag = true;
 					break;
 				}
 				if (kBtnGallery.contains(ev.mouse.x, ev.mouse.y)) {
-					doGallery();
-					dirty = true;
-					continue;
+					_nextScreen = kScreenGallery;
+					exitFlag = true;
+					break;
 				}
 				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
 					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
@@ -2161,10 +2164,12 @@ void EEMEngine::doGallery() {
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
 				return;
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					_nextScreen = kScreenSite;
 					exitFlag = true;
 					break;
 				}
@@ -2192,18 +2197,19 @@ void EEMEngine::doGallery() {
 				const Common::Rect kBtnHelp    ( 93, 174, 115, 190); // [1] HELP
 				const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
 				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+					_nextScreen = kScreenSite;
 					exitFlag = true; break;
 				}
 				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
-					doBigMap();
+					_nextScreen = kScreenMapAlt;
 					exitFlag = true; break;
 				}
 				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
-					doAccuse();
+					_nextScreen = kScreenAccuse;
 					exitFlag = true; break;
 				}
 				if (kBtnNotebook.contains(ev.mouse.x, ev.mouse.y)) {
-					// Already came from notebook; exiting returns to it.
+					_nextScreen = kScreenNotebook;
 					exitFlag = true; break;
 				}
 				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {


Commit: 1cf324b52d5caa884d4ba69809121b48d356202e
    https://github.com/scummvm/scummvm/commit/1cf324b52d5caa884d4ba69809121b48d356202e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:55+02:00

Commit Message:
EEM: correctly save and reload viewed clues

Changed paths:
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0938072384b..a36c0188248 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1297,10 +1297,11 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	const uint32 tickMs = g_system->getMillis();
 
 	// CD hotspot rows are 14 bytes each (rect + 6 bytes of clue
-	// metadata). Floppy stores plain 8-byte rectangles only — clue
-	// data lives in a separate dialog-record list at `site_data[+6]`,
-	// keyed by hotspot index. Verified at `FUN_22dc_0b80 @ 22dc:0b80`.
-	const uint stride = _vm && _vm->isFloppy() ? 8 : 14;
+	// metadata). The seen key is the zero-based mystery-wide ordinal
+	// at +0xa, not this site's local row index. Floppy stores plain
+	// 8-byte rectangles only, so its seen key remains the row index.
+	const bool floppy = _vm && _vm->isFloppy();
+	const uint stride = floppy ? 8 : 14;
 	for (uint i = 0; i < count; i++) {
 		const byte *r = spots + i * stride;
 		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
@@ -1310,8 +1311,9 @@ void SiteScreen::renderHotspots(uint siteNum) {
 		const Common::Rect rect(MAX<int>(0, x1), MAX<int>(0, y1),
 								MIN<int>(screen->w, x2),
 								MIN<int>(screen->h, y2));
-		const bool seen = (i < Mystery::kHotSpotsCap)
-						   && _mystery->_hotSpotsSeen[i];
+		const uint seenKey = floppy ? i : READ_LE_UINT16(r + 0xa);
+		const bool seen = seenKey < Mystery::kHotSpotsCap &&
+						   _mystery->_hotSpotsSeen[seenKey];
 		if (seen) {
 			// `_DrawSolidRect` (172b:0506) — outline in palette
 			// index 0xFF (a fixed, non-cycling colour) so already-
@@ -1418,10 +1420,10 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 
 	// `_DoSiteLoop @ 168d:03f4` (after _DisplayClue):
 	//   _HotSpotsSeen[hotspot[+0xa] * 2] = _HotSpotComplete;
-	// The "seen" key is the hotspotIndex field (+0xa) — the 1-based
-	// ordinal — NOT the array index. Two hotspots can share an ordinal
-	// across sites (e.g., a partner's clue you can re-read), so this
-	// matters for cross-site state.
+	// The seen key is the hotspotIndex field (+0xa) — a zero-based
+	// mystery-wide ordinal — NOT the site's local row index. Using the
+	// local index makes unrelated hotspots on later sites inherit the
+	// first site's seen state after travel or reload.
 	const byte *spots = _mystery->hotspots(siteNum);
 	uint hotOrdinal = hotIdx; // fallback to array index
 	if (spots) {
@@ -1429,8 +1431,6 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	}
 	if (hotOrdinal < Mystery::kHotSpotsCap)
 		_mystery->_hotSpotsSeen[hotOrdinal] = 1;
-	if (hotIdx < Mystery::kHotSpotsCap)
-		_mystery->_hotSpotsSeen[hotIdx] = 1;  // also mark by array idx for our render
 	_mystery->_searchLocationNumber = (uint16)hotIdx;
 
 	// Bytes 8..9 of each 14-byte hotspot rect = byte offset within the
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 23621409806..97e83c087e4 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -80,7 +80,7 @@ void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates
 	uint16 clueOffset;          ///< +8: byte offset of ClueBlock in the mystery file
-	uint16 hotspotIndex;        ///< +10: 1-based hotspot ordinal within the site
+	uint16 hotspotIndex;        ///< +10: zero-based mystery-wide seen ordinal
 	uint16 extra;               ///< +12: unknown (zero in M0..M54)
 
 	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }


Commit: ec75e6ec08ee26087cb3fdd2ee290504ede16aa3
    https://github.com/scummvm/scummvm/commit/ec75e6ec08ee26087cb3fdd2ee290504ede16aa3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:55+02:00

Commit Message:
EEM: avoid saving slot 0

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


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 9be01f7b182..8326cf54c36 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -959,10 +959,13 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 		}
 	}
 
-	// New profile — pick the lowest unused slot. The MetaEngine caps
-	// us at 99 by default (`getMaximumSaveSlot`); 25 was the DOS
-	// original's limit (`screen8_handler` walks `*.PLR` up to 25
-	// entries in `local_8c[0x19][2]`).
+	// New profile — pick the lowest unused visible slot. Slot 0 is
+	// filtered out by `listProfiles()` because it is ScummVM's
+	// conventional autosave slot, so allocating a new profile there
+	// makes the profile disappear from the picker on the next refresh.
+	// The MetaEngine caps us at 99 by default (`getMaximumSaveSlot`);
+	// 25 was the DOS original's limit (`screen8_handler` walks `*.PLR`
+	// up to 25 entries in `local_8c[0x19][2]`).
 	if (slot < 0) {
 		const int maxSlot = getMetaEngine()->getMaximumSaveSlot();
 		Common::Array<bool> used(maxSlot + 1);
@@ -971,7 +974,7 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 			if (sl >= 0 && sl <= maxSlot)
 				used[sl] = true;
 		}
-		for (int i = 0; i <= maxSlot; i++) {
+		for (int i = 1; i <= maxSlot; i++) {
 			if (!used[i]) {
 				slot = i;
 				break;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 44a90e4fdf4..f73c751f87e 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -157,8 +157,8 @@ public:
 	// description = player name) — same approach Wetlands uses.
 
 	/// Mirrors `_SavePlayerRecord @ 1c33:034f`. Saves into the slot
-	/// whose description matches @p name, or the lowest unused slot
-	/// if no match. Returns the kNoError on success.
+	/// whose description matches @p name, or the lowest unused visible
+	/// slot if no match. Returns the kNoError on success.
 	Common::Error saveProfile(const Common::String &name);
 
 	/// Mirrors `_LoadPlayerRecord @ 1c33:03a6`. Returns false if no


Commit: 2a0ec6f7e2ab4d704e0ac3b2ab32d7e9dad85f1e
    https://github.com/scummvm/scummvm/commit/2a0ec6f7e2ab4d704e0ac3b2ab32d7e9dad85f1e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:56+02:00

Commit Message:
EEM: bring virtual keyboard when typing name

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 06c0377e833..7f045894eba 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -504,13 +504,18 @@ void EEMEngine::doNewPlayer() {
 							   0, 0, 320, 200);
 	g_system->updateScreen();
 
-	while (!shouldQuit()) {
+	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, true);
+
+	bool done = false;
+	while (!done && !shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				done = true;
+				break;
+			}
 			if (ev.type != Common::EVENT_KEYDOWN)
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
@@ -534,11 +539,13 @@ void EEMEngine::doNewPlayer() {
 					_chainStage = 1;
 					saveProfile(name);
 				}
-				return;
+				done = true;
+				break;
 			}
 			if (k == Common::KEYCODE_ESCAPE) {
 				_playerName = "Detective";
-				return;
+				done = true;
+				break;
 			}
 			if (k == Common::KEYCODE_BACKSPACE) {
 				if (!name.empty()) {
@@ -568,6 +575,8 @@ void EEMEngine::doNewPlayer() {
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
+
+	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 }
 
 int EEMEngine::doShowEnding(uint num, bool firstPage) {


Commit: 8a575a0b4f4e2a1dbbdc9e3ef0e959f16286ad04
    https://github.com/scummvm/scummvm/commit/8a575a0b4f4e2a1dbbdc9e3ef0e959f16286ad04
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:56+02:00

Commit Message:
EEM: correctly render background during enter animation

Changed paths:
    engines/eem/site.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index a36c0188248..1672ef45a61 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -573,9 +573,19 @@ void SiteScreen::enter(uint siteNum) {
 	// the arrival on so re-entries (after notebook/map/etc.) don't
 	// repeat the animation.
 	if ((int)siteNum != _lastSiteAnim) {
+		// `_EnterSiteAnim` snapshots the current screen, so populate
+		// that temporary background with the same site layers that
+		// should already be visible behind the arriving partner.
+		if (_vm->isFloppy())
+			renderFloppyDrops(siteNum);
+		else
+			renderStaticDrops(siteNum);
+		renderAnimatedDrops(siteNum, g_system->getMillis());
 		enterSiteAnim();
 		_lastSiteAnim = (int)siteNum;
-		// Re-paint the BG; the arrival animation drew on top of it.
+		// Re-paint the BG; the normal snapshot below should contain
+		// only the static layers, while animated NPCs are redrawn per
+		// tick by the frame pump.
 		renderBackground(siteNum);
 	}
 


Commit: dcb37c0a03cf1ea2246acdac995e0379a13ee515
    https://github.com/scummvm/scummvm/commit/dcb37c0a03cf1ea2246acdac995e0379a13ee515
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:56+02:00

Commit Message:
EEM: original mouse pointer and new feature to hide highlight boxes

Changed paths:
    engines/eem/detection.cpp
    engines/eem/detection.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/metaengine.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 60967493537..d0b9b5800f2 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -40,7 +40,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GUIO_NONE)
+		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
 	},
 	{
 		"eem",
@@ -50,7 +50,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GUIO_NONE)
+		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
 	},
 	{
 		// Spanish floppy release — same EEM.EXE binary as the English
@@ -64,7 +64,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::ES_ESP,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GUIO_NONE)
+		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
 	},
 
 	AD_TABLE_END_MARKER
diff --git a/engines/eem/detection.h b/engines/eem/detection.h
index 395daab1c77..7d74db6cd7f 100644
--- a/engines/eem/detection.h
+++ b/engines/eem/detection.h
@@ -26,6 +26,8 @@
 
 namespace EEM {
 
+#define GAMEOPTION_HIDE_HIGHLIGHT_BOXES GUIO_GAMEOPTIONS1
+
 enum EEMDebugChannels {
 	kDebugGeneral = 1 << 0,
 	kDebugScript  = 1 << 1,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 8326cf54c36..8959705a931 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -57,13 +57,14 @@ const uint kPicStormLogo       = 0x20b; ///< Floppy storm-logo still: PIC 0x20b
 const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
 const uint kPalStormLogo       = 0x26;  ///< Floppy `FUN_23d2_0605` palette idx
+const uint kPicMousePointer    = 0x50;  ///< Original startup pointer; 0x51 is the wait cursor
 
 const byte kSaveBodyVer = 1;
 
-// 11x16 mouse cursor — replaces the DOS hardware cursor wired in by
-// _InitMouse @ 152d:018b (INT 33h). The original game sets the cursor
-// visible/hidden via _MouseCursor; we leave it on once the screens
-// that need it (ChoosePartner, CaseSelection, sites) are reached.
+// Fallback 11x16 mouse cursor used if the selected PIC pointer cannot be
+// loaded. The original game sets the cursor visible/hidden via
+// _MouseCursor; we leave it on once the screens that need it
+// (ChoosePartner, CaseSelection, sites) are reached.
 //   0 = transparent, 1 = black outline, 2 = white fill
 const byte kCursorBitmap[11 * 16] = {
 	1,1,0,0,0,0,0,0,0,0,0,
@@ -88,11 +89,85 @@ const byte kCursorPalette[] = {
 	0x00, 0x00, 0x00, // 1 — outline
 	0xFF, 0xFF, 0xFF  // 2 — fill
 };
+const byte kCursorHotspotPalette[] = {
+	0x00, 0x00, 0x00, // 0 — transparent (key)
+	0xFF, 0x00, 0x00, // 1 — red outline
+	0xFF, 0xFF, 0xFF  // 2 — white fill
+};
+
+static void setHotspotCursorPalette(const Picture &cursor, byte transparent) {
+	byte palette[kPalSize];
+	bool used[256];
+	memset(used, 0, sizeof(used));
+
+	g_system->getPaletteManager()->grabPalette(palette, 0, 256);
+
+	for (int y = 0; y < cursor.surface.h; y++) {
+		const byte *src = (const byte *)cursor.surface.getBasePtr(0, y);
+		for (int x = 0; x < cursor.surface.w; x++) {
+			if (src[x] != transparent)
+				used[src[x]] = true;
+		}
+	}
+
+	int minLuma = 255;
+	int maxLuma = 0;
+	for (uint i = 0; i < 256; i++) {
+		if (!used[i])
+			continue;
+		const byte *rgb = palette + i * 3;
+		const int luma = (rgb[0] * 30 + rgb[1] * 59 + rgb[2] * 11) / 100;
+		minLuma = MIN(minLuma, luma);
+		maxLuma = MAX(maxLuma, luma);
+	}
+	const int outlineThreshold = (minLuma + maxLuma) / 2;
+
+	for (uint i = 0; i < 256; i++) {
+		if (!used[i])
+			continue;
+		byte *rgb = palette + i * 3;
+		const int luma = (rgb[0] * 30 + rgb[1] * 59 + rgb[2] * 11) / 100;
+		if (luma <= outlineThreshold) {
+			rgb[0] = 0xFF;
+			rgb[1] = 0x00;
+			rgb[2] = 0x00;
+		} else {
+			rgb[0] = 0xFF;
+			rgb[1] = 0xFF;
+			rgb[2] = 0xFF;
+		}
+	}
+
+	CursorMan.replaceCursorPalette(palette, 0, 256);
+}
+
+static void installMouseCursor(DBDArchive &pics, bool hotspot) {
+	Picture cursor;
+	if (pics.getPicture(kPicMousePointer, cursor) && !cursor.surface.empty()) {
+		const byte transparent = (byte)(cursor.flags >> 8);
+		CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0,
+								transparent);
+		if (hotspot)
+			setHotspotCursorPalette(cursor, transparent);
+		else
+			CursorMan.replaceCursorPalette(nullptr, 0, 0);
+		return;
+	}
+
+	warning("EEM: mouse cursor PIC 0x%x missing; using fallback cursor",
+			kPicMousePointer);
+	CursorMan.replaceCursor(kCursorBitmap, 11, 16, 0, 0, 0);
+	CursorMan.replaceCursorPalette(hotspot ? kCursorHotspotPalette
+										   : kCursorPalette,
+								   0, 3);
+}
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	: Engine(syst), _gameDescription(gameDesc), _rng("eem"),
 	  _playerName("Detective"),
 	  _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle), _partner(0) {
+	ConfMan.registerDefault("hide_highlight_boxes", false);
+
 	// `ADGameDescription::extra` is set by the matching entry in
 	// `gameDescriptions[]` ("CD" or "Floppy"). Keep variant detection
 	// purely string-based so a future re-release with a different
@@ -133,12 +208,14 @@ Common::Error EEMEngine::run() {
 	_audio->setVoiceEnabled(_voiceOn);
 	syncSoundSettings();
 
-	// _InitMouse @ 152d:018b in the original — install our 11x16 arrow,
-	// using palette index 0 as the transparency key. The cursor is left
-	// hidden through the opening anims and switched on at NewPlayer /
-	// ChoosePartner where the player actually clicks.
-	CursorMan.replaceCursor(kCursorBitmap, 11, 16, 0, 0, 0);
-	CursorMan.replaceCursorPalette(kCursorPalette, 0, 3);
+	// CD `_main @ 1a35:0f59` and floppy `_main_Floppy @ 19bb:1012`
+	// both load `_GetPicture(0x50)` as the active mouse pointer before
+	// calling `_InitMouse`. PIC 0x51 is present in both archives but has
+	// no executable xrefs and appears to be the wait cursor.
+	// CD's `_SwitchMouse` supports swapping to a hotspot cursor ID stored
+	// at search record +0x0c, but the shipped CD mystery data only uses
+	// cursor 0; floppy search records have no cursor-id field.
+	installMouseCursor(_picsArchive, false);
 	CursorMan.showMouse(false);
 
 	// _AllBlack @ 172b:0d4b paints the screen black before the first handler.
@@ -472,6 +549,15 @@ screen_loop:
 	return Common::kNoError;
 }
 
+void EEMEngine::setHotspotMouseCursor(bool active) {
+	active = active && ConfMan.getBool("hide_highlight_boxes");
+	if (_hotspotMouseCursor == active)
+		return;
+
+	_hotspotMouseCursor = active;
+	installMouseCursor(_picsArchive, active);
+}
+
 bool EEMEngine::openArchives() {
 	// _InitGraphicsSystem @ 172b:0145 opens these five .DBD/.DBX pairs.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
@@ -754,6 +840,7 @@ void EEMEngine::doSiteLoop() {
 	// Tab (next site), ESC (exit).
 	SiteScreen screen(this, &_mystery);
 	screen.run();
+	setHotspotMouseCursor(false);
 }
 
 void EEMEngine::startTravelMusic() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index f73c751f87e..3cf5f0e11f1 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -188,6 +188,10 @@ public:
 	const EEMFont &getFont() const { return _font; }
 	uint8       getPartnerIndex() const { return _partner; }
 
+	/// Switch to a lightly tinted cursor while the mouse is over a
+	/// searchable hotspot and the highlight boxes are hidden.
+	void setHotspotMouseCursor(bool active);
+
 	/// Display one ClueBlock. @p clueBlock points at the u16 frame count
 	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
 	void displayClue(const byte *clueBlock);
@@ -563,6 +567,8 @@ private:
 	/// `setPartnerEraseBg`.
 	Graphics::ManagedSurface _partnerEraseBg;
 
+	bool _hotspotMouseCursor = false;
+
 	/// XMIDI music player. Mirrors the original `MIDI.C` family
 	/// (`_MIDIPlayFile`, `_MIDIPlay`, `_StopMIDI`, `_StartTravelMusic`
 	/// at 20a2:00e2-05c9). Constructed lazily during `run()` once the
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index 21dee682b00..52b2c5e78b5 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -22,12 +22,31 @@
 #include "base/plugins.h"
 #include "engines/advancedDetector.h"
 
+#include "common/translation.h"
+
+#include "eem/detection.h"
 #include "eem/eem.h"
 
 #include "common/system.h"
 
 namespace EEM {
 
+static const ADExtraGuiOptionsMap optionsList[] = {
+	{
+		GAMEOPTION_HIDE_HIGHLIGHT_BOXES,
+		{
+			_s("Hide the highlight boxes"),
+			_s("Hide the boxes that highlight searchable clue locations."),
+			"hide_highlight_boxes",
+			false,
+			0,
+			0
+		}
+	},
+
+	AD_EXTRA_GUI_OPTIONS_TERMINATOR
+};
+
 const char *EEMEngine::getGameId() const {
 	return _gameDescription->gameId;
 }
@@ -44,6 +63,10 @@ public:
 		return "eem";
 	}
 
+	const ADExtraGuiOptionsMap *getAdvancedExtraGuiOptions() const override {
+		return EEM::optionsList;
+	}
+
 	Common::Error createInstance(OSystem *syst, Engine **engine, const ADGameDescription *desc) const override {
 		*engine = new EEM::EEMEngine(syst, desc);
 		return Common::kNoError;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 1672ef45a61..8a1cddc1ee2 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/events.h"
 #include "common/system.h"
@@ -687,6 +688,8 @@ void SiteScreen::run() {
 	if (cur >= _mystery->numSites())
 		cur = 0;
 	enter(cur);
+	Common::Point mouse = g_system->getEventManager()->getMousePos();
+	updateHotspotCursor(cur, mouse.x, mouse.y);
 
 	while (!_vm->shouldQuit()) {
 		Common::Event event;
@@ -695,8 +698,13 @@ void SiteScreen::run() {
 			switch (event.type) {
 			case Common::EVENT_QUIT:
 			case Common::EVENT_RETURN_TO_LAUNCHER:
+				_vm->setHotspotMouseCursor(false);
 				return;
 
+			case Common::EVENT_MOUSEMOVE:
+				updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
+				break;
+
 			case Common::EVENT_LBUTTONDOWN: {
 				// On-screen UI buttons. `_DoSiteLoop @ 168d:03f4` calls
 				//   _FindButton(&SiteButtons, 2, MouseX, MouseY)
@@ -723,25 +731,31 @@ void SiteScreen::run() {
 				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
 				const Common::Rect kBtnPartner ( 5,  80, 44, 110);
 				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
+					_vm->setHotspotMouseCursor(false);
 					_vm->setNextScreen(kScreenNotebook);
 					return;
 				}
 				if (kBtnMap.contains(event.mouse.x, event.mouse.y)) {
+					_vm->setHotspotMouseCursor(false);
 					// CD writes `_NextScreen = 1`; floppy writes 2.
 					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 													   : kScreenMap);
 					return;
 				}
 				if (kBtnPartner.contains(event.mouse.x, event.mouse.y)) {
+					_vm->setHotspotMouseCursor(false);
 					_vm->doHelp();
 					enter(cur);
+					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
 					break;
 				}
 				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
 				if (idx >= 0) {
+					_vm->setHotspotMouseCursor(false);
 					onHotspotClicked(cur, (uint)idx);
 					// Restore the site BG after the clue overlay.
 					enter(cur);
+					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
 				}
 				break;
 			}
@@ -756,12 +770,15 @@ void SiteScreen::run() {
 				// here is ESC (matches `_ESCHit` → "Are you sure?"
 				// → MAP).
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					_vm->setHotspotMouseCursor(false);
 					if (_vm->areYouSure()) {
 						_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 														   : kScreenMap);
 						return;
 					}
 					enter(cur);
+					mouse = g_system->getEventManager()->getMousePos();
+					updateHotspotCursor(cur, mouse.x, mouse.y);
 				}
 				break;
 
@@ -1272,8 +1289,10 @@ void SiteScreen::renderBackground(uint siteNum) {
 }
 
 void SiteScreen::renderHotspots(uint siteNum) {
-	// Hotspot outlines (`_DrawSearchButtons`): toggle via V.
-	if (!_showHotspots)
+	// Hotspot outlines (`_DrawSearchButtons`). The original always
+	// draws these; the port exposes an optional game setting to hide
+	// them for players who do not want location hints.
+	if (ConfMan.getBool("hide_highlight_boxes"))
 		return;
 
 	const byte *spots = _mystery->hotspots(siteNum);
@@ -1388,6 +1407,12 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 	return -1;
 }
 
+void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
+	if (!_vm)
+		return;
+	_vm->setHotspotMouseCursor(hotspotAtPoint(siteNum, x, y) >= 0);
+}
+
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 97e83c087e4..112092edcda 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -81,7 +81,7 @@ struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates
 	uint16 clueOffset;          ///< +8: byte offset of ClueBlock in the mystery file
 	uint16 hotspotIndex;        ///< +10: zero-based mystery-wide seen ordinal
-	uint16 extra;               ///< +12: unknown (zero in M0..M54)
+	uint16 extra;               ///< +12: CD cursor ID for _SwitchMouse; shipped data uses 0
 
 	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }
 };
@@ -109,6 +109,7 @@ private:
 	void renderBackground(uint siteNum);
 	void renderHotspots(uint siteNum);
 	int  hotspotAtPoint(uint siteNum, int x, int y) const;
+	void updateHotspotCursor(uint siteNum, int x, int y);
 	void onHotspotClicked(uint siteNum, uint hotIdx);
 
 	/// Play the partner's site-arrival sequence once `_LastSite !=
@@ -167,7 +168,6 @@ private:
 
 	EEMEngine *_vm;
 	Mystery *_mystery;
-	bool _showHotspots = true;     ///< Toggle outlines with V key.
 	int _lastSiteAnim = -1;        ///< Last site we played the arrival on.
 	int _snapshotSite = -1;        ///< Site number the snapshot belongs to.
 	Graphics::ManagedSurface _bgSnapshot;


Commit: 00c6438de3a7ea38aa01d5784664fdf5aaf65103
    https://github.com/scummvm/scummvm/commit/00c6438de3a7ea38aa01d5784664fdf5aaf65103
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:57+02:00

Commit Message:
EEM: correctly blit npc pictures

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 1420e324a7f..27efcc6c2f6 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -93,7 +93,8 @@ uint happinessLevel(int x) {
 }
 
 // Lock the framebuffer, masked-blit `p` at (x, y), unlock. The transparent
-// colour is the high byte of `p.flags` per `_Rect_Move_Mask @ 1000:03fc`.
+// colour is the high byte of `p.flags`; `_AddPicBackground @ 172b:0ed4`
+// pushes `word ptr ES:[BX] >> 8` as `_Rect_Move_Mask`'s mask byte.
 void blitMaskedToScreen(const Picture &p, int x, int y) {
 	const byte transp = (byte)(p.flags >> 8);
 	Graphics::Surface *screen = g_system->lockScreen();
@@ -730,11 +731,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			Picture charPic;
 			if (_picsArchive.getPicture(charPicId, charPic) &&
 				charX < 320 && charY < 200) {
-				const int w = MIN<int>(charPic.surface.w, 320 - charX);
-				const int h = MIN<int>(charPic.surface.h, 200 - charY);
-				if (w > 0 && h > 0)
-					g_system->copyRectToScreen(charPic.surface.getPixels(),
-						charPic.surface.pitch, charX, charY, w, h);
+				blitMaskedToScreen(charPic, charX, charY);
 			}
 		}
 


Commit: 057711225d26c5bb95abef5475121c5ffe8a7c6b
    https://github.com/scummvm/scummvm/commit/057711225d26c5bb95abef5475121c5ffe8a7c6b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:57+02:00

Commit Message:
EEM: implemented the quit dialog

Changed paths:
    engines/eem/clues.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 27efcc6c2f6..62cf4047ec2 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -117,6 +117,16 @@ void blitMaskedToScreen(const Picture &p, int x, int y) {
 	g_system->unlockScreen();
 }
 
+void blitRawToScreen(const Picture &p, int x, int y) {
+	const int w = MIN<int>(p.surface.w, 320 - x);
+	const int h = MIN<int>(p.surface.h, 200 - y);
+	if (x < 0 || y < 0 || w <= 0 || h <= 0)
+		return;
+
+	g_system->copyRectToScreen(p.surface.getPixels(), p.surface.pitch,
+							   x, y, w, h);
+}
+
 void EEMEngine::doChoosePartner() {
 	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
 	// animations on a backdrop and polls four click rectangles (two per
@@ -1341,12 +1351,6 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 }
 
 bool EEMEngine::areYouSure() {
-	// Mirrors `_AreYouSure` @ 1a35:0a5c. Original loads PIC 0x136 for the
-	// dialog body and PIC 0x1FD/0x1FE for YES/NO. We render a minimal
-	// text dialog that preserves the screen behind it.
-	if (!_font.isLoaded())
-		return true;
-
 	Graphics::Surface *screen = g_system->lockScreen();
 	Graphics::ManagedSurface saved(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -1358,24 +1362,65 @@ bool EEMEngine::areYouSure() {
 		g_system->unlockScreen();
 	}
 
-	const Common::Rect dlg(60, 70, 260, 140);
-	Graphics::ManagedSurface scratch(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	for (int row = 0; row < 200; row++)
-		memcpy((byte *)scratch.getBasePtr(0, row),
-			   (const byte *)saved.getBasePtr(0, row), 320);
-	scratch.fillRect(dlg, 0);
-	scratch.frameRect(dlg, 0xF);
-	_font.drawString(&scratch,
-		isSpanish() ? "Estas seguro que quieres salir?"
-					: "Are you sure you want to quit?",
-		dlg.left + 8, dlg.top + 8, 320, 0xF);
-	_font.drawString(&scratch,
-		isSpanish() ? "S - Si" : "Y - Yes",
-		dlg.left + 16, dlg.top + 36, 320, 0xF);
-	_font.drawString(&scratch, "N - No", dlg.left + 100, dlg.top + 36, 320, 0xF);
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+	// CD `_AreYouSure @ 1a35:0a5c` and floppy `FUN_19bb_0b43` both:
+	//   * load PIC 0x136 as the dialog body,
+	//   * load PIC 0x1fd / 0x1fe as the pressed YES / NO button images,
+	//   * center the dialog,
+	//   * hit-test YES at (x+0x0c,y+0x23)-(x+0x20,y+0x32),
+	//     and NO at (x+0x60,y+0x23)-(x+0x74,y+0x32).
+	Picture dialogPic;
+	Picture yesPic;
+	Picture noPic;
+	const bool haveOriginalDialog =
+		_picsArchive.getPicture(0x136, dialogPic) &&
+		_picsArchive.getPicture(0x1fd, yesPic) &&
+		_picsArchive.getPicture(0x1fe, noPic);
+
+	Common::Rect yesRect;
+	Common::Rect noRect;
+	int yesX = 0;
+	int yesY = 0;
+	int noX = 0;
+	int noY = 0;
+
+	if (haveOriginalDialog) {
+		const int x = (320 - dialogPic.surface.w) / 2;
+		const int y = (200 - dialogPic.surface.h) / 2;
+		yesX = x + 0x0c;
+		yesY = y + 0x23;
+		noX = x + 0x60;
+		noY = y + 0x23;
+		yesRect = Common::Rect(yesX, yesY, x + 0x20 + 1, y + 0x32 + 1);
+		noRect = Common::Rect(noX, noY, x + 0x74 + 1, y + 0x32 + 1);
+		blitMaskedToScreen(dialogPic, x, y);
+	} else if (_font.isLoaded()) {
+		const Common::Rect dlg(60, 70, 260, 140);
+		yesRect = Common::Rect(dlg.left + 16, dlg.top + 34,
+							   dlg.left + 84, dlg.top + 54);
+		noRect = Common::Rect(dlg.left + 100, dlg.top + 34,
+							  dlg.left + 160, dlg.top + 54);
+
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		for (int row = 0; row < 200; row++)
+			memcpy((byte *)scratch.getBasePtr(0, row),
+				   (const byte *)saved.getBasePtr(0, row), 320);
+		scratch.fillRect(dlg, 0);
+		scratch.frameRect(dlg, 0xF);
+		_font.drawString(&scratch,
+			isSpanish() ? "Estas seguro que quieres salir?"
+						: "Are you sure you want to quit?",
+			dlg.left + 8, dlg.top + 8, 320, 0xF);
+		_font.drawString(&scratch,
+			isSpanish() ? "S - Si" : "Y - Yes",
+			dlg.left + 16, dlg.top + 36, 320, 0xF);
+		_font.drawString(&scratch, "N - No", dlg.left + 100,
+						 dlg.top + 36, 320, 0xF);
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, 320, 200);
+	} else {
+		return true;
+	}
 	g_system->updateScreen();
 
 	bool result = false;
@@ -1401,6 +1446,24 @@ bool EEMEngine::areYouSure() {
 					result = false; decided = true; break;
 				}
 			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				if (yesRect.contains(ev.mouse.x, ev.mouse.y)) {
+					if (haveOriginalDialog) {
+						blitRawToScreen(yesPic, yesX, yesY);
+						g_system->updateScreen();
+						g_system->delayMillis(90);
+					}
+					result = true; decided = true; break;
+				}
+				if (noRect.contains(ev.mouse.x, ev.mouse.y)) {
+					if (haveOriginalDialog) {
+						blitRawToScreen(noPic, noX, noY);
+						g_system->updateScreen();
+						g_system->delayMillis(90);
+					}
+					result = false; decided = true; break;
+				}
+			}
 		}
 		if (decided)
 			break;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7f045894eba..ca62f428557 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -940,58 +940,6 @@ void EEMEngine::doSetup() {
 	};
 	draw();
 
-	// Modal "Are you sure?" yes/no prompt. Mirrors `_AreYouSure @
-	// 1a35:0a5c` — the original draws a centred message, listens for
-	// Y/Enter (confirm) or N/ESC (cancel). We render a minimal
-	// overlay with Y / N keys (and click on left/right halves) so
-	// the Quit button gives the player a chance to back out.
-	auto areYouSure = [&]() -> bool {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		Graphics::Surface *cur = g_system->lockScreen();
-		if (cur) {
-			for (int row = 0; row < 200; row++)
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)cur->getBasePtr(0, row), 320);
-			g_system->unlockScreen();
-		}
-		const Common::Rect kBox(80, 80, 240, 120);
-		scratch.fillRect(kBox, 0x00);
-		_font.drawString(&scratch,
-			isSpanish() ? "Estas seguro?" : "Are you sure?",
-			100, 88, 200, 0xF);
-		_font.drawString(&scratch,
-			isSpanish() ? "S = si   N = no" : "Y = yes   N = no",
-			100, 102, 200, 0xF);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-		while (!shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-					return true;
-				if (ev.type == Common::EVENT_KEYDOWN) {
-					const Common::KeyCode k = ev.kbd.keycode;
-					// Spanish prompt is "S = si" — accept both Y and S.
-					if (k == Common::KEYCODE_y ||
-						k == Common::KEYCODE_s ||
-						k == Common::KEYCODE_RETURN)
-						return true;
-					if (k == Common::KEYCODE_n ||
-						k == Common::KEYCODE_ESCAPE)
-						return false;
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					return ev.mouse.x < 160;
-				}
-			}
-			g_system->delayMillis(15);
-		}
-		return false;
-	};
-
 	// Render `picId` and block until click/key. Returns the pressed
 	// keycode (KEYCODE_ESCAPE for an explicit bail, KEYCODE_INVALID
 	// for a click or any other key). When `transparent` is true,


Commit: 6ce915b5a9648c1dfb93e79ae687719840a6d84d
    https://github.com/scummvm/scummvm/commit/6ce915b5a9648c1dfb93e79ae687719840a6d84d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:57+02:00

Commit Message:
EEM: implemented the (im)patience animation

Changed paths:
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 8a1cddc1ee2..c47e8882f4d 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -286,6 +286,18 @@ const AnimScriptLong kAnimScriptsLong[] = {
 				  1,2,3,4,5,6,7,8,9,10,11,12 } },
 };
 
+// `_PatientSequence` and `_ImpatientSequence` are standalone script
+// pointers, not entries in `_AnimationSequences`. CD has the data but
+// never calls the switchers; floppy calls them from `_DoSiteLoop_Floppy`.
+// We intentionally enable the same switch for both builds.
+static const uint8 kPatientSequence[]   = { 0,0,0,0,0,0,0,0,0,2 };
+static const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
+
+// Test-shortened impatience delay. The original stores an hour-rounded
+// wall-clock value via DOS gettime; this keeps the same reset/switch
+// behavior but makes the feature observable during normal testing.
+static const uint32 kImpatienceDelayMs = 60 * 1000;
+
 // Look up the script for `seqnum`. Returns the frame array + length,
 // or `(nullptr, 0)` if no script is known — caller falls back to
 // flipbook cycling so unknown anims still animate (just without idle
@@ -317,6 +329,16 @@ static AnimScriptRef findAnimScript(uint16 seqnum) {
 	return r;
 }
 
+static uint frameFromScriptAtTick(const uint8 *frames, uint len,
+								  uint numFrames, uint32 tickMs) {
+	const uint kFramePeriodMs = 100;
+	if (!frames || len == 0)
+		return numFrames > 0 ? (uint)((tickMs / kFramePeriodMs) % numFrames) : 0;
+	const uint scriptIdx = (uint)((tickMs / kFramePeriodMs) % len);
+	const uint frame     = frames[scriptIdx];
+	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
+}
+
 void auditPartnerAnims(EEMEngine *vm) {
 	// Cross-check every registered partner-subset script against the
 	// underlying ANI.DBD entry it references. If the script asks for
@@ -432,15 +454,10 @@ void auditPartnerAnims(EEMEngine *vm) {
 // symptom.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	const AnimScriptRef s = findAnimScript(seqnum);
-	const uint kFramePeriodMs = 100;
-	if (!s.frames || s.len == 0)
-		return numFrames > 0 ? (uint)((tickMs / kFramePeriodMs) % numFrames) : 0;
-	const uint scriptIdx = (uint)((tickMs / kFramePeriodMs) % s.len);
-	const uint frame     = s.frames[scriptIdx];
 	// The script can in theory request a frame that's outside the
 	// animation's actual frame count (a misencoded script). Clamp so
 	// we don't read past `anim[]` in the caller.
-	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
+	return frameFromScriptAtTick(s.frames, s.len, numFrames, tickMs);
 }
 
 // Generic "play `unfold` once, then loop `waitSeq` forever" walker.
@@ -486,7 +503,7 @@ uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
 									  numFrames, elapsedMs);
 }
 
-void SiteScreen::enter(uint siteNum) {
+void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	if (!_mystery || !_mystery->isLoaded()) {
 		warning("SiteScreen::enter: no mystery loaded");
 		return;
@@ -503,6 +520,10 @@ void SiteScreen::enter(uint siteNum) {
 	// 0xffff (= -1, becomes 0 on the first `_UpdateAnimations`
 	// tick).
 	_waitPhaseAnchor = g_system->getMillis();
+	if (resetPartnerMood) {
+		_partnerWaitMood = kPartnerWaitDefault;
+		initImpatienceCounter();
+	}
 
 	// Capture whether this is the first time the player enters this
 	// site BEFORE we mark it visited — `_DoSiteLoop @ 168d:03f4`
@@ -675,6 +696,30 @@ void SiteScreen::enter(uint siteNum) {
 	}
 }
 
+void SiteScreen::initImpatienceCounter() {
+	_impatientDeadlineMs = g_system->getMillis() + kImpatienceDelayMs;
+}
+
+bool SiteScreen::checkImpatienceCounter() {
+	const uint32 now = g_system->getMillis();
+	const bool impatient = (int32)(now - _impatientDeadlineMs) >= 0;
+	if (impatient)
+		initImpatienceCounter();
+	return impatient;
+}
+
+void SiteScreen::notePartnerActivity() {
+	// Mirrors `_Switch2Patient(WaitHandle)` plus
+	// `_InitImpatientCounter()` in the floppy site loop, but only for
+	// deliberate actions in the port: clicks and key presses. Passive
+	// mouse movement should not make impatience impossible to see.
+	const bool wasImpatient = _partnerWaitMood == kPartnerWaitImpatient;
+	_partnerWaitMood = kPartnerWaitPatient;
+	initImpatienceCounter();
+	if (wasImpatient)
+		debugC(1, kDebugSite, "Partner impatience: reset to patient");
+}
+
 void SiteScreen::run() {
 	if (!_mystery || !_mystery->isLoaded())
 		return;
@@ -731,11 +776,13 @@ void SiteScreen::run() {
 				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
 				const Common::Rect kBtnPartner ( 5,  80, 44, 110);
 				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
+					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					_vm->setNextScreen(kScreenNotebook);
 					return;
 				}
 				if (kBtnMap.contains(event.mouse.x, event.mouse.y)) {
+					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					// CD writes `_NextScreen = 1`; floppy writes 2.
 					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
@@ -745,7 +792,8 @@ void SiteScreen::run() {
 				if (kBtnPartner.contains(event.mouse.x, event.mouse.y)) {
 					_vm->setHotspotMouseCursor(false);
 					_vm->doHelp();
-					enter(cur);
+					notePartnerActivity();
+					enter(cur, false);
 					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
 					break;
 				}
@@ -754,13 +802,17 @@ void SiteScreen::run() {
 					_vm->setHotspotMouseCursor(false);
 					onHotspotClicked(cur, (uint)idx);
 					// Restore the site BG after the clue overlay.
-					enter(cur);
+					notePartnerActivity();
+					enter(cur, false);
 					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
+				} else {
+					notePartnerActivity();
 				}
 				break;
 			}
 
 			case Common::EVENT_KEYDOWN:
+				notePartnerActivity();
 				// `_DoSiteLoop @ 168d:07e1` only dispatches on the
 				// 6-entry table at `168d:09d5` (TAB / ENTER / arrow
 				// keys for hotspot cursor cycling) plus ESC handled
@@ -776,7 +828,7 @@ void SiteScreen::run() {
 														   : kScreenMap);
 						return;
 					}
-					enter(cur);
+					enter(cur, false);
 					mouse = g_system->getEventManager()->getMousePos();
 					updateHotspotCursor(cur, mouse.x, mouse.y);
 				}
@@ -803,6 +855,10 @@ void SiteScreen::run() {
 		// park as the original.
 		const uint32 now = g_system->getMillis();
 		if (_snapshotSite == (int)cur && now - _lastTickMs >= 100) {
+			if (checkImpatienceCounter()) {
+				_partnerWaitMood = kPartnerWaitImpatient;
+				debugC(1, kDebugSite, "Partner impatience: switched to impatient");
+			}
 			restoreBgSnapshot();
 			renderAnimatedDrops(cur, now);
 			renderPartner(cur, now);
@@ -1212,8 +1268,19 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	const uint32 elapsed = (tickMs >= _waitPhaseAnchor)
 							? (tickMs - _waitPhaseAnchor)
 							: tickMs;
-	const uint frameIdx = partnerFrameAtTick((uint16)animId,
-											  (uint)anim.size(), elapsed);
+	uint frameIdx = 0;
+	if (_partnerWaitMood == kPartnerWaitImpatient) {
+		frameIdx = frameFromScriptAtTick(kImpatientSequence,
+										 ARRAYSIZE(kImpatientSequence),
+										 (uint)anim.size(), elapsed);
+	} else if (_partnerWaitMood == kPartnerWaitPatient) {
+		frameIdx = frameFromScriptAtTick(kPatientSequence,
+										 ARRAYSIZE(kPatientSequence),
+										 (uint)anim.size(), elapsed);
+	} else {
+		frameIdx = partnerFrameAtTick((uint16)animId,
+									  (uint)anim.size(), elapsed);
+	}
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 112092edcda..06bc8595369 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -100,7 +100,7 @@ public:
 		: _vm(vm), _mystery(mystery) {}
 
 	/** Enter site @p siteNum. Renders BG + hotspots; runs the input loop. */
-	void enter(uint siteNum);
+	void enter(uint siteNum, bool resetPartnerMood = true);
 
 	/// Run the per-mystery loop: site -> map -> next site, ESC exits.
 	void run();
@@ -111,6 +111,9 @@ private:
 	int  hotspotAtPoint(uint siteNum, int x, int y) const;
 	void updateHotspotCursor(uint siteNum, int x, int y);
 	void onHotspotClicked(uint siteNum, uint hotIdx);
+	void initImpatienceCounter();
+	bool checkImpatienceCounter();
+	void notePartnerActivity();
 
 	/// Play the partner's site-arrival sequence once `_LastSite !=
 	/// _SiteNumber`. Mirrors `_EnterSiteAnim @ 1000:9b21` — animation
@@ -168,10 +171,17 @@ private:
 
 	EEMEngine *_vm;
 	Mystery *_mystery;
+	enum PartnerWaitMood {
+		kPartnerWaitDefault,
+		kPartnerWaitPatient,
+		kPartnerWaitImpatient
+	};
 	int _lastSiteAnim = -1;        ///< Last site we played the arrival on.
 	int _snapshotSite = -1;        ///< Site number the snapshot belongs to.
 	Graphics::ManagedSurface _bgSnapshot;
 	uint32 _lastTickMs = 0;        ///< Last frame-pump tick in ms.
+	uint32 _impatientDeadlineMs = 0; ///< Test-shortened impatience deadline.
+	PartnerWaitMood _partnerWaitMood = kPartnerWaitDefault;
 
 	/// Wall-clock timestamp at which the partner's wait animation
 	/// "started" (or last restarted). The site loop renders the


Commit: 3e9ba587e4e062b839b93940b8f8c410d44e4876
    https://github.com/scummvm/scummvm/commit/3e9ba587e4e062b839b93940b8f8c410d44e4876
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:58+02:00

Commit Message:
EEM: improved scrapbook code

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 3cf5f0e11f1..792db1288d2 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -412,7 +412,8 @@ private:
 	/// @ 1df2:044c`. The file format is a 2-byte page count followed
 	/// by N pages, each `{ u16 picNum, u16 x1, u16 y1, u16 x2, u16 y2,
 	/// char text[] (null-terminated, ParseString placeholders) }`.
-	/// Blocks until the player clicks past the last page or hits ESC.
+	/// Blocks until the player exits the ending view or crosses either
+	/// page boundary.
 	/// `_ShowOneScrap @ 1f78:0773` is just `_DisplayEnding(num, 1)`,
 	/// so this same call covers the post-mystery scrapbook view from
 	/// the action menu.
@@ -430,14 +431,15 @@ private:
 	/// Mirrors `_DisplayEnding`'s `[BP-0x18]` return at 1df2:0723.
 	int doShowEnding(uint num, bool firstPage = true);
 
-	/// Walk every solved mystery in tier @p stage (1=Junior, 2=Senior,
-	/// 3=Master) and display each one's ending pages in sequence.
+	/// Browse the scrapbook tier @p stage (1=Junior, 2=Senior,
+	/// 3=Master), moving between mystery endings with the original
+	/// `_DisplayEnding` return direction.
 	/// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`: the original
 	/// computes the mystery range from `(stage - 1) * 0x18 + 1` to
-	/// `(stage - 1) * 0x18 + 0x18` and skips entries whose
-	/// `_3f9b[i] == 0` (unsolved) so the scrapbook only contains
-	/// completed cases. Used by both the setup-screen ScrapBook
-	/// buttons and the action-menu "See ScrapBook 1/2/3" entries.
+	/// `(stage - 1) * 0x18 + 0x18`. If this is the current chain stage,
+	/// unsolved entries are skipped; completed tiers are already solved.
+	/// Used by both the setup-screen ScrapBook buttons and the
+	/// action-menu "See ScrapBook 1/2/3" entries.
 	void doShowScrapbook(uint stage);
 
 	void doCaseSelection();
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index ca62f428557..b746dd267bc 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -649,21 +649,24 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	// Walk page records. Each page header is 10 bytes; text is
 	// null-terminated and follows the header.
 	uint pageOffsets[8];   // ENDING_RANGE_MAX from `_DisplayEnding`
-	const uint kMaxPages = MIN<uint>(pageCount,
-									 (uint)(sizeof(pageOffsets) / sizeof(uint)));
+	const uint maxPages = MIN<uint>(pageCount,
+									(uint)(sizeof(pageOffsets) / sizeof(uint)));
 	uint cursor = 2;
-	for (uint p = 0; p < kMaxPages; p++) {
-		pageOffsets[p] = cursor;
+	uint validPages = 0;
+	for (uint p = 0; p < maxPages; p++) {
 		if (cursor + 10 >= size)
 			break;
+		pageOffsets[validPages++] = cursor;
 		// Skip the 10-byte header and find the null terminator.
 		cursor += 10;
 		while (cursor < size && buf[cursor] != 0)
 			cursor++;
 		cursor++;  // past the null
 	}
+	if (validPages == 0)
+		return 0;
 
-	uint pageIdx = firstPage ? 0 : (kMaxPages - 1);
+	uint pageIdx = firstPage ? 0 : (validPages - 1);
 	int direction = 0;     // -1 / 0 / +1, see header doc.
 	bool exitLoop = false;
 	bool dirty = true;
@@ -702,15 +705,6 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 										   textW, text, 0);
 			}
 
-			// Page indicator at top-right ("page 1/3"). Stays in the
-			// main font + color 0xF so it doesn't blend into the
-			// newspaper masthead.
-			if (_font.isLoaded() && kMaxPages > 1) {
-				const Common::String hdr = Common::String::format(
-					"%u/%u", (unsigned)pageIdx + 1, (unsigned)kMaxPages);
-				_font.drawString(&scratch, hdr, 280, 4, 32, 0xF);
-			}
-
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, 320, 200);
 			g_system->updateScreen();
@@ -726,22 +720,17 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				switch (ev.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
-					// ESC is the ONLY way out of the newspaper view.
-					// Departs from the original `_DisplayEnding`, which
-					// also exits on boundary arrow keys / clicks
-					// (1df2:0689 / 1df2:06a0); the boundary-exit path is
-					// what fed `[BP-0x18]` to `_ShowScrapbook` for
-					// per-mystery scrapbook navigation. We don't expose
-					// that — clicking ESC closes the scrapbook entirely.
 					direction = 0;
 					exitLoop = true;
 					break;
 				case Common::KEYCODE_LEFT:
 				case Common::KEYCODE_PAGEUP:
-					// Clamp at page 0 — never exit on LEFT.
 					if (pageIdx > 0) {
 						pageIdx--;
 						dirty = true;
+					} else {
+						direction = -1;
+						exitLoop = true;
 					}
 					break;
 				case Common::KEYCODE_RIGHT:
@@ -750,10 +739,12 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 				case Common::KEYCODE_KP_ENTER:
 				case Common::KEYCODE_SPACE:
 				case Common::KEYCODE_TAB:
-					// Clamp at last page — never exit on RIGHT either.
-					if (pageIdx + 1 < kMaxPages) {
+					if (pageIdx + 1 < validPages) {
 						pageIdx++;
 						dirty = true;
+					} else {
+						direction = 1;
+						exitLoop = true;
 					}
 					break;
 				default:
@@ -763,22 +754,30 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 					break;
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Mouse clicks shift pages too — never exit on click.
-				// Use the original PrevPage/NextPage rect split for
-				// click direction (29be:1078 / 29be:1080); clicks
-				// outside both rects fall through to next-page so the
-				// player still gets some feedback.
-				const Common::Rect kPrevPageRect(0, 0, 27, 200);
+				// Original PrevPage/NextPage rects at 29be:1078 /
+				// 29be:1080. Clicks outside both rects exit with
+				// direction 0.
+				const Common::Rect kPrevPageRect(0, 0, 28, 200);
+				const Common::Rect kNextPageRect(28, 7, 317, 197);
 				if (kPrevPageRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (pageIdx > 0) {
 						pageIdx--;
 						dirty = true;
+					} else {
+						direction = -1;
+						exitLoop = true;
 					}
-				} else {
-					if (pageIdx + 1 < kMaxPages) {
+				} else if (kNextPageRect.contains(ev.mouse.x, ev.mouse.y)) {
+					if (pageIdx + 1 < validPages) {
 						pageIdx++;
 						dirty = true;
+					} else {
+						direction = 1;
+						exitLoop = true;
 					}
+				} else {
+					direction = 0;
+					exitLoop = true;
 				}
 				if (exitLoop)
 					break;
@@ -791,40 +790,53 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 }
 
 void EEMEngine::doShowScrapbook(uint stage) {
-	// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`. Walk the
-	// stage's mystery range and call `_DisplayEnding` on each solved
-	// mystery. The original splits the 55 cases into three tiers:
-	//   stage 1 (Junior) → mysteries  1..0x18 (24 cases)
-	//   stage 2 (Senior) → mysteries 0x19..0x30 (24 cases)
-	//   stage 3 (Master) → mysteries 0x31..0x36 (6 cases)
-	// Each tier's range is `lo = (stage-1)*0x18 + 1`, `hi = lo + 0x17`
-	// (verified at 1f78:064b: `iVar1 = (param_1 - 1) * 0x18; uVar2 =
-	// iVar1 + 1`). The current-stage filter at 1f78:065e
-	// (`if (DAT_2d5d_3f99 == param_1)`) skips unsolved mysteries
-	// inside the player's CURRENT tier — completed tiers show every
-	// mystery regardless. We mirror that exactly.
+	// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`. The original
+	// splits the cases into 24-entry tiers, then feeds each ending
+	// viewer return direction back into the tier walk:
+	//   -1 -> previous mystery, opened at its last page
+	//    0 -> close scrapbook
+	//   +1 -> next mystery, opened at its first page
+	// When the requested tier is the player's current chain stage, the
+	// original skips unsolved entries; completed tiers are already solved,
+	// so they are shown as a full range.
 	if (stage < 1 || stage > 3)
 		return;
-	const uint lo = (stage - 1) * 0x18 + 1;
-	const uint hi = lo + 0x17;
+	const int solvedCount =
+		(int)(sizeof(_mysteriesSolved) / sizeof(_mysteriesSolved[0]));
+	const int lo = (int)(stage - 1) * 0x18 + 1;
+	const int hi = MIN<int>(lo + 0x18, solvedCount);
+	if (lo >= hi)
+		return;
 	const bool currentTier = (stage == _chainStage);
 
-	// `doShowEnding` only reports `direction = 0` now (ESC), so we
-	// can't use the original 1f78:067e/0698 forward-backward walk.
-	// Instead iterate every solved mystery in the tier linearly:
-	// each ESC closes the current newspaper and moves us to the
-	// next solved entry. Departs from `_ShowScrapbook @ 1f78:0642`
-	// (which let the player browse via the boundary keys), but
-	// matches the user-requested "ESC is the only exit" rule.
-	for (uint m = lo; m <= hi && !shouldQuit(); m++) {
-		if (m >= sizeof(_mysteriesSolved))
+	int mystery = lo;
+	if (currentTier) {
+		while (mystery < hi && _mysteriesSolved[mystery] == 0)
+			mystery++;
+	}
+
+	bool firstPage = true;
+	while (!shouldQuit() && mystery >= lo && mystery < hi) {
+		const int direction = doShowEnding((uint)mystery, firstPage);
+		if (direction < 0) {
+			if (mystery == lo)
+				break;
+			mystery--;
+			if (currentTier) {
+				while (mystery >= lo && _mysteriesSolved[mystery] == 0)
+					mystery--;
+			}
+			firstPage = false;
+		} else if (direction > 0) {
+			mystery++;
+			if (currentTier) {
+				while (mystery < hi && _mysteriesSolved[mystery] == 0)
+					mystery++;
+			}
+			firstPage = true;
+		} else {
 			break;
-		// Current-tier filter (1f78:0664). Completed tiers show all
-		// 24 entries; the active tier hides unsolved ones because
-		// the player hasn't earned that scrapbook page yet.
-		if (currentTier && _mysteriesSolved[m] == 0)
-			continue;
-		(void)doShowEnding(m, /*firstPage=*/true);
+		}
 	}
 }
 
@@ -1176,31 +1188,29 @@ void EEMEngine::doSetup() {
 			}
 
 			// ScrapBook 1 / 2 / 3 (buttons [3] / [4] / [5]). Original
-			// handlers at 1f78:021F (`_ShowScrapbook(0, 1)`) /
-			// 1f78:022E (gated chain >= 2 / `_ShowScrapbook(0, 2)`) /
-			// 1f78:0244 (gated chain >= 3 / `_ShowScrapbook(0, 3)`).
-			// Convert the original's `(0, stage)` invocation into our
-			// `doShowScrapbook(stage)` (we collapse the param_1=0
-			// "no-current-mystery" indirection — relevant only for
-			// the post-win callsite).
-			auto runScrapbook = [&](uint stage) {
+			// handlers call `_ShowScrapbook(stage, 0)`, with stages 2
+			// and 3 gated by chain progress.
+			if (kScrap1Btn.contains(mx, my)) {
 				CursorMan.showMouse(false);
-				doShowScrapbook(stage);
+				doShowScrapbook(1);
 				CursorMan.showMouse(true);
 				setSitePalette(0);
-			};
-			if (kScrap1Btn.contains(mx, my)) {
-				runScrapbook(1);
 				dirty = true;
 				continue;
 			}
 			if (kScrap2Btn.contains(mx, my) && _chainStage >= 2) {
-				runScrapbook(2);
+				CursorMan.showMouse(false);
+				doShowScrapbook(2);
+				CursorMan.showMouse(true);
+				setSitePalette(0);
 				dirty = true;
 				continue;
 			}
 			if (kScrap3Btn.contains(mx, my) && _chainStage >= 3) {
-				runScrapbook(3);
+				CursorMan.showMouse(false);
+				doShowScrapbook(3);
+				CursorMan.showMouse(true);
+				setSitePalette(0);
 				dirty = true;
 				continue;
 			}


Commit: 5042edef3af445d4d7d272f15601179d6a51b6ac
    https://github.com/scummvm/scummvm/commit/5042edef3af445d4d7d272f15601179d6a51b6ac
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:58+02:00

Commit Message:
EEM: highlight scrapbook interactive zones

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 8959705a931..6ca48a2a385 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -61,6 +61,10 @@ const uint kPicMousePointer    = 0x50;  ///< Original startup pointer; 0x51 is t
 
 const byte kSaveBodyVer = 1;
 
+// Internal test switch: populate ScrapBook 1 at startup without exposing a
+// game option or changing save format. Set false before release.
+const bool kDebugPopulateScrapbook1AtStartup = false;
+
 // Fallback 11x16 mouse cursor used if the selected PIC pointer cannot be
 // loaded. The original game sets the cursor visible/hidden via
 // _MouseCursor; we leave it on once the screens that need it
@@ -89,13 +93,13 @@ const byte kCursorPalette[] = {
 	0x00, 0x00, 0x00, // 1 — outline
 	0xFF, 0xFF, 0xFF  // 2 — fill
 };
-const byte kCursorHotspotPalette[] = {
+const byte kCursorInteractivePalette[] = {
 	0x00, 0x00, 0x00, // 0 — transparent (key)
 	0xFF, 0x00, 0x00, // 1 — red outline
 	0xFF, 0xFF, 0xFF  // 2 — white fill
 };
 
-static void setHotspotCursorPalette(const Picture &cursor, byte transparent) {
+static void setInteractiveCursorPalette(const Picture &cursor, byte transparent) {
 	byte palette[kPalSize];
 	bool used[256];
 	memset(used, 0, sizeof(used));
@@ -141,14 +145,14 @@ static void setHotspotCursorPalette(const Picture &cursor, byte transparent) {
 	CursorMan.replaceCursorPalette(palette, 0, 256);
 }
 
-static void installMouseCursor(DBDArchive &pics, bool hotspot) {
+static void installMouseCursor(DBDArchive &pics, bool interactive) {
 	Picture cursor;
 	if (pics.getPicture(kPicMousePointer, cursor) && !cursor.surface.empty()) {
 		const byte transparent = (byte)(cursor.flags >> 8);
 		CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0,
 								transparent);
-		if (hotspot)
-			setHotspotCursorPalette(cursor, transparent);
+		if (interactive)
+			setInteractiveCursorPalette(cursor, transparent);
 		else
 			CursorMan.replaceCursorPalette(nullptr, 0, 0);
 		return;
@@ -157,8 +161,8 @@ static void installMouseCursor(DBDArchive &pics, bool hotspot) {
 	warning("EEM: mouse cursor PIC 0x%x missing; using fallback cursor",
 			kPicMousePointer);
 	CursorMan.replaceCursor(kCursorBitmap, 11, 16, 0, 0, 0);
-	CursorMan.replaceCursorPalette(hotspot ? kCursorHotspotPalette
-										   : kCursorPalette,
+	CursorMan.replaceCursorPalette(interactive ? kCursorInteractivePalette
+											   : kCursorPalette,
 								   0, 3);
 }
 
@@ -183,6 +187,17 @@ EEMEngine::~EEMEngine() {
 	delete _music;
 }
 
+void EEMEngine::applyStartupTestOverrides() {
+	if (!kDebugPopulateScrapbook1AtStartup)
+		return;
+
+	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
+		_mysteriesSolved[i] = 1;
+
+	debugC(1, kDebugGeneral,
+		   "startup test override: populated ScrapBook 1 mystery flags");
+}
+
 Common::Error EEMEngine::run() {
 	// _SetMode13X @ 1000:0358 enters VGA mode 13h (320x200x256).
 	initGraphics(320, 200);
@@ -243,6 +258,7 @@ Common::Error EEMEngine::run() {
 	if (wantedSave >= 0) {
 		const Common::Error err = loadGameState(wantedSave);
 		if (err.getCode() == Common::kNoError) {
+			applyStartupTestOverrides();
 			CursorMan.showMouse(true);
 			if (_mystery.isLoaded()) {
 				debugC(1, kDebugGeneral,
@@ -390,6 +406,8 @@ Common::Error EEMEngine::run() {
 	// picks "[New Player]".
 	if (!shouldQuit())
 		doProfilePicker();
+	if (!shouldQuit())
+		applyStartupTestOverrides();
 	if (!shouldQuit())
 		doChoosePartner();
 
@@ -549,15 +567,18 @@ screen_loop:
 	return Common::kNoError;
 }
 
-void EEMEngine::setHotspotMouseCursor(bool active) {
-	active = active && ConfMan.getBool("hide_highlight_boxes");
-	if (_hotspotMouseCursor == active)
+void EEMEngine::setInteractiveMouseCursor(bool active) {
+	if (_interactiveMouseCursor == active)
 		return;
 
-	_hotspotMouseCursor = active;
+	_interactiveMouseCursor = active;
 	installMouseCursor(_picsArchive, active);
 }
 
+void EEMEngine::setHotspotMouseCursor(bool active) {
+	setInteractiveMouseCursor(active);
+}
+
 bool EEMEngine::openArchives() {
 	// _InitGraphicsSystem @ 172b:0145 opens these five .DBD/.DBX pairs.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 792db1288d2..d6080d93fbd 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -188,8 +188,12 @@ public:
 	const EEMFont &getFont() const { return _font; }
 	uint8       getPartnerIndex() const { return _partner; }
 
-	/// Switch to a lightly tinted cursor while the mouse is over a
-	/// searchable hotspot and the highlight boxes are hidden.
+	/// Switch to the red-outline cursor used to hint at interactive
+	/// regions that do not have their own original highlight art.
+	void setInteractiveMouseCursor(bool active);
+
+	/// Switch to the interactive cursor while the mouse is over a
+	/// searchable hotspot.
 	void setHotspotMouseCursor(bool active);
 
 	/// Display one ClueBlock. @p clueBlock points at the u16 frame count
@@ -302,6 +306,8 @@ public:
 	bool areYouSure();
 
 private:
+	void applyStartupTestOverrides();
+
 	/**
 	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
 	 * Each iteration calls the screen handler that matches _nextScreen.
@@ -426,7 +432,7 @@ private:
 	/// Returns the direction the user wants the caller to navigate:
 	///   -1 → previous mystery (LEFT pressed on the first page or
 	///        click in `PrevPageRect` while on first page),
-	///    0 → exit the scrapbook (ESC or click outside both rects),
+	///    0 → exit the scrapbook (ESC / quit; central clicks are ignored),
 	///   +1 → next mystery (RIGHT/SPACE/Enter/click on last page).
 	/// Mirrors `_DisplayEnding`'s `[BP-0x18]` return at 1df2:0723.
 	int doShowEnding(uint num, bool firstPage = true);
@@ -569,7 +575,7 @@ private:
 	/// `setPartnerEraseBg`.
 	Graphics::ManagedSurface _partnerEraseBg;
 
-	bool _hotspotMouseCursor = false;
+	bool _interactiveMouseCursor = false;
 
 	/// XMIDI music player. Mirrors the original `MIDI.C` family
 	/// (`_MIDIPlayFile`, `_MIDIPlay`, `_StopMIDI`, `_StartTravelMusic`
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index b746dd267bc..d7dd55bf19d 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -55,6 +55,9 @@ const GallerySlot kGallerySlots[5] = {
 	{ 191,  90 }  // 4
 };
 
+constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
+constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
+
 // Floppy gallery slot positions verified at `2608:0x16c` (5 ×
 // {u16 x, u16 y}) — read by `_DrawGallery_Floppy @ 154e:0045`'s
 // `[BX + 0x16c]` (x) and `[BX + 0x16e]` (y) loads. The floppy
@@ -596,14 +599,14 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	// colour), with no drop shadow. Verified at the call site asm
 	// 1df2:04cf-1df2:04f4 (Ghidra mis-paired the two trailing args).
 	//
-	// Page navigation mirrors the original key/click handlers
+	// Keyboard page navigation mirrors the original handlers
 	// (1df2:0689 / 1df2:06a0): LEFT decrements pageIdx, RIGHT (or
-	// SPACE / Enter / click) increments it. Hitting the boundary
-	// (LEFT on page 0, RIGHT on last page) sets `[BP-0x18]` to -1 / 1
-	// respectively and exits — that return value is what
-	// `_ShowScrapbook` uses to walk forward / backward through
-	// solved mysteries (see 1f78:0664-1f78:069c). ESC and clicks
-	// outside both PrevPage / NextPage rects exit with `[BP-0x18]=0`.
+	// SPACE / Enter) increments it. Hitting the boundary (LEFT on page
+	// 0, RIGHT on last page) sets `[BP-0x18]` to -1 / 1 respectively
+	// and exits — that return value is what `_ShowScrapbook` uses to
+	// walk forward / backward through solved mysteries (see
+	// 1f78:0664-1f78:069c). Mouse navigation is intentionally limited
+	// to the red-highlighted edge rects so central page clicks are ignored.
 	//
 	// `firstPage=false` opens the ending at the LAST page (used by
 	// `doShowScrapbook` after a "previous mystery" navigation —
@@ -645,6 +648,7 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	// text in particular is palette index 0 (= newspaper black) so we
 	// MUST switch palettes before rendering.
 	setSitePalette(0);
+	CursorMan.showMouse(true);
 
 	// Walk page records. Each page header is 10 bytes; text is
 	// null-terminated and follows the header.
@@ -670,6 +674,11 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	int direction = 0;     // -1 / 0 / +1, see header doc.
 	bool exitLoop = false;
 	bool dirty = true;
+	const Common::Point mousePos = g_system->getEventManager()->getMousePos();
+	setInteractiveMouseCursor(kEndingPrevPageRect.contains(mousePos.x,
+														   mousePos.y) ||
+							  kEndingNextPageRect.contains(mousePos.x,
+														   mousePos.y));
 	while (!shouldQuit() && !exitLoop) {
 		if (dirty) {
 			const uint off = pageOffsets[pageIdx];
@@ -715,8 +724,14 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-				return 0;
+				direction = 0;
+				exitLoop = true;
+				break;
 			}
+			if (ev.type == Common::EVENT_MOUSEMOVE)
+				setInteractiveMouseCursor(
+					kEndingPrevPageRect.contains(ev.mouse.x, ev.mouse.y) ||
+					kEndingNextPageRect.contains(ev.mouse.x, ev.mouse.y));
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				switch (ev.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
@@ -754,12 +769,10 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 					break;
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Original PrevPage/NextPage rects at 29be:1078 /
-				// 29be:1080. Clicks outside both rects exit with
-				// direction 0.
-				const Common::Rect kPrevPageRect(0, 0, 28, 200);
-				const Common::Rect kNextPageRect(28, 7, 317, 197);
-				if (kPrevPageRect.contains(ev.mouse.x, ev.mouse.y)) {
+				setInteractiveMouseCursor(
+					kEndingPrevPageRect.contains(ev.mouse.x, ev.mouse.y) ||
+					kEndingNextPageRect.contains(ev.mouse.x, ev.mouse.y));
+				if (kEndingPrevPageRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (pageIdx > 0) {
 						pageIdx--;
 						dirty = true;
@@ -767,7 +780,7 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 						direction = -1;
 						exitLoop = true;
 					}
-				} else if (kNextPageRect.contains(ev.mouse.x, ev.mouse.y)) {
+				} else if (kEndingNextPageRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (pageIdx + 1 < validPages) {
 						pageIdx++;
 						dirty = true;
@@ -775,9 +788,6 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 						direction = 1;
 						exitLoop = true;
 					}
-				} else {
-					direction = 0;
-					exitLoop = true;
 				}
 				if (exitLoop)
 					break;
@@ -786,6 +796,7 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
+	setInteractiveMouseCursor(false);
 	return direction;
 }
 
@@ -1191,25 +1202,19 @@ void EEMEngine::doSetup() {
 			// handlers call `_ShowScrapbook(stage, 0)`, with stages 2
 			// and 3 gated by chain progress.
 			if (kScrap1Btn.contains(mx, my)) {
-				CursorMan.showMouse(false);
 				doShowScrapbook(1);
-				CursorMan.showMouse(true);
 				setSitePalette(0);
 				dirty = true;
 				continue;
 			}
 			if (kScrap2Btn.contains(mx, my) && _chainStage >= 2) {
-				CursorMan.showMouse(false);
 				doShowScrapbook(2);
-				CursorMan.showMouse(true);
 				setSitePalette(0);
 				dirty = true;
 				continue;
 			}
 			if (kScrap3Btn.contains(mx, my) && _chainStage >= 3) {
-				CursorMan.showMouse(false);
 				doShowScrapbook(3);
-				CursorMan.showMouse(true);
 				setSitePalette(0);
 				dirty = true;
 				continue;
@@ -1493,9 +1498,7 @@ void EEMEngine::doCaseSelection() {
 		// — viewing the scrapbook never starts a new case.
 		const uint stage = (pick == kPickScrap1) ? 1
 						 : (pick == kPickScrap2) ? 2 : 3;
-		CursorMan.showMouse(false);
 		doShowScrapbook(stage);
-		CursorMan.showMouse(true);
 		setSitePalette(0);
 		_mystery.clear();
 		return;


Commit: 916c34e2b2163222ad0cba2f06518cc53a604cd4
    https://github.com/scummvm/scummvm/commit/916c34e2b2163222ad0cba2f06518cc53a604cd4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:58+02:00

Commit Message:
EEM: highlight pda interactive zones

Changed paths:
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index c47e8882f4d..1a71f83e417 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -35,6 +35,10 @@
 
 namespace EEM {
 
+constexpr Common::Rect kSitePdaRect(Common::Point(35, 111), 21, 25);
+constexpr Common::Rect kSitePartnerFootMapRect(Common::Point(7, 177), 50, 23);
+constexpr Common::Rect kSitePartnerHeadHintRect(Common::Point(5, 80), 39, 30);
+
 // Masked blit a Picture into a ManagedSurface. Pixels equal to `transp`
 // (the high byte of `pic.flags`, per `_Rect_Move_Mask @ 1000:03fc`) are
 // skipped. Used by `enterSiteAnim` for both skateboard + KD slide-in
@@ -761,27 +765,24 @@ void SiteScreen::run() {
 				//                                       (CD `_NextScreen = 1`,
 				//                                       floppy = 2)
 				// Test the buttons before falling through to hotspots so
-				// a click on the PDA / map icon doesn't accidentally
+				// a click on the PDA / partner foot doesn't accidentally
 				// trigger a hotspot underneath.
-				const Common::Rect kBtnNotebook(35, 111, 56, 136);
-				const Common::Rect kBtnMap     ( 7, 177, 57, 200);
-				// Partner area — port-only enhancement so the player
+				// Partner head is a port-only enhancement so the player
 				// can click the host sprite for a hint, mirroring the
 				// PDA's rect-3 / gallery's rect-3 behaviour. The
 				// original site loop's `_FindButton(&SiteButtons, 2,
 				// ...)` only checks notebook + map, but the same
-				// partner-click → `_KDHelp` shortcut is wired in
+				// partner-click -> `_KDHelp` shortcut is wired in
 				// `_HandleNoteButton[3]` (0x0403) and
 				// `_HandleGalleryButton[3]` (0x061e). Rect matches
 				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
-				const Common::Rect kBtnPartner ( 5,  80, 44, 110);
-				if (kBtnNotebook.contains(event.mouse.x, event.mouse.y)) {
+				if (kSitePdaRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					_vm->setNextScreen(kScreenNotebook);
 					return;
 				}
-				if (kBtnMap.contains(event.mouse.x, event.mouse.y)) {
+				if (kSitePartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					// CD writes `_NextScreen = 1`; floppy writes 2.
@@ -789,7 +790,7 @@ void SiteScreen::run() {
 													   : kScreenMap);
 					return;
 				}
-				if (kBtnPartner.contains(event.mouse.x, event.mouse.y)) {
+				if (kSitePartnerHeadHintRect.contains(event.mouse.x, event.mouse.y)) {
 					_vm->setHotspotMouseCursor(false);
 					_vm->doHelp();
 					notePartnerActivity();
@@ -1477,7 +1478,10 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 	if (!_vm)
 		return;
-	_vm->setHotspotMouseCursor(hotspotAtPoint(siteNum, x, y) >= 0);
+	const bool siteControl = kSitePdaRect.contains(x, y) ||
+							 kSitePartnerFootMapRect.contains(x, y) ||
+							 kSitePartnerHeadHintRect.contains(x, y);
+	_vm->setHotspotMouseCursor(siteControl || hotspotAtPoint(siteNum, x, y) >= 0);
 }
 
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index d7dd55bf19d..0eec0d2fe5e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -58,6 +58,55 @@ const GallerySlot kGallerySlots[5] = {
 constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
 constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
 
+constexpr Common::Rect kPdaHelpRect(Common::Point(93, 174), 22, 16);
+constexpr Common::Rect kPdaNotebookRect(Common::Point(134, 174), 21, 16);
+constexpr Common::Rect kPdaGalleryRect(Common::Point(157, 174), 21, 16);
+constexpr Common::Rect kPdaPartnerHeadHintRect(Common::Point(5, 80), 39, 30);
+constexpr Common::Rect kPdaAccuseRect(Common::Point(180, 174), 21, 16);
+constexpr Common::Rect kPdaPageNextRect(Common::Point(204, 174), 20, 16);
+constexpr Common::Rect kPdaPagePrevRect(Common::Point(226, 174), 21, 16);
+constexpr Common::Rect kPdaHelp2Rect(Common::Point(267, 174), 21, 16);
+constexpr Common::Rect kPdaPartnerFootMapRect(Common::Point(7, 177), 50, 23);
+constexpr Common::Rect kPdaSiteRect(Common::Point(35, 111), 21, 25);
+
+bool notebookButtonAt(int x, int y) {
+	return kPdaHelpRect.contains(x, y) ||
+		   kPdaGalleryRect.contains(x, y) ||
+		   kPdaPartnerHeadHintRect.contains(x, y) ||
+		   kPdaAccuseRect.contains(x, y) ||
+		   kPdaPageNextRect.contains(x, y) ||
+		   kPdaPagePrevRect.contains(x, y) ||
+		   kPdaHelp2Rect.contains(x, y) ||
+		   kPdaPartnerFootMapRect.contains(x, y) ||
+		   kPdaSiteRect.contains(x, y);
+}
+
+bool galleryButtonAt(int x, int y) {
+	return kPdaSiteRect.contains(x, y) ||
+		   kPdaPartnerFootMapRect.contains(x, y) ||
+		   kPdaAccuseRect.contains(x, y) ||
+		   kPdaNotebookRect.contains(x, y) ||
+		   kPdaHelpRect.contains(x, y) ||
+		   kPdaPartnerHeadHintRect.contains(x, y);
+}
+
+bool rectListContains(const Common::Array<Common::Rect> &rects, int x, int y) {
+	for (uint i = 0; i < rects.size(); i++) {
+		if (rects[i].contains(x, y))
+			return true;
+	}
+	return false;
+}
+
+bool gallerySlotAt(const Common::Array<Common::Rect> &rects,
+				   const Common::Array<int> &suspects, int x, int y) {
+	for (uint i = 0; i < rects.size() && i < suspects.size(); i++) {
+		if (suspects[i] >= 0 && rects[i].contains(x, y))
+			return true;
+	}
+	return false;
+}
+
 // Floppy gallery slot positions verified at `2608:0x16c` (5 ×
 // {u16 x, u16 y}) — read by `_DrawGallery_Floppy @ 154e:0045`'s
 // `[BX + 0x16c]` (x) and `[BX + 0x16e]` (y) loads. The floppy
@@ -1749,15 +1798,6 @@ void EEMEngine::doNotebook() {
 	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
 	//   rect 9 (0,0)     → 0x03ed = same as rect 8
 	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
-	const Common::Rect kBtnHelp1   ( 93, 174, 115, 190);  // [1] HELP
-	const Common::Rect kBtnGallery (157, 174, 178, 190);  // [2] GALLERY
-	const Common::Rect kBtnPartner (  5,  80,  44, 110);  // [3] KD HELP
-	const Common::Rect kBtnAccuse  (180, 174, 201, 190);  // [4] SOLVE
-	const Common::Rect kBtnPageNext(204, 174, 224, 190);  // [5] PAGE NEXT
-	const Common::Rect kBtnPagePrev(226, 174, 247, 190);  // [6] PAGE PREV
-	const Common::Rect kBtnMap     (  7, 177,  57, 200);  // [7] MAP
-	const Common::Rect kBtnSite    ( 35, 111,  56, 136);  // [8] SITE
-	const Common::Rect kBtnHelp2   (267, 174, 288, 190);  // [10] extra HELP
 	// (`_NoteButtons @ 29be:0147` actually has rect [10] at
 	// (267,174,288,190) — small button on the right of the bottom
 	// bar that the original handler dispatch table at 161e:04ec
@@ -1772,6 +1812,10 @@ void EEMEngine::doNotebook() {
 	(void)hoveredNoteSlot;
 
 	drawNotebookFrame(page);
+	Common::Point mouse = g_system->getEventManager()->getMousePos();
+	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
+							  rectListContains(_notebookSlotRects,
+											   mouse.x, mouse.y));
 
 	uint32 lastDraw = g_system->getMillis();
 
@@ -1786,6 +1830,13 @@ void EEMEngine::doNotebook() {
 				exitFlag = true;
 				break;
 			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				setInteractiveMouseCursor(notebookButtonAt(ev.mouse.x,
+														   ev.mouse.y) ||
+										  rectListContains(_notebookSlotRects,
+														   ev.mouse.x,
+														   ev.mouse.y));
+			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_nextScreen = kScreenSite;
@@ -1809,52 +1860,55 @@ void EEMEngine::doNotebook() {
 				// button 0 / 9 are dead zones, so check the actionable
 				// rects directly. Earlier rects "win" when overlapping
 				// (matches `_FindButton`).
-				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenSite;
 					exitFlag = true;
 					break;  // back to site
 				}
-				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPartnerFootMapRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenMapAlt;
 					exitFlag = true;
 					break;
 				}
-				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPartnerHeadHintRect.contains(ev.mouse.x, ev.mouse.y)) {
+					setInteractiveMouseCursor(false);
 					doHelp();              // _KDHelp = host hint
 					dirty = true;
 					continue;
 				}
-				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaAccuseRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenAccuse;
 					exitFlag = true;
 					break;
 				}
-				if (kBtnGallery.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaGalleryRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenGallery;
 					exitFlag = true;
 					break;
 				}
-				if (kBtnHelp1.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
 					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
 					// blits PICs 0x63 / 0x1ae fullscreen for click-through.
+					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
 					continue;
 				}
-				if (kBtnPagePrev.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPagePrevRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (page > 0)
 						page--;
 					dirty = true;
 					continue;
 				}
-				if (kBtnPageNext.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPageNextRect.contains(ev.mouse.x, ev.mouse.y)) {
 					page++;
 					dirty = true;
 					continue;
 				}
-				if (kBtnHelp2.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaHelp2Rect.contains(ev.mouse.x, ev.mouse.y)) {
 					// `_NoteButtons[10]` → handler 0x03f9 = same
 					// `_InterfaceHelp(0)` as button [1].
+					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
 					continue;
@@ -1887,10 +1941,15 @@ void EEMEngine::doNotebook() {
 		if (dirty || now - lastDraw >= 100) {
 			drawNotebookFrame(page);
 			lastDraw = now;
+			mouse = g_system->getEventManager()->getMousePos();
+			setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
+									  rectListContains(_notebookSlotRects,
+													   mouse.x, mouse.y));
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
+	setInteractiveMouseCursor(false);
 }
 
 void EEMEngine::drawNotebookFrame(int &page) {
@@ -2126,6 +2185,10 @@ void EEMEngine::doGallery() {
 	}
 
 	drawGalleryFrame(gd, num, slotRects, slotSuspect);
+	Common::Point mouse = g_system->getEventManager()->getMousePos();
+	setInteractiveMouseCursor(galleryButtonAt(mouse.x, mouse.y) ||
+							  gallerySlotAt(slotRects, slotSuspect,
+											mouse.x, mouse.y));
 	uint32 lastDraw = g_system->getMillis();
 
 	while (!shouldQuit()) {
@@ -2135,8 +2198,17 @@ void EEMEngine::doGallery() {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
 				_nextScreen = kScreenInvalid;
+				setInteractiveMouseCursor(false);
 				return;
 			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				setInteractiveMouseCursor(galleryButtonAt(ev.mouse.x,
+														  ev.mouse.y) ||
+										  gallerySlotAt(slotRects,
+														slotSuspect,
+														ev.mouse.x,
+														ev.mouse.y));
+			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_nextScreen = kScreenSite;
@@ -2160,37 +2232,37 @@ void EEMEngine::doGallery() {
 				//   rect 6 (226,247) → 0x0638 = generic exit
 				//   rect 7 (7,177)   → 0x05f7 = `_NextScreen = 2` (MAP)
 				//   rect 8 (35,111)  → 0x05e4 = `_NextScreen = 3` (SITE)
-				const Common::Rect kBtnSite    ( 35, 111,  56, 136); // [8] SITE
-				const Common::Rect kBtnMap     (  7, 177,  57, 200); // [7] MAP
-				const Common::Rect kBtnAccuse  (180, 174, 201, 190); // [4] SOLVE
-				const Common::Rect kBtnNotebook(134, 174, 155, 190); // [0] NOTEBOOK (back to PDA notes)
-				const Common::Rect kBtnHelp    ( 93, 174, 115, 190); // [1] HELP
-				const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
-				if (kBtnSite.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenSite;
-					exitFlag = true; break;
+					exitFlag = true;
+					break;
 				}
-				if (kBtnMap.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPartnerFootMapRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenMapAlt;
-					exitFlag = true; break;
+					exitFlag = true;
+					break;
 				}
-				if (kBtnAccuse.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaAccuseRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenAccuse;
-					exitFlag = true; break;
+					exitFlag = true;
+					break;
 				}
-				if (kBtnNotebook.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaNotebookRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenNotebook;
-					exitFlag = true; break;
+					exitFlag = true;
+					break;
 				}
-				if (kBtnHelp.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
 					// Gallery rect 1 → `_InterfaceHelp(0)` per jmp table at
 					// 158f:0625 (HandleGalleryButton). Same picture sequence
 					// as the notebook HELP button.
+					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					lastDraw = 0;
 					continue;
 				}
-				if (kBtnPartner.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaPartnerHeadHintRect.contains(ev.mouse.x, ev.mouse.y)) {
+					setInteractiveMouseCursor(false);
 					doHelp();
 					lastDraw = 0;
 					continue;
@@ -2228,18 +2300,18 @@ void EEMEngine::doGallery() {
 						const bool floppyMI = isFloppy();
 						const uint suspectIdx = (uint)slotSuspect[i];
 						const byte *suspect = floppyMI
-							? _mystery.floppySuspectEntry(suspectIdx)
-							: gd + suspectIdx * 0x46;
+												  ? _mystery.floppySuspectEntry(suspectIdx)
+												  : gd + suspectIdx * 0x46;
 						if (!suspect)
 							break;
 						const uint16 detailPic =
 							READ_LE_UINT16(suspect + 0);
 						const uint clueCount = floppyMI
-							? (uint)suspect[4]
-							: READ_LE_UINT16(suspect + 8);
+												   ? (uint)suspect[4]
+												   : READ_LE_UINT16(suspect + 8);
 
 						Graphics::ManagedSurface ms(320, 200,
-							Graphics::PixelFormat::createFormatCLUT8());
+													Graphics::PixelFormat::createFormatCLUT8());
 						ms.clear();
 						if (haveBg) {
 							const int bw = MIN<int>(galBg.surface.w, 320);
@@ -2260,19 +2332,20 @@ void EEMEngine::doGallery() {
 						// stays visible on the left. Without this
 						// blit the suspect-detail screen has no
 						// partner.
+						setInteractiveMouseCursor(false);
 						{
 							const uint partnerAnim =
 								(_partner == 0) ? 2 : 0x10;
 							Animation partnerAni;
 							if (_aniArchive.loadAnimation(partnerAnim,
-														   partnerAni) &&
+														  partnerAni) &&
 								!partnerAni.empty()) {
 								const uint32 now = g_system->getMillis();
 								const uint frameIdx =
 									partnerFrameAtTick(0x02,
-										(uint)partnerAni.size(), now);
+													   (uint)partnerAni.size(), now);
 								blitAnimFrameAnchored(ms.surfacePtr(),
-									partnerAni[frameIdx], 5, 0x50);
+													  partnerAni[frameIdx], 5, 0x50);
 							}
 						}
 						// Full suspect picture at (0x94, 0xf).
@@ -2399,8 +2472,10 @@ void EEMEngine::doGallery() {
 							Common::Event drain;
 							while (g_system->getEventManager()->pollEvent(drain)) {
 								if (drain.type == Common::EVENT_QUIT ||
-									drain.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									drain.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+									setInteractiveMouseCursor(false);
 									return;
+								}
 							}
 						}
 						bool back = false;
@@ -2415,8 +2490,10 @@ void EEMEngine::doGallery() {
 									break;
 								}
 								if (e2.type == Common::EVENT_QUIT ||
-									e2.type == Common::EVENT_RETURN_TO_LAUNCHER)
+									e2.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+									setInteractiveMouseCursor(false);
 									return;
+								}
 							}
 							// Per-tick `updateScreen()` so the SDL cursor
 							// follows the mouse — without it the cursor
@@ -2431,6 +2508,13 @@ void EEMEngine::doGallery() {
 						// MoreInfo screen until the next 100 ms tick.
 						drawGalleryFrame(gd, num, slotRects, slotSuspect);
 						lastDraw = g_system->getMillis();
+						mouse = g_system->getEventManager()->getMousePos();
+						setInteractiveMouseCursor(galleryButtonAt(mouse.x,
+																  mouse.y) ||
+												  gallerySlotAt(slotRects,
+																slotSuspect,
+																mouse.x,
+																mouse.y));
 						clicked = true;
 						break;
 					}
@@ -2445,6 +2529,10 @@ void EEMEngine::doGallery() {
 		if (now - lastDraw >= 100) {
 			drawGalleryFrame(gd, num, slotRects, slotSuspect);
 			lastDraw = now;
+			mouse = g_system->getEventManager()->getMousePos();
+			setInteractiveMouseCursor(galleryButtonAt(mouse.x, mouse.y) ||
+									  gallerySlotAt(slotRects, slotSuspect,
+													mouse.x, mouse.y));
 		}
 		// `g_system->updateScreen()` is what tells the framework to
 		// re-render the cursor at its current mouse position; without
@@ -2454,6 +2542,7 @@ void EEMEngine::doGallery() {
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
+	setInteractiveMouseCursor(false);
 }
 
 void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
@@ -4283,7 +4372,9 @@ void EEMEngine::doAccuse() {
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
 		// `_ShowOneScrap @ 1f78:0773` is `_DisplayEnding(num, 1)` —
-		// the multi-page per-mystery ending narrative.
+		// the multi-page per-mystery ending narrative. This shares the
+		// same red edge cursor + edge-only mouse navigation used by the
+		// scrapbook browser.
 		doShowEnding(mn);
 
 		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the


Commit: 3354f1af87df5f24b18641559fee9c4ca1f54e54
    https://github.com/scummvm/scummvm/commit/3354f1af87df5f24b18641559fee9c4ca1f54e54
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:59+02:00

Commit Message:
EEM: handle initial clues for the CD version

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 62cf4047ec2..35396daedfc 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -64,6 +64,29 @@ const int kBoyY  = 0x62; // 98
 const int kGirlX = 0x42; // 66
 const int kGirlY = 0x60; // 96
 
+uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
+	if (!clueBlock)
+		return 0;
+
+	const uint16 number = READ_LE_UINT16(clueBlock);
+	if (number == 0 || number > 32)
+		return 0;
+
+	uint marked = 0;
+	for (uint i = 0; i < number; i++) {
+		const byte *entry = clueBlock + 4 + i * 62;
+		for (uint j = 0; j < 5; j++) {
+			const uint16 note = READ_LE_UINT16(entry + 0x30 + j * 2);
+			if (note != 0xFFFF && note < Mystery::kCluesFoundCap &&
+				mystery._cluesFound[note] == 0) {
+				mystery._cluesFound[note] = 1;
+				marked++;
+			}
+		}
+	}
+	return marked;
+}
+
 // `_DoHappiness @ 172b:27b5`: each cursor zone swaps the partner's
 // sequence script to a more / less "happy" cycle. Boy seqs lifted
 // verbatim from `29be:0337` (5 × 0x14 bytes), girl seqs from
@@ -540,10 +563,24 @@ void EEMEngine::doInitClues() {
 	// dialog_records[nDialog]` (each record `11 + textCount` bytes),
 	// dispatched via `FUN_22dc_05c8 @ 22dc:05c8`. We render dialog
 	// records ourselves on floppy.
-	if (floppy)
+	if (floppy) {
 		displayFloppyBriefing(ib);
-	else
-		displayClue(ib + 4);
+	} else {
+		const byte *briefingClues = ib + 4;
+		// Ghidra confirms CD `_DoInitClues` enters the normal
+		// `_DisplayClue(_InitBlock + 2, 1)` path, and `_DisplayClue`
+		// calls `_AddNotebook` for each ClueEntry note list at
+		// +0x30..+0x39. These starting notes are required before the
+		// first PDA visit, so mark them explicitly here. The regular
+		// displayClue side-effect pass is idempotent and still handles
+		// gallery/site updates in the original order.
+		const uint marked = markClueBlockNotebookEntries(_mystery, briefingClues);
+		if (marked != 0)
+			debugC(1, kDebugScript,
+				   "doInitClues: marked %u CD briefing notebook entries",
+				   marked);
+		displayClue(briefingClues);
+	}
 }
 
 /// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that


Commit: 7ac0106b653f132bfecd216dd58e2834786184ff
    https://github.com/scummvm/scummvm/commit/7ac0106b653f132bfecd216dd58e2834786184ff
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:59+02:00

Commit Message:
EEM: improved animations

Changed paths:
    engines/eem/animation.cpp
    engines/eem/animation.h
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h


diff --git a/engines/eem/animation.cpp b/engines/eem/animation.cpp
index 929fc18025e..34364c6bf12 100644
--- a/engines/eem/animation.cpp
+++ b/engines/eem/animation.cpp
@@ -141,6 +141,14 @@ void ANMDecoder::getPalette8(byte *out) const {
 		out[i] = (byte)(_palette[i] << 2);
 }
 
+void ANMDecoder::seedFrameBuffer(const byte *pixels, uint pitch) {
+	if (!pixels || _buffer.empty() || _width == 0 || _height == 0)
+		return;
+
+	for (uint y = 0; y < _height; y++)
+		memcpy(_buffer.data() + y * _width, pixels + y * pitch, _width);
+}
+
 const byte *ANMDecoder::nextFrame() {
 	if (_nextFrameIdx >= _frameCount)
 		return nullptr;
diff --git a/engines/eem/animation.h b/engines/eem/animation.h
index 9397872e2fd..09b6e0406d1 100644
--- a/engines/eem/animation.h
+++ b/engines/eem/animation.h
@@ -67,6 +67,9 @@ public:
 	/// Fill @p out (768 bytes) with the 8-bit-shifted palette. Convenience.
 	void getPalette8(byte *out) const;
 
+	/// Seed the persistent frame buffer from the current 8-bit screen pixels.
+	void seedFrameBuffer(const byte *pixels, uint pitch);
+
 	/**
 	 * Decode the next frame in place into the internal buffer. Returns a
 	 * pointer to the @c width()*@c height() byte image, or nullptr at EOF.
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 35396daedfc..82805c95635 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -427,17 +427,23 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Composite the final frames (or first frames if skipped) so the BG
-	// is in a sensible state when displayClue overlays the speaker.
-	if (_picsArchive.getPicture(0x52, bg))
-		blitAt(bg, 0, 0);
-	if (haveGame)
-		blitMaskedToScreen(game[0], 0xcd, 0x6c);
-	if (haveBook)
-		blitMaskedToScreen(book[0], 0, 99);
-	if (haveNancy)
-		blitMaskedToScreen(nancy[0], 0x68, 0x8b);
-	g_system->updateScreen();
+	// Freeze the completed setup animation as the base for
+	// `_PlayInSequence`. The original clears the registered animations
+	// before playing the short case-type overlay, so the game/book/nancy
+	// cells do not keep cycling or wrap back to cell 0 underneath it.
+	Graphics::ManagedSurface briefingBase(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	briefingBase.clear();
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			for (int row = 0; row < 200; row++) {
+				memcpy((byte *)briefingBase.getBasePtr(0, row),
+					   (const byte *)screen->getBasePtr(0, row), 320);
+			}
+			g_system->unlockScreen();
+		}
+	}
 
 	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
 	//   Jake (partner=0):
@@ -489,16 +495,11 @@ void EEMEngine::doInitClues() {
 			for (uint frame = 0; frame < seq.size() && !shouldQuit() && !skip;
 				 frame++) {
 				const Picture &fr = seq[frame];
-				// Restore BG + base anim frames so each new frame
-				// composites cleanly.
-				if (_picsArchive.getPicture(0x52, bg))
-					blitAt(bg, 0, 0);
-				if (haveGame)
-					blitMaskedToScreen(game[frame % game.size()], 0xcd, 0x6c);
-				if (haveBook)
-					blitMaskedToScreen(book[frame % book.size()], 0, 99);
-				if (haveNancy)
-					blitMaskedToScreen(nancy[frame % nancy.size()], 0x68, 0x8b);
+				// Restore the frozen setup frame so the short overlay
+				// does not make the setup animation wrap to frame 0.
+				g_system->copyRectToScreen(briefingBase.getPixels(),
+										   briefingBase.pitch, 0, 0,
+										   320, 200);
 				// Anchor: `_PlayInSequence @ 172b:2d35-2d50` does
 				//   dstX = sx - cell[+0x8]     ; miscflags (signed)
 				//   dstY = sy - cell[+0x6]     ; rowoff   (signed)
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 6ca48a2a385..58dcfaa572b 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -99,6 +99,34 @@ const byte kCursorInteractivePalette[] = {
 	0xFF, 0xFF, 0xFF  // 2 — white fill
 };
 
+static void fadeCurrentPaletteToBlack(uint delayMs = 8) {
+	byte start[kPalSize];
+	byte stepPal[kPalSize];
+	g_system->getPaletteManager()->grabPalette(start, 0, 256);
+
+	for (int step = 15; step >= 0; step--) {
+		for (uint i = 0; i < kPalSize; i++)
+			stepPal[i] = (byte)((uint)start[i] * step / 16);
+		g_system->getPaletteManager()->setPalette(stepPal, 0, 256);
+		g_system->updateScreen();
+		if (delayMs)
+			g_system->delayMillis(delayMs);
+	}
+}
+
+static void fadePaletteFromBlack(const byte *target, uint delayMs = 8) {
+	byte stepPal[kPalSize];
+
+	for (uint step = 1; step <= 16; step++) {
+		for (uint i = 0; i < kPalSize; i++)
+			stepPal[i] = (byte)((uint)target[i] * step / 16);
+		g_system->getPaletteManager()->setPalette(stepPal, 0, 256);
+		g_system->updateScreen();
+		if (delayMs)
+			g_system->delayMillis(delayMs);
+	}
+}
+
 static void setInteractiveCursorPalette(const Picture &cursor, byte transparent) {
 	byte palette[kPalSize];
 	bool used[256];
@@ -349,7 +377,10 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro) {
 			if (_audio)
 				_audio->playVoc(Common::Path("THUNDER.VOC"));
-			playAnm(Common::Path("BOLT.ANM"));
+			playAnm(Common::Path("BOLT.ANM"), 120,
+					/*holdLastFrame=*/false, /*fadeIn=*/true);
+			waitForInput(1800);
+			fadeCurrentPaletteToBlack();
 			if (_audio)
 				_audio->stopVoice();
 		}
@@ -363,8 +394,12 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 		for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
+			const bool fadeIn = (i == 1 || i == 5);
+			if (i == 5)
+				fadeCurrentPaletteToBlack();
 			Common::String name = Common::String::format("ANIM%02d.A", i);
-			playAnm(Common::Path(name));
+			playAnm(Common::Path(name), 120,
+					/*holdLastFrame=*/false, fadeIn);
 			// `_SpoolSound(uVar3 - 1)` at 2520:08c2 — per-character
 			// VO after each anim except the last (`if (uVar3 != 0x14)`
 			// at 2520:08a8). Original blocks until done; we run async
@@ -383,7 +418,8 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 		if (!shouldQuit() && !_skipIntro)
-			playAnm(Common::Path("TITLE.ANM"), 120, /*holdLastFrame=*/true);
+			playAnm(Common::Path("TITLE.ANM"), 120,
+					/*holdLastFrame=*/true, /*fadeIn=*/true);
 	}
 	_skipIntro = false;
 
@@ -668,7 +704,8 @@ void EEMEngine::interruptAudio() {
 		_music->stop();
 }
 
-void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLastFrame) {
+void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
+						bool holdLastFrame, bool fadeIn) {
 	ANMDecoder anm;
 	if (!anm.open(path)) {
 		warning("playAnm: %s missing", path.toString().c_str());
@@ -677,17 +714,32 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs, bool holdLa
 
 	byte palette[768];
 	anm.getPalette8(palette);
-	g_system->getPaletteManager()->setPalette(palette, 0, 256);
 
 	const uint16 w = anm.width();
 	const uint16 h = anm.height();
+	{
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			if (screen->w >= w && screen->h >= h)
+				anm.seedFrameBuffer((const byte *)screen->getBasePtr(0, 0), screen->pitch);
+			g_system->unlockScreen();
+		}
+	}
 
+	bool paletteApplied = false;
 	while (!shouldQuit()) {
 		const byte *frame = anm.nextFrame();
 		if (!frame)
 			break;
 
 		g_system->copyRectToScreen(frame, w, 0, 0, w, h);
+		if (!paletteApplied) {
+			if (fadeIn)
+				fadePaletteFromBlack(palette);
+			else
+				g_system->getPaletteManager()->setPalette(palette, 0, 256);
+			paletteApplied = true;
+		}
 		g_system->updateScreen();
 
 		// Drain events and let the user skip with click/key. The original
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index d6080d93fbd..6f463b4da69 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -380,9 +380,12 @@ private:
 	 *
 	 * If @p holdLastFrame is true the call blocks on the final frame
 	 * until the user clicks or hits a key — used for the title screen.
+	 * If @p fadeIn is true the first decoded frame is copied while the
+	 * palette is black, then the animation palette is ramped in like
+	 * `_OpenFadeIn`.
 	 */
 	void playAnm(const Common::Path &path, uint frameDelayMs = 120,
-				 bool holdLastFrame = false);
+				 bool holdLastFrame = false, bool fadeIn = false);
 
 	/// Stop every active audio channel — voice, sound spool, and
 	/// MIDI. Mirrors the `_CleanMysterySounds @ 202f:05a5` +


Commit: 9653bf379238d61f7faab0be2d4a5eaa9ad72abe
    https://github.com/scummvm/scummvm/commit/9653bf379238d61f7faab0be2d4a5eaa9ad72abe
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:20:59+02:00

Commit Message:
EEM: do not close the game after the end of the scrapbook

Changed paths:
    engines/eem/eem.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 58dcfaa572b..35b76dab571 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -498,18 +498,20 @@ screen_loop:
 			// the same routing the `kScreenChooseMystery` case uses.
 			// Reachable from `_DisplayCorrect`'s 0xc write after a
 			// solve (see `ui.cpp` `_nextScreen = kScreenAction`).
+			_nextScreen = kScreenInvalid;
 			doCaseSelection();
-			_nextScreen = _mystery.isLoaded() ? kScreenInitClues
-											  : kScreenInvalid;
+			if (_mystery.isLoaded())
+				_nextScreen = kScreenInitClues;
 			break;
 
 		case kScreenChooseMystery:
 			// Handler 10 at 1a35:0e0e calls `_DoChooseMystery` which
 			// presets `_NextScreen = 0` (INIT_CLUES) before
 			// `_CaseSelection`. Same dispatch as `kScreenAction`.
+			_nextScreen = kScreenInvalid;
 			doCaseSelection();
-			_nextScreen = _mystery.isLoaded() ? kScreenInitClues
-											  : kScreenInvalid;
+			if (_mystery.isLoaded())
+				_nextScreen = kScreenInitClues;
 			break;
 
 		case kScreenInitClues:
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 0eec0d2fe5e..caaf8bcdeed 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -879,20 +879,28 @@ void EEMEngine::doShowScrapbook(uint stage) {
 	while (!shouldQuit() && mystery >= lo && mystery < hi) {
 		const int direction = doShowEnding((uint)mystery, firstPage);
 		if (direction < 0) {
-			if (mystery == lo)
-				break;
-			mystery--;
+			int prevMystery = mystery - 1;
 			if (currentTier) {
-				while (mystery >= lo && _mysteriesSolved[mystery] == 0)
-					mystery--;
+				while (prevMystery >= lo && _mysteriesSolved[prevMystery] == 0)
+					prevMystery--;
+			}
+			if (prevMystery < lo) {
+				firstPage = true;
+				continue;
 			}
+			mystery = prevMystery;
 			firstPage = false;
 		} else if (direction > 0) {
-			mystery++;
+			int nextMystery = mystery + 1;
 			if (currentTier) {
-				while (mystery < hi && _mysteriesSolved[mystery] == 0)
-					mystery++;
+				while (nextMystery < hi && _mysteriesSolved[nextMystery] == 0)
+					nextMystery++;
+			}
+			if (nextMystery >= hi) {
+				firstPage = false;
+				continue;
 			}
+			mystery = nextMystery;
 			firstPage = true;
 		} else {
 			break;
@@ -1550,6 +1558,7 @@ void EEMEngine::doCaseSelection() {
 		doShowScrapbook(stage);
 		setSitePalette(0);
 		_mystery.clear();
+		_nextScreen = kScreenChooseMystery;
 		return;
 	}
 


Commit: 5c5a2d1ccf54671d91256011c89f7151b89f328c
    https://github.com/scummvm/scummvm/commit/5c5a2d1ccf54671d91256011c89f7151b89f328c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:00+02:00

Commit Message:
EEM: honor the music config

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 82805c95635..708754fbd11 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -1065,7 +1065,8 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 
 		// Per-record voice (byte 9 high bit) — see comment in original
 		// header.
-		if ((rec[9] & 0x80) != 0 && _audio) {
+		const bool playedRecordVoice = (rec[9] & 0x80) != 0 && _audio;
+		if (playedRecordVoice) {
 			const uint slot = rec[9] & 0x7f;
 			_audio->playFloppyVoiceSlot(slot, _partner);
 		}
@@ -1264,8 +1265,14 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					firstPage = true;
 			}
 		}
-		if (skipAll)
+		if (skipAll) {
+			if (playedRecordVoice)
+				_audio->stopVoice();
 			return;
+		}
+
+		if (playedRecordVoice)
+			_audio->stopVoice();
 
 		rec += 11 + textCount;
 	}
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 35b76dab571..4513d010beb 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -938,12 +938,33 @@ void EEMEngine::startTravelMusic() {
 	// itself runs without music. Our previous `loop=true` made the
 	// music never end, leaving travel music droning through site
 	// investigation, accuse, gallery, etc.
-	if (!_music || !_mystery.isLoaded())
+	if (!_music || !_mystery.isLoaded() || !_voiceOn)
 		return;
 	const uint num = _mystery._siteNumber % 5;
 	_music->playMus(num, /*loop=*/false);
 }
 
+void EEMEngine::waitForMusicDone(uint32 maxMs) {
+	if (!_music)
+		return;
+
+	const uint32 startMs = g_system->getMillis();
+	while (_music->isPlaying() && !shouldQuit() &&
+		   g_system->getMillis() - startMs < maxMs) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				stopMusic();
+				return;
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(20);
+	}
+	stopMusic();
+}
+
 void EEMEngine::stopMusic() {
 	if (_music)
 		_music->stop();
@@ -1064,6 +1085,7 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 		s.syncAsUint16LE(mysteryNum);
 		if (!_mystery.load(mysteryNum, &_rng)) {
 			_mystery.clear();
+			resetSiteArrivalState();
 			return Common::kReadingFailed;
 		}
 		// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds` at the
@@ -1076,8 +1098,13 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 		if (_audio && !isFloppy())
 			_audio->initMysterySounds(mysteryNum);
 		_mystery.syncState(s);
+		if (_mystery._siteNumber < _mystery.numSites())
+			setSiteArrivalState(_mystery._siteNumber);
+		else
+			resetSiteArrivalState();
 	} else {
 		_mystery.clear();
+		resetSiteArrivalState();
 	}
 
 	debugC(1, kDebugGeneral,
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 6f463b4da69..78f7a5557bb 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -495,11 +495,16 @@ private:
 
 public:
 	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
-	/// based on `_mystery._siteNumber % 5` and starts it (looping). The
+	/// based on `_mystery._siteNumber % 5` and starts it one-shot. The
 	/// site loop calls this each time `enter(siteNum)` runs so the
 	/// music changes as the player travels between sites.
 	void startTravelMusic();
 
+	/// Block until the current MIDI cue finishes, then stop/unload it.
+	/// Mirrors the `_IsMIDIPlaying` spin + `_StopMIDI` cleanup in
+	/// `_DoSiteLoop` after the CD travel cue.
+	void waitForMusicDone(uint32 maxMs = 60000);
+
 	/// Stop any currently playing MIDI track. Mirrors `_StopMIDI @
 	/// 20a2:0512` — used by `_DoSiteLoop @ 168d:06c0` after the
 	/// one-shot travel track plays out and by `_DisplayCorrect /
@@ -580,6 +585,11 @@ private:
 
 	bool _interactiveMouseCursor = false;
 
+	/// Site whose entrance animation has already played in the current
+	/// mystery. Kept on the engine, not SiteScreen, because PDA/gallery
+	/// screens destroy and recreate SiteScreen when returning to the site.
+	int _lastSiteArrivalAnim = -1;
+
 	/// XMIDI music player. Mirrors the original `MIDI.C` family
 	/// (`_MIDIPlayFile`, `_MIDIPlay`, `_StopMIDI`, `_StartTravelMusic`
 	/// at 20a2:00e2-05c9). Constructed lazily during `run()` once the
@@ -598,6 +608,18 @@ public:
 	/// member itself public. Mirrors the original's direct write to
 	/// `_NextScreen @ 2d5d:3f26` from anywhere in the engine.
 	void setNextScreen(ScreenId s) { _nextScreen = s; }
+	bool shouldPlaySiteArrival(uint siteNum) const {
+		return _lastSiteArrivalAnim != (int)siteNum;
+	}
+	void markSiteArrivalPlayed(uint siteNum) {
+		_lastSiteArrivalAnim = (int)siteNum;
+	}
+	void resetSiteArrivalState() {
+		_lastSiteArrivalAnim = -1;
+	}
+	void setSiteArrivalState(uint siteNum) {
+		_lastSiteArrivalAnim = (int)siteNum;
+	}
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 1a71f83e417..eeb7d78bd3d 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -540,10 +540,13 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	debugC(1, kDebugSite, "Entering site %u (%u hotspots)",
 		   siteNum, _mystery->hotspotCount(siteNum));
 
+	const bool playArrival = _vm->shouldPlaySiteArrival(siteNum);
+
 	// `_DoTravel @ 168d:02da` calls `_StartTravelMusic` after the
 	// destination is set. We do the same here so the music swaps as
 	// the player moves between sites.
-	_vm->startTravelMusic();
+	if (playArrival)
+		_vm->startTravelMusic();
 
 	// Palette: original `_BuildBackground` calls `GetPalette(sitenum + 1)`
 	// where sitenum is the global SITES.DBD index (= the per-mystery
@@ -595,10 +598,10 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	renderBackground(siteNum);
 
 	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` whenever
-	// `_LastSite != _SiteNumber`. We track the last site we *played*
-	// the arrival on so re-entries (after notebook/map/etc.) don't
-	// repeat the animation.
-	if ((int)siteNum != _lastSiteAnim) {
+	// `_LastSite != _SiteNumber`. Keep our guard on the engine rather
+	// than on SiteScreen, because PDA/gallery returns recreate
+	// SiteScreen and must not replay the arrival.
+	if (playArrival) {
 		// `_EnterSiteAnim` snapshots the current screen, so populate
 		// that temporary background with the same site layers that
 		// should already be visible behind the arriving partner.
@@ -608,21 +611,15 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 			renderStaticDrops(siteNum);
 		renderAnimatedDrops(siteNum, g_system->getMillis());
 		enterSiteAnim();
-		_lastSiteAnim = (int)siteNum;
+		_vm->markSiteArrivalPlayed(siteNum);
+		if (!_vm->isFloppy())
+			_vm->waitForMusicDone();
 		// Re-paint the BG; the normal snapshot below should contain
 		// only the static layers, while animated NPCs are redrawn per
 		// tick by the frame pump.
 		renderBackground(siteNum);
 	}
 
-	// Stop the travel music explicitly. `_DoSiteLoop @ 168d:06c0`
-	// waits for the one-shot travel track to play out and then calls
-	// `_StopMIDI()` before the interactive site phase begins —
-	// blocking the engine while it spins. We just stop now so the
-	// site investigation runs without music (matches the original
-	// silent-investigation phase).
-	_vm->stopMusic();
-
 	// Static drops (Loop 2 from `_DoSiteLoop`) — no animation, baked
 	// into the BG snapshot the run() pump uses to restore. Floppy
 	// stores them in a different shape (drops sub-struct after the
@@ -742,12 +739,12 @@ void SiteScreen::run() {
 
 	while (!_vm->shouldQuit()) {
 		Common::Event event;
-		bool exitRequested = false;
 		while (g_system->getEventManager()->pollEvent(event)) {
 			switch (event.type) {
 			case Common::EVENT_QUIT:
 			case Common::EVENT_RETURN_TO_LAUNCHER:
 				_vm->setHotspotMouseCursor(false);
+				_vm->stopMusic();
 				return;
 
 			case Common::EVENT_MOUSEMOVE:
@@ -759,9 +756,9 @@ void SiteScreen::run() {
 				//   _FindButton(&SiteButtons, 2, MouseX, MouseY)
 				// where `SiteButtons` is two 8-byte rectangles at
 				// 29be:0x274 (verified via 168d:0729-0848):
-				//   Button 0: (35, 111) - (56, 136)  → notebook
+				//   Button 0: (35, 111) - (56, 136)  -> notebook
 				//                                       (`_NextScreen = 4`)
-				//   Button 1: (7, 177)  - (57, 200)  → map
+				//   Button 1: (7, 177)  - (57, 200)  -> map
 				//                                       (CD `_NextScreen = 1`,
 				//                                       floppy = 2)
 				// Test the buttons before falling through to hotspots so
@@ -780,6 +777,7 @@ void SiteScreen::run() {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					_vm->setNextScreen(kScreenNotebook);
+					_vm->stopMusic();
 					return;
 				}
 				if (kSitePartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
@@ -788,6 +786,7 @@ void SiteScreen::run() {
 					// CD writes `_NextScreen = 1`; floppy writes 2.
 					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 													   : kScreenMap);
+					_vm->stopMusic();
 					return;
 				}
 				if (kSitePartnerHeadHintRect.contains(event.mouse.x, event.mouse.y)) {
@@ -818,15 +817,14 @@ void SiteScreen::run() {
 				// 6-entry table at `168d:09d5` (TAB / ENTER / arrow
 				// keys for hotspot cursor cycling) plus ESC handled
 				// separately at 168d:07a9. We don't implement the
-				// hotspot cursor cycling — clicks are the primary
-				// interaction — so the only keyboard binding kept
-				// here is ESC (matches `_ESCHit` → "Are you sure?"
-				// → MAP).
+				// hotspot cursor cycling, so the only keyboard binding kept
+				// here is ESC (matches `_ESCHit` -> "Are you sure?" -> MAP).
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_vm->setHotspotMouseCursor(false);
 					if (_vm->areYouSure()) {
 						_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 														   : kScreenMap);
+						_vm->stopMusic();
 						return;
 					}
 					enter(cur, false);
@@ -839,14 +837,14 @@ void SiteScreen::run() {
 				break;
 			}
 		}
-		if (exitRequested)
-			return;
 
 		// Hotspot side effects can invalidate the active mystery; exit
 		// immediately rather than tick another frame against stale BG
 		// snapshots / hotspot tables.
-		if (!_mystery || !_mystery->isLoaded())
+		if (!_mystery || !_mystery->isLoaded()) {
+			_vm->stopMusic();
 			return;
+		}
 
 		// Per-tick frame pump (mirrors `_CheckFrameRate` +
 		// `_UpdateAnimations` at the top of `_DoSiteLoop`'s main loop).
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 06bc8595369..d6f6557c381 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -176,7 +176,6 @@ private:
 		kPartnerWaitPatient,
 		kPartnerWaitImpatient
 	};
-	int _lastSiteAnim = -1;        ///< Last site we played the arrival on.
 	int _snapshotSite = -1;        ///< Site number the snapshot belongs to.
 	Graphics::ManagedSurface _bgSnapshot;
 	uint32 _lastTickMs = 0;        ///< Last frame-pump tick in ms.
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index caaf8bcdeed..fdc71dc6695 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1540,8 +1540,11 @@ void EEMEngine::doCaseSelection() {
 		if (!_mystery.load(0, &_rng)) {
 			warning("doCaseSelection: failed to load practice mystery");
 			_mystery.clear();
-		} else if (_audio && !isFloppy()) {
-			_audio->initMysterySounds(0);
+			resetSiteArrivalState();
+		} else {
+			resetSiteArrivalState();
+			if (_audio && !isFloppy())
+				_audio->initMysterySounds(0);
 		}
 		return;
 	}
@@ -1762,6 +1765,7 @@ void EEMEngine::doCaseSelection() {
 		_mystery.clear();
 		return;
 	}
+	resetSiteArrivalState();
 	if (_audio && !isFloppy())
 		_audio->initMysterySounds(mn);
 	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
@@ -4134,7 +4138,7 @@ void EEMEngine::doAccuse() {
 		// Step 1 — alibi music. Original blocks until MIDI 6 ends with
 		// the gallery still on screen. We poll `_music->isPlaying`;
 		// click/ESC aborts early.
-		if (_music) {
+		if (_music && _voiceOn) {
 			_music->playMus(6, /*loop=*/false);
 			const uint32 musStart = g_system->getMillis();
 			bool aborted = false;
@@ -4142,8 +4146,10 @@ void EEMEngine::doAccuse() {
 				Common::Event ev;
 				while (g_system->getEventManager()->pollEvent(ev)) {
 					if (ev.type == Common::EVENT_QUIT ||
-						ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+						_music->stop();
 						return;
+					}
 					if (ev.type == Common::EVENT_KEYDOWN ||
 						ev.type == Common::EVENT_LBUTTONDOWN) {
 						aborted = true;
@@ -4362,7 +4368,7 @@ void EEMEngine::doAccuse() {
 		}
 		g_system->updateScreen();
 
-		if (_music)
+		if (_music && _voiceOn)
 			_music->playMus(5, /*loop=*/false);
 
 		// Chain-by-chain RECAP. Partner enumerates every required
@@ -4374,6 +4380,8 @@ void EEMEngine::doAccuse() {
 		const byte *solved = _mystery.solvedClueBlock();
 		if (solved)
 			displayClue(solved);
+		if (_music && _voiceOn)
+			_music->stop();
 
 		// `_DifferenceAnimation("scrapbk.ani")` (1df2:0848) — the
 		// physical scrapbook flip animation that introduces the
@@ -4741,6 +4749,7 @@ void EEMEngine::doAccuseFloppy() {
 		//                 every mystery in the current tier is solved).
 		//   1d40:0982  _SavePlayerRecord  (= saveProfile)
 		//   1d40:0985  _DeleteMysteryFile (= mystery cleanup)
+		//   1d40:0991  MakeSolvedSound = 1; ShowOneScrap(mystery)
 		//   1d40:09b0  _NextScreen = 0xc.
 		const uint mn = _mystery.number();
 
@@ -4819,6 +4828,8 @@ void EEMEngine::doAccuseFloppy() {
 			const uint count = chain[0];
 			displayFloppyDialogRecords(chain + 1, count, 0);
 		}
+		if (_music && _voiceOn)
+			_music->stop();
 
 		// Mark mystery solved with first-try bonus tracking.
 		// `_DisplayCorrect_Floppy @ 1d40:0939`:
@@ -4859,6 +4870,27 @@ void EEMEngine::doAccuseFloppy() {
 			}
 		}
 
+		// `_DisplayCorrect_Floppy` sets `MakeSolvedSound` before the
+		// newly solved ending is shown. `FUN_1d40_05b7` reads byte 0 of
+		// `E<num>.BIN` and maps values 0..2 through the table at
+		// `2608:0c5e` to VOC slots 0x15, 0x16, 0x17.
+		if (_audio && _voiceOn) {
+			Common::File ending;
+			const Common::String fname =
+				Common::String::format("E%u.BIN", mn);
+			if (ending.open(Common::Path(fname)) && ending.size() > 0) {
+				static const uint8 kSolvedVoiceSlot[3] = {
+					0x15, 0x16, 0x17
+				};
+				const byte type = ending.readByte();
+				if (type < ARRAYSIZE(kSolvedVoiceSlot)) {
+					_audio->playFloppyVoiceSlot(kSolvedVoiceSlot[type],
+												_partner);
+					_audio->waitForVoiceDone();
+				}
+			}
+		}
+
 		// Persist progress before clearing the in-progress mystery.
 		// `_DisplayCorrect_Floppy @ 1d40:0982` calls
 		// `_SavePlayerRecord` then `FUN_22dc_0dbd` (which deletes the
@@ -5024,6 +5056,8 @@ void EEMEngine::doAccuseFloppy() {
 	// Our `showFloppyKDHint(slot)` reads `kdIdx + slot * 2`, so slot
 	// 5 is the right argument here.
 	showFloppyKDHint(5);
+	if (_music && _voiceOn)
+		_music->stop();
 
 	_mystery._firstTry = false;
 	_nextScreen = _lastScreen != kScreenInvalid


Commit: a1b4c596bc9ce82061e417eb84ff6816f12f411f
    https://github.com/scummvm/scummvm/commit/a1b4c596bc9ce82061e417eb84ff6816f12f411f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:00+02:00

Commit Message:
EEM: added missing voices when selecting clues

Changed paths:
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 3b6cb6b3c98..efbada6cab1 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -558,6 +558,43 @@ int Mystery::selectedPoints() const {
 	return total;
 }
 
+int Mystery::foundPoints() const {
+	const byte *ni = noteIndex();
+	const uint16 cnt = noteIndexCount();
+	if (!ni || cnt == 0)
+		return 0;
+
+	uint16 scores[Mystery::kCluesFoundCap] = {};
+	uint scoreCount = 0;
+	const uint maxIdx = MIN<uint>(cnt, kCluesFoundCap);
+	for (uint i = 0; i < maxIdx; i++) {
+		if (_cluesFound[i] == 0)
+			continue;
+		const uint16 pts = _isFloppy ? ni[i * 7 + 6]
+									 : READ_LE_UINT16(ni + i * 4 + 2);
+		scores[scoreCount++] = pts;
+	}
+
+	const uint topN = MIN<uint>(5u, scoreCount);
+	for (uint k = 0; k < topN; k++) {
+		uint best = k;
+		for (uint j = k + 1; j < scoreCount; j++) {
+			if (scores[j] > scores[best])
+				best = j;
+		}
+		if (best != k) {
+			const uint16 tmp = scores[k];
+			scores[k] = scores[best];
+			scores[best] = tmp;
+		}
+	}
+
+	int total = 0;
+	for (uint k = 0; k < topN; k++)
+		total += scores[k];
+	return total;
+}
+
 void Mystery::syncState(Common::Serializer &s) {
 	s.syncBytes(_cluesFound, kCluesFoundCap);
 	s.syncBytes(_noteSelected, kCluesFoundCap);
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 82c881803cd..c9694d1965e 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -159,6 +159,10 @@ public:
 	/// `_GetSelectedPoints` @ 1df2:00bd.
 	int selectedPoints() const;
 
+	/// Sum of the top five point values among found notebook entries.
+	/// Mirrors `_GetFoundPoints` @ 1df2:0098.
+	int foundPoints() const;
+
 	/// True when `selectedPoints() > 99`. Mirrors `_SolvedCheck`.
 	bool solvedCheck() const { return selectedPoints() > 99; }
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fdc71dc6695..5e1b23a8c12 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3625,6 +3625,83 @@ void EEMEngine::doAccuse() {
 		return;
 	}
 
+	// `_AccuseEntry @ 1df2:0ff8` runs before the red accuse-notes
+	// picker. It scores the top five FOUND clues, says how close the
+	// player is, and only lets `_DoAccuse` continue when that score is
+	// at least 0x65.
+	const byte *entryKdIdx = _mystery.kdTextIndex();
+	if (!entryKdIdx)
+		return;
+	const int foundPoints = _mystery.foundPoints();
+	uint entryKDSpeak = 0;
+	uint16 entryTextOff = 0xFFFF;
+	bool canAccuse = false;
+	Common::String entryText;
+	if (foundPoints == 0) {
+		entryKDSpeak = 9;
+		entryText = "3We're not ready to solve this mystery yet.  "
+					"Let's keep investigating until we have some more solid "
+					"evidence to make our case!";
+	} else if (foundPoints < 0x32) {
+		entryKDSpeak = 0;
+		entryTextOff = READ_LE_UINT16(entryKdIdx + 0);
+	} else if (foundPoints < 0x65) {
+		entryKDSpeak = 1;
+		entryTextOff = READ_LE_UINT16(entryKdIdx + 2);
+	} else {
+		entryKDSpeak = 2;
+		entryTextOff = READ_LE_UINT16(entryKdIdx + 4);
+		canAccuse = true;
+	}
+	if (entryText.empty() && entryTextOff != 0xFFFF) {
+		entryText = parseString(_mystery.textAt(entryTextOff),
+								_playerName, _partner);
+	}
+	if (!entryText.empty()) {
+		Graphics::ManagedSurface ms(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		ms.clear();
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			ms.simpleBlitFrom(*cur);
+			g_system->unlockScreen();
+		}
+		const byte firstChar = (byte)entryText[0];
+		const uint16 bubNum = getKDTextBalloon(firstChar);
+		if (firstChar >= '0' && firstChar <= '9')
+			entryText.deleteChar(0);
+		Picture balloon;
+		const bool haveBalloon =
+			_balloonArchive.size() > (bubNum & 0x7F) &&
+			_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+		const int balloonX = 0x21;
+		int balloonY = 1;
+		if (haveBalloon && balloon.surface.h < 0x4e)
+			balloonY = (0x50 - balloon.surface.h) / 2;
+		if (haveBalloon) {
+			const byte transp = (byte)(balloon.flags >> 8);
+			ms.transBlitFrom(balloon.surface,
+							 Common::Point(balloonX, balloonY),
+							 (uint32)transp);
+		}
+		uint16 tx = 5, ty = 4, tw = 155;
+		getBalloonInsets(bubNum, tx, ty, tw);
+		if (_font.isLoaded()) {
+			_font.drawWordWrapped(&ms, balloonX + tx, balloonY + ty,
+								  tw, entryText, haveBalloon ? 0 : 0xF);
+		}
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
+								   0, 0, 320, 200);
+		g_system->updateScreen();
+		if (_audio)
+			_audio->sayKDDigital(entryKdIdx, entryKDSpeak, _partner);
+		waitForInput(60000);
+	}
+	if (!canAccuse) {
+		_nextScreen = _lastScreen != kScreenInvalid
+						? (ScreenId)_lastScreen : kScreenSite;
+		return;
+	}
 
 	// Mirrors `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
 	//   1. ACCUSE-NOTES SCREEN (PIC 0x1A7, the red "accuse-mode" BG):
@@ -3780,6 +3857,7 @@ void EEMEngine::doAccuse() {
 	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
 	//   WordWrap(0x21+tbl[bub].x, y+tbl[bub].y, tbl[bub].w, text, color=0)
 	//     tbl @ 29be:0875, 10-byte entries (1df2:0ad6-0af1)
+	//   _SayKDDigital(4); _Wait();                        (1df2:0b09-0b11)
 	const byte *kdIdx = _mystery.kdTextIndex();
 	if (kdIdx) {
 		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
@@ -3878,6 +3956,8 @@ void EEMEngine::doAccuse() {
 				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
 					0, 0, 320, 200);
 				g_system->updateScreen();
+				if (_audio)
+					_audio->sayKDDigital(kdIdx, 4, _partner);
 				waitForInput(8000);
 			}
 		}
@@ -3978,13 +4058,13 @@ void EEMEngine::doAccuse() {
 		return;
 
 	// Real chain evaluation. Mirrors the original two-gate accusation:
-	//   1. `_AccuseEntry @ 1df2:0ff8` checks `_GetFoundPoints() >= 100`
-	//      — gates whether the suspect picker is even reachable. We
-	//      `_SolvedCheck → selectedPoints > 99` is now gated at the
-	//      TOP of `doAccuse` — by the time we reach this point we
-	//      already know `solvedCheck()` was true (the picker wouldn't
-	//      have opened otherwise).
-	//   2. `_WITCH @ 1df2:089f` checks `GalleryData[picked*0x46+0x02] ==
+	//   1. `_AccuseEntry @ 1df2:0ff8` checked top-five found points
+	//      before the note picker, so by this point the player has
+	//      enough evidence to attempt an accusation.
+	//   2. `_SolvedCheck @ 1df2:00ec` checked selectedPoints > 99
+	//      before opening this suspect picker, so the clue set is
+	//      valid if we got here.
+	//   3. `_WITCH @ 1df2:089f` checks `GalleryData[picked*0x46+0x02] ==
 	//      0xFFFF`. Innocent suspects store an alibi-text TextBlock
 	//      offset there; the guilty one uses the sentinel.
 	const int points          = _mystery.selectedPoints();


Commit: 631819628568d080bcc5a8548a45b444f92c6b2d
    https://github.com/scummvm/scummvm/commit/631819628568d080bcc5a8548a45b444f92c6b2d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:00+02:00

Commit Message:
EEM: don't allow to select more than 5 clues in accusation mode

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 5e1b23a8c12..2ff97c65045 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3256,12 +3256,13 @@ bool EEMEngine::doAccuseNotes() {
 	//     for selected (1df2:0c2c sets it on entry).
 	//   * Click on a clue toggles its selection
 	//     (`_SearchNoteAreas` + `_SwapColors`).
-	//   * Click `_NoteButtons[2]` (rect at `(157, 174, 178, 190)`,
-	//     the original's "go to gallery" button) jumps to the
-	//     evidence check; `_HandleAccuseNoteButton(2)` returns 2
-	//     and the outer loop forces `uStack_8 = uStack_a` to
-	//     trigger `_SolvedCheck`.
-	//   * ESC sets `_NextScreen = 3` and exits.
+	//   * Click `_NoteButtons[4]` (rect at `(180, 174, 201, 190)`,
+	//     the original's solve button) jumps to the evidence check;
+	//     `_HandleAccuseNoteButton(4)` returns 2 and the outer loop
+	//     forces `uStack_8 = uStack_a` to trigger `_SolvedCheck`.
+	//   * CD and floppy originals only cancel this screen through ESC.
+	//     ScummVM also wires the visible PDA navigation buttons so the
+	//     accusation can be abandoned without a keyboard.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return false;
 	const byte *ni = _mystery.noteIndex();
@@ -3300,8 +3301,10 @@ bool EEMEngine::doAccuseNotes() {
 	const int rectH = 159 - 27;
 
 	// `_NoteButtons` rects (verified at `29be:0147`). `_DoAccuse`
-	// re-uses the same table as `_DoNotebook`, but only SOLVE /
-	// PAGE NEXT / PAGE PREV do anything; others sit inert.
+	// re-uses the same table as `_DoNotebook`, but the original
+	// handler only routes SOLVE / PAGE NEXT / PAGE PREV; ScummVM
+	// additionally routes the visible site/map/notebook/gallery
+	// buttons below for pointer-only cancellation.
 	// `_HandleAccuseNoteButton @ 1df2:0990` returns `DI` (initialised
 	// to 0) and only sets `DI = 2` in the `i == 4` branch (asm:
 	// `1df2:09b2: MOV DI, 0x2`). The outer loop's `iVar6 == 2` test
@@ -3471,17 +3474,31 @@ bool EEMEngine::doAccuseNotes() {
 
 	rebuildPagination();
 	draw();
+	Common::Point mouse = g_system->getEventManager()->getMousePos();
+	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
+							  kPdaNotebookRect.contains(mouse.x, mouse.y) ||
+							  rectListContains(slotRects, mouse.x, mouse.y));
 
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
 				return false;
+			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				setInteractiveMouseCursor(
+					notebookButtonAt(ev.mouse.x, ev.mouse.y) ||
+					kPdaNotebookRect.contains(ev.mouse.x, ev.mouse.y) ||
+					rectListContains(slotRects, ev.mouse.x, ev.mouse.y));
+			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					_nextScreen = kScreenSite;
 					return false;
+				}
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT &&
 					page > 0) {
 					page--;
@@ -3495,6 +3512,29 @@ bool EEMEngine::doAccuseNotes() {
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				const int mx = ev.mouse.x;
 				const int my = ev.mouse.y;
+				if (kPdaSiteRect.contains(mx, my)) {
+					_nextScreen = kScreenSite;
+					return false;
+				}
+				if (kPdaPartnerFootMapRect.contains(mx, my)) {
+					_nextScreen = kScreenMapAlt;
+					return false;
+				}
+				if (kPdaNotebookRect.contains(mx, my)) {
+					_nextScreen = kScreenNotebook;
+					return false;
+				}
+				if (kPdaGalleryRect.contains(mx, my)) {
+					_nextScreen = kScreenGallery;
+					return false;
+				}
+				if (kPdaHelpRect.contains(mx, my) ||
+					kPdaHelp2Rect.contains(mx, my)) {
+					setInteractiveMouseCursor(false);
+					doInterfaceHelp(0);
+					dirty = true;
+					continue;
+				}
 				// Page navigation — `_NoteButtons[5]` / `[6]`,
 				// dispatched in `_HandleAccuseNoteButton @
 				// 1df2:0990`. Only effective if there's another
@@ -3548,42 +3588,18 @@ bool EEMEngine::doAccuseNotes() {
 				for (uint i = 0; i < slotRects.size(); i++) {
 					if (slotRects[i].contains(mx, my)) {
 						const uint clueId = slotClues[i];
-						_mystery._noteSelected[clueId] ^= 1;
-						dirty = true;
-
-						// Debug: dump current user-selected score so
-						// the post-selection 100-point gate behaviour
-						// is visible while picking. Mirrors the
-						// floppy `FUN_1d40_0c48` (sum of `note[+6]`
-						// across `_NoteSelected != 0`) and the CD
-						// `selectedPoints()` (sum of `note[+2]` u16
-						// across `_NoteSelected != 0`).
-						{
-							int total = 0;
-							uint selectedCount = 0;
-							const uint maxIdx = MIN<uint>(niCount,
-								Mystery::kCluesFoundCap);
-							const bool floppy = isFloppy();
-							for (uint j = 0; j < maxIdx; j++) {
-								if (!_mystery._noteSelected[j])
-									continue;
-								selectedCount++;
-								if (floppy) {
-									total += (int)ni[j * 7 + 6];
-								} else {
-									const int16 pts =
-										(int16)READ_LE_UINT16(
-											ni + j * 4 + 2);
-									total += (int)pts;
-								}
+						if (!_mystery._noteSelected[clueId]) {
+							uint selected = 0;
+							for (uint j = 0; j < found.size(); j++) {
+								if (_mystery._noteSelected[found[j]])
+									selected++;
 							}
-							warning("EEM accuse: clue %u %s "
-									"(selected=%u points=%d)",
-									clueId,
-									_mystery._noteSelected[clueId]
-										? "ON" : "OFF",
-									selectedCount, total);
+							if (selected >= expected)
+								break;
 						}
+						_mystery._noteSelected[clueId] =
+							_mystery._noteSelected[clueId] ? 0 : 1;
+						dirty = true;
 						break;
 					}
 				}
@@ -3730,12 +3746,14 @@ void EEMEngine::doAccuse() {
 
 	// ACCUSE-NOTES SCREEN — let the player commit which N clues they
 	// believe solve the case. Mirrors the click-driven selection of
-	// `_DoAccuse @ 1df2:0bdd`'s outer loop. ESC / cancel returns to
-	// the site (matches `_DoAccuse @ 1df2:0c11` writing
-	// `_NextScreen = 3` on ESC).
+	// `_DoAccuse @ 1df2:0bdd`'s outer loop. ESC returns to the site
+	// (matches `_DoAccuse @ 1df2:0c11` writing `_NextScreen = 3`);
+	// pointer navigation can also leave for the PDA, gallery, or map.
 	if (!doAccuseNotes()) {
-		_nextScreen = _lastScreen != kScreenInvalid
-						? (ScreenId)_lastScreen : kScreenSite;
+		if (_nextScreen == kScreenAccuse) {
+			_nextScreen = _lastScreen != kScreenInvalid
+							? (ScreenId)_lastScreen : kScreenSite;
+		}
 		return;
 	}
 
@@ -4630,10 +4648,12 @@ void EEMEngine::doAccuseFloppy() {
 	// matching the CD's `_DoAccuse @ 1df2:0bdd` expected count.
 	// `doAccuseNotes()` already handles this UI for both variants
 	// (note text reading is variant-aware via `noteTextOff`); it
-	// returns true on commit, false on ESC.
+	// returns true on commit, false on ESC / pointer navigation.
 	if (!doAccuseNotes()) {
-		_nextScreen = _lastScreen != kScreenInvalid
-			? (ScreenId)_lastScreen : kScreenSite;
+		if (_nextScreen == kScreenAccuse) {
+			_nextScreen = _lastScreen != kScreenInvalid
+				? (ScreenId)_lastScreen : kScreenSite;
+		}
 		return;
 	}
 


Commit: a0d9c693402c09a30564a36fc98ad223c16591b7
    https://github.com/scummvm/scummvm/commit/a0d9c693402c09a30564a36fc98ad223c16591b7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:01+02:00

Commit Message:
EEM: fixed regression on music handling during enter animation

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 4513d010beb..f7523be122b 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -958,6 +958,11 @@ void EEMEngine::waitForMusicDone(uint32 maxMs) {
 				stopMusic();
 				return;
 			}
+			if (ev.type == Common::EVENT_KEYDOWN ||
+				ev.type == Common::EVENT_LBUTTONDOWN) {
+				stopMusic();
+				return;
+			}
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(20);
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 78f7a5557bb..ae1a6de44d8 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -500,9 +500,9 @@ public:
 	/// music changes as the player travels between sites.
 	void startTravelMusic();
 
-	/// Block until the current MIDI cue finishes, then stop/unload it.
-	/// Mirrors the `_IsMIDIPlaying` spin + `_StopMIDI` cleanup in
-	/// `_DoSiteLoop` after the CD travel cue.
+	/// Block until the current MIDI cue finishes or the player skips it,
+	/// then stop/unload it. Mirrors the `_IsMIDIPlaying` spin +
+	/// `_StopMIDI` cleanup in `_DoSiteLoop` after the CD travel cue.
 	void waitForMusicDone(uint32 maxMs = 60000);
 
 	/// Stop any currently playing MIDI track. Mirrors `_StopMIDI @
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index eeb7d78bd3d..a5051688000 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -610,10 +610,14 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		else
 			renderStaticDrops(siteNum);
 		renderAnimatedDrops(siteNum, g_system->getMillis());
-		enterSiteAnim();
+		const bool skippedArrival = enterSiteAnim();
 		_vm->markSiteArrivalPlayed(siteNum);
-		if (!_vm->isFloppy())
-			_vm->waitForMusicDone();
+		if (!_vm->isFloppy()) {
+			if (skippedArrival)
+				_vm->stopMusic();
+			else
+				_vm->waitForMusicDone();
+		}
 		// Re-paint the BG; the normal snapshot below should contain
 		// only the static layers, while animated NPCs are redrawn per
 		// tick by the frame pump.
@@ -873,7 +877,7 @@ void SiteScreen::run() {
 	}
 }
 
-void SiteScreen::enterSiteAnim() {
+bool SiteScreen::enterSiteAnim() {
 	// Mirrors `_EnterSiteAnim @ 1000:9b21`. Two phases, both partner
 	// dependent:
 	//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny). Sprite
@@ -886,7 +890,7 @@ void SiteScreen::enterSiteAnim() {
 	// motion (a runtime-calibrated speed value); we use a fixed 4 px
 	// per tick which feels close to the DOS pacing.
 	if (!_vm || !_mystery)
-		return;
+		return false;
 	const uint8 partner = _vm->getPartnerIndex();
 	const uint kSkateAni = (partner == 0) ? 6  : 0xe;
 	const uint kKDAni    = (partner == 0) ? 7  : 0xf;
@@ -895,7 +899,7 @@ void SiteScreen::enterSiteAnim() {
 	// Snapshot the current screen so we can restore between frames.
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
-		return;
+		return false;
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	bg.simpleBlitFrom(*screen);
@@ -929,7 +933,7 @@ void SiteScreen::enterSiteAnim() {
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_KEYDOWN ||
 					ev.type == Common::EVENT_LBUTTONDOWN) {
-					return; // user-skip — bail out of the animation
+					return true; // user-skip
 				}
 			}
 
@@ -979,12 +983,13 @@ void SiteScreen::enterSiteAnim() {
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_KEYDOWN ||
 					ev.type == Common::EVENT_LBUTTONDOWN) {
-					return;
+					return true;
 				}
 			}
 			g_system->delayMillis(80);
 		}
 	}
+	return false;
 }
 
 void SiteScreen::renderStaticDrops(uint siteNum) {
diff --git a/engines/eem/site.h b/engines/eem/site.h
index d6f6557c381..07b34874d07 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -119,7 +119,8 @@ private:
 	/// _SiteNumber`. Mirrors `_EnterSiteAnim @ 1000:9b21` — animation
 	/// 6 (Jake) / 14 (Jenny) skateboards in from the right edge along
 	/// the bottom, then animation 7 / 15 slides KD in from the left.
-	void enterSiteAnim();
+	/// Returns true when the player skipped it with input.
+	bool enterSiteAnim();
 
 	/// Draw the persistent in-site partner sprite (Jake or Jenny
 	/// standing/idling) at the position from `_WaitAnims` @ 29be:021c.


Commit: 33e2ff6fdad0c6c961f09bd403367bd04b75be34
    https://github.com/scummvm/scummvm/commit/33e2ff6fdad0c6c961f09bd403367bd04b75be34
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:01+02:00

Commit Message:
EEM: implemented badge handling when a case is over

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2ff97c65045..30dfb17f7f6 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -57,6 +57,9 @@ const GallerySlot kGallerySlots[5] = {
 
 constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
 constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
+constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
+constexpr uint16 kFirstTryBadgePic = 0x205;
+constexpr Common::Point kFirstTryBadgePos(0x1e, 9);
 
 constexpr Common::Rect kPdaHelpRect(Common::Point(93, 174), 22, 16);
 constexpr Common::Rect kPdaNotebookRect(Common::Point(134, 174), 21, 16);
@@ -633,12 +636,15 @@ void EEMEngine::doNewPlayer() {
 
 int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @
-	// 1df2:044c`. File format (verified by reading E0.BIN's bytes):
+	// 1df2:044c` on CD and `FUN_1d40_05b7` + `FUN_1d40_031e` on
+	// floppy. CD ending file format:
 	//   u16 pageCount
 	//   for each page:
 	//     u16 picNum
 	//     u16 x1, y1, x2, y2  (story rect — passed to WordWrap)
 	//     char text[]        (null-terminated, ParseString opcodes)
+	// Floppy files prepend a small title header and use a shared
+	// newspaper background (PIC 0x8b) plus per-page overlay pictures.
 	//
 	// The original swaps the font: `_FreeFont(); _LoadFont("tiny.fnt")`
 	// at 1df2:055f-1df2:0563, calls `_GetPalette(0)` (site palette 0),
@@ -679,10 +685,6 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		return 0;
 	}
 
-	const uint16 pageCount = READ_LE_UINT16(buf.data());
-	if (pageCount == 0)
-		return 0;
-
 	// Mirrors 1df2:0558-1df2:056a — `_FreeFont(); _LoadFont(tiny.fnt)`.
 	// The newspaper layout uses TINY.FNT (smaller glyphs) so the body
 	// copy fits in the columns. `_LoadFont(font.fnt)` is restored at
@@ -699,26 +701,69 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	setSitePalette(0);
 	CursorMan.showMouse(true);
 
-	// Walk page records. Each page header is 10 bytes; text is
-	// null-terminated and follows the header.
+	const bool floppyEnding = isFloppy();
 	uint pageOffsets[8];   // ENDING_RANGE_MAX from `_DisplayEnding`
-	const uint maxPages = MIN<uint>(pageCount,
-									(uint)(sizeof(pageOffsets) / sizeof(uint)));
-	uint cursor = 2;
+	const uint pageOffsetCap =
+		(uint)(sizeof(pageOffsets) / sizeof(pageOffsets[0]));
 	uint validPages = 0;
-	for (uint p = 0; p < maxPages; p++) {
-		if (cursor + 10 >= size)
-			break;
-		pageOffsets[validPages++] = cursor;
-		// Skip the 10-byte header and find the null terminator.
-		cursor += 10;
-		while (cursor < size && buf[cursor] != 0)
-			cursor++;
-		cursor++;  // past the null
+
+	if (floppyEnding) {
+		// Floppy `E<num>.BIN` starts with:
+		//   u8 type, 3 bytes of title metadata, char title[], u8 pageCount
+		// followed by pages:
+		//   u8 overlayCount, N * { u16 picNum, u16 x, u8 y },
+		//   u16 x1, y1, x2, y2, char text[].
+		uint titleEnd = 4;
+		while (titleEnd < size && buf[titleEnd] != 0)
+			titleEnd++;
+		if (titleEnd + 2 >= size)
+			return 0;
+		const uint pageCount = buf[titleEnd + 1];
+		uint cursor = titleEnd + 2;
+		const uint maxPages = MIN<uint>(pageCount, pageOffsetCap);
+		for (uint p = 0; p < maxPages; p++) {
+			if (cursor >= size)
+				break;
+			const uint pageStart = cursor;
+			const uint overlayCount = buf[cursor++];
+			const uint overlaysSize = overlayCount * 5;
+			if (cursor + overlaysSize + 8 >= size)
+				break;
+			cursor += overlaysSize + 8;
+			while (cursor < size && buf[cursor] != 0)
+				cursor++;
+			if (cursor >= size)
+				break;
+			pageOffsets[validPages++] = pageStart;
+			cursor++;  // past the null
+		}
+	} else {
+		const uint16 pageCount = READ_LE_UINT16(buf.data());
+		if (pageCount == 0)
+			return 0;
+		const uint maxPages = MIN<uint>(pageCount, pageOffsetCap);
+		uint cursor = 2;
+		for (uint p = 0; p < maxPages; p++) {
+			if (cursor + 10 >= size)
+				break;
+			pageOffsets[validPages++] = cursor;
+			// Skip the 10-byte header and find the null terminator.
+			cursor += 10;
+			while (cursor < size && buf[cursor] != 0)
+				cursor++;
+			cursor++;  // past the null
+		}
 	}
 	if (validPages == 0)
 		return 0;
 
+	const bool showFirstTryBadge =
+		num < sizeof(_mysteriesSolved) && _mysteriesSolved[num] == 2;
+	Picture firstTryBadge;
+	const bool haveFirstTryBadge =
+		showFirstTryBadge &&
+		_picsArchive.getPicture(kFirstTryBadgePic, firstTryBadge);
+
 	uint pageIdx = firstPage ? 0 : (validPages - 1);
 	int direction = 0;     // -1 / 0 / +1, see header doc.
 	bool exitLoop = false;
@@ -731,25 +776,68 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	while (!shouldQuit() && !exitLoop) {
 		if (dirty) {
 			const uint off = pageOffsets[pageIdx];
-			if (off + 10 >= size)
-				break;
-			const uint16 picNum = READ_LE_UINT16(buf.data() + off);
-			const uint16 x1     = READ_LE_UINT16(buf.data() + off + 2);
-			const uint16 y1     = READ_LE_UINT16(buf.data() + off + 4);
-			const uint16 x2     = READ_LE_UINT16(buf.data() + off + 6);
-			(void)READ_LE_UINT16(buf.data() + off + 8);  // y2 (unused — WordWrap2 takes width only)
-
-			Picture bg;
+			uint16 x1 = 0;
+			uint16 y1 = 0;
+			uint16 x2 = 0;
+			const char *raw = nullptr;
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.clear();
-			if (_picsArchive.getPicture(picNum, bg))
-				scratch.simpleBlitFrom(bg.surface);
+
+			if (floppyEnding) {
+				Picture bg;
+				if (_picsArchive.getPicture(kFloppyEndingBackgroundPic, bg))
+					scratch.simpleBlitFrom(bg.surface);
+
+				uint cursor = off;
+				if (cursor >= size)
+					break;
+				const uint overlayCount = buf[cursor++];
+				for (uint i = 0; i < overlayCount; i++) {
+					if (cursor + 5 > size)
+						break;
+					const uint16 picNum = READ_LE_UINT16(buf.data() + cursor);
+					const uint16 px = READ_LE_UINT16(buf.data() + cursor + 2);
+					const byte py = buf[cursor + 4];
+					Picture overlay;
+					if (_picsArchive.getPicture(picNum, overlay)) {
+						const byte transp = (byte)(overlay.flags >> 8);
+						scratch.transBlitFrom(overlay.surface,
+											  Common::Point(px, py), transp);
+					}
+					cursor += 5;
+				}
+				if (cursor + 8 >= size)
+					break;
+				x1 = READ_LE_UINT16(buf.data() + cursor);
+				y1 = READ_LE_UINT16(buf.data() + cursor + 2);
+				x2 = READ_LE_UINT16(buf.data() + cursor + 4);
+				(void)READ_LE_UINT16(buf.data() + cursor + 6);
+				raw = (const char *)buf.data() + cursor + 8;
+			} else {
+				if (off + 10 >= size)
+					break;
+				const uint16 picNum = READ_LE_UINT16(buf.data() + off);
+				x1 = READ_LE_UINT16(buf.data() + off + 2);
+				y1 = READ_LE_UINT16(buf.data() + off + 4);
+				x2 = READ_LE_UINT16(buf.data() + off + 6);
+				(void)READ_LE_UINT16(buf.data() + off + 8);  // y2 (unused — WordWrap2 takes width only)
+
+				Picture bg;
+				if (_picsArchive.getPicture(picNum, bg))
+					scratch.simpleBlitFrom(bg.surface);
+				raw = (const char *)buf.data() + off + 10;
+			}
+
+			if (pageIdx == 0 && haveFirstTryBadge) {
+				const byte transp = (byte)(firstTryBadge.flags >> 8);
+				scratch.transBlitFrom(firstTryBadge.surface,
+									  kFirstTryBadgePos, transp);
+			}
 
 			// Story text. The bytes are a null-terminated string with
 			// `_ParseString` placeholders (0x80 = player name, 0x82
 			// = partner first name, etc.).
-			const char *raw = (const char *)buf.data() + off + 10;
 			const Common::String text = parseString(raw, _playerName, _partner);
 
 			// Use TINY.FNT (`_LoadFont(@29be:10a5)` at 1df2:055f-0563)
@@ -4991,6 +5079,13 @@ void EEMEngine::doAccuseFloppy() {
 			}
 		}
 
+		// `_DisplayCorrect_Floppy @ 1d40:0991` calls
+		// `FUN_1ee2_06ac(mystery)`, whose body is just
+		// `FUN_1d40_05b7(mystery, 1)` plus font cleanup. That is the
+		// floppy ending/scrapbook viewer, and it receives the
+		// first-try badge state from the solved table entry above.
+		doShowEnding(mn);
+
 		// Persist progress before clearing the in-progress mystery.
 		// `_DisplayCorrect_Floppy @ 1d40:0982` calls
 		// `_SavePlayerRecord` then `FUN_22dc_0dbd` (which deletes the


Commit: 1423b3442bf1f4ac52bd25aa65dc419ac14ea055
    https://github.com/scummvm/scummvm/commit/1423b3442bf1f4ac52bd25aa65dc419ac14ea055
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:01+02:00

Commit Message:
EEM: shiny map markers

Changed paths:
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/site.h b/engines/eem/site.h
index 07b34874d07..110c560df05 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -76,6 +76,11 @@ uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY);
 
+/// Rotate one VGA palette range by one slot. Mirrors `_ColorCycle`
+/// and is used by site color cycles, hotspot marching ants, and the
+/// BigMap marker shine.
+void cyclePaletteRange(uint8 start, uint8 end);
+
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
 struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 30dfb17f7f6..a5612b345a0 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2885,6 +2885,8 @@ void EEMEngine::doBigMap() {
 		if (now - mapLastTick >= 100) {
 			mapLastTick = now;
 			drawBigMapOverview(now - mapStartTick);
+			cyclePaletteRange(0xf7, 0xfa);
+			cyclePaletteRange(0xfb, 0xfe);
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);


Commit: 9e532d8f9691ea295ab990fcaa42adb912630cea
    https://github.com/scummvm/scummvm/commit/9e532d8f9691ea295ab990fcaa42adb912630cea
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:02+02:00

Commit Message:
EEM: allow to come back from zoomed map to the overview

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index a5612b345a0..7ed2cac176e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2806,286 +2806,313 @@ void EEMEngine::doBigMap() {
 
 	CursorMan.showMouse(true);
 
-	// `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
-	setSitePalette(0x24);
-
-	const Common::Rect kSetupRect(0xc7, 0x12, 0xc7 + 0x32, 0x12 + 0xa); // approx; original from globals
-	(void)kSetupRect; // not yet wired into our overlay
-
-	// ------------------------------------------------------------------
-	// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
-	// ------------------------------------------------------------------
-
-	// Anchor for the partner-sprite timeline. `_DoBigMap`'s
-	// `_NewAnimation` call seeds the slot's frame index to 0xffff so
-	// the first `_UpdateAnimations` tick starts at script[0]; we mirror
-	// that by passing elapsed-since-open (zero on the first paint) into
-	// `bigMapPartnerFrameAtTick`, which plays the unfold once and then
-	// loops the wait sequence.
-	const uint32 mapStartTick = g_system->getMillis();
-	drawBigMapOverview(0);
-	uint32 mapLastTick = mapStartTick;
-
-	// Static rectangles read directly from the binary at the labelled
-	// addresses (CD `29be:0x1596` / floppy `2608:0x13fe..0x143e`).
-	// Format is {x1, y1, x2, y2}. The floppy click table at
-	// `2608:1436` (verified at `_BigMapInteractionLoop_Floppy @
-	// 1fed:0a3a`) puts the setup button at (251, 3, 315, 42) — 1 px
-	// up and 1 px left of the CD's (252, 4, 315, 42). The floppy
-	// PIC 0x42 BG paints the visible button border at the same
-	// pixels, so use the variant-specific rect to match the
-	// hit-test region the original uses for that variant.
-	const Common::Rect kBigMapWindow   (  0,   0, 247, 192); // 29be:1596
-	const Common::Rect kSetupBtnRect   = isFloppy()
-		? Common::Rect(251, 3, 315, 42)   // 2608:1436
-		: Common::Rect(252, 4, 315, 42);  // 29be:15ce
-
-	bool wantZoom = false;
-	int zoomX = 0;
-	int zoomY = 0;
 	while (!shouldQuit()) {
-		Common::Event ev;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_KEYDOWN &&
-				ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-				return;
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// SetupButtonRect → `_NextScreen = 6` (the original's
-				// settings screen, mirrors `_DoBigMap @ 20fe:0c33`
-				// where it pushes `_PressButton` then writes
-				// `_NextScreen = 6`). Now wired to the actual
-				// `doSetup` handler instead of dropping the player
-				// out to the launcher.
-				if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
-					_nextScreen = kScreenSetup;
+		setInteractiveMouseCursor(false);
+		setSitePalette(0x24); // `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
+
+		// ------------------------------------------------------------------
+		// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
+		// ------------------------------------------------------------------
+
+		// Anchor for the partner-sprite timeline. `_DoBigMap`'s
+		// `_NewAnimation` call seeds the slot's frame index to 0xffff so
+		// the first `_UpdateAnimations` tick starts at script[0]; we mirror
+		// that by passing elapsed-since-open (zero on the first paint) into
+		// `bigMapPartnerFrameAtTick`, which plays the unfold once and then
+		// loops the wait sequence.
+		const uint32 mapStartTick = g_system->getMillis();
+		drawBigMapOverview(0);
+		uint32 mapLastTick = mapStartTick;
+
+		// Static rectangles read directly from the binary at the labelled
+		// addresses (CD `29be:0x1596` / floppy `2608:0x13fe..0x143e`).
+		// Format is {x1, y1, x2, y2}. The floppy click table at
+		// `2608:1436` (verified at `_BigMapInteractionLoop_Floppy @
+		// 1fed:0a3a`) puts the setup button at (251, 3, 315, 42) — 1 px
+		// up and 1 px left of the CD's (252, 4, 315, 42). The floppy
+		// PIC 0x42 BG paints the visible button border at the same
+		// pixels, so use the variant-specific rect to match the
+		// hit-test region the original uses for that variant.
+		const Common::Rect kBigMapWindow(0, 0, 247, 192); // 29be:1596
+		const Common::Rect kSetupBtnRect = isFloppy()
+			? Common::Rect(251, 3, 315, 42)   // 2608:1436
+			: Common::Rect(252, 4, 315, 42);  // 29be:15ce
+
+		bool wantZoom = false;
+		int zoomX = 0;
+		int zoomY = 0;
+		while (!shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 					return;
-				}
-				// Click in the BigMapWindow → zoom. Original formula:
-				//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
-				if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
-					int sx = ev.mouse.x * 2;
-					int sy = ev.mouse.y * 2;
-					sx = (sx < 0x75) ? 0 : sx - 0x74;
-					sy = (sy < 0x56) ? 0 : sy - 0x55;
-					zoomX = sx;
-					zoomY = sy;
-					wantZoom = true;
-					break;
+				if (ev.type == Common::EVENT_KEYDOWN &&
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return;
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					// SetupButtonRect → `_NextScreen = 6` (the original's
+					// settings screen, mirrors `_DoBigMap @ 20fe:0c33`
+					// where it pushes `_PressButton` then writes
+					// `_NextScreen = 6`). Now wired to the actual
+					// `doSetup` handler instead of dropping the player
+					// out to the launcher.
+					if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
+						_nextScreen = kScreenSetup;
+						return;
+					}
+					// Click in the BigMapWindow → zoom. Original formula:
+					//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
+					if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
+						int sx = ev.mouse.x * 2;
+						int sy = ev.mouse.y * 2;
+						sx = (sx < 0x75) ? 0 : sx - 0x74;
+						sy = (sy < 0x56) ? 0 : sy - 0x55;
+						zoomX = sx;
+						zoomY = sy;
+						wantZoom = true;
+						break;
+					}
 				}
 			}
-		}
-		if (wantZoom)
-			break;
-		// Cycle the partner-sprite frame every 100 ms (matching the
-		// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
-		const uint32 now = g_system->getMillis();
-		if (now - mapLastTick >= 100) {
-			mapLastTick = now;
-			drawBigMapOverview(now - mapStartTick);
-			cyclePaletteRange(0xf7, 0xfa);
-			cyclePaletteRange(0xfb, 0xfe);
-		}
-		g_system->updateScreen();
-		g_system->delayMillis(10);
-	}
-
-	if (!wantZoom)
-		return;
-
-	// ------------------------------------------------------------------
-	// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
-	// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
-	// ------------------------------------------------------------------
+			if (wantZoom)
+				break;
 
-	Common::File f;
-	if (!f.open(Common::Path("BIGMAP.PIC"))) {
-		warning("doBigMap: BIGMAP.PIC missing for detail view");
-		return;
-	}
-	const uint16 mapH = f.readUint16LE();
-	const uint16 mapW = f.readUint16LE();
-	if (mapW == 0 || mapH == 0)
-		return;
-	Common::Array<byte> mapPixels((uint32)mapW * mapH);
-	if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
-		warning("doBigMap: short read on BIGMAP.PIC for detail view");
-		return;
-	}
+			// Cycle the partner-sprite frame every 100 ms (matching the
+			// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
+			const uint32 now = g_system->getMillis();
+			if (now - mapLastTick >= 100) {
+				mapLastTick = now;
+				drawBigMapOverview(now - mapStartTick);
+				cyclePaletteRange(0xf7, 0xfa);
+				cyclePaletteRange(0xfb, 0xfe);
+			}
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
 
-	const int kMapWinW = 0xe9; // 233
-	const int kMapWinH = 0xab; // 171
-	const int kMapWinX = 2;
-	const int kMapWinY = 2;
+		if (!wantZoom)
+			return;
 
-	int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
-	int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
+		// ------------------------------------------------------------------
+		// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
+		// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
+		// ------------------------------------------------------------------
 
-	// Anchor the detail-screen partner timeline (mirrors `_DoMapScreen`'s
-	// `_NewAnimation` seeding the slot's frame index to 0xffff). The
-	// unfold (script 0x13) plays once, then `_SmallMapWaitSeq` loops.
-	const uint32 detailStartTick = g_system->getMillis();
-	drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
-	uint32 detailLastTick = detailStartTick;
+		Common::File f;
+		if (!f.open(Common::Path("BIGMAP.PIC"))) {
+			warning("doBigMap: BIGMAP.PIC missing for detail view");
+			return;
+		}
+		const uint16 mapH = f.readUint16LE();
+		const uint16 mapW = f.readUint16LE();
+		if (mapW == 0 || mapH == 0)
+			return;
+		Common::Array<byte> mapPixels((uint32)mapW * mapH);
+		if (f.read(mapPixels.data(), mapPixels.size()) != mapPixels.size()) {
+			warning("doBigMap: short read on BIGMAP.PIC for detail view");
+			return;
+		}
 
-	while (!shouldQuit()) {
-		Common::Event ev;
-		bool dirty = false;
-		while (g_system->getEventManager()->pollEvent(ev)) {
-			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-				return;
-			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return;  // exit detail back to caller (site loop / engine)
-				const int kStep = 16;
-				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
-					scrollX = MAX<int>(0, scrollX - kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW),
-						scrollX + kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
-					scrollY = MAX<int>(0, scrollY - kStep);
-					dirty = true;
-				} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
-					scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH),
-						scrollY + kStep);
-					dirty = true;
-				}
-			}
-			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Scroll arrows + slider rects live in `SmallMapButtons`
-				// at 29be:0x159e (six 8-byte rects in order: Y-up, Y-down,
-				// X-left, X-right, right-panel, top-right) plus the
-				// dedicated `XSliderRect @ 29be:15d6` and
-				// `YSliderRect @ 29be:15de`. Format {x1,y1,x2,y2}.
-				const Common::Rect kArrowYUp   (237,   2, 247,  11);
-				const Common::Rect kArrowYDown (237, 163, 247, 172);
-				const Common::Rect kArrowXLeft (  2, 175,  12, 185);
-				const Common::Rect kArrowXRight(224, 175, 234, 185);
-				const Common::Rect kXSlider    ( 15, 175, 221, 185);
-				const Common::Rect kYSlider    (237,  14, 247, 160);
-				const Common::Rect kSetupBtn = isFloppy()
-					? Common::Rect(251, 3, 315, 42)   // 2608:1436
-					: Common::Rect(252, 4, 315, 42);  // 29be:15ce
-
-				const int kArrowStep = 16;
-				const int kSliderRange = mapW - kMapWinW;
-				const int kSliderRangeY = mapH - kMapWinH;
-
-				if (kSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
-					// `_DoMapScreen @ 20fe:1560` writes `_NextScreen
-					// = 6` (= kScreenSetup) and `INC [BP-8]` to bail
-					// out of the detail loop — verified via the byte
-					// search for `c7 06 16 79 06 00`, which finds the
-					// imm at exactly this site and `_DoBigMap @
-					// 20fe:0c33`. Same `SetupButtonRect @ 29be:15ce`
-					// rect used by both the overview and the detail
-					// (no per-screen rect duplication in the binary).
-					// The detail/zoom state is lost on return because
-					// the screen driver re-enters BigMap at stage 1 —
-					// this matches the original behaviour.
-					_nextScreen = kScreenSetup;
+		const int kMapWinW = 0xe9; // 233
+		const int kMapWinH = 0xab; // 171
+		const int kMapWinX = 2;
+		const int kMapWinY = 2;
+
+		int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
+		int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
+
+		// Anchor the detail-screen partner timeline (mirrors `_DoMapScreen`'s
+		// `_NewAnimation` seeding the slot's frame index to 0xffff). The
+		// unfold (script 0x13) plays once, then `_SmallMapWaitSeq` loops.
+		const uint32 detailStartTick = g_system->getMillis();
+		drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
+		uint32 detailLastTick = detailStartTick;
+		bool returnToOverview = false;
+
+		// `SmallMapButtons[4]` is the large right-side panel below setup:
+		// CD/floppy both store `(252, 43, 320, 200)`. Its handler at
+		// CD `20fe:156c` kills the zoom animation, sets `_NextScreen = 1`
+		// and calls `_DoBigMap` again, so mouse players can return to the
+		// overview without leaving the map screen.
+		const Common::Rect kBigMapReturnRect(252, 43, 320, 200);
+		const Common::Rect kArrowYUp(237, 2, 247, 11);
+		const Common::Rect kArrowYDown(237, 163, 247, 172);
+		const Common::Rect kArrowXLeft(2, 175, 12, 185);
+		const Common::Rect kArrowXRight(224, 175, 234, 185);
+		const Common::Rect kXSlider(15, 175, 221, 185);
+		const Common::Rect kYSlider(237, 14, 247, 160);
+		const Common::Rect kDetailSetupBtn = isFloppy()
+			? Common::Rect(251, 3, 315, 42)   // 2608:1436
+			: Common::Rect(252, 4, 315, 42);  // 29be:15ce
+		const int kArrowStep = 16;
+		const int kSliderRange = mapW - kMapWinW;
+		const int kSliderRangeY = mapH - kMapWinH;
+		const Common::Point detailMouse =
+			g_system->getEventManager()->getMousePos();
+		setInteractiveMouseCursor(
+			kBigMapReturnRect.contains(detailMouse.x, detailMouse.y) ||
+			kDetailSetupBtn.contains(detailMouse.x, detailMouse.y));
+
+		while (!shouldQuit() && !returnToOverview) {
+			Common::Event ev;
+			bool dirty = false;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					setInteractiveMouseCursor(false);
 					return;
 				}
-				if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollY = MAX<int>(0, scrollY - kArrowStep);
-					dirty = true;
-				} else if (kArrowYDown.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollY = MIN<int>(MAX<int>(0, kSliderRangeY),
-						scrollY + kArrowStep);
-					dirty = true;
-				} else if (kArrowXLeft.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollX = MAX<int>(0, scrollX - kArrowStep);
-					dirty = true;
-				} else if (kArrowXRight.contains(ev.mouse.x, ev.mouse.y)) {
-					scrollX = MIN<int>(MAX<int>(0, kSliderRange),
-						scrollX + kArrowStep);
-					dirty = true;
-				} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
-					// Click on X slider track → jump scrollX so the
-					// click position maps proportionally into the map.
-					if (kSliderRange > 0) {
-						const int t = ev.mouse.x - kXSlider.left;
-						const int tw = kXSlider.width();
-						scrollX = MAX<int>(0, MIN<int>(kSliderRange,
-							t * kSliderRange / MAX<int>(1, tw)));
-						dirty = true;
+				if (ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+						setInteractiveMouseCursor(false);
+						return;  // exit detail back to caller (site loop / engine)
 					}
-				} else if (kYSlider.contains(ev.mouse.x, ev.mouse.y)) {
-					if (kSliderRangeY > 0) {
-						const int t = ev.mouse.y - kYSlider.top;
-						const int th = kYSlider.height();
-						scrollY = MAX<int>(0, MIN<int>(kSliderRangeY,
-							t * kSliderRangeY / MAX<int>(1, th)));
+					const int kStep = 16;
+					if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
+						scrollX = MAX<int>(0, scrollX - kStep);
+						dirty = true;
+					} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
+						scrollX = MIN<int>(MAX<int>(0, mapW - kMapWinW),
+							scrollX + kStep);
+						dirty = true;
+					} else if (ev.kbd.keycode == Common::KEYCODE_UP) {
+						scrollY = MAX<int>(0, scrollY - kStep);
+						dirty = true;
+					} else if (ev.kbd.keycode == Common::KEYCODE_DOWN) {
+						scrollY = MIN<int>(MAX<int>(0, mapH - kMapWinH),
+							scrollY + kStep);
 						dirty = true;
 					}
-				} else if (ev.mouse.x >= kMapWinX &&
-						   ev.mouse.x < kMapWinX + kMapWinW &&
-						   ev.mouse.y >= kMapWinY &&
-						   ev.mouse.y < kMapWinY + kMapWinH) {
-					// Hit-test the per-site button at its actual bbox
-					// (`_StampButtons` records the rect at SmallMap +8/+0xa
-					// with the button PIC's width/height).
-					const bool fmap = _mystery.isLoaded() && isFloppy();
-					for (uint i = 0; i < _mystery.numSites(); i++) {
-						if (!_mystery._onSites[i] &&
-							i != _mystery._siteNumber)
-							continue;
-						const byte *entry = _mystery.mapEntry(i);
-						if (!entry)
-							continue;
-						uint16 mx;
-						uint16 my;
-						uint16 buttonId;
-						if (fmap) {
-							// Floppy detail view: click rect on
-							// BIGMAP.PIC at (+0, +2), labelled BUTTON.DBD
-							// entry ID at entry+4 (per
-							// `FUN_1fed_0c3e @ 1fed:0c3e`).
-							mx = READ_LE_UINT16(entry + 0x0);
-							my = READ_LE_UINT16(entry + 0x2);
-							buttonId = (uint16)entry[0x4];
-						} else {
-							buttonId = READ_LE_UINT16(entry + 0x0);
-							mx       = READ_LE_UINT16(entry + 0x8);
-							my       = READ_LE_UINT16(entry + 0xa);
+				}
+				if (ev.type == Common::EVENT_MOUSEMOVE)
+					setInteractiveMouseCursor(
+						kBigMapReturnRect.contains(ev.mouse.x, ev.mouse.y) ||
+						kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y));
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					setInteractiveMouseCursor(
+						kBigMapReturnRect.contains(ev.mouse.x, ev.mouse.y) ||
+						kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y));
+					if (kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
+						// `_DoMapScreen @ 20fe:1560` writes `_NextScreen
+						// = 6` (= kScreenSetup) and `INC [BP-8]` to bail
+						// out of the detail loop — verified via the byte
+						// search for `c7 06 16 79 06 00`, which finds the
+						// imm at exactly this site and `_DoBigMap @
+						// 20fe:0c33`. Same `SetupButtonRect @ 29be:15ce`
+						// rect used by both the overview and the detail
+						// (no per-screen rect duplication in the binary).
+						// The detail/zoom state is lost on return because
+						// the screen driver re-enters BigMap at stage 1 —
+						// this matches the original behaviour.
+						_nextScreen = kScreenSetup;
+						setInteractiveMouseCursor(false);
+						return;
+					}
+					if (kBigMapReturnRect.contains(ev.mouse.x, ev.mouse.y)) {
+						returnToOverview = true;
+						break;
+					} else if (kArrowYUp.contains(ev.mouse.x, ev.mouse.y)) {
+						scrollY = MAX<int>(0, scrollY - kArrowStep);
+						dirty = true;
+					} else if (kArrowYDown.contains(ev.mouse.x, ev.mouse.y)) {
+						scrollY = MIN<int>(MAX<int>(0, kSliderRangeY),
+							scrollY + kArrowStep);
+						dirty = true;
+					} else if (kArrowXLeft.contains(ev.mouse.x, ev.mouse.y)) {
+						scrollX = MAX<int>(0, scrollX - kArrowStep);
+						dirty = true;
+					} else if (kArrowXRight.contains(ev.mouse.x, ev.mouse.y)) {
+						scrollX = MIN<int>(MAX<int>(0, kSliderRange),
+							scrollX + kArrowStep);
+						dirty = true;
+					} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
+						// Click on X slider track → jump scrollX so the
+						// click position maps proportionally into the map.
+						if (kSliderRange > 0) {
+							const int t = ev.mouse.x - kXSlider.left;
+							const int tw = kXSlider.width();
+							scrollX = MAX<int>(0, MIN<int>(kSliderRange,
+								t * kSliderRange / MAX<int>(1, tw)));
+							dirty = true;
 						}
-						Picture button;
-						int bw = 16;
-						int bh = 16;
-						if (_buttonArchive.loadEntry(buttonId, button)) {
-							bw = button.surface.w;
-							bh = button.surface.h;
+					} else if (kYSlider.contains(ev.mouse.x, ev.mouse.y)) {
+						if (kSliderRangeY > 0) {
+							const int t = ev.mouse.y - kYSlider.top;
+							const int th = kYSlider.height();
+							scrollY = MAX<int>(0, MIN<int>(kSliderRangeY,
+								t * kSliderRangeY / MAX<int>(1, th)));
+							dirty = true;
 						}
-						const int sx = (int)mx - scrollX + kMapWinX;
-						const int sy = (int)my - scrollY + kMapWinY;
-						if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
-							ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
-							_mystery._lastSite = _mystery._siteNumber;
-							_mystery._siteNumber = (uint16)i;
-							return;
+					} else if (ev.mouse.x >= kMapWinX &&
+							   ev.mouse.x < kMapWinX + kMapWinW &&
+							   ev.mouse.y >= kMapWinY &&
+							   ev.mouse.y < kMapWinY + kMapWinH) {
+						// Hit-test the per-site button at its actual bbox
+						// (`_StampButtons` records the rect at SmallMap +8/+0xa
+						// with the button PIC's width/height).
+						const bool fmap = _mystery.isLoaded() && isFloppy();
+						for (uint i = 0; i < _mystery.numSites(); i++) {
+							if (!_mystery._onSites[i] &&
+								i != _mystery._siteNumber)
+								continue;
+							const byte *entry = _mystery.mapEntry(i);
+							if (!entry)
+								continue;
+							uint16 mx;
+							uint16 my;
+							uint16 buttonId;
+							if (fmap) {
+								// Floppy detail view: click rect on
+								// BIGMAP.PIC at (+0, +2), labelled BUTTON.DBD
+								// entry ID at entry+4 (per
+								// `FUN_1fed_0c3e @ 1fed:0c3e`).
+								mx = READ_LE_UINT16(entry + 0x0);
+								my = READ_LE_UINT16(entry + 0x2);
+								buttonId = (uint16)entry[0x4];
+							} else {
+								buttonId = READ_LE_UINT16(entry + 0x0);
+								mx       = READ_LE_UINT16(entry + 0x8);
+								my       = READ_LE_UINT16(entry + 0xa);
+							}
+							Picture button;
+							int bw = 16;
+							int bh = 16;
+							if (_buttonArchive.loadEntry(buttonId, button)) {
+								bw = button.surface.w;
+								bh = button.surface.h;
+							}
+							const int sx = (int)mx - scrollX + kMapWinX;
+							const int sy = (int)my - scrollY + kMapWinY;
+							if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
+								ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
+								_mystery._lastSite = _mystery._siteNumber;
+								_mystery._siteNumber = (uint16)i;
+								setInteractiveMouseCursor(false);
+								return;
+							}
 						}
 					}
 				}
 			}
+			if (returnToOverview)
+				break;
+
+			// Cycle the partner sprite at 100 ms ticks (same cadence as
+			// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
+			const uint32 now = g_system->getMillis();
+			if (now - detailLastTick >= 100) {
+				detailLastTick = now;
+				dirty = true;
+			}
+			if (dirty)
+				drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH,
+					now - detailStartTick);
+			g_system->updateScreen();
+			g_system->delayMillis(10);
 		}
-		// Cycle the partner sprite at 100 ms ticks (same cadence as
-		// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
-		const uint32 now = g_system->getMillis();
-		if (now - detailLastTick >= 100) {
-			detailLastTick = now;
-			dirty = true;
-		}
-		if (dirty)
-			drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH,
-							 now - detailStartTick);
-		g_system->updateScreen();
-		g_system->delayMillis(10);
+		if (!returnToOverview)
+			return;
 	}
 }
 


Commit: ba0b1afbc3d03fcd8782a0e8ed0aa7f06c040fba
    https://github.com/scummvm/scummvm/commit/ba0b1afbc3d03fcd8782a0e8ed0aa7f06c040fba
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:02+02:00

Commit Message:
EEM: mt32 music

Changed paths:
    engines/eem/detection.cpp
    engines/eem/music.cpp
    engines/eem/music.h


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index d0b9b5800f2..fd620dd89f4 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -31,6 +31,8 @@ const PlainGameDescriptor eemGames[] = {
 	{ nullptr, nullptr }
 };
 
+#define GUI_OPTIONS_EEM GUIO3(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+
 const ADGameDescription gameDescriptions[] = {
 	{
 		"eem",
@@ -40,7 +42,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
+		GUI_OPTIONS_EEM
 	},
 	{
 		"eem",
@@ -50,7 +52,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
+		GUI_OPTIONS_EEM
 	},
 	{
 		// Spanish floppy release — same EEM.EXE binary as the English
@@ -64,7 +66,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::ES_ESP,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUIO1(GAMEOPTION_HIDE_HIGHLIGHT_BOXES)
+		GUI_OPTIONS_EEM
 	},
 
 	AD_TABLE_END_MARKER
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
index 80842663a09..1bd50f83cce 100644
--- a/engines/eem/music.cpp
+++ b/engines/eem/music.cpp
@@ -22,6 +22,7 @@
 #include "audio/midiparser.h"
 #include "audio/miles.h"
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/file.h"
 #include "common/textconsole.h"
@@ -31,15 +32,23 @@
 
 namespace EEM {
 
+namespace {
+
+const int kMidiDriverFlags = MDT_MIDI | MDT_ADLIB | MDT_PREFER_MT32;
+
+} // End of anonymous namespace
+
 MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 	// Mirrors `_InitMIDI @ 20a2:013a` which used `_AIL_register_driver`
 	// to walk the .ADV files (ADLIB.ADV, SBFM.ADV, MT32MPU.ADV, etc.)
 	// and pick a backend. We honour the launcher's "Music driver"
-	// setting and only force AdLib / MT-32 paths through Miles when the
-	// detected device matches.
+	// setting while preferring MT-32 when no concrete device was chosen,
+	// like other ScummVM engines with native MT-32 scores.
 	const MidiDriver::DeviceHandle dev =
-		MidiDriver::detectDevice(MDT_MIDI | MDT_ADLIB);
-	const MusicType musicType = MidiDriver::getMusicType(dev);
+		MidiDriver::detectDevice(kMidiDriverFlags);
+	MusicType musicType = MidiDriver::getMusicType(dev);
+	if (musicType == MT_GM && ConfMan.getBool("native_mt32"))
+		musicType = MT_MT32;
 
 	switch (musicType) {
 	case MT_ADLIB:
@@ -64,7 +73,7 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 		break;
 	default:
 		_milesAudioMode = false;
-		createDriver(MDT_MIDI | MDT_ADLIB);
+		createDriver(kMidiDriverFlags);
 		break;
 	}
 
diff --git a/engines/eem/music.h b/engines/eem/music.h
index 50dedeb9eb7..7e8c1fc817d 100644
--- a/engines/eem/music.h
+++ b/engines/eem/music.h
@@ -53,8 +53,8 @@ namespace EEM {
  * different timbres. ScummVM ships a Miles AdLib driver
  * (`Audio::MidiDriver_Miles_AdLib_create`) that loads `SAMPLE.AD` and
  * implements the same install-on-demand workflow, so we use it for
- * AdLib output. MT-32 / GM fall back to the generic driver via
- * `Audio::MidiPlayer::createDriver`.
+ * AdLib output. MT-32 uses ScummVM's Miles MT-32 driver path, and any
+ * other MIDI output falls back to `Audio::MidiPlayer::createDriver`.
  *
  * Available music files in the game directory:
  *   - THEME.XMI   — opening anims (looping) + title screen


Commit: fd806599d283c57063b40afa2a092f3993df2828
    https://github.com/scummvm/scummvm/commit/fd806599d283c57063b40afa2a092f3993df2828
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:02+02:00

Commit Message:
EEM: message sliding animation for some screens

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7ed2cac176e..af1a43a8767 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -72,6 +72,18 @@ constexpr Common::Rect kPdaHelp2Rect(Common::Point(267, 174), 21, 16);
 constexpr Common::Rect kPdaPartnerFootMapRect(Common::Point(7, 177), 50, 23);
 constexpr Common::Rect kPdaSiteRect(Common::Point(35, 111), 21, 25);
 
+constexpr uint16 kProfilePickerRevealPic = 0x105;
+constexpr int kProfilePickerRevealX = 0x3e;
+constexpr int kProfilePickerRevealY = 0xb3;
+
+constexpr uint16 kNameEntryPeekPic = 0x107;
+constexpr int kNameEntryPeekX = 0x3e;
+constexpr int kNameEntryPeekY = 0xb3;
+
+constexpr uint16 kCaseSelectionRevealPic = 0x53;
+constexpr int kCaseSelectionRevealX = 0x3e;
+constexpr int kCaseSelectionRevealY = 0xb2;
+
 bool notebookButtonAt(int x, int y) {
 	return kPdaHelpRect.contains(x, y) ||
 		   kPdaGalleryRect.contains(x, y) ||
@@ -149,6 +161,143 @@ int nextLiveSlot(const Common::Array<Common::Rect> &slotRects,
 	return from;
 }
 
+void copyToScreen(Graphics::ManagedSurface &scratch) {
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
+void blitMaskedPicSlice(Graphics::ManagedSurface &dst, const Picture &pic,
+						int srcX, int srcY, int w, int h,
+						int dstX, int dstY) {
+	if (pic.surface.empty() || w <= 0 || h <= 0)
+		return;
+
+	const byte transp = (byte)(pic.flags >> 8);
+	for (int row = 0; row < h; row++) {
+		const int sy = srcY + row;
+		const int dy = dstY + row;
+		if (sy < 0 || sy >= pic.surface.h || dy < 0 || dy >= dst.h)
+			continue;
+		for (int col = 0; col < w; col++) {
+			const int sx = srcX + col;
+			const int dx = dstX + col;
+			if (sx < 0 || sx >= pic.surface.w || dx < 0 || dx >= dst.w)
+				continue;
+			const byte c = *(const byte *)pic.surface.getBasePtr(sx, sy);
+			if (c != transp)
+				*(byte *)dst.getBasePtr(dx, dy) = c;
+		}
+	}
+}
+
+void blitMaskedPic(Graphics::ManagedSurface &dst, const Picture &pic,
+				   int x, int y) {
+	blitMaskedPicSlice(dst, pic, 0, 0, pic.surface.w, pic.surface.h, x, y);
+}
+
+void blitMaskedPicRightReveal(Graphics::ManagedSurface &dst,
+							  const Picture &pic, int x, int y,
+							  int visibleW) {
+	const int w = CLIP<int>(visibleW, 0, pic.surface.w);
+	if (w == 0)
+		return;
+	blitMaskedPicSlice(dst, pic, 0, 0, w, pic.surface.h,
+					   x + pic.surface.w - w, y);
+}
+
+void blitMaskedPicBottomReveal(Graphics::ManagedSurface &dst,
+							   const Picture &pic, int x, int y,
+							   int visibleH) {
+	const int h = CLIP<int>(visibleH, 0, pic.surface.h);
+	if (h == 0)
+		return;
+	blitMaskedPicSlice(dst, pic, 0, 0, pic.surface.w, h,
+					   x, y + pic.surface.h - h);
+}
+
+bool pumpQuitEvents(EEMEngine *vm) {
+	Common::Event ev;
+	while (g_system->getEventManager()->pollEvent(ev)) {
+		if (ev.type == Common::EVENT_QUIT ||
+			ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+			return true;
+	}
+	return vm && vm->shouldQuit();
+}
+
+void drawCaseBookTitle(Graphics::ManagedSurface &scratch, const EEMEngine *vm,
+					   uint book) {
+	if (!vm || !vm->getFont().isLoaded())
+		return;
+
+	const bool spanish = vm->isSpanish();
+	const Common::String title = (book == 3)
+		? Common::String(spanish ? "Libro de Retos" : "Challenge Book")
+		: Common::String::format(spanish ? "Lib. %u" : "Book %u", book);
+	const int titleW = vm->getFont().getStringWidth(title);
+	const int titleX = (0xba - titleW) / 2 + 0x3c;
+	vm->getFont().drawString(&scratch, title, titleX, 12, 320, 0xF);
+}
+
+void drawNameEntryFrame(EEMEngine *vm, const Picture *bg, bool haveBG,
+						const Picture *peek, const Common::String &name,
+						const char *prompt) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (haveBG)
+		scratch.simpleBlitFrom(bg->surface);
+	if (peek)
+		blitMaskedPic(scratch, *peek, kNameEntryPeekX, kNameEntryPeekY);
+	vm->getFont().drawString(&scratch, prompt, 80, 40, 240, 0xF);
+	vm->getFont().drawString(&scratch, name + "_", 80, 80, 240, 0xF);
+	copyToScreen(scratch);
+}
+
+bool animateNameEntryPeek(EEMEngine *vm, const Picture *bg, bool haveBG,
+						  const Picture *peek) {
+	if (!peek || peek->surface.empty())
+		return false;
+
+	for (int w = 1; w <= peek->surface.w; w++) {
+		if (pumpQuitEvents(vm))
+			return true;
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveBG)
+			scratch.simpleBlitFrom(bg->surface);
+		blitMaskedPicRightReveal(scratch, *peek,
+								  kNameEntryPeekX, kNameEntryPeekY, w);
+		copyToScreen(scratch);
+		g_system->delayMillis(10);
+	}
+	return false;
+}
+
+bool animateProfilePickerReveal(EEMEngine *vm, const Picture *bg,
+								bool haveBG, const Picture *reveal) {
+	if (!reveal || reveal->surface.empty())
+		return false;
+
+	for (int h = 1; h <= reveal->surface.h; h++) {
+		if (pumpQuitEvents(vm))
+			return true;
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveBG)
+			scratch.simpleBlitFrom(bg->surface);
+		blitMaskedPicBottomReveal(scratch, *reveal,
+								   kProfilePickerRevealX,
+								   kProfilePickerRevealY, h);
+		copyToScreen(scratch);
+		g_system->delayMillis(10);
+	}
+	return false;
+}
+
 // Snapshot of `doCaseSelection`'s captured locals, used by
 // `drawCaseSelectionFrame` (which replaces the original lambda). Lives
 // on the stack inside `doCaseSelection`; never escapes.
@@ -156,6 +305,8 @@ struct CaseSelectionView {
 	EEMEngine *vm;
 	const Picture *caseBg;
 	bool haveCaseBg;
+	const Picture *revealPic;
+	bool haveRevealPic;
 	const Animation *kdAnim;
 	bool haveKdAnim;
 	uint16 kdAnimId;     ///< 0x15 / 0x16 — looked up in kAnimScripts
@@ -165,6 +316,7 @@ struct CaseSelectionView {
 	const char *const *pickLabel;
 	const bool *pickEnabled;
 	uint pick;
+	uint book;
 };
 
 // Mystery list shown in the "Choose A Mystery" sub-screen. Mirrors
@@ -213,6 +365,12 @@ struct CaseSubmenuView {
 	EEMEngine *vm;
 	const Picture *caseBg;
 	bool haveCaseBg;
+	const Picture *revealPic;
+	bool haveRevealPic;
+	const Animation *kdAnim;
+	bool haveKdAnim;
+	int kdAnimX;
+	int kdAnimY;
 	const Common::StringArray *names;
 	const Common::Array<bool> *solvedFlags;
 	uint topRow;
@@ -220,6 +378,72 @@ struct CaseSubmenuView {
 	uint book;            ///< 1..3 — for the "Book N" / "Challenge Book" title
 };
 
+void drawCaseGreeter(Graphics::ManagedSurface &scratch,
+					 const Animation *kdAnim, bool haveKdAnim,
+					 int kdAnimX, int kdAnimY) {
+	if (!haveKdAnim || !kdAnim || kdAnim->empty())
+		return;
+
+	// `_CaseSelection` registers the partner-specific ANI slot, but
+	// drives it with script 0x15 regardless of partner. The chooser
+	// loop advances it through `_UpdateAnimations` after each
+	// `_CheckFrameRate` tick.
+	const uint32 now = g_system->getMillis();
+	const uint frameIdx = partnerFrameAtTick(0x15, (uint)kdAnim->size(), now);
+	blitAnimFrameAnchored(scratch.surfacePtr(), (*kdAnim)[frameIdx],
+						  kdAnimX, kdAnimY);
+}
+
+void drawCaseBase(Graphics::ManagedSurface &scratch, EEMEngine *vm,
+				  const Picture *caseBg, bool haveCaseBg,
+				  const Picture *revealPic, bool haveRevealPic,
+				  const Animation *kdAnim, bool haveKdAnim,
+				  int kdAnimX, int kdAnimY, uint book) {
+	scratch.clear();
+	if (haveCaseBg && caseBg)
+		scratch.simpleBlitFrom(caseBg->surface);
+	if (haveRevealPic && revealPic)
+		blitMaskedPic(scratch, *revealPic,
+					   kCaseSelectionRevealX, kCaseSelectionRevealY);
+	drawCaseBookTitle(scratch, vm, book);
+	drawCaseGreeter(scratch, kdAnim, haveKdAnim, kdAnimX, kdAnimY);
+}
+
+bool animateCaseSelectionReveal(EEMEngine *vm, const Picture *caseBg,
+								bool haveCaseBg, const Picture *revealPic,
+								bool haveRevealPic, const Animation *kdAnim,
+								bool haveKdAnim, int kdAnimX, int kdAnimY,
+								uint book) {
+	if (!haveRevealPic || !revealPic || revealPic->surface.empty())
+		return false;
+
+	const int steps = vm && vm->isFloppy()
+		? revealPic->surface.w : revealPic->surface.h;
+	for (int i = 1; i <= steps; i++) {
+		if (pumpQuitEvents(vm))
+			return true;
+		Graphics::ManagedSurface scratch(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.clear();
+		if (haveCaseBg && caseBg)
+			scratch.simpleBlitFrom(caseBg->surface);
+		if (vm && vm->isFloppy()) {
+			blitMaskedPicRightReveal(scratch, *revealPic,
+									  kCaseSelectionRevealX,
+									  kCaseSelectionRevealY, i);
+		} else {
+			blitMaskedPicBottomReveal(scratch, *revealPic,
+									   kCaseSelectionRevealX,
+									   kCaseSelectionRevealY, i);
+		}
+		drawCaseBookTitle(scratch, vm, book);
+		drawCaseGreeter(scratch, kdAnim, haveKdAnim, kdAnimX, kdAnimY);
+		copyToScreen(scratch);
+		g_system->delayMillis(8);
+	}
+	return false;
+}
+
 // Mirrors `_DoChoose`'s `DrawList @ 1c33:040d`. 12 visible rows × 10 px
 // at (61, 35); colour palette: 0x13 = highlighted (selected), 0x1B =
 // greyed (already solved), 0x5C = default. We approximate with the
@@ -229,25 +453,12 @@ struct CaseSubmenuView {
 void drawCaseSubmenu(const CaseSubmenuView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
-	if (v.haveCaseBg)
-		scratch.simpleBlitFrom(v.caseBg->surface);
+	drawCaseBase(scratch, v.vm, v.caseBg, v.haveCaseBg,
+				 v.revealPic, v.haveRevealPic,
+				 v.kdAnim, v.haveKdAnim, v.kdAnimX, v.kdAnimY, v.book);
 	if (!v.vm->getFont().isLoaded() || !v.names)
 		return;
 
-	// Top centred title. `_CaseSelection @ 1c33:0aa3` formats "Book %d"
-	// for tiers 1/2 and "Challenge Book" (sprintf with no arg) for
-	// tier 3. `_Show_String(0xc, (0xba - width)/2 + 0x3c, …, 0x10)`
-	// places it horizontally centred over the panel. Spanish floppy
-	// uses "Lib. %u" / "Libro de Retos" (verified in Spanish EEM.EXE).
-	const bool spanish = v.vm && v.vm->isSpanish();
-	const Common::String title = (v.book == 3)
-		? Common::String(spanish ? "Libro de Retos" : "Challenge Book")
-		: Common::String::format(spanish ? "Lib. %u" : "Book %u", v.book);
-	const int titleW = v.vm->getFont().getStringWidth(title);
-	const int titleX = (0xba - titleW) / 2 + 0x3c;
-	v.vm->getFont().drawString(&scratch, title, titleX, 12, 320, 0xF);
-
 	const int kListX  = 61;
 	const int kListW  = 238 - kListX;
 	const int kListY0 = 35;
@@ -298,38 +509,16 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 		scratch.frameRect(thumb, 0xF);
 	}
 
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
+	copyToScreen(scratch);
 }
 
 void drawCaseSelectionFrame(const CaseSelectionView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
-	if (v.haveCaseBg)
-		scratch.simpleBlitFrom(v.caseBg->surface);
-
-	// KD greeter frame — masked-blit current animation cell at
-	// (0x112, 0x50). 100 ms tick matches `_CheckFrameRate`. The
-	// original `_CaseSelection @ 1c33:0a87` calls `_NewAnimation(...,
-	// CONCAT22(0x15, ...), ..., seqnum=0x15, ...)` so the script
-	// key is 0x15 regardless of partner — even Jenny's CELLS (loaded
-	// via animID 0x16 = ANI.DBD slot) get driven by Jake's 0x15
-	// blink script. Both 0x15 and 0x16 are aliases of 0x00 in our
-	// table so the result is identical, but routing through 0x15
-	// matches the binary.
-	if (v.haveKdAnim) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x15,
-												  (uint)v.kdAnim->size(), now);
-		// Anchor-aware blit. Same rendering path used everywhere
-		// the partner is registered through `_NewAnimation` in the
-		// original.
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  (*v.kdAnim)[frameIdx],
-							  v.kdAnimX, v.kdAnimY);
-	}
+	drawCaseBase(scratch, v.vm, v.caseBg, v.haveCaseBg,
+				 v.revealPic, v.haveRevealPic,
+				 v.kdAnim, v.haveKdAnim, v.kdAnimX, v.kdAnimY, v.book);
+
 	if (v.vm->getFont().isLoaded()) {
 		// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
 		// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
@@ -339,17 +528,6 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 		const int kListY0 = 35;
 		const int kLineH  = 10;
 
-		// Top centred "Book %d" / "Challenge Book" title — sprintf
-		// format strings at 29be:0deb / 29be:0dfa shown via
-		// `_Show_String(0xc, (0xba - width)/2 + 0x3c, …)` in the
-		// original. We don't track challenge tier yet so always
-		// show "Book 1".
-		const Common::String book = v.vm->isSpanish()
-			? Common::String("Lib. 1") : Common::String("Book 1");
-		const int titleW = v.vm->getFont().getStringWidth(book);
-		const int titleX = (0xba - titleW) / 2 + 0x3c;
-		v.vm->getFont().drawString(&scratch, book, titleX, 12, 320, 0xF);
-
 		// Render 11 list rows: separator + menu item pairs.
 		//   row 0  separator
 		//   row 1  Choose A Mystery
@@ -373,9 +551,7 @@ void drawCaseSelectionFrame(const CaseSelectionView &v) {
 									   kListX, y, kListW, color);
 		}
 	}
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
+	copyToScreen(scratch);
 }
 
 void EEMEngine::doProfilePicker() {
@@ -426,14 +602,18 @@ void EEMEngine::doProfilePicker() {
 
 	int sel = 0;
 	bool done = false;
+	Picture bg;
+	const bool haveBG = _picsArchive.getPicture(0x104, bg);
+	Picture reveal;
+	const bool haveReveal =
+		_picsArchive.getPicture(kProfilePickerRevealPic, reveal);
 
 	// Picker geometry: `DrawList @ 1c33:040d` is called from
 	// `screen8_handler @ 1c33:1012` with `(_TextBox + 3, DAT_29be_0d02)`.
 	// `_TextBox @ 29be:0d00` holds {x1=58, y1=35, x2=238, y2=158} so
 	// the list origin is (61, 35), 10 px per row, max 12 visible
-	// rows. The "Pick a player" caption is part of the BG (PIC 0x104)
-	// — `screen8_handler` never draws it as text — so an extra
-	// `drawString` would overlay on top of the baked-in heading.
+	// rows. `screen8_handler` slides PIC 0x105 into the lower strip
+	// before calling `_DoChoose`; it does not draw that caption as text.
 	const int kListX = 61;
 	const int kListY = 35;
 	const int kLineH = 10;
@@ -441,18 +621,21 @@ void EEMEngine::doProfilePicker() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
-		Picture bg;
-		if (_picsArchive.getPicture(0x104, bg))
+		if (haveBG)
 			scratch.simpleBlitFrom(bg.surface);
+		if (haveReveal)
+			blitMaskedPic(scratch, reveal,
+						   kProfilePickerRevealX, kProfilePickerRevealY);
 		for (uint i = 0; i < entries.size(); i++) {
 			const byte color = ((int)i == sel) ? 0xF : 0x8;
 			_font.drawString(&scratch, entries[i].label,
 							 kListX, kListY + (int)i * kLineH, 220, color);
 		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
+		copyToScreen(scratch);
 	};
+	if (animateProfilePickerReveal(this, &bg, haveBG,
+								   haveReveal ? &reveal : nullptr))
+		return;
 	draw();
 
 	while (!done && !shouldQuit()) {
@@ -524,8 +707,7 @@ void EEMEngine::doProfilePicker() {
 void EEMEngine::doNewPlayer() {
 	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
 	// 0x104 + character peek pic 0x107, then shows "Please type your
-	// name" and accepts up to 12 characters until Enter. We render a
-	// minimal version: black screen + prompt.
+	// name" and accepts up to 12 characters until Enter.
 	if (!_font.isLoaded()) {
 		_playerName = "Detective";
 		return;
@@ -538,6 +720,8 @@ void EEMEngine::doNewPlayer() {
 	// The original also slides in PIC 0x107 (a peeking character).
 	Picture bg;
 	const bool haveBG = _picsArchive.getPicture(0x104, bg);
+	Picture peek;
+	const bool havePeek = _picsArchive.getPicture(kNameEntryPeekPic, peek);
 
 	// Localized name-entry prompt. Spanish text is taken from the
 	// Spanish floppy EEM.EXE ("Teclea tu nombre"). The colon suffix is
@@ -545,19 +729,13 @@ void EEMEngine::doNewPlayer() {
 	const char *prompt = isSpanish()
 		? "Teclea tu nombre:" : "Please type your name:";
 
+	if (animateNameEntryPeek(this, &bg, haveBG, havePeek ? &peek : nullptr))
+		return;
 	// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
 	// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
 	// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
-	Graphics::ManagedSurface scratch(320, 200,
-		Graphics::PixelFormat::createFormatCLUT8());
-	scratch.clear();
-	if (haveBG)
-		scratch.simpleBlitFrom(bg.surface);
-	_font.drawString(&scratch, prompt, 80, 40, 240, 0xF);
-	_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
-	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
-	g_system->updateScreen();
+	drawNameEntryFrame(this, &bg, haveBG, havePeek ? &peek : nullptr,
+					   name, prompt);
 
 	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, true);
 
@@ -616,16 +794,8 @@ void EEMEngine::doNewPlayer() {
 			}
 		}
 		if (dirty) {
-			// Re-render with the updated `name`. Same body as the
-			// initial render above — only `name + "_"` changes.
-			scratch.clear();
-			if (haveBG)
-				scratch.simpleBlitFrom(bg.surface);
-			_font.drawString(&scratch, prompt, 80, 40, 240, 0xF);
-			_font.drawString(&scratch, name + "_", 80, 80, 240, 0xF);
-			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
-			g_system->updateScreen();
+			drawNameEntryFrame(this, &bg, haveBG, havePeek ? &peek : nullptr,
+							   name, prompt);
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
@@ -1482,6 +1652,9 @@ void EEMEngine::doCaseSelection() {
 	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
 	Picture caseBg;
 	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
+	Picture revealPic;
+	const bool haveRevealPic =
+		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
 
 	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` (1c33:0b7e-0ba1)
 	// loads anim 0x15 (Jake-paired) or 0x16 (Jenny-paired) and registers
@@ -1495,11 +1668,15 @@ void EEMEngine::doCaseSelection() {
 							 && !kdAnim.empty();
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
+	const uint caseBook = (_chainStage == 3) ? 3 :
+						  (_chainStage == 2) ? 2 : 1;
 
 	CaseSelectionView v;
 	v.vm = this;
 	v.caseBg = &caseBg;
 	v.haveCaseBg = haveCaseBg;
+	v.revealPic = &revealPic;
+	v.haveRevealPic = haveRevealPic;
 	v.kdAnim = &kdAnim;
 	v.haveKdAnim = haveKdAnim;
 	v.kdAnimId = (uint16)kKdAniId;
@@ -1509,7 +1686,13 @@ void EEMEngine::doCaseSelection() {
 	v.pickLabel = kPickLabel;
 	v.pickEnabled = kPickEnabled;
 	v.pick = pick;
+	v.book = caseBook;
 
+	if (animateCaseSelectionReveal(this, &caseBg, haveCaseBg,
+								   &revealPic, haveRevealPic,
+								   &kdAnim, haveKdAnim,
+								   kKdAnimX, kKdAnimY, caseBook))
+		return;
 	drawCaseSelectionFrame(v);
 	uint32 lastTick = g_system->getMillis();
 
@@ -1723,13 +1906,25 @@ void EEMEngine::doCaseSelection() {
 	sv.vm = this;
 	sv.caseBg = &caseBg;
 	sv.haveCaseBg = haveCaseBg;
+	sv.revealPic = &revealPic;
+	sv.haveRevealPic = haveRevealPic;
+	sv.kdAnim = &kdAnim;
+	sv.haveKdAnim = haveKdAnim;
+	sv.kdAnimX = kKdAnimX;
+	sv.kdAnimY = kKdAnimY;
 	sv.names = &names;
 	sv.solvedFlags = &solvedFlags;
 	sv.topRow = topRow;
 	sv.selRow = selRow;
 	sv.book = book;
 
+	if (animateCaseSelectionReveal(this, &caseBg, haveCaseBg,
+								   &revealPic, haveRevealPic,
+								   &kdAnim, haveKdAnim,
+								   kKdAnimX, kKdAnimY, book))
+		return;
 	drawCaseSubmenu(sv);
+	uint32 submenuLastTick = g_system->getMillis();
 	bool confirmed = false;
 	while (!confirmed && !shouldQuit()) {
 		Common::Event ev;
@@ -1838,7 +2033,11 @@ void EEMEngine::doCaseSelection() {
 				continue;
 			}
 		}
-		if (dirty) {
+		const uint32 now = g_system->getMillis();
+		const bool animTick = haveKdAnim && now - submenuLastTick >= 100;
+		if (animTick)
+			submenuLastTick = now;
+		if (dirty || animTick) {
 			sv.topRow = topRow;
 			sv.selRow = selRow;
 			drawCaseSubmenu(sv);


Commit: 6494a017697d0d4e9bd35559e0801598bd51c591
    https://github.com/scummvm/scummvm/commit/6494a017697d0d4e9bd35559e0801598bd51c591
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:03+02:00

Commit Message:
EEM: complete handler for screen profile

Changed paths:
    engines/eem/eem.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index f7523be122b..dba23490674 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -582,6 +582,27 @@ screen_loop:
 			doSetup();
 			break;
 
+		case kScreenProfile:
+			// Handler 8 is the player/profile picker. CD
+			// `screen8_handler @ 1c33:1012` loads an existing player
+			// record or runs `_NewPlayer`; floppy
+			// `_HandleScreen8_NewPlayer_Floppy @ 19bb:0ec2` then
+			// writes screen 9. Mirror that route inline: after the
+			// profile is selected, choose a partner, then continue to
+			// the selected profile's loaded case if ScummVM save state
+			// had one, otherwise to case selection.
+			_nextScreen = kScreenInvalid;
+			_mystery.clear();
+			doProfilePicker();
+			if (!shouldQuit())
+				applyStartupTestOverrides();
+			if (!shouldQuit())
+				doChoosePartner();
+			if (!shouldQuit())
+				_nextScreen = _mystery.isLoaded() ? kScreenMap
+												  : kScreenChooseMystery;
+			break;
+
 		case kScreenAccuse:
 			// Handler 7 runs the accusation flow. A failed accusation
 			// returns to `_LastScreen`; a correct solution writes
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index af1a43a8767..8a2fab8286a 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -75,6 +75,18 @@ constexpr Common::Rect kPdaSiteRect(Common::Point(35, 111), 21, 25);
 constexpr uint16 kProfilePickerRevealPic = 0x105;
 constexpr int kProfilePickerRevealX = 0x3e;
 constexpr int kProfilePickerRevealY = 0xb3;
+constexpr int kProfileListX = 61;
+constexpr int kProfileListY = 35;
+constexpr int kProfileListW = 220;
+constexpr int kProfileLineH = 10;
+constexpr int kProfileVisibleRows = 12;
+constexpr Common::Rect kChooserOkRect(Common::Point(12, 63), 29, 24);
+constexpr Common::Rect kChooserHelpRect(Common::Point(12, 100), 29, 24);
+constexpr Common::Rect kChooserExitRect(Common::Point(12, 137), 29, 24);
+constexpr Common::Rect kChooserUpArrowRect(Common::Point(240, 31), 10, 12);
+constexpr Common::Rect kChooserDnArrowRect(Common::Point(240, 148), 10, 11);
+constexpr Common::Rect kChooserListRect(Common::Point(58, 35), 180, 123);
+constexpr Common::Rect kChooserNewPlayerRect(Common::Point(61, 176), 185, 15);
 
 constexpr uint16 kNameEntryPeekPic = 0x107;
 constexpr int kNameEntryPeekX = 0x3e;
@@ -298,6 +310,62 @@ bool animateProfilePickerReveal(EEMEngine *vm, const Picture *bg,
 	return false;
 }
 
+struct ProfilePickerEntry {
+	Common::String label;
+	int slot;       ///< -1 means "create new"
+};
+
+struct ProfilePickerView {
+	EEMEngine *vm;
+	const Picture *bg;
+	bool haveBG;
+	const Picture *reveal;
+	bool haveReveal;
+	const Common::Array<ProfilePickerEntry> *entries;
+	int selected;
+	int start;
+};
+
+void clampProfileScroll(int &selected, int &start, int count) {
+	if (count <= 0) {
+		selected = 0;
+		start = 0;
+		return;
+	}
+	selected = CLIP<int>(selected, 0, count - 1);
+	const int maxStart = MAX<int>(0, count - kProfileVisibleRows);
+	start = CLIP<int>(start, 0, maxStart);
+	if (selected < start)
+		start = selected;
+	if (selected >= start + kProfileVisibleRows)
+		start = selected - kProfileVisibleRows + 1;
+	start = CLIP<int>(start, 0, maxStart);
+}
+
+void drawProfilePickerFrame(const ProfilePickerView &v) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (v.haveBG)
+		scratch.simpleBlitFrom(v.bg->surface);
+	if (v.haveReveal)
+		blitMaskedPic(scratch, *v.reveal,
+					   kProfilePickerRevealX, kProfilePickerRevealY);
+
+	const int count = v.entries ? (int)v.entries->size() : 0;
+	for (int row = 0; row < kProfileVisibleRows; row++) {
+		const int idx = v.start + row;
+		if (idx >= count)
+			break;
+		const byte color = idx == v.selected ? 0xF : 0x8;
+		v.vm->getFont().drawString(&scratch, (*v.entries)[idx].label,
+									kProfileListX,
+									kProfileListY + row * kProfileLineH,
+									kProfileListW, color);
+	}
+	copyToScreen(scratch);
+}
+
 // Snapshot of `doCaseSelection`'s captured locals, used by
 // `drawCaseSelectionFrame` (which replaces the original lambda). Lives
 // on the stack inside `doCaseSelection`; never escapes.
@@ -561,7 +629,8 @@ void EEMEngine::doProfilePicker() {
 	// hands the list to `_DoChoose`. If no profiles exist (loop hits
 	// `local_20 == 0` at 1c33:1170), it falls straight into
 	// `_NewPlayer`. Selecting an entry calls `_LoadPlayerRecord` and
-	// returns; selecting the "exit" sentinel goes back to title.
+	// returns; the 0xfffe / 0xffff chooser sentinels both enter
+	// `_NewPlayer` in this screen.
 
 	// Palette reset. `screen8_handler` runs `_FadeOut(); _GetPalette(0);
 	// _GetBackground(0x104);` before the picker, so the BG always
@@ -584,24 +653,26 @@ void EEMEngine::doProfilePicker() {
 	}
 
 	// Build the visible list: existing profile names + "[New Player]".
-	struct Entry {
-		Common::String label;
-		int slot;       ///< -1 means "create new"
-	};
-	Common::Array<Entry> entries;
+	// The DOS picker also has a bottom click area at 29be:0d08 that
+	// returns 0xfffe and immediately enters `_NewPlayer`; keeping the
+	// explicit row makes that affordance visible in ScummVM while the
+	// bottom rect remains active too.
+	Common::Array<ProfilePickerEntry> entries;
 	for (const SaveStateDescriptor &s : saves) {
-		Entry e;
+		ProfilePickerEntry e;
 		e.label = s.getDescription();
 		e.slot  = s.getSaveSlot();
 		entries.push_back(e);
 	}
-	Entry newEntry;
+	ProfilePickerEntry newEntry;
 	newEntry.label = "[New Player]";
 	newEntry.slot  = -1;
 	entries.push_back(newEntry);
 
 	int sel = 0;
+	int start = 0;
 	bool done = false;
+	bool createNew = false;
 	Picture bg;
 	const bool haveBG = _picsArchive.getPicture(0x104, bg);
 	Picture reveal;
@@ -614,29 +685,19 @@ void EEMEngine::doProfilePicker() {
 	// the list origin is (61, 35), 10 px per row, max 12 visible
 	// rows. `screen8_handler` slides PIC 0x105 into the lower strip
 	// before calling `_DoChoose`; it does not draw that caption as text.
-	const int kListX = 61;
-	const int kListY = 35;
-	const int kLineH = 10;
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveBG)
-			scratch.simpleBlitFrom(bg.surface);
-		if (haveReveal)
-			blitMaskedPic(scratch, reveal,
-						   kProfilePickerRevealX, kProfilePickerRevealY);
-		for (uint i = 0; i < entries.size(); i++) {
-			const byte color = ((int)i == sel) ? 0xF : 0x8;
-			_font.drawString(&scratch, entries[i].label,
-							 kListX, kListY + (int)i * kLineH, 220, color);
-		}
-		copyToScreen(scratch);
-	};
+	ProfilePickerView view;
+	view.vm = this;
+	view.bg = &bg;
+	view.haveBG = haveBG;
+	view.reveal = &reveal;
+	view.haveReveal = haveReveal;
+	view.entries = &entries;
+	view.selected = sel;
+	view.start = start;
 	if (animateProfilePickerReveal(this, &bg, haveBG,
 								   haveReveal ? &reveal : nullptr))
 		return;
-	draw();
+	drawProfilePickerFrame(view);
 
 	while (!done && !shouldQuit()) {
 		Common::Event ev;
@@ -652,10 +713,24 @@ void EEMEngine::doProfilePicker() {
 				switch (ev.kbd.keycode) {
 				case Common::KEYCODE_UP:
 					sel = (sel + (int)entries.size() - 1) % (int)entries.size();
+					clampProfileScroll(sel, start, (int)entries.size());
 					dirty = true;
 					break;
 				case Common::KEYCODE_DOWN:
 					sel = (sel + 1) % (int)entries.size();
+					clampProfileScroll(sel, start, (int)entries.size());
+					dirty = true;
+					break;
+				case Common::KEYCODE_PAGEUP:
+					start -= kProfileVisibleRows;
+					sel = start;
+					clampProfileScroll(sel, start, (int)entries.size());
+					dirty = true;
+					break;
+				case Common::KEYCODE_PAGEDOWN:
+					start += kProfileVisibleRows;
+					sel = start;
+					clampProfileScroll(sel, start, (int)entries.size());
 					dirty = true;
 					break;
 				case Common::KEYCODE_RETURN:
@@ -663,17 +738,64 @@ void EEMEngine::doProfilePicker() {
 					committed = true;
 					break;
 				case Common::KEYCODE_ESCAPE:
-					_playerName = "Detective";
-					return;
+					createNew = true;
+					committed = true;
+					break;
 				default:
 					break;
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				const int hit = (ev.mouse.y - kListY) / kLineH;
-				if (hit >= 0 && hit < (int)entries.size()) {
-					sel = hit;
+				if (kChooserOkRect.contains(ev.mouse.x, ev.mouse.y)) {
 					committed = true;
+					break;
+				}
+				if (kChooserExitRect.contains(ev.mouse.x, ev.mouse.y) ||
+					kChooserNewPlayerRect.contains(ev.mouse.x, ev.mouse.y)) {
+					createNew = true;
+					committed = true;
+					break;
+				}
+				if (kChooserUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					if (start > 0) {
+						start--;
+						if (sel >= start + kProfileVisibleRows)
+							sel = start + kProfileVisibleRows - 1;
+						clampProfileScroll(sel, start, (int)entries.size());
+						dirty = true;
+					}
+					break;
+				}
+				if (kChooserDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+					const int maxStart = MAX<int>(0,
+						(int)entries.size() - kProfileVisibleRows);
+					if (start < maxStart) {
+						start++;
+						if (sel < start)
+							sel = start;
+						clampProfileScroll(sel, start, (int)entries.size());
+						dirty = true;
+					}
+					break;
+				}
+				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// `screen8_handler` sets `Chelp = 0`, so the
+					// original ignores this chooser button here.
+					break;
+				}
+				if (kChooserListRect.contains(ev.mouse.x, ev.mouse.y)) {
+					const int hit = (ev.mouse.y - kProfileListY) /
+									kProfileLineH;
+					const int idx = start + hit;
+					if (hit >= 0 && hit < kProfileVisibleRows &&
+						idx >= 0 && idx < (int)entries.size()) {
+						if (idx == sel) {
+							committed = true;
+							break;
+						}
+						sel = idx;
+						dirty = true;
+					}
 				}
 			}
 			if (committed)
@@ -683,13 +805,21 @@ void EEMEngine::doProfilePicker() {
 			done = true;
 			break;
 		}
-		if (dirty)
-			draw();
+		if (dirty) {
+			view.selected = sel;
+			view.start = start;
+			drawProfilePickerFrame(view);
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 
-	const Entry &e = entries[sel];
+	if (createNew) {
+		doNewPlayer();
+		return;
+	}
+
+	const ProfilePickerEntry &e = entries[sel];
 	if (e.slot < 0) {
 		doNewPlayer();
 	} else {
@@ -1504,12 +1634,13 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Profile (button [2]). Goes back to the profile picker
-			// in the original (`_NextScreen = 8`). Treat the same way
-			// as Done for now — switching profiles mid-game isn't
-			// wired in our port and would discard mystery state.
+			// Profile (button [2]). Original handler writes
+			// `_NextScreen = 8`, returning to the player/profile
+			// picker. Save the current profile settings first, then
+			// let the screen driver run profile → partner → case/map.
 			if (kProfileBtn.contains(mx, my)) {
-				leaveSetup();
+				saveProfile(_playerName);
+				_nextScreen = kScreenProfile;
 				return;
 			}
 


Commit: a1da71fd4dea62eee29dc3530910a2cb6f07fa99
    https://github.com/scummvm/scummvm/commit/a1da71fd4dea62eee29dc3530910a2cb6f07fa99
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:03+02:00

Commit Message:
EEM: missing button code to reload the current profile

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 8a2fab8286a..986ee11a41f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1859,8 +1859,8 @@ void EEMEngine::doCaseSelection() {
 					break;
 				}
 				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// HELP placeholder — original calls `_DisplayHint`;
-					// our help screen is wired to `H` later in the flow.
+					// `_ActionScreen` sets `Chelp = 0`, so `_DoChoose`
+					// ignores this middle button on the top-level menu.
 					continue;
 				}
 				// List panel: click on a non-separator row selects the
@@ -2074,6 +2074,18 @@ void EEMEngine::doCaseSelection() {
 					_mystery.clear();
 					return;
 				}
+				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// In original `_CaseSelection`, this button is
+					// PIC 0x123 and returns 0xfffe, which calls
+					// `_ChooseSavedGame`. The ScummVM port stores the
+					// in-progress case in the profile save instead of
+					// original per-mystery files, so route to screen 8
+					// (profile picker) as the load/resume path.
+					saveProfile(_playerName);
+					_mystery.clear();
+					_nextScreen = kScreenProfile;
+					return;
+				}
 				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (topRow > 0) { topRow--; dirty = true; }
 					continue;


Commit: d3fcc8c249b223d7ff8407e8e0f77237b9edb8aa
    https://github.com/scummvm/scummvm/commit/d3fcc8c249b223d7ff8407e8e0f77237b9edb8aa
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:03+02:00

Commit Message:
EEM: implement missing animations for floppy release

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index dba23490674..12596f8fe42 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -283,6 +283,7 @@ Common::Error EEMEngine::run() {
 	const int wantedSave = ConfMan.hasKey("save_slot")
 		? ConfMan.getInt("save_slot") : -1;
 	bool resumed = false;
+	bool skippedIntro = false;
 	if (wantedSave >= 0) {
 		const Common::Error err = loadGameState(wantedSave);
 		if (err.getCode() == Common::kNoError) {
@@ -362,9 +363,6 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro)
 			playAnm(Common::Path("MOVIE.ANM"), 120,
 					/*holdLastFrame=*/false);
-		if (!shouldQuit() && !_skipIntro)
-			playAnm(Common::Path("TITLE.ANM"), 120,
-					/*holdLastFrame=*/true);
 	} else {
 		showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
@@ -421,8 +419,14 @@ Common::Error EEMEngine::run() {
 			playAnm(Common::Path("TITLE.ANM"), 120,
 					/*holdLastFrame=*/true, /*fadeIn=*/true);
 	}
+	skippedIntro = _skipIntro;
 	_skipIntro = false;
 
+	if (isFloppy() && !shouldQuit() && !skippedIntro) {
+		_nextScreen = kScreenTitle;
+		goto screen_loop;
+	}
+
 	// After the title chain, the original goes Title (B) -> screen 8
 	// (NewPlayer / saved-record selection) -> screen 9 (ChoosePartner) ->
 	// screen A (CaseSelection) -> site loop. We mirror the same order.
@@ -488,6 +492,22 @@ screen_loop:
 		debugC(1, kDebugGeneral, "screenDriver: id=%d", (int)current);
 
 		switch (current) {
+		case kScreenTitle:
+			// Floppy handler 0xb (`_HandleScreen11_Title_Floppy`) calls
+			// `_DoTitle_Floppy`, whose `_PlayTitleANM_Floppy(1)` file
+			// table entry is `TITLE.ANM`. The opening driver stops after
+			// `MOVIE.ANM`; this live screen owns the title wait and then
+			// writes `_NextScreen = 8` for the profile picker.
+			_nextScreen = kScreenProfile;
+			if (isFloppy()) {
+				CursorMan.showMouse(false);
+				playAnm(Common::Path("TITLE.ANM"), 120,
+						/*holdLastFrame=*/true, /*fadeIn=*/true);
+				_skipIntro = false;
+				CursorMan.showMouse(true);
+			}
+			break;
+
 		case kScreenAction:
 			// Post-mystery menu. The original's `_ActionScreen @
 			// 1c33:195b` shows the 5-entry "Choose A Mystery /
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index ae1a6de44d8..44c5bc4a905 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -71,10 +71,10 @@ class MusicPlayer;
  *   10 (0xa) CHOOSE_MYSTERY → `_DoChooseMystery` + `_CaseSelection`;
  *                    starts with _NextScreen=0 so a successful pick
  *                    falls through to INIT_CLUES.
- *   11 (0xb) TITLE  → set _NextScreen=8 then dispatch (TITLE.ANM is
- *                    actually shown earlier by `_DoOpeningAnims`, this
- *                    handler is the post-intro "fall into PROFILE"
- *                    redirect)
+ *   11 (0xb) TITLE  → floppy `_DoTitle_Floppy` plays TITLE.ANM, waits
+ *                    for input, then writes =8. CD shows TITLE.ANM in
+ *                    `_DoOpeningAnims`, so it usually never enters this
+ *                    handler.
  *   12 (0xc) ACTION → `_ActionScreen` @ 1c33:195b — post-mystery menu
  *                    ("Solve a Mystery", scrapbook, more mysteries,
  *                    setup). Action 1 sets =10 (CHOOSE_MYSTERY).
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index efbada6cab1..c6079dac3f7 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -48,6 +48,8 @@ void Mystery::clear() {
 	_floppySuspectsOff = _floppyHintBlockOff = _floppyNoteIndexOff = 0;
 	_floppyGalleryOff = _floppyTextOff = _floppyKDTextOff = 0;
 	_floppySolvedOff = 0;
+	_floppySiteAnimData.clear();
+	memset(_floppySiteAnimSiteOff, 0, sizeof(_floppySiteAnimSiteOff));
 	memset(_aChain, 0, sizeof(_aChain));
 	memset(_bChain, 0, sizeof(_bChain));
 	memset(_cChain, 0, sizeof(_cChain));
@@ -214,6 +216,7 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_firstTry = true;
 		_searchLocationNumber = _siteNumber = 0xFFFF;
 		_lastSite = 0x1B;
+		loadFloppySiteAnimData();
 
 		debugC(1, kDebugMystery,
 			   "Mystery::load(%u) floppy: sites=0x%04x siteIdx=0x%04x "
@@ -310,6 +313,74 @@ const byte *Mystery::siteData(uint siteNum) const {
 	return _data.data() + dataOff;
 }
 
+const byte *Mystery::floppySiteAnimData(uint siteNum) const {
+	if (!_isFloppy || siteNum >= kVisitedSiteCap)
+		return nullptr;
+	const uint16 off = _floppySiteAnimSiteOff[siteNum];
+	if (off == 0 || off >= _floppySiteAnimData.size())
+		return nullptr;
+	return _floppySiteAnimData.data() + off;
+}
+
+void Mystery::loadFloppySiteAnimData() {
+	_floppySiteAnimData.clear();
+	memset(_floppySiteAnimSiteOff, 0, sizeof(_floppySiteAnimSiteOff));
+
+	Common::File f;
+	if (!f.open(Common::Path("ANI.BIN"))) {
+		warning("Mystery::loadFloppySiteAnimData: ANI.BIN missing");
+		return;
+	}
+
+	const int32 size = f.size();
+	if (size <= 0 || size > 0xFFFF) {
+		warning("Mystery::loadFloppySiteAnimData: invalid ANI.BIN size %d",
+				size);
+		return;
+	}
+
+	_floppySiteAnimData.resize((uint)size);
+	if (f.read(_floppySiteAnimData.data(), (uint32)size) != (uint32)size) {
+		warning("Mystery::loadFloppySiteAnimData: short ANI.BIN read");
+		_floppySiteAnimData.clear();
+		return;
+	}
+
+	const uint tableOff = _number * 2;
+	if (tableOff + 2 > _floppySiteAnimData.size())
+		return;
+	uint32 pos = READ_LE_UINT16(_floppySiteAnimData.data() + tableOff);
+	if (pos == 0 || pos >= _floppySiteAnimData.size())
+		return;
+
+	for (uint site = 0; site < _numSites && site < kVisitedSiteCap; site++) {
+		const uint32 start = pos;
+		if (pos + 1 > _floppySiteAnimData.size())
+			break;
+		const uint cycles = _floppySiteAnimData[pos++];
+		if (pos + cycles * 2 + 1 > _floppySiteAnimData.size()) {
+			warning("Mystery::loadFloppySiteAnimData: malformed cycles "
+					"for mystery %u site %u", _number, site);
+			break;
+		}
+		pos += cycles * 2;
+
+		const uint anims = _floppySiteAnimData[pos++];
+		if (pos + anims * 4 > _floppySiteAnimData.size()) {
+			warning("Mystery::loadFloppySiteAnimData: malformed anims "
+					"for mystery %u site %u", _number, site);
+			break;
+		}
+		_floppySiteAnimSiteOff[site] = (uint16)start;
+		pos += anims * 4;
+	}
+
+	debugC(1, kDebugMystery,
+		   "Mystery::loadFloppySiteAnimData(%u): base=0x%04x sites=%u",
+		   _number, READ_LE_UINT16(_floppySiteAnimData.data() + tableOff),
+		   _numSites);
+}
+
 const byte *Mystery::hotspots(uint siteNum) const {
 	if (_isFloppy) {
 		// Floppy: hotspot table sits inside the per-site sub-blob.
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index c9694d1965e..eed403b0bdb 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -131,6 +131,11 @@ public:
 	/// referenced by SiteIndex[@p siteNum].
 	const byte *siteData(uint siteNum) const;
 
+	/// Floppy-only pointer to the matching `ANI.BIN` per-site animation
+	/// block. Layout: u8 cycleCount, cycleCount × {u8 start, u8 end},
+	/// u8 animCount, animCount × {u8 animId, u16 x, u8 y}.
+	const byte *floppySiteAnimData(uint siteNum) const;
+
 	/// Pointer to the hotspot rectangle array for site @p siteNum.
 	/// Each rect is 14 bytes: x1, y1, x2, y2, then 6 bytes of clue data.
 	const byte *hotspots(uint siteNum) const;
@@ -236,8 +241,11 @@ private:
 	uint16 _floppyTextOff = 0;       ///< header[+0xc] → text block
 	uint16 _floppyKDTextOff = 0;     ///< header[+0x10] → KDTextIndex
 	uint16 _floppySolvedOff = 0;     ///< header[+0x12] → solved clue chain
+	Common::Array<byte> _floppySiteAnimData;
+	uint16 _floppySiteAnimSiteOff[kVisitedSiteCap] = {};
 
 	uint16 readU16(uint offset) const;
+	void loadFloppySiteAnimData();
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index a5051688000..f48d44dc276 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1083,7 +1083,7 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	//   per entry at siteData[+0x48 + i*6]: {animId, x, y}
 	//   animId == -1 → `_ColorCycle(x, y)` palette range (handled
 	//                  in the run() loop's frame pump as palette
-	//                  rotation; not yet implemented).
+	//                  rotation).
 	//   else → `_GetAnimation(animId)` + `_NewAnimation` then
 	//          `_UpdateAnimations @ 172b:09c1` walks a sequence
 	//          script (entries are frame indices; 0x80 = end-of-loop,
@@ -1092,6 +1092,49 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	//          raw animation frames in order using a global tick.
 	if (!_mystery)
 		return;
+
+	if (_vm && _vm->isFloppy()) {
+		// Floppy extra site anims live in `ANI.BIN`, not in the
+		// mystery's site_data. `_ReadMystery_Floppy` asks
+		// `_GetSiteAnimData_Floppy(mystery)` for the case block, then
+		// walks one entry per site:
+		//   u8 cycleCount, cycleCount × {u8 start, u8 end},
+		//   u8 animCount, animCount × {u8 animId, u16 x, u8 y}.
+		// `_DoSiteLoop_Floppy` registers at most four of these in local
+		// animation slots, so cap the draw loop the same way.
+		const byte *siteAnim = _mystery->floppySiteAnimData(siteNum);
+		if (!siteAnim)
+			return;
+		const uint cycles = siteAnim[0];
+		const byte *animList = siteAnim + 1 + cycles * 2;
+		const uint animCount = animList[0];
+		animList++;
+		if (animCount == 0)
+			return;
+
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (!screen)
+			return;
+
+		const uint maxAnims = MIN<uint>(animCount, 4);
+		for (uint i = 0; i < maxAnims; i++) {
+			const byte *e = animList + i * 4;
+			const uint animId = e[0];
+			const int16 x = (int16)READ_LE_UINT16(e + 1);
+			const int16 y = (int16)e[3];
+			Animation anim;
+			if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
+				continue;
+			const uint frameIdx = partnerFrameAtTick((uint16)animId,
+													  (uint)anim.size(),
+													  tickMs);
+			blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
+		}
+
+		g_system->unlockScreen();
+		return;
+	}
+
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -1136,16 +1179,22 @@ void SiteScreen::scanColorCycles(uint siteNum) {
 	_colorCycles.clear();
 	if (!_mystery)
 		return;
-	// Floppy site data has a different layout (per `_DoSiteLoop_Floppy
-	// @ 1652:03a3`: site index is 2-byte u16 entries, site data starts
-	// with an offset to a sub-structure with picID + 5-byte hotspot
-	// entries — there's no `[+0xa]` anim count at the same place). The
-	// CD-shaped offsets read garbage and run past the buffer end. Until
-	// the floppy color-cycle layout is reverse-engineered, skip the
-	// scan: the floppy still does palette F9..FE rotation in its own
-	// driver, but our hotspot palette override works without it.
-	if (_vm && _vm->isFloppy())
+
+	if (_vm && _vm->isFloppy()) {
+		const byte *siteAnim = _mystery->floppySiteAnimData(siteNum);
+		if (!siteAnim)
+			return;
+		const uint cycles = siteAnim[0];
+		for (uint i = 0; i < cycles && _colorCycles.size() < 5; i++) {
+			ColorCycleRange r;
+			r.start = siteAnim[1 + i * 2];
+			r.end = siteAnim[1 + i * 2 + 1];
+			if (r.end > r.start)
+				_colorCycles.push_back(r);
+		}
 		return;
+	}
+
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 986ee11a41f..eeb01b38ac1 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -134,6 +134,19 @@ bool gallerySlotAt(const Common::Array<Common::Rect> &rects,
 	return false;
 }
 
+const byte *advanceFloppyDialogRecords(const byte *rec, uint count,
+									   const byte *end) {
+	for (uint i = 0; i < count; i++) {
+		if (!rec || rec + 11 > end)
+			return nullptr;
+		const uint len = 11u + rec[10];
+		if (rec + len > end)
+			return nullptr;
+		rec += len;
+	}
+	return rec;
+}
+
 // Floppy gallery slot positions verified at `2608:0x16c` (5 ×
 // {u16 x, u16 y}) — read by `_DrawGallery_Floppy @ 154e:0045`'s
 // `[BX + 0x16c]` (x) and `[BX + 0x16e]` (y) loads. The floppy
@@ -5300,8 +5313,8 @@ void EEMEngine::doAccuseFloppy() {
 		//   1d40:08c0  _MIDIPlayFile("travel-2.xmi");
 		//   1d40:08d0  walk solved chain via _DisplayHotspotClue_Floppy
 		//                 + _WaitForClick per record. Mid-recap: when
-		//                 only 3 records remain, play TITLE.ANM(0)
-		//                 (transition graphic; not yet ported).
+		//                 only 3 records remain, play
+		//                 _PlayTitleANM_Floppy(0) = SCRAPBK.ANI.
 		//   1d40:0939  ((u16 *)0x3054)[mysteryNum] =
 		//                  _firstTry ? 2 : 1;
 		//   1d40:0941  tier-promotion check (advance _chainStage when
@@ -5381,11 +5394,34 @@ void EEMEngine::doAccuseFloppy() {
 
 		// Walk the solved-clue chain. Header[+0x12] points at a
 		// `count` byte followed by `count` dialog records (same layout
-		// as hotspot dialogs).
+		// as hotspot dialogs). When only three records remain, the
+		// original clears animation slots and calls
+		// `_PlayTitleANM_Floppy(0)`. The title helper's file table maps
+		// index 0 to `SCRAPBK.ANI` and index 1 to `TITLE.ANM`, so this
+		// is the same scrapbook flip transition used by the CD win flow,
+		// just inserted before the last three floppy recap records.
 		const byte *chain = _mystery.solvedClueBlock();
 		if (chain) {
 			const uint count = chain[0];
-			displayFloppyDialogRecords(chain + 1, count, 0);
+			const byte *records = chain + 1;
+			const byte *end = bufBase + mysSize;
+			if (count > 3) {
+				const uint beforeScrapbook = count - 3;
+				const byte *tail =
+					advanceFloppyDialogRecords(records, beforeScrapbook,
+											   end);
+				if (tail) {
+					displayFloppyDialogRecords(records, beforeScrapbook, 1);
+					playAnm(Common::Path("SCRAPBK.ANI"), 120,
+							/*holdLastFrame=*/false, /*fadeIn=*/true);
+					displayFloppyDialogRecords(tail, 3, 1);
+				} else {
+					warning("doAccuseFloppy: malformed solved chain");
+					displayFloppyDialogRecords(records, count, 1);
+				}
+			} else {
+				displayFloppyDialogRecords(records, count, 1);
+			}
 		}
 		if (_music && _voiceOn)
 			_music->stop();


Commit: 2e25ab2f46f3701803e1dbd02f20cf1fb3515b84
    https://github.com/scummvm/scummvm/commit/2e25ab2f46f3701803e1dbd02f20cf1fb3515b84
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:04+02:00

Commit Message:
EEM: corrected text size

Changed paths:
    engines/eem/clues.cpp
    engines/eem/font.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 708754fbd11..2d72d4e45b1 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -1107,7 +1107,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			getBalloonInsets(balloonId, textXIns, textYIns, textWidth);
 		const int textX = ballX + textXIns;
 		const int balloonH = haveBalloon ? balloon.surface.h : 200;
-		const int lineH    = _font.getFontHeight() + 1;
+		const int lineH    = _font.getFontHeight();
 
 		// Pagination state — `FUN_22dc_05c8`'s text-idx loop uses
 		// `local_1c` (set from the PREVIOUS text's flag bit) to decide
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index c8c42360550..36d584c62b5 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -130,7 +130,7 @@ int EEMFont::drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y,
 							 uint32 color) const {
 	Common::Array<Common::String> lines;
 	wordWrapText(s, width, lines);
-	const int lineH = getFontHeight() + 1;
+	const int lineH = getFontHeight();
 	for (uint i = 0; i < lines.size(); i++)
 		drawString(dst, lines[i], x, y + (int)i * lineH, width, color);
 	return (int)lines.size() * lineH;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index eeb01b38ac1..61854c997bf 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2511,7 +2511,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 						   _playerName, _partner);
 	};
 	{
-		const int lineH = _font.getFontHeight() + 1;
+		const int lineH = _font.getFontHeight();
 		int y = kRectY;
 		while (clueCursor < (int)found.size()) {
 			const uint clueId = found[clueCursor];
@@ -2559,7 +2559,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		// matches the original design.
 		Common::Array<Common::String> wrapped;
 		_font.wordWrapText(txt, kRectW, wrapped);
-		const int lineH = _font.getFontHeight() + 1;
+		const int lineH = _font.getFontHeight();
 		const int h = (int)wrapped.size() * lineH;
 		const byte color = _mystery._noteSelected[clueId] ? 0x3C : 0x5C;
 		for (uint li = 0; li < wrapped.size(); li++) {
@@ -2833,7 +2833,7 @@ void EEMEngine::doGallery() {
 						const byte *ni = _mystery.noteIndex();
 						const uint16 niCount = _mystery.noteIndexCount();
 						int yPos = ry;
-						const int lineH = _font.getFontHeight() + 1;
+						const int lineH = _font.getFontHeight();
 						bool drewAny = false;
 						const uint clueMax = floppyMI ? clueCount : 30u;
 						for (uint k = 0; k < clueCount && k < clueMax; k++) {
@@ -3832,7 +3832,7 @@ bool EEMEngine::doAccuseNotes() {
 	auto rebuildPagination = [&]() {
 		numPages = 1;
 		pageBreaks[0] = 0;
-		const int lineH = _font.getFontHeight() + 1;
+		const int lineH = _font.getFontHeight();
 		int y = rectY;
 		for (uint i = 0; i < found.size(); i++) {
 			const uint clueId = found[i];
@@ -3888,7 +3888,7 @@ bool EEMEngine::doAccuseNotes() {
 		// "accuse-mode" look together with PIC 0x1A7.
 		slotRects.clear();
 		slotClues.clear();
-		const int lineH = _font.getFontHeight() + 1;
+		const int lineH = _font.getFontHeight();
 		const int startIdx = pageBreaks[page];
 		const int endIdx   = (page + 1 < numPages)
 			? pageBreaks[page + 1]


Commit: e84bf0e7981aeae007259442c6d73c90f4297919
    https://github.com/scummvm/scummvm/commit/e84bf0e7981aeae007259442c6d73c90f4297919
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:04+02:00

Commit Message:
EEM: keep proper track of clues in floppy version

Changed paths:
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index c6079dac3f7..5982e5c5fc5 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -464,6 +464,23 @@ uint16 Mystery::noteIndexCount() const {
 	return (uint16)((_galleryOffset - _noteOffset) / stride);
 }
 
+bool Mystery::noteHasNotebookText(uint clueId) const {
+	const byte *ni = noteIndex();
+	const uint16 cnt = noteIndexCount();
+	if (!ni || clueId >= cnt)
+		return false;
+
+	if (!_isFloppy)
+		return true;
+
+	// `_DrawNotes_Floppy @ 15e0:01e8` first checks `_TextSeen[idx]`,
+	// then skips the row when `*(u16 *)(notes + idx * 7) == 0`.
+	// Many floppy dialog records are spoken-only lines: they must still
+	// be marked seen so site dialog does not repeat, but they are not
+	// notebook clues and should not render fallback "note N" labels.
+	return READ_LE_UINT16(ni + clueId * 7) != 0;
+}
+
 const byte *Mystery::kdTextIndex() const {
 	if (!isLoaded() || _kdTextOffset >= _data.size())
 		return nullptr;
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index eed403b0bdb..8ef1c584715 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -104,6 +104,12 @@ public:
 	/// Number of entries in NoteIndex.
 	uint16 noteIndexCount() const;
 
+	/// True when @p clueId has a visible notebook/accuse text entry.
+	/// Floppy dialog text indices may be spoken-only records with a
+	/// zero notebook text offset; those are marked seen but skipped by
+	/// `_DrawNotes_Floppy`.
+	bool noteHasNotebookText(uint clueId) const;
+
 	/// Pointer to the KDTextIndex; first u16s are TextBlock offsets for
 	/// host hint lines.
 	const byte *kdTextIndex() const;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 61854c997bf..2493117b0a2 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2455,7 +2455,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	// original's iteration through `_CluesFound[]`.
 	Common::Array<uint> found;
 	for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
-		if (_mystery._cluesFound[i])
+		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
 			found.push_back(i);
 	}
 	const byte *ni = _mystery.noteIndex();
@@ -3762,7 +3762,7 @@ bool EEMEngine::doAccuseNotes() {
 	// the same way).
 	Common::Array<uint> found;
 	for (uint i = 0; i < niCount && i < Mystery::kCluesFoundCap; i++) {
-		if (_mystery._cluesFound[i])
+		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
 			found.push_back(i);
 	}
 


Commit: 821f9a0ba79a98e7a24744164a5e6dc3503f59d5
    https://github.com/scummvm/scummvm/commit/821f9a0ba79a98e7a24744164a5e6dc3503f59d5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:04+02:00

Commit Message:
EEM: keep proper track of visited places in floppy version

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2493117b0a2..fed7083fb99 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -55,6 +55,43 @@ const GallerySlot kGallerySlots[5] = {
 	{ 191,  90 }  // 4
 };
 
+byte mapVisitedMarkerColor(byte color) {
+	switch (color) {
+	case 0xf7:
+	case 0xfb:
+	case 0xfd:
+		return 0x1b;
+	case 0xf8:
+	case 0xf9:
+	case 0xfa:
+	case 0xfc:
+	case 0xfe:
+		return 0x19;
+	default:
+		return color;
+	}
+}
+
+void blitBigMapMarker(Graphics::ManagedSurface &dstSurface, const Picture &marker,
+					  int x, int y, bool useVisitedColors) {
+	const byte transp = (byte)(marker.flags >> 8);
+	for (int row = 0; row < marker.surface.h; row++) {
+		const int dstY = y + row;
+		if (dstY < 0 || dstY >= dstSurface.h)
+			continue;
+		const byte *src = (const byte *)marker.surface.getBasePtr(0, row);
+		byte *dst = (byte *)dstSurface.getBasePtr(0, dstY);
+		for (int col = 0; col < marker.surface.w; col++) {
+			const int dstX = x + col;
+			if (dstX < 0 || dstX >= dstSurface.w)
+				continue;
+			if (src[col] != transp)
+				dst[dstX] = useVisitedColors ? mapVisitedMarkerColor(src[col])
+											  : src[col];
+		}
+	}
+}
+
 constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
 constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
 constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
@@ -3499,6 +3536,9 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	//   _DoneMarker  = PIC 0x20d  (already-searched site)
 	//   _SiteMarker  = PIC 0xc5   (default available site)
 	//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
+	// Floppy's `PICS.DBX` has 524 entries, so PIC 0x20d is CD-only.
+	// Its pixels are the same 7x8 marker as PIC 0xc5, with the animated
+	// 0xfb/0xfc interior remapped to static 0x1b/0x19 blue.
 	Picture done;
 	Picture normal;
 	Picture crimeM;
@@ -3530,30 +3570,20 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 							  && _mystery._visitedSite[i];
 
 		const Picture *m = nullptr;
+		bool useVisitedColors = false;
 		if (done_ && haveDone)
 			m = &done;
-		else if (crime != 0 && haveCrime)
+		else if (done_ && haveNormal) {
+			m = &normal;
+			useVisitedColors = true;
+		} else if (crime != 0 && haveCrime)
 			m = &crimeM;
 		else if (haveNormal)
 			m = &normal;
 
 		if (m) {
-			// Masked-blit the marker PIC.
-			const byte transp = (byte)(m->flags >> 8);
-			for (int row = 0; row < m->surface.h; row++) {
-				const int dstY = (int)my + row;
-				if (dstY < 0 || dstY >= 200)
-					continue;
-				const byte *src = (const byte *)m->surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < m->surface.w; col++) {
-					const int dstX = (int)mx + col;
-					if (dstX < 0 || dstX >= 320)
-						continue;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
+			blitBigMapMarker(scratch, *m, (int)mx, (int)my,
+							  useVisitedColors);
 		} else {
 			// Fallback if the markers couldn't be loaded.
 			const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);


Commit: f00c88734cb88901f496fb46d4c0a281939b6d73
    https://github.com/scummvm/scummvm/commit/f00c88734cb88901f496fb46d4c0a281939b6d73
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:05+02:00

Commit Message:
EEM: blit accuse partner in floppy version

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fed7083fb99..88aa3939004 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -92,6 +92,21 @@ void blitBigMapMarker(Graphics::ManagedSurface &dstSurface, const Picture &marke
 	}
 }
 
+void blitAccusePartner(Graphics::ManagedSurface &dstSurface,
+					   DBDArchive &aniArchive, uint8 partner,
+					   uint32 tickMs) {
+	const uint partnerAnim = (partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	if (aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+		!partnerAni.empty()) {
+		const uint frameIdx = partnerFrameAtTick(0x02,
+												 (uint)partnerAni.size(),
+												 tickMs);
+		blitAnimFrameAnchored(dstSurface.surfacePtr(),
+							  partnerAni[frameIdx], 5, 0x50);
+	}
+}
+
 constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
 constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
 constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
@@ -5654,6 +5669,11 @@ void EEMEngine::doAccuseFloppy() {
 		_font.drawWordWrapped(&scene, balloonX + tx, balloonY + ty,
 							  MAX<int>(8, (int)tw), alibi, 0);
 	}
+	// The floppy original keeps the accuse partner animation slot alive
+	// across `_DisplayAlibi_Floppy` and restores the screen with active
+	// animations between alibi phases. Stamp the same resting frame here
+	// before the KD reaction helper snapshots the current screen.
+	blitAccusePartner(scene, _aniArchive, _partner, g_system->getMillis());
 	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 


Commit: f2d1372a85de0c8056c30a4a0fb5e97f7aedb432
    https://github.com/scummvm/scummvm/commit/f2d1372a85de0c8056c30a4a0fb5e97f7aedb432
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:05+02:00

Commit Message:
EEM: use the correct initial menu screens

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 12596f8fe42..da33f550739 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -68,7 +68,7 @@ const bool kDebugPopulateScrapbook1AtStartup = false;
 // Fallback 11x16 mouse cursor used if the selected PIC pointer cannot be
 // loaded. The original game sets the cursor visible/hidden via
 // _MouseCursor; we leave it on once the screens that need it
-// (ChoosePartner, CaseSelection, sites) are reached.
+// (ChoosePartner, ActionScreen, CaseSelection, sites) are reached.
 //   0 = transparent, 1 = black outline, 2 = white fill
 const byte kCursorBitmap[11 * 16] = {
 	1,1,0,0,0,0,0,0,0,0,0,
@@ -276,10 +276,8 @@ Common::Error EEMEngine::run() {
 	//
 	//   * Save HAS a mystery in progress → resume at MAP (mirrors the
 	//     original's post-briefing state, handler 0 at 1a35:0e1d).
-	//   * Save has NO mystery → drop into the case-selection screen
-	//     (`kScreenChooseMystery`) so the player can pick which case
-	//     to play. This matches what the original `_ActionScreen`
-	//     leads to — without the redundant action menu in front.
+	//   * Save has NO mystery → drop into `_ActionScreen`, same as the
+	//     original after partner selection.
 	const int wantedSave = ConfMan.hasKey("save_slot")
 		? ConfMan.getInt("save_slot") : -1;
 	bool resumed = false;
@@ -297,8 +295,8 @@ Common::Error EEMEngine::run() {
 			} else {
 				debugC(1, kDebugGeneral,
 					   "Resuming profile from slot %d (no mystery — "
-					   "→ case selection)", wantedSave);
-				_nextScreen = kScreenChooseMystery;
+					   "→ action screen)", wantedSave);
+				_nextScreen = kScreenAction;
 			}
 			resumed = true;
 		}
@@ -429,7 +427,8 @@ Common::Error EEMEngine::run() {
 
 	// After the title chain, the original goes Title (B) -> screen 8
 	// (NewPlayer / saved-record selection) -> screen 9 (ChoosePartner) ->
-	// screen A (CaseSelection) -> site loop. We mirror the same order.
+	// screen C (ActionScreen). Choosing a mystery there enters screen A
+	// (CaseSelection) and then the site loop.
 	// Mouse stays hidden through the opening anims; show it now for
 	// the interactive screens (matches `_MouseCursor = 1` at the tail
 	// of `_NewPlayer`).
@@ -463,17 +462,9 @@ Common::Error EEMEngine::run() {
 	//
 	// `_DoChoosePartner @ 1a35:099d` sets `_NextScreen = 0xc` (= the
 	// original `_ActionScreen` — "Choose A Mystery / Practice Mystery /
-	// See ScrapBook 1..3"). In our port that menu lives inside
-	// `doCaseSelection`, which already mirrors `_ActionScreen`'s 5-entry
-	// list (verified against `ActionNames @ 29be:0d6a`) and rolls the
-	// individual-mystery picker (`_CaseSelection @ 1c33:0a87`) into the
-	// same flow. So we route straight to `kScreenChooseMystery`; the
-	// `kScreenAction` (= 0xc) state still exists for the post-win path
-	// (`_DisplayCorrect @ 1df2:0895` writes 0xc) but its handler also
-	// dispatches `doCaseSelection`. The previous `doActionScreen` was a
-	// synthetic stub ("What now?" / "Solve a Mystery" / "Look at My
-	// Books") whose strings are nowhere in the binary — confirmed by a
-	// `search_strings` for "What" returning zero matches.
+	// See ScrapBook 1..3"). That screen is separate from handler 10's
+	// `_DoChooseMystery` / `_CaseSelection`, which is where the "Book N"
+	// title is drawn.
 	//
 	// Mid-mystery profile resume: if the profile picker loaded a
 	// save whose `hasMystery` flag was set, `_mystery.isLoaded()` is
@@ -485,7 +476,7 @@ Common::Error EEMEngine::run() {
 	// profile-level state via `_PlayerRecord`, not in-progress
 	// mysteries — so this is a ScummVM-only ergonomics improvement.
 	if (!shouldQuit() && !resumed)
-		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenChooseMystery;
+		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
 screen_loop:
 	while (!shouldQuit() && _nextScreen != kScreenInvalid) {
 		const ScreenId current = (ScreenId)_nextScreen;
@@ -509,28 +500,23 @@ screen_loop:
 			break;
 
 		case kScreenAction:
-			// Post-mystery menu. The original's `_ActionScreen @
-			// 1c33:195b` shows the 5-entry "Choose A Mystery /
-			// Practice / ScrapBook" picker; `doCaseSelection` is
-			// our port of that exact menu (plus the individual-case
-			// sub-picker the original handles in `_CaseSelection @
-			// 1c33:0a87`). After the player picks, fall through to
-			// the same routing the `kScreenChooseMystery` case uses.
-			// Reachable from `_DisplayCorrect`'s 0xc write after a
-			// solve (see `ui.cpp` `_nextScreen = kScreenAction`).
+			// Top-level post-profile / post-mystery menu. `_ActionScreen
+			// @ 1c33:195b` shows the 5-entry "Choose A Mystery /
+			// Practice / ScrapBook" picker and writes screen 0xa only
+			// when the player picks "Choose A Mystery".
 			_nextScreen = kScreenInvalid;
-			doCaseSelection();
-			if (_mystery.isLoaded())
+			doActionScreen();
+			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
 				_nextScreen = kScreenInitClues;
 			break;
 
 		case kScreenChooseMystery:
 			// Handler 10 at 1a35:0e0e calls `_DoChooseMystery` which
 			// presets `_NextScreen = 0` (INIT_CLUES) before
-			// `_CaseSelection`. Same dispatch as `kScreenAction`.
+			// `_CaseSelection`.
 			_nextScreen = kScreenInvalid;
 			doCaseSelection();
-			if (_mystery.isLoaded())
+			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
 				_nextScreen = kScreenInitClues;
 			break;
 
@@ -539,7 +525,7 @@ screen_loop:
 			// then writes `_NextScreen = 1` (MAP).
 			doInitClues();
 			_nextScreen = _mystery.isLoaded() ? kScreenMap
-											  : kScreenChooseMystery;
+											  : kScreenAction;
 			break;
 
 		case kScreenMap:
@@ -553,7 +539,7 @@ screen_loop:
 			// the natural next state is SITE.
 			doBigMap();
 			if (!_mystery.isLoaded())
-				_nextScreen = kScreenChooseMystery;
+				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenSite;
 			break;
@@ -565,7 +551,7 @@ screen_loop:
 			// than entering those screens as nested modals.
 			doSiteLoop();
 			if (!_mystery.isLoaded())
-				_nextScreen = kScreenChooseMystery;
+				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenInvalid;  // user quit
 			break;
@@ -576,7 +562,7 @@ screen_loop:
 			// (accuse) and then returns to this dispatcher.
 			doNotebook();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
-				_nextScreen = kScreenChooseMystery;
+				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenSite;
 			break;
@@ -587,7 +573,7 @@ screen_loop:
 			// 2, and the PDA button writes 4.
 			doGallery();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
-				_nextScreen = kScreenChooseMystery;
+				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenSite;
 			break;
@@ -620,7 +606,7 @@ screen_loop:
 				doChoosePartner();
 			if (!shouldQuit())
 				_nextScreen = _mystery.isLoaded() ? kScreenMap
-												  : kScreenChooseMystery;
+												  : kScreenAction;
 			break;
 
 		case kScreenAccuse:
@@ -629,7 +615,7 @@ screen_loop:
 			// 0xc (ACTION).
 			doAccuse();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
-				_nextScreen = kScreenChooseMystery;
+				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
 				_nextScreen = kScreenSite;
 			break;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 44c5bc4a905..bc63f993db1 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -75,9 +75,9 @@ class MusicPlayer;
  *                    for input, then writes =8. CD shows TITLE.ANM in
  *                    `_DoOpeningAnims`, so it usually never enters this
  *                    handler.
- *   12 (0xc) ACTION → `_ActionScreen` @ 1c33:195b — post-mystery menu
- *                    ("Solve a Mystery", scrapbook, more mysteries,
- *                    setup). Action 1 sets =10 (CHOOSE_MYSTERY).
+ *   12 (0xc) ACTION → `_ActionScreen` @ 1c33:195b — Choose A Mystery /
+ *                    Practice Mystery / See ScrapBook 1..3. Action 1
+ *                    sets =10 (CHOOSE_MYSTERY).
  *   0xFFFF SENTINEL → exit loop
  *
  * Screen-driver state writes verified via xrefs to `_NextScreen @
@@ -451,6 +451,7 @@ private:
 	/// action-menu "See ScrapBook 1/2/3" entries.
 	void doShowScrapbook(uint stage);
 
+	void doActionScreen();
 	void doCaseSelection();
 	void doSiteLoop();
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 88aa3939004..d7986c80c89 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -147,6 +147,13 @@ constexpr int kNameEntryPeekY = 0xb3;
 constexpr uint16 kCaseSelectionRevealPic = 0x53;
 constexpr int kCaseSelectionRevealX = 0x3e;
 constexpr int kCaseSelectionRevealY = 0xb2;
+constexpr uint16 kActionScreenBackgroundPic = 0x104;
+constexpr uint16 kActionScreenDecorPic = 0x0009;
+constexpr int kActionScreenDecorX = 10;
+constexpr int kActionScreenDecorY = 0x87;
+constexpr byte kChooserCycleStart = 0x6f;
+constexpr byte kChooserCycleEnd = 0x73;
+constexpr uint32 kChooserCycleMillis = 100;
 
 bool notebookButtonAt(int x, int y) {
 	return kPdaHelpRect.contains(x, y) ||
@@ -244,6 +251,13 @@ void copyToScreen(Graphics::ManagedSurface &scratch) {
 	g_system->updateScreen();
 }
 
+void cycleChooserPalette() {
+	// `_DoChoose` / `_DoListPicker_Floppy` rotate 0x6f..0x73 on each
+	// frame tick while waiting for input. These colors are used by the
+	// animated chooser surface baked into the menu backgrounds.
+	cyclePaletteRange(kChooserCycleStart, kChooserCycleEnd);
+}
+
 void blitMaskedPicSlice(Graphics::ManagedSurface &dst, const Picture &pic,
 						int srcX, int srcY, int w, int h,
 						int dstX, int dstY) {
@@ -431,25 +445,19 @@ void drawProfilePickerFrame(const ProfilePickerView &v) {
 	copyToScreen(scratch);
 }
 
-// Snapshot of `doCaseSelection`'s captured locals, used by
-// `drawCaseSelectionFrame` (which replaces the original lambda). Lives
-// on the stack inside `doCaseSelection`; never escapes.
-struct CaseSelectionView {
+// Snapshot of `doActionScreen`'s captured locals, used by
+// `drawActionMenuFrame`. Lives on the stack inside `doActionScreen`;
+// never escapes.
+struct ActionMenuView {
 	EEMEngine *vm;
-	const Picture *caseBg;
-	bool haveCaseBg;
-	const Picture *revealPic;
-	bool haveRevealPic;
-	const Animation *kdAnim;
-	bool haveKdAnim;
-	uint16 kdAnimId;     ///< 0x15 / 0x16 — looked up in kAnimScripts
-	int kdAnimX;
-	int kdAnimY;
+	const Picture *bg;
+	bool haveBg;
+	const Picture *decor;
+	bool haveDecor;
 	const char *separator;
 	const char *const *pickLabel;
 	const bool *pickEnabled;
 	uint pick;
-	uint book;
 };
 
 // Mystery list shown in the "Choose A Mystery" sub-screen. Mirrors
@@ -486,6 +494,16 @@ Common::StringArray loadBookNames(uint book) {
 	return names;
 }
 
+void clampCaseTopRow(uint &topRow, uint listLen, uint visibleRows) {
+	if (listLen <= visibleRows) {
+		topRow = 0;
+		return;
+	}
+	const uint maxTop = listLen - visibleRows;
+	if (topRow > maxTop)
+		topRow = maxTop;
+}
+
 // Per-mystery sub-chooser ("Choose A Mystery") view.
 //
 // `names` are the entries from BOOK%d.NME (in display order — index 0
@@ -645,12 +663,15 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 	copyToScreen(scratch);
 }
 
-void drawCaseSelectionFrame(const CaseSelectionView &v) {
+void drawActionMenuFrame(const ActionMenuView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
-	drawCaseBase(scratch, v.vm, v.caseBg, v.haveCaseBg,
-				 v.revealPic, v.haveRevealPic,
-				 v.kdAnim, v.haveKdAnim, v.kdAnimX, v.kdAnimY, v.book);
+	scratch.clear();
+	if (v.haveBg && v.bg)
+		scratch.simpleBlitFrom(v.bg->surface);
+	if (v.haveDecor && v.decor)
+		blitMaskedPic(scratch, *v.decor,
+					   kActionScreenDecorX, kActionScreenDecorY);
 
 	if (v.vm->getFont().isLoaded()) {
 		// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
@@ -763,6 +784,7 @@ void EEMEngine::doProfilePicker() {
 								   haveReveal ? &reveal : nullptr))
 		return;
 	drawProfilePickerFrame(view);
+	uint32 chooserLastTick = g_system->getMillis();
 
 	while (!done && !shouldQuit()) {
 		Common::Event ev;
@@ -875,6 +897,11 @@ void EEMEngine::doProfilePicker() {
 			view.start = start;
 			drawProfilePickerFrame(view);
 		}
+		const uint32 now = g_system->getMillis();
+		if (now - chooserLastTick >= kChooserCycleMillis) {
+			chooserLastTick = now;
+			cycleChooserPalette();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -1738,21 +1765,18 @@ void EEMEngine::doSetup() {
 	}
 }
 
-void EEMEngine::doCaseSelection() {
-	// Mirrors `_CaseSelection` @ 1c33:0a87. The original draws PIC 0x41
-	// (chooser background) plus a centred "Book %d" / "Challenge Book"
-	// header at (y=12) and then calls `_DoChoose(list)` to render the
-	// menu via `DrawList` @ 1c33:040d at (_TextBox+3, DAT_29be_0d02) =
-	// (61, 35), 12 rows × 10 px line height. The menu list itself is
-	// the static array at 29be:0d6a (verified via `push 0x0d6a` at
-	// 1c33:1ab4). Strings are at 29be:0ef4 onwards. Layout:
+void EEMEngine::doActionScreen() {
+	// Mirrors `_ActionScreen` @ 1c33:195b. The original draws background
+	// PIC 0x104 plus PIC 9 at (10, 0x87), then calls `_DoChoose` with
+	// `ActionNames @ 29be:0d6a`. The "Book N" heading belongs only to
+	// `_CaseSelection`, so this top-level menu intentionally does not
+	// draw one.
+	// Layout:
 	//   list[0]  = "----------------------------------"
 	//   list[1]  = "         Choose A Mystery"
 	//   list[2..10] = alternating menu items + separators
 	// Five selectable items: Choose A Mystery / Practice Mystery /
 	// See ScrapBook 1/2/3.
-	const uint kMaxMystery = 54;
-
 	enum MenuPick {
 		kPickChoose = 0,
 		kPickPractice,
@@ -1828,8 +1852,6 @@ void EEMEngine::doCaseSelection() {
 	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
 	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
 	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
-	const Common::Rect kUpArrowRect (240,  31, 250,  43); // 29be:0cf0 scroll up
-	const Common::Rect kDnArrowRect (240, 148, 250, 159); // 29be:0cf8 scroll dn
 	const Common::Rect kListRect    ( 58,  35, 238, 158); // 29be:0d00 list panel
 
 	// The original `_NewPlayer` set `_MouseCursor = 1` on exit; the
@@ -1837,7 +1859,7 @@ void EEMEngine::doCaseSelection() {
 	// Reassert here in case anything between hid it.
 	CursorMan.showMouse(true);
 
-	// Reassert site palette 0 (the case-selection / chooser CLUT). In
+	// Reassert site palette 0 (the chooser CLUT). In
 	// the normal flow `doProfilePicker` (or the post-screen reset paths
 	// at lines 1402 / 1147 / 1121) leaves us on palette 0 already, but
 	// the launcher-resume path jumps straight here from `_AllBlack`
@@ -1845,65 +1867,29 @@ void EEMEngine::doCaseSelection() {
 	// CLUT and the player sees an empty screen.
 	setSitePalette(0);
 
-	// Mirrors `_CaseSelection`: load PIC 0x41 as the chooser backdrop.
-	Picture caseBg;
-	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
-	Picture revealPic;
-	const bool haveRevealPic =
-		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
-
-	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` (1c33:0b7e-0ba1)
-	// loads anim 0x15 (Jake-paired) or 0x16 (Jenny-paired) and registers
-	// `_NewAnimation(0x112, 0x50, ..., seqnum=0x15, prior=1)` — partner-
-	// dependent because the host KD changes who's "with him" on the
-	// briefing intro frame. Runs continuously through the menu loop via
-	// `_UpdateAnimations`. We approximate with millis-based frame cycling.
-	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
-	Animation kdAnim;
-	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
-							 && !kdAnim.empty();
-	const int kKdAnimX = 0x112;
-	const int kKdAnimY = 0x50;
-	const uint caseBook = (_chainStage == 3) ? 3 :
-						  (_chainStage == 2) ? 2 : 1;
+	Picture bg;
+	const bool haveBg = _picsArchive.getPicture(kActionScreenBackgroundPic, bg);
+	Picture decor;
+	const bool haveDecor = _picsArchive.getPicture(kActionScreenDecorPic, decor);
 
-	CaseSelectionView v;
+	ActionMenuView v;
 	v.vm = this;
-	v.caseBg = &caseBg;
-	v.haveCaseBg = haveCaseBg;
-	v.revealPic = &revealPic;
-	v.haveRevealPic = haveRevealPic;
-	v.kdAnim = &kdAnim;
-	v.haveKdAnim = haveKdAnim;
-	v.kdAnimId = (uint16)kKdAniId;
-	v.kdAnimX = kKdAnimX;
-	v.kdAnimY = kKdAnimY;
+	v.bg = &bg;
+	v.haveBg = haveBg;
+	v.decor = &decor;
+	v.haveDecor = haveDecor;
 	v.separator = kSeparator;
 	v.pickLabel = kPickLabel;
 	v.pickEnabled = kPickEnabled;
 	v.pick = pick;
-	v.book = caseBook;
 
-	if (animateCaseSelectionReveal(this, &caseBg, haveCaseBg,
-								   &revealPic, haveRevealPic,
-								   &kdAnim, haveKdAnim,
-								   kKdAnimX, kKdAnimY, caseBook))
-		return;
-	drawCaseSelectionFrame(v);
-	uint32 lastTick = g_system->getMillis();
+	drawActionMenuFrame(v);
+	uint32 chooserLastTick = g_system->getMillis();
 
 	bool exitChosen = false;
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool confirmed = false;
-		// Redraw every 100 ms so the KD greeter cycles. Mirrors the
-		// `_CheckFrameRate` cadence in `_CaseSelection`'s main loop.
-		const uint32 now = g_system->getMillis();
-		if (haveKdAnim && now - lastTick >= 100) {
-			lastTick = now;
-			v.pick = pick;
-			drawCaseSelectionFrame(v);
-		}
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
@@ -1938,7 +1924,7 @@ void EEMEngine::doCaseSelection() {
 						if (mp < kNumPicks && kPickEnabled[mp]) {
 							pick = mp;
 							v.pick = pick;
-							drawCaseSelectionFrame(v);
+							drawActionMenuFrame(v);
 							continue;
 						}
 					}
@@ -1969,7 +1955,7 @@ void EEMEngine::doCaseSelection() {
 						break;
 				}
 				v.pick = pick;
-				drawCaseSelectionFrame(v);
+				drawActionMenuFrame(v);
 				continue;
 			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
@@ -1980,15 +1966,20 @@ void EEMEngine::doCaseSelection() {
 						break;
 				}
 				v.pick = pick;
-				drawCaseSelectionFrame(v);
+				drawActionMenuFrame(v);
 				continue;
 			}
 		}
 		if (confirmed) {
 			v.pick = pick;
-			drawCaseSelectionFrame(v);
+			drawActionMenuFrame(v);
 			break;
 		}
+		const uint32 now = g_system->getMillis();
+		if (now - chooserLastTick >= kChooserCycleMillis) {
+			chooserLastTick = now;
+			cycleChooserPalette();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -1997,15 +1988,20 @@ void EEMEngine::doCaseSelection() {
 		return;
 
 	if (exitChosen) {
-		_mystery.clear();
-		_nextScreen = kScreenInvalid;
+		if (areYouSure()) {
+			_mystery.clear();
+			_nextScreen = kScreenInvalid;
+		} else {
+			drawActionMenuFrame(v);
+			_nextScreen = kScreenAction;
+		}
 		return;
 	}
 
 	// "Practice Mystery" is the tutorial → mystery 0.
 	if (pick == kPickPractice) {
 		if (!_mystery.load(0, &_rng)) {
-			warning("doCaseSelection: failed to load practice mystery");
+			warning("doActionScreen: failed to load practice mystery");
 			_mystery.clear();
 			resetSiteArrivalState();
 		} else {
@@ -2021,49 +2017,72 @@ void EEMEngine::doCaseSelection() {
 		// call `_ShowScrapbook(0, stage)` for the matching tier
 		// (verified at the action-handler jumptable bytes
 		// `01 03 05 07 09 ff` paired with handlers at 1c33:1be1).
-		// The picker here is meant to LEAVE the mystery state untouched
-		// — viewing the scrapbook never starts a new case.
+		// Viewing the scrapbook never starts a new case; return to
+		// the same action menu afterwards.
 		const uint stage = (pick == kPickScrap1) ? 1
 						 : (pick == kPickScrap2) ? 2 : 3;
 		doShowScrapbook(stage);
 		setSitePalette(0);
 		_mystery.clear();
-		_nextScreen = kScreenChooseMystery;
+		_nextScreen = kScreenAction;
 		return;
 	}
 
-	// "Choose A Mystery" sub-screen: pick a specific case from the
-	// chain-stage's roster. Mirrors `_DoChooseMystery @ 1a35:02b7` +
-	// `_CaseSelection @ 1c33:0a87`:
-	//   stage 1 (Junior, BOOK1.NME) → mysteries  1..24
-	//   stage 2 (Senior, BOOK2.NME) → mysteries 25..48
-	//   stage 3 (Master, BOOK3.NME) → mysteries 49..54
-	// `_DoChooseMystery` opens BOOK<stage>.NME and reads up to 25
-	// CRLF-terminated lines into a 25-entry FAR-pointer array passed
-	// to `_CaseSelection`. The grey mask `_Greys = &mysteriesSolved +
-	// stageLo` (1c33:0b22) makes already-solved entries unselectable.
+	_nextScreen = kScreenChooseMystery;
+}
+
+void EEMEngine::doCaseSelection() {
+	// Mirrors `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @
+	// 1c33:0a87`. `_DoChooseMystery` loads BOOK<stage>.NME and
+	// `_CaseSelection` draws PIC 0x41 plus the centered "Book N" /
+	// "Challenge Book" title. This screen is entered only after the
+	// player selects "Choose A Mystery" on `_ActionScreen`.
+	const uint kMaxMystery = 54;
+
+	CursorMan.showMouse(true);
+	setSitePalette(0);
+	_mystery.clear();
+	resetSiteArrivalState();
+
+	Picture caseBg;
+	const bool haveCaseBg = _picsArchive.getPicture(0x41, caseBg);
+	Picture revealPic;
+	const bool haveRevealPic =
+		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
+
+	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` loads anim 0x15
+	// (Jake-paired) or 0x16 (Jenny-paired), then runs it through the
+	// chooser loop via `_UpdateAnimations`.
+	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
+	Animation kdAnim;
+	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
+							 && !kdAnim.empty();
+	const int kKdAnimX = 0x112;
+	const int kKdAnimY = 0x50;
+
+	// Stage roster:
+	//   stage 1 (Junior, BOOK1.NME) -> mysteries  1..24
+	//   stage 2 (Senior, BOOK2.NME) -> mysteries 25..48
+	//   stage 3 (Master, BOOK3.NME) -> mysteries 49..54
 	uint stageLo = 1, stageHi = 0x18;
 	uint book = 1;
 	switch (_chainStage) {
 	case 2: stageLo = 0x19; stageHi = 0x30; book = 2; break;
 	case 3: stageLo = 0x31; stageHi = 0x36; book = 3; break;
-	default: break;  // stage 1 (or fallback)
+	default: break;
 	}
 	if (stageHi > kMaxMystery)
 		stageHi = kMaxMystery;
 
 	const Common::StringArray names = loadBookNames(book);
 	if (names.empty()) {
-		warning("doCaseSelection: BOOK%u.NME failed to load — bailing",
-				book);
-		_mystery.clear();
+		warning("doCaseSelection: BOOK%u.NME failed to load", book);
 		return;
 	}
 	const uint listLen = MIN<uint>((uint)names.size(), stageHi - stageLo + 1);
 
 	// Per-row solved flags. `_DoChoose @ 1c33:0521` skips solved entries
-	// when seeding the initial selection (`while *_Greys[select] != 0`)
-	// and again per-click via the same mask check.
+	// when seeding the initial selection and ignores clicks on them.
 	Common::Array<bool> solvedFlags;
 	solvedFlags.resize(listLen);
 	for (uint i = 0; i < listLen; i++) {
@@ -2072,32 +2091,19 @@ void EEMEngine::doCaseSelection() {
 			mn < sizeof(_mysteriesSolved) && _mysteriesSolved[mn] != 0;
 	}
 
-	// Seed the selection at the first unsolved entry — same as
-	// `_DoChoose`'s `while (*Greys[select] != 0) select++;` loop at
-	// 1c33:0524.
 	uint selRow = 0;
 	while (selRow < listLen && solvedFlags[selRow])
 		selRow++;
 	if (selRow >= listLen)
-		selRow = 0;  // every case solved — let player re-pick
+		selRow = 0;
+
 	uint topRow = 0;
 	const uint kVisible = 12;
 	if (selRow >= kVisible) {
 		topRow = selRow - kVisible / 2;
-		if (topRow + kVisible > listLen)
-			topRow = listLen > kVisible ? listLen - kVisible : 0;
+		clampCaseTopRow(topRow, listLen, kVisible);
 	}
 
-	auto clampTopRow = [&](uint &t) {
-		if (listLen <= kVisible) {
-			t = 0;
-			return;
-		}
-		const uint maxTop = listLen - kVisible;
-		if (t > maxTop)
-			t = maxTop;
-	};
-
 	CaseSubmenuView sv;
 	sv.vm = this;
 	sv.caseBg = &caseBg;
@@ -2130,49 +2136,43 @@ void EEMEngine::doCaseSelection() {
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kChooserOkRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (selRow < listLen && !solvedFlags[selRow])
 						confirmed = true;
 					break;
 				}
-				if (kExitRect.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kChooserExitRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_mystery.clear();
 					return;
 				}
-				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// In original `_CaseSelection`, this button is
-					// PIC 0x123 and returns 0xfffe, which calls
-					// `_ChooseSavedGame`. The ScummVM port stores the
-					// in-progress case in the profile save instead of
-					// original per-mystery files, so route to screen 8
-					// (profile picker) as the load/resume path.
+				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// Original `_CaseSelection` returns 0xfffe here,
+					// then `_ChooseSavedGame` runs. ScummVM stores
+					// in-progress cases in the profile, so route to
+					// the profile picker instead.
 					saveProfile(_playerName);
 					_mystery.clear();
 					_nextScreen = kScreenProfile;
 					return;
 				}
-				if (kUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kChooserUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
 					if (topRow > 0) { topRow--; dirty = true; }
 					continue;
 				}
-				if (kDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kChooserDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
 					topRow++;
-					clampTopRow(topRow);
+					clampCaseTopRow(topRow, listLen, kVisible);
 					dirty = true;
 					continue;
 				}
-				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Pick the row under the cursor — mirrors
-					// 1c33:0635 `i = (MouseY - DAT_29be_0d02) / 10;`.
+				if (kChooserListRect.contains(ev.mouse.x, ev.mouse.y)) {
 					const int kLineH = 10;
-					const int row = (ev.mouse.y - 35) / kLineH;
+					const int row = (ev.mouse.y - kChooserListRect.top) / kLineH;
 					if (row < 0 || row >= (int)kVisible)
 						continue;
 					const uint idx = topRow + (uint)row;
-					if (idx >= listLen)
+					if (idx >= listLen || solvedFlags[idx])
 						continue;
-					if (solvedFlags[idx])
-						continue;  // greyed entries ignore clicks
 					selRow = idx;
 					dirty = true;
 					continue;
@@ -2197,7 +2197,7 @@ void EEMEngine::doCaseSelection() {
 					selRow++;
 					if (selRow >= topRow + kVisible) {
 						topRow = selRow - kVisible + 1;
-						clampTopRow(topRow);
+						clampCaseTopRow(topRow, listLen, kVisible);
 					}
 					dirty = true;
 				}
@@ -2216,7 +2216,7 @@ void EEMEngine::doCaseSelection() {
 				selRow = MIN<uint>(selRow + kVisible, listLen - 1);
 				if (selRow >= topRow + kVisible) {
 					topRow = selRow - kVisible + 1;
-					clampTopRow(topRow);
+					clampCaseTopRow(topRow, listLen, kVisible);
 				}
 				dirty = true;
 				continue;
@@ -2242,10 +2242,12 @@ void EEMEngine::doCaseSelection() {
 			}
 		}
 		const uint32 now = g_system->getMillis();
-		const bool animTick = haveKdAnim && now - submenuLastTick >= 100;
-		if (animTick)
+		const bool chooserTick = now - submenuLastTick >= kChooserCycleMillis;
+		if (chooserTick) {
 			submenuLastTick = now;
-		if (dirty || animTick) {
+			cycleChooserPalette();
+		}
+		if (dirty || (chooserTick && haveKdAnim)) {
 			sv.topRow = topRow;
 			sv.selRow = selRow;
 			drawCaseSubmenu(sv);
@@ -2254,6 +2256,9 @@ void EEMEngine::doCaseSelection() {
 		g_system->delayMillis(15);
 	}
 
+	if (shouldQuit())
+		return;
+
 	const uint mn = stageLo + selRow;
 	if (!_mystery.load(mn, &_rng)) {
 		warning("doCaseSelection: failed to load mystery %u", mn);


Commit: 2398f5722175044e49a6729d7a77a32c3249ed82
    https://github.com/scummvm/scummvm/commit/2398f5722175044e49a6729d7a77a32c3249ed82
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:05+02:00

Commit Message:
EEM: refresh the correct part of the screen during case introductions

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 2d72d4e45b1..b6cc6b79c10 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -340,7 +340,8 @@ void EEMEngine::doInitClues() {
 
 	setSitePalette(0x22);
 	Picture bg;
-	if (_picsArchive.getPicture(0x52, bg))
+	const bool haveBriefingBg = _picsArchive.getPicture(0x52, bg);
+	if (haveBriefingBg)
 		blitAt(bg, 0, 0);
 
 	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
@@ -372,7 +373,7 @@ void EEMEngine::doInitClues() {
 			// "thinking" pose (cell 8) for 16 ticks instead of
 			// flipbook-cycling, and nancy waits 18 ticks before her
 			// late-arrival count-up.
-			if (_picsArchive.getPicture(0x52, bg))
+			if (haveBriefingBg)
 				blitAt(bg, 0, 0);
 			const uint32 t = frame * 100;
 			// All three briefing anims (game/book/nancy) go through
@@ -427,20 +428,38 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Freeze the completed setup animation as the base for
-	// `_PlayInSequence`. The original clears the registered animations
-	// before playing the short case-type overlay, so the game/book/nancy
-	// cells do not keep cycling or wrap back to cell 0 underneath it.
+	// Freeze only the same setup-animation band the original bakes into
+	// its background buffers before clearing the registered animations:
+	// `_VidramRectCopy(0, 0x5a, 0x28, 0x6d, 16000, 48000/32000)`.
+	// Width is in mode-X columns, so 0x28 columns = 160 pixels. This
+	// preserves the lower-left book/Nancy area but intentionally drops the
+	// right-side game animation; `_PlayInSequence` redraws that character
+	// over a clean background next. Preserving the full screen leaves the
+	// old right-side Jake/Jenny frame underneath the sequence.
 	Graphics::ManagedSurface briefingBase(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	briefingBase.clear();
+	if (haveBriefingBg) {
+		briefingBase.simpleBlitFrom(bg.surface);
+	} else {
+		Graphics::Surface *screen = g_system->lockScreen();
+		if (screen) {
+			briefingBase.simpleBlitFrom(*screen);
+			g_system->unlockScreen();
+		}
+	}
 	{
+		const int preserveX = 0;
+		const int preserveY = 0x5a;
+		const int preserveW = 0x28 * 4;
+		const int preserveH = 0x6d;
+		const Common::Rect preserveRect(preserveX, preserveY,
+										preserveX + preserveW,
+										preserveY + preserveH);
 		Graphics::Surface *screen = g_system->lockScreen();
 		if (screen) {
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)briefingBase.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
+			briefingBase.simpleBlitFrom(*screen, preserveRect,
+										Common::Point(preserveX, preserveY));
 			g_system->unlockScreen();
 		}
 	}


Commit: d00eeb0022a1966b8e1c78cbb5e77bcd4605336c
    https://github.com/scummvm/scummvm/commit/d00eeb0022a1966b8e1c78cbb5e77bcd4605336c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:06+02:00

Commit Message:
EEM: optional better fitted balloon dialog code

Changed paths:
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/detection.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/graphics.cpp
    engines/eem/metaengine.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index b6cc6b79c10..b427294b306 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -808,12 +808,11 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 												_playerName, _partner);
 
 		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
-		// `_DisplayClue`. The original looks up per-balloon text-area
-		// metadata in a table at offset 0x875 (within `_DisplayClue`'s
-		// segment); we don't have that table decoded yet, so we use a
-		// fixed inset of 8 px from the balloon's top-left.
+		// `_DisplayClue`, with the shared balloon metadata table used for
+		// text placement and the fitted balloon variant.
+		const uint16 fittedBubNum = fitBalloonToText(bubNum, text);
 		Picture balloon;
-		const uint16 balloonId = bubNum & 0x7F;
+		const uint16 balloonId = fittedBubNum & 0x7F;
 		const bool haveBalloon = bubNum != 0xFFFF &&
 			_balloonArchive.size() > balloonId &&
 			_balloonArchive.loadEntry(balloonId, balloon);
@@ -849,7 +848,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				// `_GetBalloon @ 172b:1d7d` mirrors the picture horizontally
 				// when `(bubNum & 0x80)` is set — used for right-side
 				// speakers so the tail points the other way.
-				const bool flipBalloon = (bubNum & 0x80) != 0;
+				const bool flipBalloon = (fittedBubNum & 0x80) != 0;
 				if (bw > 0 && bh > 0) {
 					for (int row = 0; row < bh; row++) {
 						const byte *src =
@@ -1111,11 +1110,57 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			}
 		}
 
+		Common::String fitText;
+		Common::String fitPage;
+		uint16 fitXIns = 0;
+		uint16 fitYIns = 0;
+		uint16 fitWidth = 142;
+		getBalloonInsets(balByte, fitXIns, fitYIns, fitWidth);
+		uint fitTextLines = 0;
+		for (uint t = 0; t < textCount; t++) {
+			const uint8 idxByte = rec[11 + t];
+			const uint8 idx = idxByte & 0x7f;
+			const uint32 noteAbs = notesBase + (uint32)idx * 7;
+			if (noteAbs + 6 > dsz)
+				continue;
+			const uint16 textOff = (_partner == 0)
+				? READ_LE_UINT16(notes + idx * 7 + 2)
+				: READ_LE_UINT16(notes + idx * 7 + 4);
+			if (textOff >= dsz)
+				continue;
+			const char *linePtr = (const char *)(bufBase + textOff);
+			uint32 lineLen = 0;
+			while (textOff + lineLen < dsz && linePtr[lineLen] != 0)
+				lineLen++;
+			Common::String raw(linePtr, lineLen);
+			Common::String parsed = parseString(raw, _playerName, _partner);
+			if (!parsed.empty()) {
+				if (!fitPage.empty())
+					fitPage += '\n';
+				fitPage += parsed;
+			}
+			const bool continuePage =
+				(idxByte & 0x80) != 0 && t + 1 < textCount;
+			if (!continuePage) {
+				Common::Array<Common::String> wrapped;
+				_font.wordWrapText(fitPage, MAX<int>(8, (int)fitWidth),
+					wrapped);
+				if (wrapped.size() > fitTextLines ||
+					(wrapped.size() == fitTextLines &&
+					 fitPage.size() > fitText.size())) {
+					fitText = fitPage;
+					fitTextLines = wrapped.size();
+				}
+				fitPage.clear();
+			}
+		}
+
 		// Pre-load balloon picture + insets once per record (constant
 		// across all paginated text indices).
+		const uint16 fittedBalByte = fitBalloonToText(balByte, fitText);
 		Picture balloon;
-		const uint16 balloonId  = balByte & 0x7F;
-		const bool   flipBall   = (balByte & 0x80) != 0;
+		const uint16 balloonId  = fittedBalByte & 0x7F;
+		const bool   flipBall   = (fittedBalByte & 0x80) != 0;
 		const bool   haveBalloon = balByte != 0xFF &&
 			_balloonArchive.size() > balloonId &&
 			_balloonArchive.loadEntry(balloonId, balloon);
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index fd620dd89f4..7fe94f56f39 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -31,7 +31,8 @@ const PlainGameDescriptor eemGames[] = {
 	{ nullptr, nullptr }
 };
 
-#define GUI_OPTIONS_EEM GUIO3(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+#define GUI_OPTIONS_EEM_FLOPPY GUIO3(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+#define GUI_OPTIONS_EEM_CD     GUIO4(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_FIT_DIALOG_BALLOONS, GUIO_MIDIADLIB, GUIO_MIDIMT32)
 
 const ADGameDescription gameDescriptions[] = {
 	{
@@ -42,7 +43,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUI_OPTIONS_EEM
+		GUI_OPTIONS_EEM_CD
 	},
 	{
 		"eem",
@@ -52,7 +53,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUI_OPTIONS_EEM
+		GUI_OPTIONS_EEM_FLOPPY
 	},
 	{
 		// Spanish floppy release — same EEM.EXE binary as the English
@@ -66,7 +67,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::ES_ESP,
 		Common::kPlatformDOS,
 		ADGF_NO_FLAGS,
-		GUI_OPTIONS_EEM
+		GUI_OPTIONS_EEM_FLOPPY
 	},
 
 	AD_TABLE_END_MARKER
diff --git a/engines/eem/detection.h b/engines/eem/detection.h
index 7d74db6cd7f..3ad726bb5f0 100644
--- a/engines/eem/detection.h
+++ b/engines/eem/detection.h
@@ -26,7 +26,8 @@
 
 namespace EEM {
 
-#define GAMEOPTION_HIDE_HIGHLIGHT_BOXES GUIO_GAMEOPTIONS1
+#define GAMEOPTION_HIDE_HIGHLIGHT_BOXES   GUIO_GAMEOPTIONS1
+#define GAMEOPTION_FIT_DIALOG_BALLOONS    GUIO_GAMEOPTIONS2
 
 enum EEMDebugChannels {
 	kDebugGeneral = 1 << 0,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index da33f550739..6d4acc41d88 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -199,6 +199,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	  _playerName("Detective"),
 	  _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle), _partner(0) {
 	ConfMan.registerDefault("hide_highlight_boxes", false);
+	ConfMan.registerDefault("fit_dialog_balloons", false);
 
 	// `ADGameDescription::extra` is set by the matching entry in
 	// `gameDescriptions[]` ("CD" or "Floppy"). Keep variant detection
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index bc63f993db1..32eb70388f5 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -254,6 +254,11 @@ public:
 	/// ctype-bit-1 (= digit) at `29be:2be1 + char`.
 	uint16 getKDTextBalloon(byte firstChar) const;
 
+	/// Cleanup for overly tall original speech balloons. Keeps the original
+	/// bubble family and mirror bit, but picks a shorter sibling when the
+	/// wrapped text leaves enough empty lines.
+	uint16 fitBalloonToText(uint16 bubNum, const Common::String &text);
+
 	/// Substitute the 0x80..0x89 control bytes the engine uses inside
 	/// `TextBlock` strings. Mirrors `_ParseString @ 1b66:07c3`; jump
 	/// table at 1b66:0cbe. Used by every clue / hint / balloon caller.
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 4b9eae7cbf8..ca91bcc0349 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/events.h"
 #include "common/file.h"
@@ -85,6 +86,52 @@ const BalloonInsets kBalloonInsetTable[] = {
 	{ 5, 8, 158, 160, 45 }, { 5, 8, 176, 176, 50 }, { 8, 7, 142, 142, 71 }
 };
 
+struct BalloonFamily {
+	uint16 first;
+	uint16 last;
+};
+
+const BalloonFamily kBalloonFamilies[] = {
+	{ 0x00, 0x06 },
+	{ 0x07, 0x0d },
+	{ 0x0e, 0x14 },
+	{ 0x15, 0x1b },
+	{ 0x1c, 0x22 },
+	{ 0x23, 0x29 },
+	{ 0x2a, 0x30 },
+	{ 0x31, 0x31 },
+	{ 0x32, 0x32 },
+	{ 0x33, 0x33 },
+};
+
+bool findBalloonFamily(uint16 balloonId, uint16 &first, uint16 &last) {
+	for (uint i = 0; i < ARRAYSIZE(kBalloonFamilies); i++) {
+		if (balloonId >= kBalloonFamilies[i].first &&
+			balloonId <= kBalloonFamilies[i].last) {
+			first = kBalloonFamilies[i].first;
+			last  = kBalloonFamilies[i].last;
+			return true;
+		}
+	}
+	first = last = balloonId;
+	return false;
+}
+
+// Lines that fit inside balloon @p balloonId. The metadata-table `indDY`
+// is the designed bottom of the text area, NOT the indicator-drawing
+// position alone — for families 3, 4, 6 and the singletons the bubble
+// graphic continues below `indDY` with shadow / tail decoration that
+// text must not encroach on. Image height is therefore an overestimate;
+// `indDY` is the artist-intended last text line.
+uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
+	const uint idx = balloonId & 0x7F;
+	if (idx >= ARRAYSIZE(kBalloonInsetTable) || lineH <= 0)
+		return 0;
+
+	const BalloonInsets &insets = kBalloonInsetTable[idx];
+	return MAX<uint>(1, ((int)insets.indDY - (int)insets.y) / lineH + 1);
+}
+
 // Floppy KDHelp hotspot-searched check. Mirrors
 // `FUN_22dc_096c @ 22dc:096c`: walks the per-site dialog records at
 // `site_data[+6]` to skip `hotspotIdx` hotspots, then returns the
@@ -229,6 +276,7 @@ void EEMEngine::doHelp() {
 
 		Common::String text = parseString(Common::String(txt),
 										   _playerName, _partner);
+		balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
 		Graphics::ManagedSurface ms(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
@@ -411,9 +459,10 @@ void EEMEngine::doHelp() {
 	// has to.
 	const byte firstChar =
 		text.empty() ? (byte)0 : (byte)text[0];
-	const uint16 bubNum = getKDTextBalloon(firstChar);
+	uint16 bubNum = getKDTextBalloon(firstChar);
 	if (firstChar >= '0' && firstChar <= '9')
 		text.deleteChar(0);
+	bubNum = fitBalloonToText(bubNum, text);
 	Picture balloon;
 	const bool haveBalloon =
 		_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -566,6 +615,72 @@ void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
 	}
 }
 
+uint16 EEMEngine::fitBalloonToText(uint16 bubNum,
+								   const Common::String &text) {
+	// Opt-in via the "Better fit for dialog balloons" game option, and
+	// CD-only — the floppy build's balloon archive / inset table hasn't
+	// been validated for shrinking yet, so leave it on the original
+	// artist-chosen bubble.
+	if (isFloppy() || !ConfMan.getBool("fit_dialog_balloons"))
+		return bubNum;
+
+	const uint16 originalId = bubNum & 0x7F;
+	if (bubNum == 0xFFFF || text.empty() || !_font.isLoaded() ||
+		originalId >= ARRAYSIZE(kBalloonInsetTable))
+		return bubNum;
+
+	const BalloonInsets &originalInsets = kBalloonInsetTable[originalId];
+	const int lineH = _font.getFontHeight();
+	const uint originalCapacity = getBalloonLineCapacity(originalId, lineH);
+	if (originalCapacity == 0)
+		return bubNum;
+
+	Common::Array<Common::String> lines;
+	_font.wordWrapText(text, MAX<int>(8, (int)originalInsets.w), lines);
+	if (lines.empty())
+		return bubNum;
+
+	const uint usedLines = lines.size();
+	if (usedLines > originalCapacity)
+		return bubNum;
+
+	uint16 familyFirst = 0;
+	uint16 familyLast = 0;
+	if (!findBalloonFamily(originalId, familyFirst, familyLast))
+		return bubNum;
+
+	uint16 chosenId = originalId;
+	uint chosenCapacity = originalCapacity;
+	debug(
+		   "fitBalloonToText: original 0x%02x, usedLines=%u, capacity=%u, family=0x%02x-0x%02x",
+		   (int)originalId, usedLines, originalCapacity,
+		   (int)familyFirst, (int)familyLast);
+	while (chosenId > familyFirst) {
+		if (chosenCapacity < usedLines)
+			break;
+
+		const uint16 candidateId = chosenId - 1;
+		if (candidateId >= ARRAYSIZE(kBalloonInsetTable) ||
+			kBalloonInsetTable[candidateId].w != originalInsets.w)
+			break;
+		const uint candidateCapacity =
+			getBalloonLineCapacity(candidateId, lineH);
+		if (candidateCapacity < usedLines)
+			break;
+		chosenId = candidateId;
+		chosenCapacity = candidateCapacity;
+	}
+
+	if (chosenId == originalId)
+		return bubNum;
+
+	debug(
+		   "fitBalloonToText: 0x%02x -> 0x%02x (%u lines, capacity %u -> %u)",
+		   (int)originalId, (int)chosenId, usedLines,
+		   originalCapacity, chosenCapacity);
+	return (bubNum & 0x80) | chosenId;
+}
+
 bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
 								  uint16 &yInset, uint16 &textW) const {
 	// `kBalloonInsetTable` lives at file scope above; see comment there.
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index 52b2c5e78b5..04e778c35bc 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -43,6 +43,18 @@ static const ADExtraGuiOptionsMap optionsList[] = {
 			0
 		}
 	},
+	{
+		GAMEOPTION_FIT_DIALOG_BALLOONS,
+		{
+			_s("Better fit for dialog balloons"),
+			_s("Pick a smaller speech-bubble graphic when the wrapped "
+			   "text doesn't need the original size."),
+			"fit_dialog_balloons",
+			false,
+			0,
+			0
+		}
+	},
 
 	AD_EXTRA_GUI_OPTIONS_TERMINATOR
 };
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index d7986c80c89..1c3c0863398 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -4205,9 +4205,10 @@ void EEMEngine::doAccuse() {
 			g_system->unlockScreen();
 		}
 		const byte firstChar = (byte)entryText[0];
-		const uint16 bubNum = getKDTextBalloon(firstChar);
+		uint16 bubNum = getKDTextBalloon(firstChar);
 		if (firstChar >= '0' && firstChar <= '9')
 			entryText.deleteChar(0);
+		bubNum = fitBalloonToText(bubNum, entryText);
 		Picture balloon;
 		const bool haveBalloon =
 			_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -4329,13 +4330,14 @@ void EEMEngine::doAccuse() {
 		}
 		const byte firstChar =
 			hint.empty() ? (byte)0 : (byte)hint[0];
-		const uint16 bubNum = getKDTextBalloon(firstChar);
+		uint16 bubNum = getKDTextBalloon(firstChar);
 		// Strip the digit prefix used for balloon dispatch — it's
 		// consumed by the original at `_DisplayAlibi @ 1df2:0163`
 		// (`str = pbVar7 + 1`) and shouldn't appear in the rendered
 		// text. `_GetKDTextBalloon` itself doesn't advance past it.
 		if (firstChar >= '0' && firstChar <= '9')
 			hint.deleteChar(0);
+		bubNum = fitBalloonToText(bubNum, hint);
 		Picture balloon;
 		const bool haveBalloon =
 			_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -4413,7 +4415,7 @@ void EEMEngine::doAccuse() {
 				// the dispatch result is the same either way.
 				const byte firstChar =
 					hint.empty() ? (byte)0 : (byte)hint[0];
-				const uint16 bubNum = getKDTextBalloon(firstChar);
+				uint16 bubNum = getKDTextBalloon(firstChar);
 				// Strip the digit prefix used for balloon dispatch.
 				// `_DisplayAlibi @ 1df2:0163` does `str = pbVar7 + 1`
 				// after using `*str` for `bindx`. Same pattern used by
@@ -4422,6 +4424,7 @@ void EEMEngine::doAccuse() {
 				// the intro balloon shows e.g. "1Ready to solve?".
 				if (firstChar >= '0' && firstChar <= '9')
 					hint.deleteChar(0);
+				bubNum = fitBalloonToText(bubNum, hint);
 				Picture balloon;
 				const bool haveBalloon =
 					_balloonArchive.size() > (bubNum & 0x7F) &&
@@ -4660,7 +4663,8 @@ void EEMEngine::doAccuse() {
 		}
 		if (bindx >= 16)
 			bindx = 2;
-		const uint16 bubNum = kAlibiBubbles[bindx];
+		uint16 bubNum = kAlibiBubbles[bindx];
+		bubNum = fitBalloonToText(bubNum, alibi);
 
 		Picture alibiBg;
 		const bool haveAlibiBg = _picsArchive.getPicture(0x3e, alibiBg);
@@ -4825,9 +4829,10 @@ void EEMEngine::doAccuse() {
 			}
 			if (!react.empty()) {
 				const byte rChar = (byte)react[0];
-				const uint16 rBub = getKDTextBalloon(rChar);
+				uint16 rBub = getKDTextBalloon(rChar);
 				if (rChar >= '0' && rChar <= '9')
 					react.deleteChar(0);
+				rBub = fitBalloonToText(rBub, react);
 				Picture rBalloon;
 				const bool haveR =
 					_balloonArchive.size() > (rBub & 0x7F) &&
@@ -5085,6 +5090,7 @@ void EEMEngine::doAccuseFloppy() {
 		}
 		Common::String text =
 			parseString(Common::String(txt), _playerName, _partner);
+		balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
 		Graphics::ManagedSurface ms(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		Graphics::Surface *cur = g_system->lockScreen();
@@ -5620,6 +5626,7 @@ void EEMEngine::doAccuseFloppy() {
 		balloonRaw = kFloppyAlibiBalloonByDigit[(int)(alibi[0] - '0')];
 		alibi.deleteChar(0);
 	}
+	balloonRaw = fitBalloonToText((uint16)balloonRaw, alibi);
 	const uint balloonIdx = balloonRaw & 0x7F;
 	const bool flipBalloon = (balloonRaw & 0x80) != 0;
 


Commit: 8ca74796331ffae0b9f49a844ed190491bb8313b
    https://github.com/scummvm/scummvm/commit/8ca74796331ffae0b9f49a844ed190491bb8313b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:06+02:00

Commit Message:
EEM: only some keys will advance dialog

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index b427294b306..68a66b1aa0d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -926,6 +926,9 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 
 		// Wait for click/key to advance — only if we drew something.
 		// ESC skips the entire dialogue rather than just one entry.
+		// Only Return / KP-Enter / Space advance one entry; other keys
+		// are ignored so accidental keystrokes don't blow past dialog
+		// the player hasn't finished reading.
 		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
 			bool advance = false;
 			bool skipAll = false;
@@ -950,8 +953,14 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						interruptAudio();
 						break;
 					}
-					if (ev.type == Common::EVENT_LBUTTONDOWN ||
-						ev.type == Common::EVENT_KEYDOWN) {
+					if (ev.type == Common::EVENT_LBUTTONDOWN) {
+						advance = true;
+						break;
+					}
+					if (ev.type == Common::EVENT_KEYDOWN &&
+						(ev.kbd.keycode == Common::KEYCODE_RETURN ||
+						 ev.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+						 ev.kbd.keycode == Common::KEYCODE_SPACE)) {
 						advance = true;
 						break;
 					}
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 6d4acc41d88..85a533fee88 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -864,6 +864,10 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 	// active audio so the theme / voice / spool don't bleed past
 	// the abort. Mirrors the `_CleanMysterySounds` + `_StopMIDI`
 	// pair around the title wait in `_DoOpeningAnims`.
+	//
+	// Only Return / KP-Enter / Space / Escape advance — letting any key
+	// dismiss balloons makes typing-while-reading (or a stuck modifier)
+	// blow past dialog the player hasn't finished reading.
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
 		Common::Event event;
@@ -877,8 +881,13 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_skipIntro = true;
 					interruptAudio();
+					return;
+				}
+				if (event.kbd.keycode == Common::KEYCODE_RETURN ||
+					event.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+					event.kbd.keycode == Common::KEYCODE_SPACE) {
+					return;
 				}
-				return;
 			}
 		}
 		g_system->updateScreen();


Commit: 794eeec90c42c5472ff7aed52707120024529c6a
    https://github.com/scummvm/scummvm/commit/794eeec90c42c5472ff7aed52707120024529c6a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:06+02:00

Commit Message:
EEM: strips leading spaces

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 68a66b1aa0d..27089a2696f 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -675,7 +675,25 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 			break;
 		}
 	}
-	return out;
+
+	// Strip leading spaces at the start of each emitted line. Mirrors
+	// `_DoWordWrap @ 1b66:04a7`, which advances past spaces at the
+	// start of every output line via `for (; str[last] == ' '; last++)`.
+	// ~60% of mystery-text strings carry 1-2 leading spaces in the data
+	// (verified across all CD M*.BIN files); the original WordWrap
+	// discards them, so we do the same before the text reaches
+	// `Font::wordWrapText` (which only trims at wrap-induced line
+	// boundaries, not at start-of-input or after an embedded '\n').
+	Common::String cleaned;
+	bool atLineStart = true;
+	for (uint i = 0; i < out.size(); i++) {
+		const char ch = out[i];
+		if (atLineStart && ch == ' ')
+			continue;
+		cleaned += ch;
+		atLineStart = (ch == '\n');
+	}
+	return cleaned;
 }
 
 void EEMEngine::applyClueSideEffects(const byte *c) {


Commit: 3a87e8fc489eaca75c90176ee46adc12c1dcdaab
    https://github.com/scummvm/scummvm/commit/3a87e8fc489eaca75c90176ee46adc12c1dcdaab
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:07+02:00

Commit Message:
EEM: refactory doGallery into several functions

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 32eb70388f5..8578d1a2d6c 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -221,6 +221,18 @@ public:
 	/// Show suspect gallery. Mirrors `_DrawGallery` @ 158f:0046.
 	void doGallery();
 
+	/// Suspect-detail view inside the gallery. Mirrors
+	/// `MoreInfo @ 158f:0419`: paints PIC 0x3f + suspect picture at
+	/// (0x94, 0x0f), paginates the suspect's found clues inside the
+	/// `_GalleryNoteRect`, and dispatches PDA bottom-bar buttons via
+	/// `_HandleMoreButton @ 158f:027d`. Sets `_nextScreen` and
+	/// returns true when the user picks a button that should exit
+	/// `doGallery` outright (NOTEBOOK / ACCUSE / MAP); returns false
+	/// for plain dismissal (ESC / GALLERY button) so the caller stays
+	/// on the portrait grid.
+	bool moreInfo(const byte *gd, uint suspectIdx,
+				   const Picture &galBg, bool haveBg);
+
 	/// Show big map; click chooses next site. Mirrors `_DoBigMap` @ 20fe:09e7.
 	void doBigMap();
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 1c3c0863398..696a5e389d8 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2787,235 +2787,9 @@ void EEMEngine::doGallery() {
 					if (slotSuspect[i] < 0)
 						continue;
 					if (slotRects[i].contains(ev.mouse.x, ev.mouse.y)) {
-						// `MoreInfo(i)` — show the suspect detail page.
-						// Mirrors `MoreInfo @ 158f:0419`:
-						//   _RefreshGalleryBackground();
-						//   _GetPicture(*(u16*)(gd + i*0x46));
-						//   _AddPicBackground(pic, 0x94, 0xf);
-						//   _DrawGalleryNotes(gd + i*0x46);
-						//   loop until ESC or button click.
-						// Suspect data layout differs by variant:
-						//   * CD (`158f:0419`): fixed 0x46-byte stride.
-						//     +0..1   picId
-						//     +8..9   clue count (u16)
-						//     +0xa..  array of u16 clue IDs (max 30,
-						//             terminated by 0xFFFF if short).
-						//   * Floppy (`_MoreInfo_Floppy = 154e:042b` →
-						//     `FUN_154e_0201` → `FUN_15e0_01e8`):
-						//     variable-stride.
-						//     +0..1   picId
-						//     +2..3   alibi (0xFFFF = guilty)
-						//     +4      clue count (u8)
-						//     +5..    u8 clue IDs (per the asm at
-						//             154e:020e..0282 which calls
-						//             `FUN_15e0_01e8(rect, entry+5,
-						//                            entry[4], NULL)`).
-						const bool floppyMI = isFloppy();
-						const uint suspectIdx = (uint)slotSuspect[i];
-						const byte *suspect = floppyMI
-												  ? _mystery.floppySuspectEntry(suspectIdx)
-												  : gd + suspectIdx * 0x46;
-						if (!suspect)
-							break;
-						const uint16 detailPic =
-							READ_LE_UINT16(suspect + 0);
-						const uint clueCount = floppyMI
-												   ? (uint)suspect[4]
-												   : READ_LE_UINT16(suspect + 8);
-
-						Graphics::ManagedSurface ms(320, 200,
-													Graphics::PixelFormat::createFormatCLUT8());
-						ms.clear();
-						if (haveBg) {
-							const int bw = MIN<int>(galBg.surface.w, 320);
-							const int bh = MIN<int>(galBg.surface.h, 200);
-							for (int row = 0; row < bh; row++) {
-								memcpy((byte *)ms.getBasePtr(0, row),
-									   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-							}
-						}
-						// Partner sprite at (5, 0x50). The original
-						// `MoreInfo @ 158f:0419` calls
-						// `_RefreshGalleryBackground` (clears the
-						// portrait grid) but the partner anim slot
-						// registered by `_DoGallery` keeps painting
-						// at every `_UpdateAnimations` tick — the
-						// suspect detail pic covers the right side
-						// only (drawn at 0x94, 0xf), so the partner
-						// stays visible on the left. Without this
-						// blit the suspect-detail screen has no
-						// partner.
-						setInteractiveMouseCursor(false);
-						{
-							const uint partnerAnim =
-								(_partner == 0) ? 2 : 0x10;
-							Animation partnerAni;
-							if (_aniArchive.loadAnimation(partnerAnim,
-														  partnerAni) &&
-								!partnerAni.empty()) {
-								const uint32 now = g_system->getMillis();
-								const uint frameIdx =
-									partnerFrameAtTick(0x02,
-													   (uint)partnerAni.size(), now);
-								blitAnimFrameAnchored(ms.surfacePtr(),
-													  partnerAni[frameIdx], 5, 0x50);
-							}
-						}
-						// Full suspect picture at (0x94, 0xf).
-						Picture detail;
-						if (_picsArchive.getPicture(detailPic, detail)) {
-							const byte transp =
-								(byte)(detail.flags >> 8);
-							const int dx = 0x94, dy = 0x0f;
-							const int dw = MIN<int>(detail.surface.w, 320 - dx);
-							const int dh = MIN<int>(detail.surface.h, 200 - dy);
-							for (int row = 0; row < dh; row++) {
-								const byte *src =
-									(const byte *)detail.surface.getBasePtr(0, row);
-								byte *dst =
-									(byte *)ms.getBasePtr(0, dy + row);
-								for (int col = 0; col < dw; col++) {
-									if (src[col] != transp)
-										dst[dx + col] = src[col];
-								}
-							}
-						}
-						// Suspect's clue notes inside _GalleryNoteRect
-						// = (78, 93, 288, 152), per 29be:0100. Cyan text
-						// renders directly on the PDA's natural blue
-						// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
-						const int rx = 78, ry = 93;
-						const int rw = 288 - 78, rh = 152 - 93;
-
-						const byte *ni = _mystery.noteIndex();
-						const uint16 niCount = _mystery.noteIndexCount();
-						int yPos = ry;
-						const int lineH = _font.getFontHeight();
-						bool drewAny = false;
-						const uint clueMax = floppyMI ? clueCount : 30u;
-						for (uint k = 0; k < clueCount && k < clueMax; k++) {
-							const uint16 clueId = floppyMI
-								? (uint16)suspect[5 + k]
-								: READ_LE_UINT16(suspect + 0xa + k * 2);
-							if (!floppyMI && clueId == 0xFFFF)
-								break;
-							if (clueId >= Mystery::kCluesFoundCap ||
-								!_mystery._cluesFound[clueId])
-								continue;
-							if (!ni || clueId >= niCount)
-								continue;
-							// Floppy notes are 7-byte entries:
-							//   +0..1 clue text (absolute offset)
-							//   +2..3 Jake spoken line
-							//   +4..5 Jenny spoken line
-							//   +6    score
-							// `_DrawNotes_Floppy / FUN_15e0_01e8`'s
-							// `*(int *)(notes + idx * 7)` shows the
-							// notebook always uses +0 — the partner-
-							// agnostic clue statement. The +2/+4
-							// offsets are the partner spoken lines used
-							// by `FUN_22dc_05c8` when rendering dialog
-							// records (NOT this notebook view). CD
-							// notes are 4 bytes: u16 textOff, u16
-							// score.
-							Common::String txt;
-							if (floppyMI) {
-								const uint16 textOff =
-									READ_LE_UINT16(ni + clueId * 7);
-								const byte *bb = _mystery.blobAt(0);
-								const uint32 dsz =
-									_mystery.dataSize();
-								if (bb && textOff != 0 &&
-									textOff < dsz) {
-									const char *p =
-										(const char *)(bb + textOff);
-									uint32 len = 0;
-									while (textOff + len < dsz &&
-										   p[len] != 0)
-										len++;
-									txt = parseString(
-										Common::String(p, len),
-										_playerName, _partner);
-								}
-							} else {
-								const uint16 textOff =
-									READ_LE_UINT16(ni + clueId * 4);
-								txt = parseString(
-									_mystery.textAt(textOff),
-									_playerName, _partner);
-							}
-							if (txt.empty())
-								continue;
-							const byte color =
-								_mystery._noteSelected[clueId] ? 0x3C : 0x5C;
-							const int hLine = _font.drawWordWrapped(
-								&ms, rx, yPos, rw, txt, color);
-							yPos += hLine + 7;
-							drewAny = true;
-							if (yPos + lineH > ry + rh)
-								break;
-						}
-						if (!drewAny && _font.isLoaded()) {
-							_font.drawString(&ms,
-								isSpanish()
-									? "Aun no hay pistas para este sospechoso."
-									: "No clues yet for this suspect.",
-								rx, ry, rw, 0x5C);
-						}
-						// Header / footer text.
-						if (_font.isLoaded()) {
-							_font.drawString(&ms,
-								isSpanish() ? "EXPEDIENTE" : "SUSPECT FILE",
-								rx, ry - 11, rw, 0x3C);
-							_font.drawString(&ms,
-								isSpanish() ? "(clic / ESC: volver)"
-											: "(click / ESC: back)",
-								rx, ry + rh + 2, rw, 0x3C);
-						}
-						g_system->copyRectToScreen(ms.getPixels(),
-							ms.pitch, 0, 0, 320, 200);
-						g_system->updateScreen();
-
-						// Wait for click or ESC. Drain the queued
-						// LBUTTONDOWN that triggered this MoreInfo first
-						// so we don't immediately accept it as the
-						// dismiss event.
-						g_system->delayMillis(150);
-						{
-							Common::Event drain;
-							while (g_system->getEventManager()->pollEvent(drain)) {
-								if (drain.type == Common::EVENT_QUIT ||
-									drain.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-									setInteractiveMouseCursor(false);
-									return;
-								}
-							}
-						}
-						bool back = false;
-						while (!back && !shouldQuit()) {
-							Common::Event e2;
-							while (g_system->getEventManager()->pollEvent(e2)) {
-								if (e2.type == Common::EVENT_LBUTTONDOWN ||
-									(e2.type == Common::EVENT_KEYDOWN &&
-									 (e2.kbd.keycode == Common::KEYCODE_ESCAPE ||
-									  e2.kbd.keycode == Common::KEYCODE_RETURN))) {
-									back = true;
-									break;
-								}
-								if (e2.type == Common::EVENT_QUIT ||
-									e2.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-									setInteractiveMouseCursor(false);
-									return;
-								}
-							}
-							// Per-tick `updateScreen()` so the SDL cursor
-							// follows the mouse — without it the cursor
-							// freezes on entry to the MoreInfo screen
-							// (we never repaint here, so the cursor never
-							// gets drawn at its current position).
-							g_system->updateScreen();
-							g_system->delayMillis(20);
-						}
+						if (moreInfo(gd, (uint)slotSuspect[i],
+									 galBg, haveBg))
+							exitFlag = true;
 						// Force gallery redraw immediately so the
 						// player isn't left looking at the dismissed
 						// MoreInfo screen until the next 100 ms tick.
@@ -3058,6 +2832,358 @@ void EEMEngine::doGallery() {
 	setInteractiveMouseCursor(false);
 }
 
+bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
+						  const Picture &galBg, bool haveBg) {
+	// Suspect-detail page. Mirrors `MoreInfo @ 158f:0419`:
+	//   _RefreshGalleryBackground();
+	//   _GetPicture(*(u16*)(gd + i*0x46));
+	//   _AddPicBackground(pic, 0x94, 0xf);
+	//   _DrawGalleryNotes(gd + i*0x46);
+	//   loop until ESC or button click.
+	// Suspect data layout differs by variant:
+	//   * CD (`158f:0419`): fixed 0x46-byte stride.
+	//     +0..1   picId
+	//     +8..9   clue count (u16)
+	//     +0xa..  array of u16 clue IDs (max 30,
+	//             terminated by 0xFFFF if short).
+	//   * Floppy (`_MoreInfo_Floppy = 154e:042b` →
+	//     `FUN_154e_0201` → `FUN_15e0_01e8`):
+	//     variable-stride.
+	//     +0..1   picId
+	//     +2..3   alibi (0xFFFF = guilty)
+	//     +4      clue count (u8)
+	//     +5..    u8 clue IDs (per the asm at
+	//             154e:020e..0282 which calls
+	//             `FUN_15e0_01e8(rect, entry+5,
+	//                            entry[4], NULL)`).
+	const bool floppyMI = isFloppy();
+	const byte *suspect = floppyMI
+							  ? _mystery.floppySuspectEntry(suspectIdx)
+							  : gd + suspectIdx * 0x46;
+	if (!suspect)
+		return false;
+	const uint16 detailPic = READ_LE_UINT16(suspect + 0);
+	const uint clueCount = floppyMI
+							   ? (uint)suspect[4]
+							   : READ_LE_UINT16(suspect + 8);
+
+	setInteractiveMouseCursor(false);
+
+	// Suspect's clue notes inside _GalleryNoteRect
+	// = (78, 93, 288, 152), per 29be:0100. Cyan text
+	// renders directly on the PDA's natural blue
+	// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
+	const int rx = 78, ry = 93;
+	const int rw = 288 - 78, rh = 152 - 93;
+	const int lineH = _font.getFontHeight();
+	const uint clueMax = floppyMI ? clueCount : 30u;
+	const byte *ni = _mystery.noteIndex();
+	const uint16 niCount = _mystery.noteIndexCount();
+
+	// Pagination matches `_DrawNotes @ 161e:01d0` / `MoreInfo @
+	// 158f:0419`: the original tracks `_NextClue` across redraws and
+	// only emits clues whose wrapped height fits the rect, deferring
+	// the rest to the next page. `_PageBreaks[]` stores the
+	// entry-point for each page so PREV (button 6 / case 6 at
+	// 158f:03b8 — `SUB _CurrentPage, 2; _NextClue = _PageBreaks[...]`)
+	// can step back. We mirror that with an explicit stack of
+	// page-start indices: forward push, back pop.
+	uint pageStart = 0;
+	Common::Array<uint> pageStack;
+	bool back = false;
+	bool exitGallery = false;
+	bool isFirstShow = true;
+
+	while (!back && !shouldQuit()) {
+		Graphics::ManagedSurface ms(320, 200,
+			Graphics::PixelFormat::createFormatCLUT8());
+		ms.clear();
+		if (haveBg) {
+			const int bw = MIN<int>(galBg.surface.w, 320);
+			const int bh = MIN<int>(galBg.surface.h, 200);
+			for (int row = 0; row < bh; row++) {
+				memcpy((byte *)ms.getBasePtr(0, row),
+					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
+			}
+		}
+		// Partner sprite at (5, 0x50). The original `MoreInfo @
+		// 158f:0419` calls `_RefreshGalleryBackground` (clears the
+		// portrait grid) but the partner anim slot registered by
+		// `_DoGallery` keeps painting at every `_UpdateAnimations`
+		// tick — the suspect detail pic covers the right side only
+		// (drawn at 0x94, 0xf), so the partner stays visible on the
+		// left. Re-blit per page so the frame stays current.
+		{
+			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+			Animation partnerAni;
+			if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+				!partnerAni.empty()) {
+				const uint32 now = g_system->getMillis();
+				const uint frameIdx = partnerFrameAtTick(0x02,
+					(uint)partnerAni.size(), now);
+				blitAnimFrameAnchored(ms.surfacePtr(),
+									  partnerAni[frameIdx], 5, 0x50);
+			}
+		}
+		// Full suspect picture at (0x94, 0xf).
+		Picture detail;
+		if (_picsArchive.getPicture(detailPic, detail)) {
+			const byte transp = (byte)(detail.flags >> 8);
+			const int dx = 0x94, dy = 0x0f;
+			const int dw = MIN<int>(detail.surface.w, 320 - dx);
+			const int dh = MIN<int>(detail.surface.h, 200 - dy);
+			for (int row = 0; row < dh; row++) {
+				const byte *src = (const byte *)
+					detail.surface.getBasePtr(0, row);
+				byte *dst = (byte *)ms.getBasePtr(0, dy + row);
+				for (int col = 0; col < dw; col++) {
+					if (src[col] != transp)
+						dst[dx + col] = src[col];
+				}
+			}
+		}
+
+		// Walk the clue list from `pageStart`. Skip clues that aren't
+		// found / lack a note entry, then measure the wrapped height
+		// before drawing. If a clue would overflow and we've already
+		// drawn at least one, defer it to the next page; if it's the
+		// FIRST clue on this page (one clue too tall to ever fit),
+		// draw it anyway so progress is guaranteed.
+		int yPos = ry;
+		bool drewAny = false;
+		uint k = pageStart;
+		bool reachedEnd = false;
+		for (; k < clueCount && k < clueMax; k++) {
+			const uint16 clueId = floppyMI
+				? (uint16)suspect[5 + k]
+				: READ_LE_UINT16(suspect + 0xa + k * 2);
+			if (!floppyMI && clueId == 0xFFFF) {
+				reachedEnd = true;
+				break;
+			}
+			if (clueId >= Mystery::kCluesFoundCap ||
+				!_mystery._cluesFound[clueId])
+				continue;
+			if (!ni || clueId >= niCount)
+				continue;
+			// Floppy notes: 7-byte entries (+0..1 clue text, +2..3 Jake
+			// spoken, +4..5 Jenny spoken, +6 score). Notebook always
+			// uses +0 (partner-agnostic statement) per `FUN_15e0_01e8`.
+			// CD notes are 4-byte: u16 textOff + u16 score.
+			Common::String txt;
+			if (floppyMI) {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
+				const byte *bb = _mystery.blobAt(0);
+				const uint32 dsz = _mystery.dataSize();
+				if (bb && textOff != 0 && textOff < dsz) {
+					const char *p = (const char *)(bb + textOff);
+					uint32 len = 0;
+					while (textOff + len < dsz && p[len] != 0)
+						len++;
+					txt = parseString(Common::String(p, len),
+									  _playerName, _partner);
+				}
+			} else {
+				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+				txt = parseString(_mystery.textAt(textOff),
+								  _playerName, _partner);
+			}
+			if (txt.empty())
+				continue;
+
+			Common::Array<Common::String> wrapped;
+			_font.wordWrapText(txt, MAX<int>(8, rw), wrapped);
+			const int hClue = (int)wrapped.size() * lineH;
+			if (yPos + hClue > ry + rh && drewAny) {
+				// Defer to next page.
+				break;
+			}
+			const byte color = _mystery._noteSelected[clueId]
+								   ? 0x3C : 0x5C;
+			for (uint l = 0; l < wrapped.size(); l++) {
+				_font.drawString(&ms, wrapped[l], rx,
+					yPos + (int)l * lineH, MAX<int>(8, rw), color);
+			}
+			yPos += hClue + 7;
+			drewAny = true;
+		}
+		if (k >= clueCount || k >= clueMax)
+			reachedEnd = true;
+		const uint pageEnd = k;
+		const bool hasMore = !reachedEnd;
+		const bool hasPrev = !pageStack.empty();
+
+		if (pageStart == 0 && !drewAny && _font.isLoaded()) {
+			_font.drawString(&ms,
+				isSpanish()
+					? "Aun no hay pistas para este sospechoso."
+					: "No clues yet for this suspect.",
+				rx, ry, MAX<int>(8, rw), 0x5C);
+		}
+		// Header / footer text. The PDA's NEXT (case 5) and PREV
+		// (case 6) icons are visible on the bottom bar — the footer
+		// just covers dismissal here.
+		if (_font.isLoaded()) {
+			_font.drawString(&ms,
+				isSpanish() ? "EXPEDIENTE" : "SUSPECT FILE",
+				rx, ry - 11, MAX<int>(8, rw), 0x3C);
+			_font.drawString(&ms,
+				isSpanish() ? "(ESC: volver)" : "(ESC: back)",
+				rx, ry + rh + 2, MAX<int>(8, rw), 0x3C);
+		}
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
+			0, 0, 320, 200);
+		g_system->updateScreen();
+
+		// Drain the queued LBUTTONDOWN that brought us into MoreInfo
+		// so it doesn't get caught by the input loop below. Subsequent
+		// pages don't need this.
+		if (isFirstShow) {
+			isFirstShow = false;
+			g_system->delayMillis(150);
+			Common::Event drain;
+			while (g_system->getEventManager()->pollEvent(drain)) {
+				if (drain.type == Common::EVENT_QUIT ||
+					drain.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					_nextScreen = kScreenInvalid;
+					return true;
+				}
+			}
+		}
+
+		// Wait for input. Each PDA button maps to a distinct action,
+		// mirroring `_HandleMoreButton @ 158f:027d`:
+		//   case 0 NOTEBOOK    -> NextScreen=4
+		//   case 1 HELP        -> _InterfaceHelp(0)
+		//   case 2 GALLERY     -> close MoreInfo
+		//   case 3 PARTNER head-> _KDHelp + redraw
+		//   case 4 ACCUSE      -> NextScreen=7
+		//   case 5 PAGE NEXT   -> next page
+		//   case 6 PAGE PREV   -> prev page
+		//   case 7 MAP         -> NextScreen=2
+		//   case 8 SITE        -> no-op
+		//   case 10 HELP (alt) -> _InterfaceHelp(0)
+		// Clicks outside any known button rect are no-ops, matching
+		// the original (which short-circuits on `_FindButton == -1`).
+		// ESC closes; PgDn / PgUp / Return / Backspace also paginate
+		// as a modern accessibility convenience.
+		bool advance = false;
+		bool prev = false;
+		bool redraw = false;
+		while (!back && !advance && !prev && !redraw && !shouldQuit()) {
+			Common::Event e2;
+			while (g_system->getEventManager()->pollEvent(e2)) {
+				if (e2.type == Common::EVENT_QUIT ||
+					e2.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					_nextScreen = kScreenInvalid;
+					return true;
+				}
+				if (e2.type == Common::EVENT_KEYDOWN &&
+					e2.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					back = true;
+					break;
+				}
+				if (e2.type == Common::EVENT_LBUTTONDOWN) {
+					const int mx = e2.mouse.x;
+					const int my = e2.mouse.y;
+					debugC(2, kDebugGfx,
+						"MoreInfo click (%d,%d) hasMore=%d hasPrev=%d",
+						mx, my, (int)hasMore, (int)hasPrev);
+					if (kPdaNotebookRect.contains(mx, my)) {
+						_nextScreen = kScreenNotebook;
+						exitGallery = true;
+						back = true;
+						break;
+					}
+					if (kPdaAccuseRect.contains(mx, my)) {
+						_nextScreen = kScreenAccuse;
+						exitGallery = true;
+						back = true;
+						break;
+					}
+					if (kPdaPartnerFootMapRect.contains(mx, my)) {
+						_nextScreen = kScreenMapAlt;
+						exitGallery = true;
+						back = true;
+						break;
+					}
+					if (kPdaHelpRect.contains(mx, my) ||
+						kPdaHelp2Rect.contains(mx, my)) {
+						// Help overlay; re-render the current page
+						// after it dismisses.
+						setInteractiveMouseCursor(false);
+						doInterfaceHelp(0);
+						redraw = true;
+						break;
+					}
+					if (kPdaGalleryRect.contains(mx, my)) {
+						// Case 2: close MoreInfo and return to the
+						// portrait grid.
+						back = true;
+						break;
+					}
+					if (kPdaPageNextRect.contains(mx, my)) {
+						if (hasMore)
+							advance = true;
+						break;
+					}
+					if (kPdaPagePrevRect.contains(mx, my)) {
+						if (hasPrev)
+							prev = true;
+						break;
+					}
+					if (kPdaPartnerHeadHintRect.contains(mx, my)) {
+						// Case 3: ask KD for a hint then redraw the
+						// current page over the clean BG.
+						setInteractiveMouseCursor(false);
+						doHelp();
+						redraw = true;
+						break;
+					}
+					// Case 8 SITE and click outside any known button
+					// = no-op. The original `_FindButton`
+					// short-circuits when no rect matches.
+					break;
+				}
+				if (e2.type == Common::EVENT_KEYDOWN && hasMore &&
+					(e2.kbd.keycode == Common::KEYCODE_RETURN ||
+					 e2.kbd.keycode == Common::KEYCODE_KP_ENTER ||
+					 e2.kbd.keycode == Common::KEYCODE_SPACE ||
+					 e2.kbd.keycode == Common::KEYCODE_PAGEDOWN ||
+					 e2.kbd.keycode == Common::KEYCODE_RIGHT)) {
+					advance = true;
+					break;
+				}
+				if (e2.type == Common::EVENT_KEYDOWN && hasPrev &&
+					(e2.kbd.keycode == Common::KEYCODE_BACKSPACE ||
+					 e2.kbd.keycode == Common::KEYCODE_LEFT ||
+					 e2.kbd.keycode == Common::KEYCODE_PAGEUP)) {
+					prev = true;
+					break;
+				}
+			}
+			// Per-tick `updateScreen()` so the SDL cursor follows the
+			// mouse — without it the cursor freezes on entry to the
+			// MoreInfo screen.
+			g_system->updateScreen();
+			g_system->delayMillis(20);
+		}
+
+		if (advance) {
+			pageStack.push_back(pageStart);
+			pageStart = pageEnd;
+		} else if (prev && !pageStack.empty()) {
+			pageStart = pageStack.back();
+			pageStack.pop_back();
+		}
+		// `redraw` falls through with the same `pageStart` and
+		// re-iterates the outer while, repainting on top of whatever
+		// the help overlay left behind.
+	}
+
+	return exitGallery;
+}
+
 void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 								  Common::Array<Common::Rect> &slotRects,
 								  Common::Array<int> &slotSuspect) {


Commit: b25891a6870885f6b151bcfade71f373976f243d
    https://github.com/scummvm/scummvm/commit/b25891a6870885f6b151bcfade71f373976f243d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:07+02:00

Commit Message:
EEM: openMainMenuDialog when using ESC in most of the screens

Changed paths:
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index f48d44dc276..8b4c6c3fb25 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -817,20 +817,15 @@ void SiteScreen::run() {
 
 			case Common::EVENT_KEYDOWN:
 				notePartnerActivity();
-				// `_DoSiteLoop @ 168d:07e1` only dispatches on the
-				// 6-entry table at `168d:09d5` (TAB / ENTER / arrow
-				// keys for hotspot cursor cycling) plus ESC handled
-				// separately at 168d:07a9. We don't implement the
-				// hotspot cursor cycling, so the only keyboard binding kept
-				// here is ESC (matches `_ESCHit` -> "Are you sure?" -> MAP).
+				// `_DoSiteLoop @ 168d:07e1` originally routed ESC
+				// through `_ESCHit` -> "Are you sure?" -> MAP. The
+				// site has visible MAP / SETUP / etc. controls along
+				// the top bar, so ESC now opens the ScummVM in-game
+				// menu (save / load / quit) instead of the back-nav
+				// shortcut.
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_vm->setHotspotMouseCursor(false);
-					if (_vm->areYouSure()) {
-						_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
-														   : kScreenMap);
-						_vm->stopMusic();
-						return;
-					}
+					_vm->openMainMenuDialog();
 					enter(cur, false);
 					mouse = g_system->getEventManager()->getMousePos();
 					updateHotspotCursor(cur, mouse.x, mouse.y);
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 696a5e389d8..fc49dbbf559 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1934,9 +1934,8 @@ void EEMEngine::doActionScreen() {
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
 			if (k == Common::KEYCODE_ESCAPE) {
-				exitChosen = true;
-				confirmed = true;
-				break;
+				openMainMenuDialog();
+				continue;
 			}
 			if (k == Common::KEYCODE_RETURN ||
 				k == Common::KEYCODE_KP_ENTER) {
@@ -2173,6 +2172,13 @@ void EEMEngine::doCaseSelection() {
 					const uint idx = topRow + (uint)row;
 					if (idx >= listLen || solvedFlags[idx])
 						continue;
+					// Second click on the already-selected row counts as
+					// the OK button — saves the player a trip down to the
+					// bottom-bar after picking a mystery.
+					if (idx == selRow) {
+						confirmed = true;
+						break;
+					}
 					selRow = idx;
 					dirty = true;
 					continue;
@@ -2183,8 +2189,8 @@ void EEMEngine::doCaseSelection() {
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
 			if (k == Common::KEYCODE_ESCAPE) {
-				_mystery.clear();
-				return;
+				openMainMenuDialog();
+				continue;
 			}
 			if (k == Common::KEYCODE_RETURN ||
 				k == Common::KEYCODE_KP_ENTER) {
@@ -2352,9 +2358,8 @@ void EEMEngine::doNotebook() {
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					_nextScreen = kScreenSite;
-					exitFlag = true;
-					break;
+					openMainMenuDialog();
+					continue;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
 					ev.kbd.keycode == Common::KEYCODE_PAGEUP) {
@@ -2724,9 +2729,8 @@ void EEMEngine::doGallery() {
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					_nextScreen = kScreenSite;
-					exitFlag = true;
-					break;
+					openMainMenuDialog();
+					continue;
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -3080,7 +3084,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 				}
 				if (e2.type == Common::EVENT_KEYDOWN &&
 					e2.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					back = true;
+					openMainMenuDialog();
+					redraw = true;
 					break;
 				}
 				if (e2.type == Common::EVENT_LBUTTONDOWN) {
@@ -3386,8 +3391,10 @@ void EEMEngine::doBigMap() {
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 					return;
 				if (ev.type == Common::EVENT_KEYDOWN &&
-					ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return;
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					openMainMenuDialog();
+					continue;
+				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN) {
 					// SetupButtonRect → `_NextScreen = 6` (the original's
 					// settings screen, mirrors `_DoBigMap @ 20fe:0c33`
@@ -3503,8 +3510,9 @@ void EEMEngine::doBigMap() {
 				}
 				if (ev.type == Common::EVENT_KEYDOWN) {
 					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						setInteractiveMouseCursor(false);
-						return;  // exit detail back to caller (site loop / engine)
+						openMainMenuDialog();
+						dirty = true;
+						continue;
 					}
 					const int kStep = 16;
 					if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
@@ -4144,8 +4152,9 @@ bool EEMEngine::doAccuseNotes() {
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					_nextScreen = kScreenSite;
-					return false;
+					openMainMenuDialog();
+					dirty = true;
+					continue;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT &&
 					page > 0) {
@@ -4666,7 +4675,9 @@ void EEMEngine::doAccuse() {
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				switch (ev.kbd.keycode) {
 				case Common::KEYCODE_ESCAPE:
-					return;
+					openMainMenuDialog();
+					dirty = true;
+					continue;
 				case Common::KEYCODE_TAB:
 				case Common::KEYCODE_RIGHT:
 					highlighted = nextLiveSlot(slotRects, highlighted, +1);
@@ -5446,8 +5457,11 @@ void EEMEngine::doAccuseFloppy() {
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
 			if (ev.type == Common::EVENT_KEYDOWN) {
-				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE)
-					return;
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					openMainMenuDialog();
+					drawGallery(highlighted, slotRects, slotSuspect);
+					continue;
+				}
 				if (ev.kbd.keycode == Common::KEYCODE_TAB ||
 					ev.kbd.keycode == Common::KEYCODE_RIGHT) {
 					highlighted = (highlighted + 1) % MAX<int>(1, (int)num);


Commit: a6c48ae179ca0ad35dd46f346abe8a9ddd19aced
    https://github.com/scummvm/scummvm/commit/a6c48ae179ca0ad35dd46f346abe8a9ddd19aced
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:07+02:00

Commit Message:
EEM: improved animation precision with correct framerate and full script table

Changed paths:
    engines/eem/site.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 8b4c6c3fb25..a6a7647c956 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -251,6 +251,41 @@ const AnimScript kAnimScripts[] = {
 	// 0x16 (29be:185e, alias of 0x00) — Jenny CaseSelection greeter,
 	// same blink script as 0x15.
 	{ 0x16, 10, { 0,0,0,0,0,0,0,0,0,2 } },
+	// Site / drop scripts ≤28 frames — see `_AnimationSequences @
+	// 29be:22d4`. Many of these are short count-ups used by ambient
+	// animations (people walking, vehicles passing) that the original
+	// drives one entry per `_CheckFrameRate` tick (~140 ms). Without
+	// these, our generic fallback cycles through every animation cell
+	// at one entry per tick and the ambient anims look 2-3× too fast.
+	// 0x1b (29be:192e, alias of 0x07) — walk-cycle 0..9.
+	{ 0x1b, 10, { 0,1,2,3,4,5,6,7,8,9 } },
+	// 0x1c (29be:21a8) — short 6-frame count-up.
+	{ 0x1c,  6, { 0,1,2,3,4,5 } },
+	// 0x1d (29be:21a8, alias of 0x1c).
+	{ 0x1d,  6, { 0,1,2,3,4,5 } },
+	// 0x21 (29be:1b86) — paired-step idle bob.
+	{ 0x21, 14, { 0,0,0,1,1,2,2,3,3,3,2,2,1,1 } },
+	// 0x25 (29be:218a) — 3-frame trigger.
+	{ 0x25,  3, { 0,1,2 } },
+	// 0x26 (29be:1d3e) — count-up 0..17 (18 frames).
+	{ 0x26, 18, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17 } },
+	// 0x27 (29be:1d64) — count-up 0..11 (12 frames).
+	{ 0x27, 12, { 0,1,2,3,4,5,6,7,8,9,10,11 } },
+	// 0x2a (29be:1e50) — 5-step micro-anim.
+	{ 0x2a,  5, { 0,1,2,2,3 } },
+	// 0x2e (29be:21ce) — count-up 0..12 (13 frames).
+	{ 0x2e, 13, { 0,1,2,3,4,5,6,7,8,9,10,11,12 } },
+	// 0x2f (29be:21ea) — count-up 0..22 (23 frames).
+	{ 0x2f, 23, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+				  20,21,22 } },
+	// 0x32 (29be:219c) — count-up 0..4 (5 frames).
+	{ 0x32,  5, { 0,1,2,3,4 } },
+	// 0x33 (29be:2192) — count-up 0..3 (4 frames).
+	{ 0x33,  4, { 0,1,2,3 } },
+	// 0x34 (29be:219c, alias of 0x32).
+	{ 0x34,  5, { 0,1,2,3,4 } },
+	// 0x35 (29be:21b6) — count-up 0..10 (11 frames).
+	{ 0x35, 11, { 0,1,2,3,4,5,6,7,8,9,10 } },
 	// Briefing animations — `_DoInitClues @ 1a35:0411` calls
 	// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), 1, ...)`
 	// for the game animation (always anim ID 0x17 — even Jenny's
@@ -265,29 +300,228 @@ const AnimScript kAnimScripts[] = {
 };
 static_assert(true, "see kAnimScriptsLong below for >28-frame scripts");
 
-// Scripts longer than 28 frames live here so the main `kAnimScripts`
-// table can keep its tight `frames[28]` storage (the lookup in
-// `findAnimScript` checks both arrays). Used for the briefing
-// animations whose original scripts run 30 frames each.
+// Scripts longer than 28 frames live here. The lookup in
+// `findAnimScript` checks both arrays. Stored as
+// `(seqnum, len, ptr)` so each script can be any length without
+// bloating every entry — the longest (0x22) runs 115 frames.
 struct AnimScriptLong {
 	uint16 seqnum;
-	uint8 len;
-	uint8 frames[36];
+	uint16 len;
+	const uint8 *frames;
+};
+
+// Briefing animations — `_DoInitClues @ 1a35:0411` calls
+// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), 1, ...)` for the
+// game animation (always anim ID 0x17 — even Jenny's briefing reuses
+// Jake's SCRIPT, even though the loaded ANI.DBD cells come from her
+// partner-specific entry 0x3b). Same pattern for book (0x18) / nancy
+// (0x19).
+static const uint8 kScript17[] = {
+	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+	20,21,22,23,24,25,26,27,28,29
+};
+static const uint8 kScript18[] = {
+	0,1,2,3,4,5,6,7,8,8,8,8,8,8,8,8,
+	8,8,8,8,8,8,8,9,10,11,12,13,14,15
+};
+static const uint8 kScript19[] = {
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+	1,2,3,4,5,6,7,8,9,10,11,12
+};
+
+// Site / NPC drop scripts (29be:22d4 entries 0x1a..0x36 minus the
+// short ones that fit in `kAnimScripts`). Many entries deliberately
+// repeat the same frame several times — that's the original's
+// "frame-hold" mechanism (the per-tick walk advances exactly one
+// entry, so K repeats hold the frame for K * `kFramePeriodMs` ≈
+// K * 140 ms). Without these scripts our generic fallback cycles
+// through every animation cell at one entry per tick, which is the
+// "site animations run too fast" symptom.
+
+// 0x1a (29be:19a4) — count-up 0..7, long idle hold, repeat 1..7,
+// idle, mirror 7..0, idle (77 entries).
+static const uint8 kScript1a[] = {
+	0,1,2,3,4,5,6,7,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+	1,2,3,4,5,6,7,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+	7,6,5,4,3,2,1,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x1e (29be:1a40) — slow walk-stutter with idle tail (76 entries).
+static const uint8 kScript1e[] = {
+	0,1,2,3,3,3,3,4,4,3,4,4,4,4,4,3,
+	5,5,5,5,5,5,5,5,4,4,4,4,4,4,4,4,
+	5,5,5,5,5,5,6,5,6,5,7,7,7,7,7,7,
+	7,8,7,7,7,7,7,8,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x1f (29be:1ada) — 0..5, idle, 0..5, idle, 6..8 alternation,
+// idle (50 entries).
+static const uint8 kScript1f[] = {
+	0,1,2,3,4,5,
+	0,0,0,0,
+	1,2,3,4,5,
+	0,0,0,0,0,
+	6,7,8,8,8,7,6,7,8,8,8,7,
+	6,7,8,8,8,7,6,7,8,8,8,7,
+	6,
+	0,0,0,0,0
+};
+// 0x20 (29be:1b40) — count-up 0..33 (34 frames).
+static const uint8 kScript20[] = {
+	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+	20,21,22,23,24,25,26,27,28,29,30,31,32,33
+};
+// 0x22 (29be:1ba4) — long held-frame walker 0..22 with idle tail
+// (115 entries; most frames held 4-7 ticks each).
+static const uint8 kScript22[] = {
+	0,
+	1,1,1,1,1,
+	2,2,2,2,2,
+	3,3,3,3,3,
+	4,4,4,4,
+	5,5,5,5,
+	6,6,6,6,
+	7,7,7,7,
+	8,8,8,8,
+	9,9,9,9,
+	10,10,10,10,10,
+	11,11,11,11,11,
+	12,12,12,12,12,12,
+	13,13,13,13,13,
+	14,14,14,14,
+	15,15,15,15,
+	16,16,16,16,16,16,16,
+	17,17,17,17,
+	18,18,18,18,
+	19,19,19,19,
+	20,20,20,20,
+	21,21,21,21,
+	22,22,22,22,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x23 (29be:1c8c) — 29 entries: 0, 6 holds of 1, count-up 2..4,
+// down-up gesture, 5 idle frames.
+static const uint8 kScript23[] = {
+	0,1,1,1,1,1,1,
+	2,3,4,3,2,
+	5,5,5,
+	2,3,4,3,3,3,3,3,
+	0,0,0,0,0,0
 };
+// 0x24 (29be:1cc8) — bell-curve hold (58 entries): 0,0, 1,1, 2,2,
+// 3 held for 26 ticks, mirror back, idle.
+static const uint8 kScript24[] = {
+	0,0,1,1,2,2,
+	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
+	2,2,1,1,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x28 (29be:1d7e) — gentle hold 0..3 with long hold on 3, mirror
+// back, idle (45 entries).
+static const uint8 kScript28[] = {
+	0,1,1,2,2,
+	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
+	2,2,1,1,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x29 (29be:1dda) — paired-step count-up 0..21 plus idle
+// (58 entries).
+static const uint8 kScript29[] = {
+	0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,
+	11,11,12,12,13,13,14,14,15,15,16,16,17,17,18,18,19,19,
+	20,20,21,21,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x2b (29be:1e5c) — count-up 0..11 with each frame held 4 ticks
+// (48 entries).
+static const uint8 kScript2b[] = {
+	0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,
+	4,4,4,4,5,5,5,5,6,6,6,6,7,7,7,7,
+	8,8,8,8,9,9,9,9,10,10,10,10,11,11,11,11
+};
+// 0x2c (29be:1ebe) — alternation walk 0..19 with idle tail
+// (54 entries).
+static const uint8 kScript2c[] = {
+	0,1,2,3,4,5,
+	0,
+	6,7,8,9,10,10,10,10,10,10,
+	11,11,11,
+	12,13,14,15,
+	0,
+	16,17,18,19,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x2d (29be:1f2c) — count-up 0..11 with each frame held 8 ticks
+// (96 entries).
+static const uint8 kScript2d[] = {
+	0,0,0,0,0,0,0,0,
+	1,1,1,1,1,1,1,1,
+	2,2,2,2,2,2,2,2,
+	3,3,3,3,3,3,3,3,
+	4,4,4,4,4,4,4,4,
+	5,5,5,5,5,5,5,5,
+	6,6,6,6,6,6,6,6,
+	7,7,7,7,7,7,7,7,
+	8,8,8,8,8,8,8,8,
+	9,9,9,9,9,9,9,9,
+	10,10,10,10,10,10,10,10,
+	11,11,11,11,11,11,11,11
+};
+// 0x30 (29be:1fee) — 0,0, count-up 1..19, idle, mirror down, extra
+// idle (86 entries).
+static const uint8 kScript30[] = {
+	0,0,
+	1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+	0,0,0,0,0,0,0,0,0,0,
+	19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,
+	5,4,4,3,3,2,2,1,1,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+// 0x31 (29be:209c) — paired-step idle alternations (57 entries).
+static const uint8 kScript31[] = {
+	0,0,0,1,1,1,
+	0,0,0,1,1,1,
+	2,2,2,3,3,3,
+	2,2,2,3,3,3,
+	4,4,4,5,5,5,
+	4,4,4,5,5,5,
+	3,3,3,2,2,2,
+	3,3,3,2,2,2,
+	1,1,1,
+	0,0,0,
+	1,1,1
+};
+// 0x36 (29be:2110) — 0..8 forward, 1..8 forward, frame 1 held 20
+// ticks, 8..0 mirror, idle tail (60 entries).
+static const uint8 kScript36[] = {
+	0,1,2,3,4,5,6,7,8,
+	1,2,3,4,5,6,7,8,
+	1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
+	8,7,6,5,4,3,2,1,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
+};
+
 const AnimScriptLong kAnimScriptsLong[] = {
-	// 0x17 (29be:221a) — briefing game count-up 0..29 (30 frames),
-	// drives the per-tick frame walk of the game piece animation
-	// during `_DoInitClues`.
-	{ 0x17, 30, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
-				  20,21,22,23,24,25,26,27,28,29 } },
-	// 0x18 (29be:2296) — briefing book: counts up to cell 8 then
-	// holds for 16 ticks (the "thinking" pose) then count up 9..15.
-	{ 0x18, 30, { 0,1,2,3,4,5,6,7,8,8,8,8,8,8,8,8,
-				  8,8,8,8,8,8,8,9,10,11,12,13,14,15 } },
-	// 0x19 (29be:2258) — briefing nancy: 18 idle ticks then
-	// count-up 1..12 (the late-arriving sidekick pose).
-	{ 0x19, 30, { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
-				  1,2,3,4,5,6,7,8,9,10,11,12 } },
+	{ 0x17, 30,  kScript17 },
+	{ 0x18, 30,  kScript18 },
+	{ 0x19, 30,  kScript19 },
+	{ 0x1a, 77,  kScript1a },
+	{ 0x1e, 76,  kScript1e },
+	{ 0x1f, 50,  kScript1f },
+	{ 0x20, 34,  kScript20 },
+	{ 0x22, 115, kScript22 },
+	{ 0x23, 29,  kScript23 },
+	{ 0x24, 58,  kScript24 },
+	{ 0x28, 45,  kScript28 },
+	{ 0x29, 58,  kScript29 },
+	{ 0x2b, 48,  kScript2b },
+	{ 0x2c, 54,  kScript2c },
+	{ 0x2d, 96,  kScript2d },
+	{ 0x30, 86,  kScript30 },
+	{ 0x31, 57,  kScript31 },
+	{ 0x36, 60,  kScript36 },
 };
 
 // `_PatientSequence` and `_ImpatientSequence` are standalone script
@@ -308,7 +542,7 @@ static const uint32 kImpatienceDelayMs = 60 * 1000;
 // holds).
 struct AnimScriptRef {
 	const uint8 *frames;
-	uint8 len;
+	uint16 len;
 };
 static AnimScriptRef findAnimScript(uint16 seqnum) {
 	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++) {
@@ -333,9 +567,17 @@ static AnimScriptRef findAnimScript(uint16 seqnum) {
 	return r;
 }
 
+// Original frame period from `_InitFrameCounter @ 1a35:01ae`:
+// `LastFrame = (cs_within_hour) + 0xe`, with `cs_within_hour` =
+// `((ti_min * 60) + ti_sec) * 100 + ti_hund` (Borland C `struct time`
+// memory order is min, hour, hund, sec). The `+ 0xe` is 14
+// centiseconds → ~140 ms per frame, matching `_CheckFrameRate @
+// 1a35:0204`. Earlier 100 ms ran the partner / hotspot animations
+// roughly 1.4× faster than the original.
+static const uint kFramePeriodMs = 140;
+
 static uint frameFromScriptAtTick(const uint8 *frames, uint len,
 								  uint numFrames, uint32 tickMs) {
-	const uint kFramePeriodMs = 100;
 	if (!frames || len == 0)
 		return numFrames > 0 ? (uint)((tickMs / kFramePeriodMs) % numFrames) : 0;
 	const uint scriptIdx = (uint)((tickMs / kFramePeriodMs) % len);
@@ -474,7 +716,6 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 static uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 									   const uint8 *waitSeq, uint waitSeqLen,
 									   uint numFrames, uint32 elapsedMs) {
-	const uint kFramePeriodMs = 100;
 	const uint tick = elapsedMs / kFramePeriodMs;
 	const uint frame = (tick < unfoldLen)
 		? unfold[tick]
@@ -849,10 +1090,12 @@ void SiteScreen::run() {
 		// `_UpdateAnimations` at the top of `_DoSiteLoop`'s main loop).
 		// Restore the static BG snapshot, redraw animated NPCs +
 		// partner at the current frame, then re-render hotspots on
-		// top. We tick at 100 ms (~10 FPS) which is in the same ball
-		// park as the original.
+		// top. The original ticks at 14 cs (~140 ms, see
+		// `kFramePeriodMs` above) — we matched 100 ms before, which
+		// ran site animations ~1.4× too fast.
 		const uint32 now = g_system->getMillis();
-		if (_snapshotSite == (int)cur && now - _lastTickMs >= 100) {
+		if (_snapshotSite == (int)cur &&
+			now - _lastTickMs >= kFramePeriodMs) {
 			if (checkImpatienceCounter()) {
 				_partnerWaitMood = kPartnerWaitImpatient;
 				debugC(1, kDebugSite, "Partner impatience: switched to impatient");


Commit: 28556038dfe1d4ec44622e0ffd332ad464b0ff5d
    https://github.com/scummvm/scummvm/commit/28556038dfe1d4ec44622e0ffd332ad464b0ff5d
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:08+02:00

Commit Message:
EEM: use simpleBlitFrom

Changed paths:
    engines/eem/clues.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 27089a2696f..c167e3f12d3 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -744,10 +744,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	{
 		Graphics::Surface *screen = g_system->lockScreen();
 		if (screen) {
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)bg.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
+			bg.simpleBlitFrom(*screen);
 			g_system->unlockScreen();
 		}
 	}
@@ -844,10 +841,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				break;
 			Graphics::ManagedSurface scratch(320, 200,
 				Graphics::PixelFormat::createFormatCLUT8());
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)scratch.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
+			scratch.simpleBlitFrom(*screen);
 			g_system->unlockScreen();
 
 			int textX = bubX;
@@ -1035,10 +1029,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	{
 		Graphics::Surface *screen = g_system->lockScreen();
 		if (screen) {
-			for (int row = 0; row < 200; row++) {
-				memcpy((byte *)bg.getBasePtr(0, row),
-					   (const byte *)screen->getBasePtr(0, row), 320);
-			}
+			bg.simpleBlitFrom(*screen);
 			g_system->unlockScreen();
 		}
 	}
@@ -1491,10 +1482,7 @@ bool EEMEngine::areYouSure() {
 	Graphics::ManagedSurface saved(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	if (screen) {
-		for (int row = 0; row < 200; row++) {
-			memcpy((byte *)saved.getBasePtr(0, row),
-				   (const byte *)screen->getBasePtr(0, row), 320);
-		}
+		saved.simpleBlitFrom(*screen);
 		g_system->unlockScreen();
 	}
 
@@ -1538,9 +1526,7 @@ bool EEMEngine::areYouSure() {
 
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
-		for (int row = 0; row < 200; row++)
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)saved.getBasePtr(0, row), 320);
+		scratch.simpleBlitFrom(saved);
 		scratch.fillRect(dlg, 0);
 		scratch.frameRect(dlg, 0xF);
 		_font.drawString(&scratch,
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index fc49dbbf559..004e38dda5e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1523,9 +1523,7 @@ void EEMEngine::doSetup() {
 			// transparent pixels show the setup BG underneath.
 			Graphics::Surface *cur = g_system->lockScreen();
 			if (cur) {
-				for (int row = 0; row < 200; row++)
-					memcpy((byte *)scratch.getBasePtr(0, row),
-						   (const byte *)cur->getBasePtr(0, row), 320);
+				scratch.simpleBlitFrom(*cur);
 				g_system->unlockScreen();
 			}
 			const byte transp = (byte)(pic.flags >> 8);
@@ -2902,14 +2900,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		Graphics::ManagedSurface ms(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
-		if (haveBg) {
-			const int bw = MIN<int>(galBg.surface.w, 320);
-			const int bh = MIN<int>(galBg.surface.h, 200);
-			for (int row = 0; row < bh; row++) {
-				memcpy((byte *)ms.getBasePtr(0, row),
-					   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-			}
-		}
+		if (haveBg)
+			ms.simpleBlitFrom(galBg.surface);
 		// Partner sprite at (5, 0x50). The original `MoreInfo @
 		// 158f:0419` calls `_RefreshGalleryBackground` (clears the
 		// portrait grid) but the partner anim slot registered by
@@ -3207,14 +3199,8 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
-	if (haveBg) {
-		const int bw = MIN<int>(galBg.surface.w, 320);
-		const int bh = MIN<int>(galBg.surface.h, 200);
-		for (int row = 0; row < bh; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)galBg.surface.getBasePtr(0, row), bw);
-		}
-	}
+	if (haveBg)
+		scratch.simpleBlitFrom(galBg.surface);
 
 	// Partner sprite frame @ (5, 0x50). The original `_DoGallery @
 	// 158f:065b` registers `_NewAnimation(..., CONCAT22(2, ...), ...)`
@@ -4458,9 +4444,7 @@ void EEMEngine::doAccuse() {
 		ms.clear();
 		Graphics::Surface *cur = g_system->lockScreen();
 		if (cur) {
-			for (int row = 0; row < 200; row++)
-				memcpy((byte *)ms.getBasePtr(0, row),
-					   (const byte *)cur->getBasePtr(0, row), 320);
+			ms.simpleBlitFrom(*cur);
 			g_system->unlockScreen();
 		}
 		const byte firstChar =
@@ -4590,20 +4574,14 @@ void EEMEngine::doAccuse() {
 				{
 					Graphics::Surface *cur = g_system->lockScreen();
 					if (cur) {
-						for (int row = 0; row < 200; row++)
-							memcpy((byte *)ms.getBasePtr(0, row),
-								   (const byte *)cur->getBasePtr(0, row), 320);
+						ms.simpleBlitFrom(*cur);
 						g_system->unlockScreen();
 					} else if (haveAccuseBg) {
 						// Fallback: lockScreen failed somehow; at least
 						// fill from PIC 0x3f so we don't render against
-						// stale memory.
-						const int bw = MIN<int>(accuseBg.surface.w, 320);
-						const int bh = MIN<int>(accuseBg.surface.h, 200);
-						for (int row = 0; row < bh; row++) {
-							memcpy((byte *)ms.getBasePtr(0, row),
-								   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
-						}
+						// stale memory. simpleBlitFrom auto-clips, so
+						// no MIN(w, 320)/MIN(h, 200) needed.
+						ms.simpleBlitFrom(accuseBg.surface);
 					}
 				}
 				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)
@@ -5892,14 +5870,8 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
-	if (haveAccuseBg) {
-		const int bw = MIN<int>(accuseBg.surface.w, 320);
-		const int bh = MIN<int>(accuseBg.surface.h, 200);
-		for (int row = 0; row < bh; row++) {
-			memcpy((byte *)scratch.getBasePtr(0, row),
-				   (const byte *)accuseBg.surface.getBasePtr(0, row), bw);
-		}
-	}
+	if (haveAccuseBg)
+		scratch.simpleBlitFrom(accuseBg.surface);
 
 	// Partner sprite, drawn BEFORE portraits so the suspect grid
 	// covers it where they overlap (the gallery slots start at


Commit: b47044e739601668ae672ce3192961e01af7d5d2
    https://github.com/scummvm/scummvm/commit/b47044e739601668ae672ce3192961e01af7d5d2
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:08+02:00

Commit Message:
EEM: more simpleBlitFrom

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 004e38dda5e..f427f7b57ac 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2925,18 +2925,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		Picture detail;
 		if (_picsArchive.getPicture(detailPic, detail)) {
 			const byte transp = (byte)(detail.flags >> 8);
-			const int dx = 0x94, dy = 0x0f;
-			const int dw = MIN<int>(detail.surface.w, 320 - dx);
-			const int dh = MIN<int>(detail.surface.h, 200 - dy);
-			for (int row = 0; row < dh; row++) {
-				const byte *src = (const byte *)
-					detail.surface.getBasePtr(0, row);
-				byte *dst = (byte *)ms.getBasePtr(0, dy + row);
-				for (int col = 0; col < dw; col++) {
-					if (src[col] != transp)
-						dst[dx + col] = src[col];
-				}
-			}
+			ms.transBlitFrom(detail.surface,
+							 Common::Point(0x94, 0x0f), transp);
 		}
 
 		// Walk the clue list from `pageStart`. Skip clues that aren't
@@ -4588,16 +4578,9 @@ void EEMEngine::doAccuse() {
 				// skips pixels equal to `pic[0] >> 8`.
 				if (haveBalloon) {
 					const byte transp = (byte)(balloon.flags >> 8);
-					const int bw = MIN<int>(balloon.surface.w, 320 - balloonX);
-					const int bh = MIN<int>(balloon.surface.h, 200 - balloonY);
-					for (int row = 0; row < bh; row++) {
-						const byte *src = (const byte *)balloon.surface.getBasePtr(0, row);
-						byte *dst = (byte *)ms.getBasePtr(balloonX, balloonY + row);
-						for (int col = 0; col < bw; col++) {
-							if (src[col] != transp)
-								dst[col] = src[col];
-						}
-					}
+					ms.transBlitFrom(balloon.surface,
+									 Common::Point(balloonX, balloonY),
+									 transp);
 				}
 				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
 				uint16 tx = 5;


Commit: e40f51e2dc7190b652aabb5a68eb83aee2774ead
    https://github.com/scummvm/scummvm/commit/e40f51e2dc7190b652aabb5a68eb83aee2774ead
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:08+02:00

Commit Message:
EEM: use transBlitFrom

Changed paths:
    engines/eem/clues.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index c167e3f12d3..4de2cddd03e 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -857,24 +857,16 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				// the transparent colour to `_Rect_Move_Mask`. The
 				// on-disk u16 at file offset 0 maps to `Picture::flags`.
 				const byte transp = (byte)(balloon.flags >> 8);
-				// `_GetBalloon @ 172b:1d7d` mirrors the picture horizontally
-				// when `(bubNum & 0x80)` is set — used for right-side
-				// speakers so the tail points the other way.
+				// `_GetBalloon @ 172b:1d7d` mirrors the picture
+				// horizontally when `(bubNum & 0x80)` is set — used
+				// for right-side speakers so the tail points the
+				// other way. ScummVM's `transBlitFrom` exposes the
+				// same via its `flipped` argument.
 				const bool flipBalloon = (fittedBubNum & 0x80) != 0;
 				if (bw > 0 && bh > 0) {
-					for (int row = 0; row < bh; row++) {
-						const byte *src =
-							(const byte *)balloon.surface.getBasePtr(0, row);
-						byte *dst = (byte *)scratch.getBasePtr(bubX, bubY + row);
-						for (int col = 0; col < bw; col++) {
-							const int srcCol = flipBalloon
-								? (balloon.surface.w - 1 - col)
-								: col;
-							const byte px = src[srcCol];
-							if (px != transp)
-								dst[col] = px;
-						}
-					}
+					scratch.transBlitFrom(balloon.surface,
+										  Common::Point(bubX, bubY),
+										  transp, flipBalloon);
 				}
 				// Per-balloon metadata from `29be:0875` (52 × 10 bytes,
 				// indexed by `bubNum & 0x7F`). The original `_DisplayClue`
@@ -1188,7 +1180,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		if (haveBalloon)
 			getBalloonInsets(balloonId, textXIns, textYIns, textWidth);
 		const int textX = ballX + textXIns;
-		const int balloonH = haveBalloon ? balloon.surface.h : 200;
 		const int lineH    = _font.getFontHeight();
 
 		// Pagination state — `FUN_22dc_05c8`'s text-idx loop uses
@@ -1252,23 +1243,10 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					}
 				}
 				if (haveBalloon) {
-					const int bw = MIN<int>(balloon.surface.w, 320 - ballX);
-					const int bh = MIN<int>(balloonH, 200 - ballY);
 					const byte transp = (byte)(balloon.flags >> 8);
-					for (int row = 0; row < bh; row++) {
-						const byte *src = (const byte *)
-							balloon.surface.getBasePtr(0, row);
-						byte *dst = (byte *)
-							scratch.getBasePtr(ballX, ballY + row);
-						for (int col = 0; col < bw; col++) {
-							const int srcCol = flipBall
-								? (balloon.surface.w - 1 - col)
-								: col;
-							const byte px = src[srcCol];
-							if (px != transp)
-								dst[col] = px;
-						}
-					}
+					scratch.transBlitFrom(balloon.surface,
+										  Common::Point(ballX, ballY),
+										  transp, flipBall);
 				}
 				cursorY = ballY + textYIns;
 			}
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index f427f7b57ac..6e9fd72df76 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3773,11 +3773,8 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 
 	const int copyW = MIN<int>(mapW - scrollX, kMapWinW);
 	const int copyH = MIN<int>(mapH - scrollY, kMapWinH);
-	for (int row = 0; row < copyH; row++) {
-		memcpy((byte *)scratch.getBasePtr(kMapWinX, kMapWinY + row),
-			   mapPixels.data() + (scrollY + row) * mapW + scrollX,
-			   copyW);
-	}
+	scratch.copyRectToSurface(mapPixels.data() + scrollY * mapW + scrollX,
+							  mapW, kMapWinX, kMapWinY, copyW, copyH);
 
 	// Stamped site buttons. `_StampButtons @ 20fe:0d2f` (CD):
 	//   button = _GetButton(MapData[+0])
@@ -5205,17 +5202,8 @@ void EEMEngine::doAccuseFloppy() {
 			if (h < 0x4e)
 				balloonY = (uint16)((0x50 - h) >> 1);
 			const byte transp = (byte)(balloon.flags >> 8);
-			for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
-				 row++) {
-				const byte *src =
-					(const byte *)balloon.surface.getBasePtr(0, row);
-				byte *dst = (byte *)ms.getBasePtr(0x21, balloonY + row);
-				for (int col = 0; col < balloon.surface.w && 0x21 + col < 320;
-					 col++) {
-					if (src[col] != transp)
-						dst[col] = src[col];
-				}
-			}
+			ms.transBlitFrom(balloon.surface,
+							 Common::Point(0x21, balloonY), transp);
 		}
 		uint16 bx = 5;
 		uint16 by = 4;
@@ -5759,22 +5747,11 @@ void EEMEngine::doAccuseFloppy() {
 		const byte transp = (byte)(balloon.flags >> 8);
 		// `_GetBalloon`'s mirror flag (high bit of the table value)
 		// flips the balloon horizontally — the original applies it
-		// inside the blit primitive. We emulate by reading the source
-		// row in reverse.
-		for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
-			 row++) {
-			const byte *src =
-				(const byte *)balloon.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scene.getBasePtr(balloonX, balloonY + row);
-			for (int col = 0;
-				 col < balloon.surface.w && balloonX + col < 320; col++) {
-				const int srcCol = flipBalloon
-					? (balloon.surface.w - 1 - col) : col;
-				const byte px = src[srcCol];
-				if (px != transp)
-					dst[col] = px;
-			}
-		}
+		// inside the blit primitive; ScummVM's `transBlitFrom` exposes
+		// the same via its `flipped` argument.
+		scene.transBlitFrom(balloon.surface,
+							Common::Point(balloonX, balloonY),
+							transp, flipBalloon);
 	}
 	uint16 tx = 5, ty = 4, tw = 155;
 	getBalloonInsets(balloonIdx, tx, ty, tw);


Commit: a2554f53bd0499a73fc939be4c6edd77ebfd1876
    https://github.com/scummvm/scummvm/commit/a2554f53bd0499a73fc939be4c6edd77ebfd1876
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:09+02:00

Commit Message:
EEM: stop conversations when you skip the last phrase

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 4de2cddd03e..05fd6b64a5d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -413,7 +413,7 @@ void EEMEngine::doInitClues() {
 				while (g_system->getEventManager()->pollEvent(ev)) {
 					if (ev.type == Common::EVENT_KEYDOWN &&
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						interruptAudio();
+						interruptAudio(/*stopMusicToo=*/false);
 						skip = true;
 						break;
 					}
@@ -540,7 +540,7 @@ void EEMEngine::doInitClues() {
 					while (g_system->getEventManager()->pollEvent(ev)) {
 						if (ev.type == Common::EVENT_KEYDOWN &&
 							ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-							interruptAudio();
+							interruptAudio(/*stopMusicToo=*/false);
 							skip = true;
 							break;
 						}
@@ -953,8 +953,9 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						// playing past the dialog dismissal and
 						// bleeds into the next screen (e.g. the
 						// case-briefing voice still talking on the
-						// MAP after ESC).
-						interruptAudio();
+						// MAP after ESC). Site / briefing MIDI
+						// stays — only voice + spool halt here.
+						interruptAudio(/*stopMusicToo=*/false);
 						break;
 					}
 					if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -987,6 +988,15 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 
 		applyClueSideEffects(c);
 	}
+
+	// `_DisplayClue @ 2404:05e6` lets the per-clue voice bleed past
+	// the last entry's dismissal — `_Wait()` returns on ANY input
+	// without touching the digital playback. We deliberately diverge
+	// (mirroring `_StopTheVoice @ 1ff1:0283`'s effect, voice only,
+	// MIDI untouched) so the briefing voice line doesn't keep talking
+	// over the next screen when the player clicks through the final
+	// entry.
+	interruptAudio(/*stopMusicToo=*/false);
 }
 
 void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
@@ -1069,7 +1079,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					continue;
 				if (ev.type == Common::EVENT_KEYDOWN &&
 					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					interruptAudio();
+					interruptAudio(/*stopMusicToo=*/false);
 					return true;  // skip
 				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN ||
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 85a533fee88..5f527ba1faf 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -718,19 +718,19 @@ bool EEMEngine::setAnmPalette(const Common::Path &anmPath) {
 	return true;
 }
 
-void EEMEngine::interruptAudio() {
+void EEMEngine::interruptAudio(bool stopMusicToo) {
 	// Mirrors `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @
 	// 20a2:0512` — the original calls both whenever the player aborts
 	// the opening-anim chain or dismisses the title (`_DoOpeningAnims
 	// @ 2520:082a` writes `_LoopMIDI = 0; _StopMIDI();` after the
-	// title-input loop). We expose the same combined stop on every
-	// ESC handler so currently-playing music + voice + spool actually
-	// halt instead of bleeding through into the next screen.
+	// title-input loop). Conversation / clue-dialog skip paths pass
+	// `stopMusicToo = false` so the site / briefing MIDI keeps going
+	// across an ESC — only the per-line voice + spool need to stop.
 	if (_audio) {
 		_audio->stopVoice();
 		_audio->stopSpool();
 	}
-	if (_music)
+	if (stopMusicToo && _music)
 		_music->stop();
 }
 
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8578d1a2d6c..c942013d1cc 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -414,7 +414,12 @@ private:
 	/// and once before TITLE.ANM). Called from every ESC handler in
 	/// the intro / title chain so the theme music + voice spool
 	/// don't bleed past the abort.
-	void interruptAudio();
+	/// Stop currently-playing voice / spooled SFX. Pass `stopMusic =
+	/// true` (the default — matches `_CleanMysterySounds + _StopMIDI`)
+	/// to also halt the MIDI track; conversation / dialog skip paths
+	/// pass `false` so the site / briefing music keeps going across an
+	/// ESC.
+	void interruptAudio(bool stopMusicToo = true);
 
 	// Screen handlers — port targets in screens/ later.
 	void showEAKidsLogo();


Commit: 9ca6f602c444b6ff8ea1aabca2ffa971bc17aa8f
    https://github.com/scummvm/scummvm/commit/9ca6f602c444b6ff8ea1aabca2ffa971bc17aa8f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:09+02:00

Commit Message:
EEM: fixed highlighting bug when the cursor was moved during conversations

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 05fd6b64a5d..63b384498ca 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -934,6 +934,14 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		// are ignored so accidental keystrokes don't blow past dialog
 		// the player hasn't finished reading.
 		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
+			// Click during a clue dialog only dismisses — there are no
+			// hover-interactive areas. Drop the highlighted cursor
+			// state the site loop may have left set so the player
+			// doesn't see a "clickable" cursor stuck on top of the
+			// balloon. The post-dialog `MOUSEMOVE` in the site loop
+			// will re-evaluate against hotspots and re-enable as
+			// needed.
+			setInteractiveMouseCursor(false);
 			bool advance = false;
 			bool skipAll = false;
 			while (!advance && !shouldQuit()) {
@@ -944,6 +952,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						advance = true;
 						break;
 					}
+					if (ev.type == Common::EVENT_MOUSEMOVE) {
+						// Keep the cursor non-interactive across moves
+						// inside the dialog (defensive — in case some
+						// other code path tries to set it).
+						setInteractiveMouseCursor(false);
+						continue;
+					}
 					if (ev.type == Common::EVENT_KEYDOWN &&
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						advance = true;
@@ -1067,6 +1082,11 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		// doesn't auto-advance the new page.
 		Common::Event drain;
 		while (g_system->getEventManager()->pollEvent(drain)) {}
+		// Click during the floppy dialog only dismisses — no
+		// hover-interactive areas. Clear any stuck highlight from the
+		// site loop so the cursor stays a normal pointer over the
+		// balloon.
+		setInteractiveMouseCursor(false);
 		const uint32 minVisibleMs = 250;
 		const uint32 startedAt = g_system->getMillis();
 		while (!shouldQuit()) {
@@ -1075,6 +1095,10 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 				if (ev.type == Common::EVENT_QUIT ||
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 					return true;  // skip
+				if (ev.type == Common::EVENT_MOUSEMOVE) {
+					setInteractiveMouseCursor(false);
+					continue;
+				}
 				if (g_system->getMillis() - startedAt < minVisibleMs)
 					continue;
 				if (ev.type == Common::EVENT_KEYDOWN &&
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5f527ba1faf..de24dbedb90 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -868,6 +868,14 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 	// Only Return / KP-Enter / Space / Escape advance — letting any key
 	// dismiss balloons makes typing-while-reading (or a stuck modifier)
 	// blow past dialog the player hasn't finished reading.
+	//
+	// Drop the highlighted-cursor state any caller (site loop, gallery,
+	// notebook hover-handler) may have left on. While a balloon /
+	// intro is showing, click anywhere just dismisses — no
+	// hover-interactive areas — so a "clickable" cursor over the
+	// dialog is misleading. The caller's MOUSEMOVE handler will
+	// re-enable the highlight after we return if appropriate.
+	setInteractiveMouseCursor(false);
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
 		Common::Event event;
@@ -877,6 +885,12 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 				event.type == Common::EVENT_LBUTTONDOWN) {
 				return;
 			}
+			if (event.type == Common::EVENT_MOUSEMOVE) {
+				// Defensive: keep the cursor non-interactive across
+				// moves while the dialog is up.
+				setInteractiveMouseCursor(false);
+				continue;
+			}
 			if (event.type == Common::EVENT_KEYDOWN) {
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_skipIntro = true;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index a6a7647c956..b141704a1fe 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1039,7 +1039,12 @@ void SiteScreen::run() {
 					_vm->doHelp();
 					notePartnerActivity();
 					enter(cur, false);
-					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
+					// Re-evaluate cursor against the CURRENT pointer
+					// position, not the click that opened the help.
+					// The player may have moved off the partner-head
+					// area during the dialog.
+					mouse = g_system->getEventManager()->getMousePos();
+					updateHotspotCursor(cur, mouse.x, mouse.y);
 					break;
 				}
 				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
@@ -1049,7 +1054,12 @@ void SiteScreen::run() {
 					// Restore the site BG after the clue overlay.
 					notePartnerActivity();
 					enter(cur, false);
-					updateHotspotCursor(cur, event.mouse.x, event.mouse.y);
+					// Use CURRENT pointer position — the click pos is
+					// still inside the hotspot rect, so reusing it
+					// would leave the "clickable" cursor stuck after
+					// the conversation even if the player moved off.
+					mouse = g_system->getEventManager()->getMousePos();
+					updateHotspotCursor(cur, mouse.x, mouse.y);
 				} else {
 					notePartnerActivity();
 				}


Commit: b39c08a950680c8e7a1e952e4af1756af28102c0
    https://github.com/scummvm/scummvm/commit/b39c08a950680c8e7a1e952e4af1756af28102c0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:09+02:00

Commit Message:
EEM: added palette fadeout for the intro

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index de24dbedb90..0af062785e1 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -686,17 +686,23 @@ bool EEMEngine::loadSitePalettes() {
 	return true;
 }
 
-void EEMEngine::setSitePalette(uint num) {
-	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize) {
-		warning("setSitePalette: index %u out of range", num);
-		return;
-	}
+bool EEMEngine::getSitePalette(uint num, byte *out) const {
+	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize)
+		return false;
 	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects 8-bit
 	// (0..255), so left-shift by 2 like the original VGA hardware did.
 	const byte *src = _sitePals.data() + num * kPalSize;
-	byte expanded[kPalSize];
 	for (uint i = 0; i < kPalSize; i++)
-		expanded[i] = (byte)(src[i] << 2);
+		out[i] = (byte)(src[i] << 2);
+	return true;
+}
+
+void EEMEngine::setSitePalette(uint num) {
+	byte expanded[kPalSize];
+	if (!getSitePalette(num, expanded)) {
+		warning("setSitePalette: index %u out of range", num);
+		return;
+	}
 	g_system->getPaletteManager()->setPalette(expanded, 0, 256);
 }
 
@@ -910,8 +916,18 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 }
 
 void EEMEngine::showEAKidsLogo() {
-	// Mirrors _ShowEAKids @ 2520:05f0 (without the color-cycle loop):
-	// GetPicture(0x54), MemoryCopy to VGA, GetPalette(0x25), setmany.
+	// Mirrors `_ShowEAKids @ 2520:05f0`. The original:
+	//   1. GetPicture(0x54) + MemoryCopy to VGA + GetPalette(0x25).
+	//   2. FRAME_RATE = 25; for j in 0..1, for u in 0..0x36 (= 55):
+	//        OpenColorCycle(0x01, 0x6e)   // bg / outer ring shimmer
+	//        OpenColorCycle(0x81, 0xee)   // inner gradient shimmer
+	//        every 8 ticks: OpenColorCycle(0x70, 0x80)  // mid band
+	//   3. After the 110-tick loop: 5 more cycles of 0x70..0x80.
+	//   4. Wait 0x23 (= 35) more frames.
+	//   5. _OpenFadeOut.
+	// The cycling is what gives the EA Kids logo its characteristic
+	// shifting glow — it's NOT a static logo. ESC / click skips the
+	// remaining cycle.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicEAKidsLogo, pic)) {
 		warning("EA Kids logo (%u) load failed", kPicEAKidsLogo);
@@ -920,21 +936,95 @@ void EEMEngine::showEAKidsLogo() {
 	blitAt(pic, 0, 0);
 	setSitePalette(kPalEAKids);
 	g_system->updateScreen();
-	waitForInput(2500);
+
+	// 25 fps → 40 ms / tick. Two outer iterations × 55 ticks each = 110
+	// ticks of palette rotation. The first inner-loop iteration of each
+	// outer pass rotates the in-memory palette once *before* applying
+	// to VGA (original gates the wait + setmany on `show != 0`); we
+	// just call the setter every tick — the visible cycle starts
+	// immediately, which is the same end result.
+	const uint kFrameMs = 40;
+	int delayCount = 8;
+	bool aborted = false;
+	for (uint outer = 0; outer < 2 && !aborted && !shouldQuit(); outer++) {
+		for (uint i = 0; i < 0x37 && !aborted && !shouldQuit(); i++) {
+			cyclePaletteRangeReverse(0x01, 0x6e);
+			cyclePaletteRangeReverse(0x81, 0xee);
+			delayCount--;
+			if (delayCount == 0) {
+				delayCount = 8;
+				cyclePaletteRangeReverse(0x70, 0x80);
+			}
+			g_system->updateScreen();
+
+			// Tick wait + skip detection.
+			const uint32 frameEnd = g_system->getMillis() + kFrameMs;
+			while (g_system->getMillis() < frameEnd && !aborted) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_QUIT ||
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+						_skipIntro = true;
+						return;
+					}
+					if (ev.type == Common::EVENT_KEYDOWN ||
+						ev.type == Common::EVENT_LBUTTONDOWN) {
+						if (ev.type == Common::EVENT_KEYDOWN &&
+							ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+							_skipIntro = true;
+						}
+						aborted = true;
+						break;
+					}
+				}
+				g_system->delayMillis(5);
+			}
+		}
+	}
+
+	if (aborted)
+		return;
+
+	// Tail: 5 more rotations of the mid band, then 35 idle frames.
+	for (uint i = 0; i < 5 && !shouldQuit(); i++)
+		cyclePaletteRangeReverse(0x70, 0x80);
+	g_system->updateScreen();
+	waitForInput(0x23 * kFrameMs);
+
+	// `_OpenFadeOut @ 2520:0093` — 16 linear steps from current palette
+	// to black. Without this, the EA Kids logo cuts hard to the next
+	// screen.
+	fadeCurrentPaletteToBlack();
 }
 
 void EEMEngine::showHighScoreLogo() {
-	// Mirrors _ShowHScoreLogo @ 2520:0799 (without the wait-loop):
-	// GetPicture(0x20c), MemoryCopy to VGA, GetPalette(0x27), FadeIn.
+	// Mirrors `_ShowHScoreLogo @ 2520:0799`:
+	//   GetPicture(0x20c) + MemoryCopy to VGA + GetPalette(0x27) +
+	//   _OpenFadeIn + 50-tick wait at 25 fps + _OpenFadeOut.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicHighScoreLogo, pic)) {
 		warning("HighScore logo (%u) load failed", kPicHighScoreLogo);
 		return;
 	}
 	blitAt(pic, 0, 0);
-	setSitePalette(kPalHighScore);
+
+	// Load target palette into a buffer, force a black palette, then
+	// fade in — without the explicit black step we'd flash the full
+	// logo briefly between blit and fade.
+	byte target[kPalSize];
+	if (!getSitePalette(kPalHighScore, target)) {
+		warning("HighScore palette (%u) load failed", kPalHighScore);
+		return;
+	}
+	byte black[kPalSize] = {};
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
-	waitForInput(2500);
+	fadePaletteFromBlack(target);
+
+	// 50 ticks at 25 fps = ~2 s.
+	waitForInput(2000);
+
+	fadeCurrentPaletteToBlack();
 }
 
 void EEMEngine::showFloppyStormLogo() {
@@ -951,11 +1041,23 @@ void EEMEngine::showFloppyStormLogo() {
 		return;
 	}
 	blitAt(pic, 0, 0);
-	setSitePalette(kPalStormLogo);
+
+	byte target[kPalSize];
+	if (!getSitePalette(kPalStormLogo, target)) {
+		warning("Storm palette (%u) load failed", kPalStormLogo);
+		return;
+	}
+	byte black[kPalSize] = {};
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
+
 	if (_audio)
 		_audio->playVoc(Common::Path("THUNDER.VOC"));
-	waitForInput(2500);
+
+	fadePaletteFromBlack(target);
+	waitForInput(2000);
+	fadeCurrentPaletteToBlack();
+
 	if (_audio)
 		_audio->stopVoice();
 }
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index c942013d1cc..26cb2c8f00d 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -368,6 +368,13 @@ private:
 	 */
 	void setSitePalette(uint num);
 
+	/// Fill @p out (256 × 3 bytes) with the SITEPALS palette at index
+	/// @p num, expanded from VGA's 6-bit DAC range to 8-bit. Returns
+	/// false if the index is out of range. Used when callers need the
+	/// palette for fade-in (set black first, then fade) without
+	/// flashing the target on screen.
+	bool getSitePalette(uint num, byte *out) const;
+
 	/**
 	 * Upload a 6-bit VGA palette read from the head of an .ANM file (the
 	 * first 0x300 bytes per Load_Sequence @ 2503:0006). Used until the
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index b141704a1fe..f309a2d76bc 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -137,6 +137,32 @@ void cyclePaletteRange(uint8 start, uint8 end) {
 	g_system->getPaletteManager()->setPalette(buf, start, count);
 }
 
+void cyclePaletteRangeReverse(uint8 start, uint8 end) {
+	// Mirrors `_OpenColorCycle @ 2520:04f7`: save the END color, shift
+	// every entry up by one (END-1 → END, ...), wrap saved END to
+	// START. Visually colors march from start toward end — opposite
+	// direction from `cyclePaletteRange`. Used by the EA Kids /
+	// HighScore logo cycles.
+	if (end <= start)
+		return;
+	const uint count = (uint)end - (uint)start + 1;
+	byte buf[256 * 3];
+	g_system->getPaletteManager()->grabPalette(buf, start, count);
+	const uint last = count - 1;
+	const byte savedR = buf[last * 3 + 0];
+	const byte savedG = buf[last * 3 + 1];
+	const byte savedB = buf[last * 3 + 2];
+	for (uint i = last; i > 0; i--) {
+		buf[i * 3 + 0] = buf[(i - 1) * 3 + 0];
+		buf[i * 3 + 1] = buf[(i - 1) * 3 + 1];
+		buf[i * 3 + 2] = buf[(i - 1) * 3 + 2];
+	}
+	buf[0] = savedR;
+	buf[1] = savedG;
+	buf[2] = savedB;
+	g_system->getPaletteManager()->setPalette(buf, start, count);
+}
+
 // Per-speaker partner-position table verified against `_WaitAnims @
 // 29be:021c`. 12 bytes per entry, indexed by `siteData[+8]`. Layout:
 //   +0..1 anim Jake, +2..3 anim Jenny,
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 110c560df05..606edf14ffe 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -81,6 +81,12 @@ void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 /// BigMap marker shine.
 void cyclePaletteRange(uint8 start, uint8 end);
 
+/// Rotate one VGA palette range by one slot in the OPPOSITE direction.
+/// Mirrors `_OpenColorCycle @ 2520:04f7` (CD) / `_ReverseColorCycle_Floppy`
+/// — used by the opening-anim logos (EA Kids, etc.) where the cycle
+/// shifts END→START rather than START→END.
+void cyclePaletteRangeReverse(uint8 start, uint8 end);
+
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
 struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates


Commit: 59304750cf0a6c8c92e2177078140834f58dcfb0
    https://github.com/scummvm/scummvm/commit/59304750cf0a6c8c92e2177078140834f58dcfb0
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:10+02:00

Commit Message:
EEM: reviewed and improved comments across the engine

Changed paths:
    engines/eem/animation.h
    engines/eem/audio.cpp
    engines/eem/audio.h
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/font.cpp
    engines/eem/font.h
    engines/eem/graphics.cpp
    engines/eem/music.cpp
    engines/eem/music.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/resource.cpp
    engines/eem/resource.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/animation.h b/engines/eem/animation.h
index 09b6e0406d1..fa5c7b8f4c2 100644
--- a/engines/eem/animation.h
+++ b/engines/eem/animation.h
@@ -30,20 +30,13 @@
 namespace EEM {
 
 /**
- * Decoder for the engine's full-screen difference animations.
- *
- * Used for `BOLT.ANM`, `TITLE.ANM`, `ANIM01.A` .. `ANIM20.A`. The format is
- * documented by Load_Sequence @ 2503:0006 / OpenDifferenceAnimation @ 2520:0337:
- *
- *   - 0x300 bytes : 6-bit VGA palette
- *   - u16        : frame count
- *   - 12 bytes   : header (height @ +2, width @ +4, rest unused)
- *   - frames*u16 : packed length per frame
- *   - per frame  : `lengths[i]` bytes of RLE-packed delta data
- *
- * Each packed frame is unpacked by the custom `_ASM_Decompress` RLE
- * (1000:0953) into the persistent `_buffer`; skip opcodes preserve pixels
- * from the previous frame, which is how the difference encoding works.
+ * Decoder for BOLT.ANM, TITLE.ANM, ANIM01.A..ANIM20.A. Format from
+ * Load_Sequence @ 2503:0006 / OpenDifferenceAnimation @ 2520:0337:
+ *   - 0x300 bytes: 6-bit VGA palette
+ *   - u16: frame count
+ *   - 12 bytes: header (height @ +2, width @ +4, rest unused)
+ *   - frames*u16: packed length per frame
+ *   - per frame: lengths[i] bytes of RLE delta data (asmDecompress).
  */
 class ANMDecoder {
 public:
@@ -89,11 +82,8 @@ private:
 	uint16 _nextFrameIdx = 0;
 };
 
-/**
- * Decompress a single frame's RLE payload in place. Mirrors _ASM_Decompress
- * @ 1000:0953 byte-for-byte. @p dst already holds the previous frame; skip
- * opcodes leave those pixels untouched.
- */
+/// _ASM_Decompress @ 1000:0953. dst holds the previous frame; skip opcodes
+/// leave those pixels untouched (difference encoding).
 void asmDecompress(const byte *src, uint srcSize, byte *dst, uint dstSize);
 
 } // End of namespace EEM
diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 6ed89dcdf0c..3954fbb02f9 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -51,15 +51,8 @@ void AudioPlayer::stopAll() {
 	cleanMysterySounds();
 }
 
-// VOC playback --------------------------------------------------------
-
 void AudioPlayer::playVoc(const Common::Path &vocPath) {
-	// Voice / digital audio is gated by the `DAT_2d5d_3f97` flag in
-	// the original — verified at every callsite (`_DoChoosePartner @
-	// 1a35:098c`, `_DisplayClue @ 2404:0845`, `_DoOpeningAnims @
-	// 2520:08a8`, etc.). Setup-screen toggle and `_NewPlayer` fresh-
-	// profile init both rewrite that flag. We pull it into the audio
-	// player so callers don't have to duplicate the check.
+	// `_voiceEnabled` mirrors DAT_2d5d_3f97 (setup-screen voice toggle).
 	if (!_voiceEnabled) {
 		debugC(2, kDebugSound, "AudioPlayer: voice disabled, skipping %s",
 			   vocPath.toString().c_str());
@@ -67,7 +60,6 @@ void AudioPlayer::playVoc(const Common::Path &vocPath) {
 	}
 	stopVoice();
 
-	// Mirrors `_LoadSoundName`'s `_fopen` (1ff1:02ac).
 	Common::File *f = new Common::File();
 	if (!f->open(vocPath)) {
 		warning("AudioPlayer: %s missing", vocPath.toString().c_str());
@@ -83,9 +75,7 @@ void AudioPlayer::playVoc(const Common::Path &vocPath) {
 		return;
 	}
 
-	// `_PlayVoice` (1ff1:023e) goes through `_AIL_play_VOC_file` on the
-	// digital channel — we route through `kSpeechSoundType` so the
-	// launcher's "Speech volume" slider applies.
+	// _PlayVoice @ 1ff1:023e — route through kSpeechSoundType for the launcher slider.
 	_mixer->playStream(Audio::Mixer::kSpeechSoundType, &_voiceHandle,
 					   stream, -1, Audio::Mixer::kMaxChannelVolume,
 					   0, DisposeAfterUse::YES);
@@ -98,9 +88,8 @@ bool AudioPlayer::isVoicePlaying() const {
 }
 
 void AudioPlayer::waitForVoiceDone(uint32 maxMs) {
-	// Mirrors the wait loop at `_WaitForVoiceDone @ 1ff1:0221` — pumps
-	// events (so animations + abort-on-click still work) while waiting
-	// for the AIL voice channel to drain.
+	// _WaitForVoiceDone @ 1ff1:0221 — pumps events while the AIL voice channel
+	// drains, so animations + abort-on-click keep working during the wait.
 	const uint32 startMs = g_system->getMillis();
 	while (isVoicePlaying() && !_vm->shouldQuit() &&
 		   g_system->getMillis() - startMs < maxMs) {
@@ -124,12 +113,9 @@ void AudioPlayer::stopVoice() {
 		_mixer->stopHandle(_voiceHandle);
 }
 
-// Spool sound ---------------------------------------------------------
-
+// _ReadLong(&MysterySounds, size, sdx) @ 202f:0647.
+// 12-byte entries: (u32 offset, u32 compressed_size, u32 uncompressed_size).
 bool AudioPlayer::readSdxIndex(const Common::Path &sdxPath) {
-	// Mirrors `_ReadLong(&MysterySounds, size, sdx)` at 202f:0647 —
-	// reads the entire .SDX into memory; each 12-byte entry is
-	// (u32 offset, u32 compressed_size, u32 uncompressed_size).
 	Common::File f;
 	if (!f.open(sdxPath)) {
 		warning("AudioPlayer: %s missing", sdxPath.toString().c_str());
@@ -151,10 +137,8 @@ bool AudioPlayer::readSdxIndex(const Common::Path &sdxPath) {
 	return true;
 }
 
+// _InitMysterySounds @ 202f:05cb. Strings "m%u.sdx" @ 29be:144f, "m%u.sdb" @ 29be:145b.
 bool AudioPlayer::initMysterySounds(uint mysteryNum) {
-	// Mirrors `_InitMysterySounds @ 202f:05cb` — calls
-	// `_CleanMysterySounds` first, then sprintf-opens `m%u.sdx` (string
-	// at 29be:144f) and `m%u.sdb` (29be:145b).
 	cleanMysterySounds();
 
 	const Common::String sdxName = Common::String::format("M%u.SDX", mysteryNum);
@@ -180,12 +164,11 @@ void AudioPlayer::cleanMysterySounds() {
 	_currentMystery = -1;
 }
 
+// pcm must be allocated with malloc(): Audio::makeRawStream takes ownership
+// and frees it via free() on stream destruction (NOT delete/delete[]).
 void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
 								Audio::SoundHandle &handle,
 								Audio::Mixer::SoundType type) {
-	// `Audio::makeRawStream` takes ownership and `free()`s the buffer
-	// on stream destruction — so the caller must `malloc` (not `new`)
-	// the PCM buffer.
 	Audio::SeekableAudioStream *stream =
 		Audio::makeRawStream(pcm, size, sampleRate, Audio::FLAG_UNSIGNED,
 							 DisposeAfterUse::YES);
@@ -199,11 +182,10 @@ void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
 					   DisposeAfterUse::YES);
 }
 
-// Floppy per-partner voice table — verified by reading the raw filename
-// pointers at `2608:0f0e` (Jake) and `2608:0f76` (Jenny) in EEM.EXE,
-// each 26 × FAR-ptr to a NUL-terminated `*.voc` filename. Indexed by
-// `_LoadSoundName_Floppy @ 1f4e:0305`. Slots match across partners:
-//   12 = PHONESL.VOC, 20 = partner intro, 25 = THUNDER.VOC.
+// Floppy per-partner voice tables, indexed by _LoadSoundName_Floppy @ 1f4e:0305.
+// Filename FAR-ptr arrays at 2608:0f0e (Jake) and 2608:0f76 (Jenny), each
+// 26 * FAR-ptr to a NUL-terminated `*.voc` name. Slots align across partners,
+// e.g. 12 = PHONESL.VOC, 20 = partner intro, 25 = THUNDER.VOC.
 static const char *const kFloppyJakeVoiceTable[26] = {
 	"DING.VOC",       "M-0083SL.VOC", "M-0085SL.VOC", "NEWSCAN.VOC",
 	"M-0089SL.VOC",   "M-0091SL.VOC", "M-0092SL.VOC", "NEWSSHRT.VOC",
@@ -225,7 +207,7 @@ static const char *const kFloppyJennyVoiceTable[26] = {
 
 void AudioPlayer::playFloppyVoiceSlot(uint slot, uint partner) {
 	if (slot >= 26)
-		slot = 0;  // mirrors `_LoadSoundName_Floppy`'s `if (0x19 < slot) slot = 0;`
+		slot = 0;  // _LoadSoundName_Floppy: if (0x19 < slot) slot = 0;
 	const char *name = (partner == 0)
 		? kFloppyJakeVoiceTable[slot]
 		: kFloppyJennyVoiceTable[slot];
@@ -239,9 +221,7 @@ void AudioPlayer::spoolSound(uint num) {
 		return;
 	}
 	if (_currentMystery < 0) {
-		// No SDB/SDX bundle is loaded — floppy install (no `M*.SDB`)
-		// or pre-mystery state. Silently no-op; per-voice VOC playback
-		// for floppy lives elsewhere (TODO).
+		// No SDB/SDX bundle loaded (floppy install or pre-mystery state).
 		debugC(2, kDebugSound,
 			   "AudioPlayer: spoolSound(%u) skipped (no mystery sounds)", num);
 		return;
@@ -266,17 +246,14 @@ void AudioPlayer::spoolSound(uint num) {
 		return;
 	}
 
-	// Mirrors the two `_fgetc(in)` reads at 202f:02da-e1: byte 0 =
-	// Sound Blaster Time Constant, byte 1 = total AIL playback blocks.
-	// Convert TC -> sample rate via the standard SB formula. The block
-	// count is only used internally by the AIL DDS pipeline; ScummVM's
-	// mixer doesn't need it.
+	// _UncompressedSound 2-byte header @ 202f:02da-e1:
+	//   byte 0 = Sound Blaster Time Constant
+	//   byte 1 = total AIL playback blocks (internal to AIL DDS; unused here)
 	const byte tc          = sdb.readByte();
-	(void)sdb.readByte(); // total blocks — unused outside AIL
+	(void)sdb.readByte(); // AIL block count, unused outside AIL
 
-	// SB Time Constant: rate = 1000000 / (256 - tc). e.g. tc=0xD2 →
-	// 22 kHz. Guard against the degenerate tc=0xFF (would divide by 1
-	// → 1 MHz, well above what the mixer can resample sanely).
+	// SB Time Constant formula: rate = 1000000 / (256 - tc).
+	// e.g. tc=0xD2 -> 22 kHz. tc=0xFF would divide by 1 (1 MHz, nonsense); clamp.
 	const uint sampleRate = (tc < 0xFF)
 		? (uint)(1000000u / (256u - tc))
 		: 44100u;
@@ -285,11 +262,7 @@ void AudioPlayer::spoolSound(uint num) {
 	uint32 audioSize = 0;
 
 	if (entry.compressedSize == entry.uncompressedSize) {
-		// `_UncompressedSound @ 202f:03e6` — already raw PCM. The
-		// `_SpoolSound` equality check at 202f:06e6 is `comp == uncomp`;
-		// in that case `len = uncompressed_size` bytes follow the
-		// 2-byte header. Original reads in 16 KB chunks; we slurp the
-		// lot at once.
+		// _UncompressedSound @ 202f:03e6 — raw PCM follows the 2-byte header.
 		audioSize = entry.uncompressedSize;
 		pcm = (byte *)malloc(audioSize);
 		if (!pcm) {
@@ -303,18 +276,15 @@ void AudioPlayer::spoolSound(uint num) {
 			return;
 		}
 	} else {
-		// `_DeCompressSound @ 202f:02ad` → `EXPLODE @ 25c6:0d01`
-		// (PKWARE DCL "Implode") with READDISKSOUND/WRITESOUND
-		// callbacks. EXPLODE drives both ends via its OWN end-of-stream
-		// marker (length token 519); the SDX `compressed_size` /
-		// `uncompressed_size` are loose hints, NOT exact lengths —
-		// 202f:0332 even computes `destSize - 2` and never reads it.
-		// ScummVM's fixed-size `decompressDCL` overload errors when
-		// the actual output exceeds our pre-allocated buffer (which
-		// we saw on every M0 clue voice). Use the dynamic-sized
-		// overload instead — it lets the DCL stream terminate at its
-		// own marker. Source is bounded to the rest of the SDB so
-		// the bit reader can't fall off the end.
+		// _DeCompressSound @ 202f:02ad -> EXPLODE @ 25c6:0d01 (PKWARE DCL Implode),
+		// driven by READDISKSOUND/WRITESOUND callbacks. EXPLODE terminates on its
+		// own length token 519 (end-of-stream marker), not on the SDX sizes:
+		// 202f:0332 even computes `destSize - 2` and never reads it. The SDX
+		// `compressed_size` / `uncompressed_size` fields are loose hints.
+		// ScummVM's fixed-size `decompressDCL` overload errors when actual output
+		// exceeds the pre-allocated buffer (observed on M0 clue voices), so use
+		// the dynamic-sized overload and let DCL terminate at its own marker.
+		// Source is bounded to the rest of the SDB so the bit reader stays in range.
 		const uint32 streamStart = (uint32)sdb.pos();
 		Common::SeekableSubReadStream sub(&sdb, streamStart,
 										   (uint32)sdb.size(),
@@ -343,11 +313,7 @@ void AudioPlayer::spoolSound(uint num) {
 		   num, tc, sampleRate, audioSize,
 		   entry.compressedSize == entry.uncompressedSize ? "raw" : "DCL");
 
-	// `_AIL_start_digital_playback` at 202f:040c — we route through
-	// `kSFXSoundType` so the launcher's "SFX volume" slider applies
-	// (this is the same slider the original would've targeted via
-	// `_AIL_set_digital_master_volume`). Voice clips on the spool path
-	// are gameplay SFX, not the speech-only VOC stream.
+	// _AIL_start_digital_playback @ 202f:040c. Spool clips are gameplay SFX.
 	playPcmBuffer(pcm, audioSize, sampleRate, _spoolHandle,
 				  Audio::Mixer::kSFXSoundType);
 }
@@ -380,17 +346,13 @@ void AudioPlayer::stopSpool() {
 		_mixer->stopHandle(_spoolHandle);
 }
 
+// _SayKDDigital @ 2404:0fbc.
+//   slot = kdspeak * 2 + (partner == Jake ? 1 : 0); sound = digital[slot+1] - 1
+// KDDigitalIndex = KDTextIndex + 0x12 (set by _ReadMystery @ 2404:0163-0167).
 void AudioPlayer::sayKDDigital(const byte *kdTextIndex, uint kdspeak,
 							   uint partner) {
 	if (!kdTextIndex || _currentMystery < 0)
 		return;
-	// `_SayKDDigital @ 2404:0fbc`:
-	//   iVar1 = kdspeak * 2;
-	//   if (_Partner == 0) iVar1++;            // Jake offset
-	//   sound = *(u16 *)(KDDigitalIndex + (iVar1 + 1) * 2) - 1;
-	//   _SpoolSound(sound);
-	// KDDigitalIndex sits 18 bytes (`+ 0x12`) after KDTextIndex per
-	// `_ReadMystery` 2404:0163-0167.
 	const byte *digital = kdTextIndex + 0x12;
 	const uint slot = (kdspeak * 2) + (partner == 0 ? 1u : 0u) + 1u;
 	const uint16 raw = READ_LE_UINT16(digital + slot * 2);
diff --git a/engines/eem/audio.h b/engines/eem/audio.h
index 12667f1179d..1d986ac47bb 100644
--- a/engines/eem/audio.h
+++ b/engines/eem/audio.h
@@ -35,119 +35,84 @@ namespace EEM {
 class EEMEngine;
 
 /**
- * Non-MIDI audio (digitised voice + sound effects). Mirrors the
- * original `SOUND.C` / `SPOOLSND.C` source files in `EEMCD.EXE` —
- * the AIL digital playback path used for both standalone .VOC files
- * and the per-mystery `M%d.SDB` spool stream.
+ * Non-MIDI audio (digitised voice + SFX). Mirrors SOUND.C / SPOOLSND.C.
  *
- * Two pathways:
+ * - VOC files (THUNDER.VOC, PHONE.VOC, JEN.VOC/JAKE.VOC): _LoadSoundName +
+ *   _PlayVoice @ 1ff1:0299 / 1ff1:023e via _AIL_play_VOC_file.
+ * - Mystery spool stream: _InitMysterySounds @ 202f:05cb opens M%d.SDX/.SDB.
+ *   Each SDX entry is (offset, compSize, uncompSize). Equal sizes => raw PCM
+ *   (_UncompressedSound @ 202f:03e6); otherwise PKWARE DCL Implode
+ *   (_DeCompressSound @ 202f:02ad). Each blob starts with SB Time Constant +
+ *   AIL block count, then 8-bit unsigned PCM.
  *
- * 1. **VOC playback** — `_LoadSoundName @ 1ff1:0299` reads a Creative
- *    Voice File into memory and `_PlayVoice @ 1ff1:023e` hands it to
- *    `_AIL_play_VOC_file`. Used for THUNDER.VOC (Storm logo),
- *    PHONE.VOC (briefing), JEN.VOC / JAKE.VOC (partner choose).
- *
- * 2. **Spool stream** — `_InitMysterySounds @ 202f:05cb` opens
- *    `m%d.sdx` (29be:144f) and `m%d.sdb` (29be:145b) for the active
- *    mystery. `_SpoolSound(num) @ 202f:068d` indexes into the SDX
- *    table (12 bytes per entry: u32 file_offset, u32 compressed_size,
- *    u32 uncompressed_size). If sizes match it streams via
- *    `_UncompressedSound @ 202f:03e6`; otherwise it EXPLODE-decompresses
- *    via `_DeCompressSound @ 202f:02ad`.
- *
- *    Each entry's data starts with 2 metadata bytes — Sound Blaster
- *    Time Constant (`rate = 1000000 / (256 - tc)`) and the AIL block
- *    count — followed by the (optionally) PKWARE-DCL-compressed
- *    8-bit unsigned PCM stream. We use ScummVM's `Common::decompressDCL`
- *    + `Audio::makeRawStream` to reach the same audio at the same
- *    sample rate.
- *
- * Mystery 60 (`M60.SDB/SDX`) holds the 19 voiceovers played between
- * ANIM01..ANIM20 in `_DoOpeningAnims` (it loads `_InitMysterySounds(0x3c)`
- * before the loop and `_SpoolSound(uVar3 - 1)` between every clip).
- * Mysteries 0..55 hold each case's per-clue voice plus the partner's
- * digital lines (`_KDDigitalIndex` table within the .SD blob).
+ * M60.SDB/SDX holds the 19 voiceovers between ANIM01..ANIM20 in
+ * _DoOpeningAnims (loaded via _InitMysterySounds(0x3c)).
+ * M0..M55 hold per-mystery clue voice + partner KDDigital lines.
  */
 class AudioPlayer {
 public:
 	explicit AudioPlayer(EEMEngine *vm);
 	~AudioPlayer();
 
-	/// Mirrors the gate in every original audio call site
-	/// (`_DisplayClue` 2404:0845, `_DoChoosePartner` 1a35:098c,
-	/// `_DoOpeningAnims` 2520:08a8, `_DisplayCorrect` 1df2:0780, ...)
-	/// — every `_PlayVoice` / `_SpoolSound` is wrapped in
-	/// `if ((DAT_2d5d_3f97 != 0) && (_VoiceAvailable != 0))`. The
-	/// engine pulls `_voiceOn` (= `DAT_2d5d_3f97`) into here so we
-	/// can early-return at the audio boundary instead of duplicating
-	/// the gate at every call site.
+	/// Setup-screen voice toggle. Mirrors DAT_2d5d_3f97 — every original
+	/// `_PlayVoice` / `_SpoolSound` is wrapped in
+	/// `if ((DAT_2d5d_3f97 != 0) && (_VoiceAvailable != 0))`; we pull the
+	/// flag in here so callers don't duplicate the gate.
 	void setVoiceEnabled(bool enabled) { _voiceEnabled = enabled; }
 	bool voiceEnabled() const { return _voiceEnabled; }
 
-	// VOC playback ----------------------------------------------------
-
-	/// Mirrors `_LoadSoundName` + `_PlayVoice`. Loads the named .VOC
-	/// from the game directory and hands it to the speech mixer
-	/// channel. A new `playVoc` cancels any prior voice.
+	/// Loads the named .VOC from the game directory and hands it to the
+	/// speech mixer channel. Mirrors _LoadSoundName + _PlayVoice
+	/// @ 1ff1:0299 / 1ff1:023e. A new playVoc cancels any prior voice.
 	void playVoc(const Common::Path &vocPath);
 
-	/// Mirrors `_VoicePlaying @ 1ff1:01f9`.
+	/// _VoicePlaying @ 1ff1:01f9.
 	bool isVoicePlaying() const;
 
-	/// Mirrors `_WaitForVoiceDone @ 1ff1:0221`. Blocks (with frame /
-	/// event pumping, like the rest of the engine's busy-loops) until
-	/// the voice clip finishes. Returns early if the user clicks /
-	/// presses a key — same abort behaviour the original
-	/// `_AIL_stop_digital_playback` callback installed.
+	/// _WaitForVoiceDone @ 1ff1:0221. Blocks (with frame + event pumping)
+	/// until the voice clip finishes; returns early on click / keypress.
 	void waitForVoiceDone(uint32 maxMs = 60000);
 
-	/// Mirrors `_StopTheVoice @ 1ff1:0283`.
+	/// _StopTheVoice @ 1ff1:0283.
 	void stopVoice();
 
-	/// Floppy variant: play a VOC by 0..25 slot index in the per-partner
-	/// voice table. Mirrors `_LoadSoundName_Floppy @ 1f4e:0305` which
-	/// indexes the table at `2608:0f0e` (Jake) / `2608:0f76` (Jenny).
-	/// `partner` is 0 for Jake, 1 for Jenny. Common slots: 12 =
-	/// PHONESL.VOC, 20 = partner intro voice, 25 = THUNDER.VOC.
+	/// Play a floppy VOC by 0..25 slot index in the per-partner voice
+	/// table. Mirrors _LoadSoundName_Floppy @ 1f4e:0305 (tables at
+	/// 2608:0f0e Jake / 2608:0f76 Jenny). partner: 0=Jake, 1=Jenny.
+	/// Common slots: 12 = PHONESL.VOC, 20 = partner intro, 25 = THUNDER.VOC.
 	void playFloppyVoiceSlot(uint slot, uint partner);
 
-	// Mystery sound spool ---------------------------------------------
-
-	/// Mirrors `_InitMysterySounds @ 202f:05cb`. Loads `M%u.SDX` into
-	/// memory and remembers the corresponding `M%u.SDB` path.
+	/// Loads M%u.SDX into memory and remembers the matching M%u.SDB path.
+	/// Mirrors _InitMysterySounds @ 202f:05cb.
 	bool initMysterySounds(uint mysteryNum);
 
-	/// Mirrors `_CleanMysterySounds @ 202f:05a5`.
+	/// _CleanMysterySounds @ 202f:05a5.
 	void cleanMysterySounds();
 
-	/// Mirrors `_SpoolSound @ 202f:068d`. Reads + decompresses entry
-	/// `num` from the active SDB and queues it for SFX playback. The
-	/// original blocks until playback finishes — for ScummVM we let
-	/// the mixer run asynchronously and expose `waitForSpoolDone` so
-	/// callers that need the original "block-then-continue" semantics
-	/// can opt in.
+	/// Reads + decompresses SDX entry `num` from the active SDB and queues
+	/// it for SFX playback. Mirrors _SpoolSound @ 202f:068d. Original blocks
+	/// until playback finishes; we run async — use waitForSpoolDone to opt
+	/// in to the original block-then-continue semantics.
 	void spoolSound(uint num);
 
-	/// Mirrors the abort-on-input wait loop inside `_UncompressedSound`
-	/// / `_DeCompressSound`. Returns when the spool clip finishes or
-	/// the user clicks / presses a key.
+	/// Wait loop inside _UncompressedSound / _DeCompressSound; aborts on
+	/// click / keypress (same abort behaviour as the original).
 	void waitForSpoolDone(uint32 maxMs = 60000);
 
-	/// Mirrors the immediate `_AIL_stop_digital_playback` exit.
+	/// Immediate _AIL_stop_digital_playback exit.
 	void stopSpool();
 
 	bool isSpoolPlaying() const;
 
-	/// Mirrors `_SayKDDigital(kdspeak) @ 2404:0fbc`. Each mystery
-	/// embeds a `KDDigitalIndex` table immediately after the 18-byte
-	/// `KDTextIndex` header (set up by `_ReadMystery @ 2404:008f`:
-	/// `_KDDigitalIndex = _KDTextIndex + 0x12`). The table is two
-	/// 1-based sound indices per `kdspeak` slot — Jen at +2, Jake at
-	/// +4 from each entry's start (+1 word for the unused header
-	/// slot). Pass the mystery's `kdTextIndex()` pointer.
+	/// Mirrors _SayKDDigital(kdspeak) @ 2404:0fbc. Each mystery embeds a
+	/// KDDigitalIndex table 18 bytes (+0x12) after its KDTextIndex header
+	/// (set up by _ReadMystery @ 2404:008f: `_KDDigitalIndex = _KDTextIndex
+	/// + 0x12`). Table is two 1-based sound indices per kdspeak slot — Jen
+	/// at +2, Jake at +4 from each entry start (+1 word skips an unused
+	/// header slot). Pass the mystery's kdTextIndex() pointer.
 	void sayKDDigital(const byte *kdTextIndex, uint kdspeak, uint partner);
 
-	/// Mirrors `_QuitSounds @ 1ff1:03c5`.
+	/// _QuitSounds @ 1ff1:03c5.
 	void stopAll();
 
 private:
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 63b384498ca..ebe7383137a 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -35,30 +35,25 @@
 #include "eem/eem.h"
 #include "eem/site.h"
 
-// EEM — clue / briefing pipeline (SCRIPT.C clue parts + KD.C briefing parts).
-// Everything that drives a `ClueBlock` through the displayClue / portrait /
-// balloon / notebook-side-effect flow plus the case-briefing intro that
-// preambles into the same flow.
+// Clue / briefing pipeline (SCRIPT.C + KD.C).
 
 namespace EEM {
 
-// Picture / animation IDs verified against `_DoChoosePartner @ 1a35:0756`.
-// `const` at namespace scope already implies internal linkage in C++,
-// so no `static` needed.
-const uint kPicChooseBackground = 0x8c; ///< `_GetBackground(0x8c)`
-const uint kAniBoy  = 8;                 ///< `_GetAnimation(8)` (Jake)
-const uint kAniGirl = 9;                 ///< `_GetAnimation(9)` (Jenny)
+// _DoChoosePartner @ 1a35:0756.
+const uint kPicChooseBackground = 0x8c; // _GetBackground(0x8c)
+const uint kAniBoy  = 8;                // Jake
+const uint kAniGirl = 9;                // Jenny
 
-// `_DoHappiness @ 172b:27b5`: cursor X picks one of 4 rects; past
-// rect 3 is treated as level 4. Verbatim from `29be:030f`.
+// _DoHappiness @ 172b:27b5 — cursor X picks one of 4 rects @ 29be:030f.
+// Past rect 3 = level 4.
 const Common::Rect kHappyZones[4] = {
-	Common::Rect(  0, 0,  70, 200), // far left  — girl very happy, boy neutral
+	Common::Rect(  0, 0,  70, 200), // far left — girl very happy, boy neutral
 	Common::Rect( 70, 0, 126, 200), // girl's column
 	Common::Rect(126, 0, 182, 200), // middle
 	Common::Rect(182, 0, 235, 200), // boy's column
 };
 
-// On-screen positions verified from `_NewAnimation` calls @ 1a35:07b9 / 07d5.
+// _NewAnimation positions @ 1a35:07b9 / 07d5.
 const int kBoyX  = 0xe2; // 226
 const int kBoyY  = 0x62; // 98
 const int kGirlX = 0x42; // 66
@@ -87,11 +82,9 @@ uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
 	return marked;
 }
 
-// `_DoHappiness @ 172b:27b5`: each cursor zone swaps the partner's
-// sequence script to a more / less "happy" cycle. Boy seqs lifted
-// verbatim from `29be:0337` (5 × 0x14 bytes), girl seqs from
-// `29be:039b`. Both cycle through 9 frames (the boy/girl anim cells
-// contain 10 cells = pairs of "neutral, smile" at increasing intensity).
+// _DoHappiness @ 172b:27b5 — per-zone sequence scripts.
+// Boy seqs @ 29be:0337 (5 × 0x14 bytes), girl seqs @ 29be:039b. 9 frames each;
+// the anim cells contain 10 cells = pairs of (neutral, smile) at 5 intensities.
 const uint8 kBoySeqs[5][9] = {
 	{ 0,0,0,0,0,0,0,1,0 }, // level 0
 	{ 2,2,2,2,2,2,2,3,2 }, // level 1
@@ -115,9 +108,10 @@ uint happinessLevel(int x) {
 	return 4; // past zone 3 → max level
 }
 
-// Lock the framebuffer, masked-blit `p` at (x, y), unlock. The transparent
-// colour is the high byte of `p.flags`; `_AddPicBackground @ 172b:0ed4`
-// pushes `word ptr ES:[BX] >> 8` as `_Rect_Move_Mask`'s mask byte.
+// Masked blit: transparent colour = high byte of p.flags.
+// _AddPicBackground @ 172b:0ed4 pushes `word ptr ES:[BX] >> 8` as
+// _Rect_Move_Mask's mask byte (the on-disk u16 at file offset 0 maps to
+// Picture::flags).
 void blitMaskedToScreen(const Picture &p, int x, int y) {
 	const byte transp = (byte)(p.flags >> 8);
 	Graphics::Surface *screen = g_system->lockScreen();
@@ -150,11 +144,10 @@ void blitRawToScreen(const Picture &p, int x, int y) {
 							   x, y, w, h);
 }
 
+// _DoChoosePartner @ 1a35:0756. The original places boy + girl animations
+// on a backdrop and polls four click rectangles (two per character); we
+// approximate with a single split at x=160 (left=Jenny, right=Jake).
 void EEMEngine::doChoosePartner() {
-	// Mirrors _DoChoosePartner @ 1a35:0756. The original places boy + girl
-	// animations on a backdrop and polls four click rectangles (two per
-	// character) for the player's choice. We approximate by splitting the
-	// screen at x=160: left half = girl (Jenny), right half = boy (Jake).
 	Picture background;
 	if (!_picsArchive.getPicture(kPicChooseBackground, background)) {
 		warning("ChoosePartner background (%u) load failed", kPicChooseBackground);
@@ -174,19 +167,11 @@ void EEMEngine::doChoosePartner() {
 
 	setAnmPalette(Common::Path("TITLE.ANM"));
 
-	// `_DoHappiness @ 172b:27b5`: the cursor's X column picks one of 4
-	// rects (29be:030f, all full-height); past rect 3 → "level 4". The
-	// per-zone sequence scripts (`kBoySeqs` / `kGirlSeqs`) live at file
-	// scope above so the gestures match the original beat-for-beat.
-
-	// `_DoChoosePartner` opens with `_SetMousePos(0xa0, 0x96)` so the
-	// cursor lands centred between the two partners — start the
-	// happiness level from that initial X.
+	// _DoChoosePartner opens with _SetMousePos(0xa0, 0x96).
 	int curMouseX = 0xa0;
 	uint level = happinessLevel(curMouseX);
-	uint seqIdx = 0;       // step within the 9-frame seq
+	uint seqIdx = 0;
 
-	// Initial render — pose 0 of whichever zone the cursor opens in.
 	blitAt(background, 0, 0);
 	blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
 		   kGirlX, kGirlY);
@@ -201,11 +186,10 @@ void EEMEngine::doChoosePartner() {
 
 	uint32 lastTick = g_system->getMillis();
 	while (!shouldQuit()) {
-		// Advance through the 9-frame seq at 100 ms — `_CheckFrameRate`
-		// cadence. The seq is short and loops; matches the original
-		// `_UpdateAnimations` which restarts at curIdx=0 on the 0x80
-		// marker. Mirrors `_DoHappiness`'s rewriting of `curIdx = 0xFFFF`
-		// when the cursor crosses zones (we restart `seqIdx` instead).
+		// 100ms tick: matches _CheckFrameRate's ~10 fps cadence. The seq
+		// is short and loops; _UpdateAnimations restarts at curIdx=0 on
+		// the 0x80 marker. _DoHappiness rewrites curIdx=0xFFFF on zone
+		// change (here we restart seqIdx).
 		if (g_system->getMillis() - lastTick > 100) {
 			lastTick = g_system->getMillis();
 			seqIdx = (seqIdx + 1) % 9;
@@ -265,16 +249,14 @@ void EEMEngine::doChoosePartner() {
 		g_system->delayMillis(20);
 	}
 
-	// Mirrors the tail of `_DoChoosePartner @ 1a35:097f` — once the
-	// player commits to a partner, load and play their intro VOC
-	// (`jen.voc` for Jenny, `jake.voc` for Jake; strings at 29be:0af1 /
-	// 29be:0af9) and block on `_WaitForVoiceDone`.
+	// Partner intro VOC (jake.voc / jen.voc @ 29be:0af9 / 29be:0af1),
+	// tail of _DoChoosePartner @ 1a35:097f.
 	if (_audio) {
 		if (isFloppy()) {
-			// Floppy `_DoChoosePartner_Floppy @ 19bb:0a8e` calls
-			// `_LoadSoundName_Floppy(0x14)` (= slot 20) which the
-			// per-partner table at `2608:0f0e` / `2608:0f76` resolves
-			// to `m-0113sl.voc` (Jake) or `f-0140sl.voc` (Jenny).
+			// Floppy _DoChoosePartner_Floppy @ 19bb:0a8e calls
+			// _LoadSoundName_Floppy(0x14) (slot 20); per-partner tables at
+			// 2608:0f0e / 2608:0f76 resolve to m-0113sl.voc (Jake) or
+			// f-0140sl.voc (Jenny).
 			_audio->playFloppyVoiceSlot(0x14, _partner);
 		} else {
 			_audio->playVoc(Common::Path(
@@ -284,24 +266,16 @@ void EEMEngine::doChoosePartner() {
 	}
 }
 
+// _DoInitClues @ 1a35:0411. Sequence:
+//   1. BG 0x52, palette 0x22
+//   2. anims: game @ (0xcd, 0x6c), book @ (0, 99), nancy @ (0x68, 0x8b) on caseType=1
+//   3. cycle game anim once (click skips)
+//   4. _PlayInSequence per partner+caseType
+//   5. _DisplayClue(InitBlock + 2, 1) — briefing dialogue
+//   6. _OnSites[startSite] = 1
+// Anim IDs: gameAni = 0x17 (Jake) / 0x3b (Jenny);
+// bookAni = 0x18 / 0x3c; nancyAni = 0x19 (caseType 1 only).
 void EEMEngine::doInitClues() {
-	// Mirrors `_DoInitClues` @ 1a35:0411. The original does:
-	//   1. _AllBlack(); _GetBackground(0x52); _GetPalette(0x22);
-	//   2. _GetAnimation(gameAni); _NewAnimation(0xcd, 0x6c, ...)
-	//      _GetAnimation(bookAni); _NewAnimation(0,    99,   ...)
-	//      (case type 1 also: _NewAnimation(0x68, 0x8b, nancyAni))
-	//   3. _UpdateAnimations(); _FadeIn();
-	//   4. while (frame != gameNum) { _CheckFrameRate(); _UpdateAnimations(); }
-	//        — cycles through the entire game animation once. Click skips.
-	//   5. _PlayInSequence(seqId, ...) — plays a follow-up sequence based
-	//      on partner + case type.
-	//   6. _DisplayClue(InitBlock + 2, 1) — the briefing dialogue.
-	//   7. _OnSites[startSite] = 1.
-	//
-	// gameAni / bookAni / nancyAni values verified directly from Ghidra:
-	//   gameAni  = 0x17 (Jake) / 0x3b (Jenny)
-	//   bookAni  = 0x18 (Jake) / 0x3c (Jenny)
-	//   nancyAni = 0x19 (case type 1 only)
 	if (!_mystery.isLoaded())
 		return;
 
@@ -309,12 +283,9 @@ void EEMEngine::doInitClues() {
 	if (!ib)
 		return;
 
-	// CD InitBlock starts with `u16 caseType; u16 startSite; <clue block>`.
-	// Floppy InitBlock starts with `u8 caseType; u8 nSubjects; subjects[];
-	// u8 nDialog; dialog_records[]` — no embedded `startSite`. Verified in
-	// `FUN_19bb_042f` (floppy briefing) where `cVar1 = *(buffer +
-	// initOffset)` reads caseType as a single byte and the dialog loop
-	// uses `local_e = byte[initOffset + 1 + nSubjects + 1]`.
+	// CD InitBlock: u16 caseType; u16 startSite; <clue block>.
+	// Floppy InitBlock (FUN_19bb_042f): u8 caseType; u8 nSubjects;
+	// subjects[]; u8 nDialog; dialog_records[]. No startSite.
 	const bool floppy = isFloppy();
 	const uint16 caseType = floppy ? (uint16)ib[0] : READ_LE_UINT16(ib);
 
@@ -325,12 +296,8 @@ void EEMEngine::doInitClues() {
 		_mystery._siteNumber = startSite;
 		_mystery._lastSite = startSite;
 	} else {
-		// Floppy InitBlock has no startSite. The floppy BigMap iterator
-		// `FUN_1fed_07ed` walks every site in the SITES section
-		// unconditionally (no `_OnSites` gate) — verified at the floppy
-		// `_DoMapScreen @ 1fed:1060` which stamps every site marker
-		// before the interaction loop. Mark every loaded site as visible
-		// so our `_onSites`-gated overview stamps the same set.
+		// Floppy _DoMapScreen @ 1fed:1060 (FUN_1fed_07ed) walks every
+		// site unconditionally — mirror that by marking all visible.
 		const uint sites = _mystery.numSites();
 		for (uint s = 0; s < sites && s < Mystery::kVisitedSiteCap; s++)
 			_mystery._onSites[s] = 1;
@@ -354,34 +321,17 @@ void EEMEngine::doInitClues() {
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
 
-	// Step 4 — cycle through the game animation once before the briefing.
-	// Mirrors the `while (uVar9 != gameNum)` loop. The original calls
-	// `_UpdateAnimations` per `_CheckFrameRate` tick (~10 fps). We use
-	// 100 ms ticks for the same cadence. Click / key skips.
+	// Cycle game animation once (10 fps = _CheckFrameRate cadence).
+	// _DoInitClues @ 1a35:0507/0541 hard-codes the SCRIPT index to Jake's
+	// IDs (0x17/0x18/0x19) regardless of partner, so look up scripts by
+	// those IDs unconditionally.
 	if (haveGame || haveBook || haveNancy) {
 		const uint frameCount = haveGame ? game.size() : 8;
 		bool skip = false;
 		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
-			// Restore BG + advance frame. The original always uses
-			// Jake's anim IDs (0x17/0x18/0x19) as the SCRIPT keys
-			// even when Jenny's CELL data is loaded — verified at
-			// `_DoInitClues @ 1a35:0507`/`0541` where
-			// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), ...)`
-			// hard-codes the script index to 0x17. So we look up
-			// `partnerFrameAtTick(0x17, ...)` regardless of partner.
-			// This gives us the correct cadence — book holds on its
-			// "thinking" pose (cell 8) for 16 ticks instead of
-			// flipbook-cycling, and nancy waits 18 ticks before her
-			// late-arrival count-up.
 			if (haveBriefingBg)
 				blitAt(bg, 0, 0);
 			const uint32 t = frame * 100;
-			// All three briefing anims (game/book/nancy) go through
-			// the original `_NewAnimation` path so per-frame anchors
-			// apply. Use `blitAnimFrameAnchored` against a locked
-			// screen surface so the briefing partner / book / nancy
-			// translate cleanly between cells instead of pinning at
-			// the same top-left.
 			Graphics::Surface *scr = g_system->lockScreen();
 			if (!scr) {
 				skip = true;
@@ -402,11 +352,7 @@ void EEMEngine::doInitClues() {
 			g_system->unlockScreen();
 			g_system->updateScreen();
 
-			// Wait 100 ms or until input. ESC also stops any pending
-			// voice / spool so audio doesn't bleed past the briefing
-			// when the player skips early — without this the per-clue
-			// voice line that `displayClue` would spool keeps playing
-			// after we've moved on to the MAP.
+			// ESC interrupts voice/spool so audio doesn't bleed into the MAP.
 			const uint32 wakeup = g_system->getMillis() + 100;
 			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
 				Common::Event ev;
@@ -428,14 +374,11 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Freeze only the same setup-animation band the original bakes into
-	// its background buffers before clearing the registered animations:
-	// `_VidramRectCopy(0, 0x5a, 0x28, 0x6d, 16000, 48000/32000)`.
-	// Width is in mode-X columns, so 0x28 columns = 160 pixels. This
-	// preserves the lower-left book/Nancy area but intentionally drops the
-	// right-side game animation; `_PlayInSequence` redraws that character
-	// over a clean background next. Preserving the full screen leaves the
-	// old right-side Jake/Jenny frame underneath the sequence.
+	// _VidramRectCopy(0, 0x5a, 0x28, 0x6d, 16000, 48000/32000): freeze the
+	// lower-left book/Nancy band the original bakes into BG buffers before
+	// clearing registered animations. Width is in mode-X cols (0x28 = 160px).
+	// Intentionally drops the right-side game anim; _PlayInSequence redraws
+	// that character over a clean BG next.
 	Graphics::ManagedSurface briefingBase(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	briefingBase.clear();
@@ -464,16 +407,12 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Step 5 — `_PlayInSequence(animSeq, 0xcd, animY)` per Ghidra:
-	//   Jake (partner=0):
-	//     caseType=1 → anim 0x38 at (0xcd, 0x6d)
-	//     caseType=2 → anim 0x37 at (0xcd, 0x6c)
-	//     caseType=3 → anim 0x39 at (0xcd, 0x6c)
-	//   Jenny (partner=1):
-	//     caseType=2 → anim 0x3a at (0xcd, 0x6c)
-	//     caseType=3 → anim 0x3d at (0xcd, 0x6c)
-	// `_PlayInSequence @ 172b:2d03` plays each frame at (sx-w, sy-rowoff)
-	// with mask blit, advancing one frame per `_CheckFrameRate` tick.
+	// _PlayInSequence @ 172b:2d03. Anim selection per partner + caseType:
+	//   Jake:  caseType 1 -> 0x38 @ (0xcd, 0x6d)
+	//          caseType 2 -> 0x37 @ (0xcd, 0x6c)
+	//          caseType 3 -> 0x39 @ (0xcd, 0x6c)
+	//   Jenny: caseType 2 -> 0x3a @ (0xcd, 0x6c)
+	//          caseType 3 -> 0x3d @ (0xcd, 0x6c)
 	uint16 seqAni = 0xFFFF;
 	uint16 seqY   = 0x6c;
 	if (_partner == 0) {
@@ -514,21 +453,13 @@ void EEMEngine::doInitClues() {
 			for (uint frame = 0; frame < seq.size() && !shouldQuit() && !skip;
 				 frame++) {
 				const Picture &fr = seq[frame];
-				// Restore the frozen setup frame so the short overlay
-				// does not make the setup animation wrap to frame 0.
 				g_system->copyRectToScreen(briefingBase.getPixels(),
 										   briefingBase.pitch, 0, 0,
 										   320, 200);
-				// Anchor: `_PlayInSequence @ 172b:2d35-2d50` does
-				//   dstX = sx - cell[+0x8]     ; miscflags (signed)
-				//   dstY = sy - cell[+0x6]     ; rowoff   (signed)
-				// Ghidra's C decompile mis-labels `cell[+0x8]` as
-				// `width`; the actual asm is `SUB AX, ES:[BX+0x8]`,
-				// which is the per-frame X anchor offset stored in our
-				// `Picture::miscflags` field. Using `fr.surface.w`
-				// shifted every frame by the cell width and made the
-				// briefing partner appear duplicated next to the BG-
-				// baked figure (mystery 1 office briefing).
+				// _PlayInSequence @ 172b:2d35-2d50:
+				//   dstX = sx - cell[+0x8]   ; signed X anchor (miscflags)
+				//   dstY = sy - cell[+0x6]   ; signed Y anchor (rowoff)
+				// asm `SUB AX, ES:[BX+0x8]` — NOT the cell width.
 				const int dstX = (int)0xcd - (int)(int16)fr.miscflags;
 				const int dstY = (int)seqY - (int)(int16)fr.rowoff;
 				blitMaskedToScreen(fr, dstX, dstY);
@@ -556,14 +487,11 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// `_DoInitClues` plays a setup-voice ONLY for caseType 2/3.
-	//   CD: caseType 2 → PHONE.VOC. caseType 3 → no voice.
-	//   Floppy `_DoInitClues_Floppy @ 19bb:042f`:
-	//     caseType 2 → `_LoadSoundName_Floppy(slot 0xc)` = PHONESL.VOC
-	//     caseType 3 → `_LoadSoundName_Floppy(slot 3)`   = NEWSCAN.VOC
-	// (newspaper-scanner sting for the "TV/news anchor" briefing
-	// variant). Other case types open straight into the briefing
-	// dialogue without it.
+	// Setup voice (caseType 2/3 only):
+	//   CD:     caseType 2 -> PHONE.VOC
+	//   Floppy (_DoInitClues_Floppy @ 19bb:042f):
+	//     caseType 2 -> slot 0xc (PHONESL.VOC)
+	//     caseType 3 -> slot 3   (NEWSCAN.VOC, news-anchor variant)
 	if (_audio) {
 		if (caseType == 2) {
 			if (floppy)
@@ -577,23 +505,15 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Step 6 — case briefing dialogue. CD InitBlock has the clue block
-	// at +4 (after `u16 caseType; u16 startSite`); floppy uses
-	// `u8 caseType; u8 nSubjects; subjects[nSubjects]; u8 nDialog;
-	// dialog_records[nDialog]` (each record `11 + textCount` bytes),
-	// dispatched via `FUN_22dc_05c8 @ 22dc:05c8`. We render dialog
-	// records ourselves on floppy.
+	// Briefing dialogue. CD: clue block @ ib+4 (after caseType,startSite).
+	// Floppy: dialog records dispatched via FUN_22dc_05c8 @ 22dc:05c8
+	// (record size = 11 + textCount bytes).
 	if (floppy) {
 		displayFloppyBriefing(ib);
 	} else {
 		const byte *briefingClues = ib + 4;
-		// Ghidra confirms CD `_DoInitClues` enters the normal
-		// `_DisplayClue(_InitBlock + 2, 1)` path, and `_DisplayClue`
-		// calls `_AddNotebook` for each ClueEntry note list at
-		// +0x30..+0x39. These starting notes are required before the
-		// first PDA visit, so mark them explicitly here. The regular
-		// displayClue side-effect pass is idempotent and still handles
-		// gallery/site updates in the original order.
+		// _DisplayClue calls _AddNotebook for each ClueEntry note list at
+		// +0x30..+0x39. Mark starting notes before the first PDA visit.
 		const uint marked = markClueBlockNotebookEntries(_mystery, briefingClues);
 		if (marked != 0)
 			debugC(1, kDebugScript,
@@ -603,27 +523,20 @@ void EEMEngine::doInitClues() {
 	}
 }
 
-/// Mirror `_ParseString` @ 1b66:07c3 — substitute the control bytes that
-/// the original engine uses as placeholders. Only the two we encounter most
-/// often (player name = 0x80, partner first name = 0x82) are substituted;
-/// other 0x8N opcodes are stripped. The original engine also handles
-/// hyphenation marks and a hint placeholder (0x89) we ignore for now.
+// _ParseString @ 1b66:07c3 (jump table @ 1b66:0cbe). Each handler reads
+// _Partner (u16 @ 0x7918) and indexes the name table @ 29be:0c28
+// ({Jake, Jennifer, he, she, him, her, his} as far pointers).
+//   0x80 player name (auto-cap word starts, uses _PlayerRecord)
+//   0x81 _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
+//   0x82 _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
+//   0x83 _Partner == 0 ? "he"       : "she"
+//   0x84 _Partner == 0 ? "him"      : "her"
+//   0x85 _Partner == 0 ? "his"      : "her"
+//   0x86..0x88 read a separate gender flag @ 0x7985 (TODO).
+//   0x89 KD hint placeholder (caller handles).
 Common::String EEMEngine::parseString(const Common::String &raw,
 									  const Common::String &playerName,
 									  uint partner) const {
-	// Substitution opcodes from `_ParseString` @ 1b66:07c3, jump-table
-	// at 1b66:0cbe. Each handler reads `_Partner` (16-bit at 0x7918)
-	// and indexes the name table at 29be:0c28 ({Jake, Jennifer, he,
-	// she, him, her, his} as far pointers).
-	//   0x80 — player's typed name (auto-cap word starts) — uses _PlayerRecord
-	//   0x81 — _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
-	//   0x82 — _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
-	//   0x83 — _Partner == 0 ? "he"       : "she"
-	//   0x84 — _Partner == 0 ? "him"      : "her"
-	//   0x85 — _Partner == 0 ? "his"      : "her"
-	//   0x86..0x88 read a different gender flag at 0x7985 — left alone
-	//     until that flag's source is traced.
-	//   0x89 — KD hint placeholder (handled by caller).
 	const bool isJake = (partner == 0);
 	Common::String out;
 	for (uint i = 0; i < raw.size(); i++) {
@@ -665,7 +578,7 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 			// through the default case and renders as a literal,
 			// pushing the line past the balloon's visual edge — the
 			// "bubbles aren't large enough" symptom. Promote it to
-			// `\n` so ScummVM's `Font::wordWrapText` (which honours
+			// `\n` so `Font::wordWrapText` (which honours
 			// embedded newlines) picks the same break point the
 			// original engine did.
 			out += '\n';
@@ -720,24 +633,21 @@ void EEMEngine::applyClueSideEffects(const byte *c) {
 	}
 }
 
+// ClueBlock layout:
+//   +0..1: number (entry count; 0 = no briefing)
+//   +2..3: pic ID for entry 0; entry N>0 uses (entry-1).lastWord
+//   +4..:  array of 62-byte entries
 void EEMEngine::displayClue(const byte *clueBlock) {
 	if (!clueBlock || !_mystery.isLoaded())
 		return;
 
-	// ClueBlock layout (verified against M0.BIN):
-	//   +0..1: number (entry count)
-	//   +2..3: pic ID for entry 0 (entry N>0 uses prev entry's last 2 bytes)
-	//   +4..:  array of 62-byte entries
 	const uint16 number = READ_LE_UINT16(clueBlock);
 	debugC(1, kDebugScript, "displayClue: %u entries", number);
-	if (number == 0 || number > 32) {
-		// number==0 = no briefing (e.g. mystery 0 case-type 4); >32 is a
-		// guard against bad pointers.
+	// number == 0 = no briefing (e.g. mystery 0 case-type 4); >32 = bad ptr.
+	if (number == 0 || number > 32)
 		return;
-	}
 
-	// Snapshot the current screen as the BG so character pics from
-	// earlier entries don't stack on top of each other.
+	// Snapshot BG so per-entry character pics don't stack.
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	bg.clear();
@@ -749,40 +659,29 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		}
 	}
 
+	// ClueEntry layout (62 bytes):
+	//   +0..1, +2..3:   p0 tx, ty       +4..5, +6..7:   p1 tx, ty
+	//   +8..9, +10..11: bubText offset p0/p1 (rel. TextBlock; -1 = none)
+	//   +12..13, +14..15: balloon pic ID p0/p1
+	//   +0x10/+0x12: bubX, bubY (partner 0)
+	//   +0x14/+0x16: bubX, bubY (partner 1)
+	//   +0x18: Jenny voice (1-based)
+	//   +0x1a: Jake  voice (1-based)
+	//   +0x30..+0x39: 5 notebook entries (-1 terminated)
+	//   +0x3a..+0x3b: KD-anim number (-1 = none)
+	// _DisplayClue @ 2404:05e6: partner 1 uses its own field set only when
+	// bubText1 != -1; otherwise falls back to partner 0 fields entirely.
+	// Partner 0 always uses field 0.
 	for (uint i = 0; i < number && !shouldQuit(); i++) {
-		// Restore BG before drawing this entry's portrait + balloon.
 		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
 		const byte *c = clueBlock + 4 + i * 62;
-		// Per-partner fields:
-		//   +0..1, +2..3: tx, ty (partner 0)
-		//   +4..5, +6..7: tx, ty (partner 1)
-		//   +8..9, +10..11: bubText offset for partner 0/1 (rel. TextBlock)
-		//   +12..13, +14..15: balloon picture ID for partner 0/1
-		//   +16..17, +18..19: bubX, bubY
-		//   +0x3a..+0x3b:    KD-anim number (-1 = none)
-		// Per `_DisplayClue` @ 2404:05e6: partner 1 uses its own field
-		// set ONLY when bubText1 is not -1; otherwise it falls back to
-		// the partner 0 fields entirely. Partner 0 always uses field 0.
-
-		// Per-clue partner reaction animation. `_DisplayClue` @
-		// 2404:0635-064b checks `clueEntry[+0x3a]` and, when not -1,
-		// calls `_DoKDAnim(num)` BEFORE drawing the speaker portrait.
-		// This is what surfaces "Jenny takes a picture with a camera"
-		// (and the matching Jake gestures) during NPC searches.
+
+		// _DisplayClue @ 2404:0635-064b: _DoKDAnim(num) runs before the
+		// speaker portrait.
 		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
 		if (kdAnimNum != -1) {
 			playKdAnim((uint16)kdAnimNum);
-			// `playKdAnim` leaves the screen on the partner-less
-			// `_partnerEraseBg`, mirroring its between-frame erase
-			// source. Re-stamp `bg` so the partner sprite captured at
-			// `displayClue` entry returns before we draw the speaker
-			// portrait + balloon. Without this the bubble lands on a
-			// partner-less screen and the partner appears invisible
-			// for the entire first iteration. Mirrors the original's
-			// `_UpdateAnimations @ 172b:09c1` swap on `0x80`: the
-			// kdAnim slot is freed and the WaitHandle (partner) slot
-			// is reactivated with frame index 0xffff, so on the next
-			// tick the partner renders at script[0] of its wait anim.
+			// _UpdateAnimations @ 172b:09c1 reactivates the wait anim.
 			g_system->copyRectToScreen(bg.getPixels(), bg.pitch,
 									   0, 0, 320, 200);
 		}
@@ -798,12 +697,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		const uint16 bubNum = READ_LE_UINT16(c + (useP1 ? 0x0E : 0x0C));
 		const char *raw   = hasText ? _mystery.textAt(textOff) : "";
 
-		// Speaker portrait. Mirrors `_DisplayClue`'s `pic[clues+i*62-2]`:
-		// for entry 0 the pic ID is in the ClueBlock header at +2; for
-		// later entries it sits in the previous entry's last 2 bytes.
-		// Speaker portrait position uses partner 0 fields (+0..+3) when
-		// _partner==0 or when partner 1 falls back; otherwise partner 1
-		// fields (+4..+7). Same logic as the original.
+		// Speaker portrait: pic[clues + i*62 - 2]. Entry 0 ID is in
+		// ClueBlock +2; entries N>0 read (entry-1)+0x3c (last word).
 		const uint16 charX  = READ_LE_UINT16(c + (useP1 ? 4 : 0));
 		const uint16 charY  = READ_LE_UINT16(c + (useP1 ? 6 : 2));
 		const uint16 charPicId = (i == 0)
@@ -817,14 +712,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			}
 		}
 
-		// Substitute control bytes (0x80..0x89) — see `parseString` for
-		// the table. 0x81 = chosen detective, 0x82 = the other one.
 		const Common::String text = parseString(raw ? raw : "",
 												_playerName, _partner);
 
-		// Speech balloon. Mirrors `_GetBalloon` + `_AddPicBackground` in
-		// `_DisplayClue`, with the shared balloon metadata table used for
-		// text placement and the fitted balloon variant.
+		// Speech balloon: _GetBalloon + _AddPicBackground.
 		const uint16 fittedBubNum = fitBalloonToText(bubNum, text);
 		Picture balloon;
 		const uint16 balloonId = fittedBubNum & 0x7F;
@@ -833,9 +724,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			_balloonArchive.loadEntry(balloonId, balloon);
 
 		if (_font.isLoaded() && !text.empty()) {
-			// Snapshot the current screen, overlay balloon + text, then
-			// copy the changed band back. This preserves the site BG
-			// underneath unchanged regions.
 			Graphics::Surface *screen = g_system->lockScreen();
 			if (!screen)
 				break;
@@ -853,27 +741,21 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (haveBalloon) {
 				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
 				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
-				// `_AddPicBackground` passes `pic->miscflags >> 8` as
-				// the transparent colour to `_Rect_Move_Mask`. The
-				// on-disk u16 at file offset 0 maps to `Picture::flags`.
+				// _AddPicBackground: transparent colour = pic->miscflags >> 8.
 				const byte transp = (byte)(balloon.flags >> 8);
-				// `_GetBalloon @ 172b:1d7d` mirrors the picture
-				// horizontally when `(bubNum & 0x80)` is set — used
-				// for right-side speakers so the tail points the
-				// other way. ScummVM's `transBlitFrom` exposes the
-				// same via its `flipped` argument.
+				// _GetBalloon @ 172b:1d7d mirrors horizontally when
+				// (bubNum & 0x80) — for right-side speakers.
 				const bool flipBalloon = (fittedBubNum & 0x80) != 0;
 				if (bw > 0 && bh > 0) {
 					scratch.transBlitFrom(balloon.surface,
 										  Common::Point(bubX, bubY),
 										  transp, flipBalloon);
 				}
-				// Per-balloon metadata from `29be:0875` (52 × 10 bytes,
-				// indexed by `bubNum & 0x7F`). The original `_DisplayClue`
-				// does `_WordWrap(bubX + table[bub].x, bubY + table[bub].y,
-				// table[bub].w, ...)`. `getBalloonInsets` is the shared
-				// accessor (defined in `graphics.cpp`); fall back to the
-				// (5, 4, 155) entry-23 inset if the lookup fails.
+				// Per-balloon insets at 29be:0875 (52 x 10 bytes,
+				// indexed by bubNum & 0x7F). _DisplayClue does
+				// _WordWrap(bubX + table[bub].x, bubY + table[bub].y,
+				//           table[bub].w, ...).
+				// Fallback (5, 4, 155) = entry-23 inset.
 				uint16 bx = 5;
 				uint16 by = 4;
 				uint16 bw_ = 155;
@@ -883,16 +765,15 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				textW = bw_;
 				copyH = bh;
 			} else {
-				// No balloon — clear a band so old pixels don't bleed.
+				// No balloon: clear band.
 				const Common::Rect band(0, bubY, 320,
 					MIN<int>(bubY + copyH, 200));
 				scratch.fillRect(band, 0);
 				copyY = bubY;
 			}
 
-			// `_DisplayClue` @ 2404:07fe passes fontColor=0 (palette
-			// index 0 of the case-briefing palette 0x22) to `_WordWrap`.
-			// Hard-coding 0xF here gave the wrong colour.
+			// _DisplayClue @ 2404:07fe passes fontColor=0 (palette
+			// index 0 of case-briefing palette 0x22).
 			_font.drawWordWrapped(&scratch, textX, textY,
 				MAX<int>(8, textW), text, 0);
 
@@ -902,22 +783,14 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			g_system->updateScreen();
 		}
 
-		// `_DisplayClue` @ 2404:0833-085a — after the balloon is drawn,
-		// spool the per-clue voice. Each ClueEntry stores two 1-based
-		// sound indices: `+0x18` (Jenny voice) and `+0x1a` (Jake voice).
-		//
-		// Critical gate (verified at 2404:0833):
+		// _DisplayClue @ 2404:0833-085a — per-clue voice gate:
 		//   if (clue[+0x18] != 0 && voiceOn && voiceAvail) {
-		//       iVar6 = clue[+0x18];           // Jenny default
-		//       if (Partner == 0) iVar6 = clue[+0x1a];  // Jake override
+		//       iVar6 = clue[+0x18];                  // Jenny default
+		//       if (Partner == 0) iVar6 = clue[+0x1a]; // Jake override
 		//       _SpoolSound(iVar6 - 1);
 		//   }
-		// The condition gates on the JENNY slot regardless of partner.
-		// Some hotspot ClueBlocks define `+0x1a` (Jake voice) but leave
-		// `+0x18` at 0 — for those, the original engine plays nothing
-		// and the entry is text-only. Our previous code gated on the
-		// partner-selected slot and ended up firing unrelated voices
-		// (e.g., a "no audio" entry triggering Jake's spoolSound).
+		// Gate is on the Jenny slot regardless of partner; entries with
+		// +0x18 == 0 but +0x1a set are text-only.
 		if (_audio) {
 			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
 			if (voiceJenny != 0 && voiceJenny != 0xFFFF) {
@@ -928,19 +801,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			}
 		}
 
-		// Wait for click/key to advance — only if we drew something.
-		// ESC skips the entire dialogue rather than just one entry.
-		// Only Return / KP-Enter / Space advance one entry; other keys
-		// are ignored so accidental keystrokes don't blow past dialog
-		// the player hasn't finished reading.
+		// Wait for click/key. ESC skips entire dialog; Return / KP-Enter /
+		// Space advance one entry.
 		if (hasText || (charPicId != 0 && charPicId != 0xFFFF)) {
-			// Click during a clue dialog only dismisses — there are no
-			// hover-interactive areas. Drop the highlighted cursor
-			// state the site loop may have left set so the player
-			// doesn't see a "clickable" cursor stuck on top of the
-			// balloon. The post-dialog `MOUSEMOVE` in the site loop
-			// will re-evaluate against hotspots and re-enable as
-			// needed.
+			// Clear any leftover highlighted-cursor state from the site loop.
 			setInteractiveMouseCursor(false);
 			bool advance = false;
 			bool skipAll = false;
@@ -953,9 +817,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						break;
 					}
 					if (ev.type == Common::EVENT_MOUSEMOVE) {
-						// Keep the cursor non-interactive across moves
-						// inside the dialog (defensive — in case some
-						// other code path tries to set it).
 						setInteractiveMouseCursor(false);
 						continue;
 					}
@@ -963,13 +824,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						advance = true;
 						skipAll = true;
-						// Cut the per-clue voice line that was just
-						// spooled — without this the voice keeps
-						// playing past the dialog dismissal and
-						// bleeds into the next screen (e.g. the
-						// case-briefing voice still talking on the
-						// MAP after ESC). Site / briefing MIDI
-						// stays — only voice + spool halt here.
+						// Cut voice + spool only (keep MIDI).
 						interruptAudio(/*stopMusicToo=*/false);
 						break;
 					}
@@ -985,16 +840,11 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						break;
 					}
 				}
-				// Tick the screen so the OSystem cursor follows the
-				// mouse — ScummVM redraws the cursor overlay only on
-				// updateScreen.
 				g_system->updateScreen();
 				g_system->delayMillis(10);
 			}
 			if (skipAll) {
-				// Apply remaining side-effects without rendering. The
-				// original silently runs the state updates even when the
-				// player skips ahead.
+				// Apply remaining side-effects without rendering.
 				for (uint k = i; k < number; k++)
 					applyClueSideEffects(clueBlock + 4 + k * 62);
 				return;
@@ -1004,13 +854,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		applyClueSideEffects(c);
 	}
 
-	// `_DisplayClue @ 2404:05e6` lets the per-clue voice bleed past
-	// the last entry's dismissal — `_Wait()` returns on ANY input
-	// without touching the digital playback. We deliberately diverge
-	// (mirroring `_StopTheVoice @ 1ff1:0283`'s effect, voice only,
-	// MIDI untouched) so the briefing voice line doesn't keep talking
-	// over the next screen when the player clicks through the final
-	// entry.
+	// _StopTheVoice @ 1ff1:0283 effect (voice only, keep MIDI). Diverges
+	// from _DisplayClue @ 2404:05e6 which lets voice bleed past dismissal.
 	interruptAudio(/*stopMusicToo=*/false);
 }
 
@@ -1027,10 +872,9 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	//   u8  sound    @ +9     (high bit = play voice, low 7 bits = slot)
 	//   u8  textCount@ +10
 	//   u8  textIdx[]@ +11    (1 byte per — low 7 bits = NOTES idx)
-	// Text offsets in NOTES are ABSOLUTE byte offsets into the mystery
-	// buffer (verified by note 0 of M0.BIN at file offset 0xd0 holding
-	// "Hello, ..., I'm ... Eagle!"), so we read text via
-	// `mystery.blobAt(noteEntry[+2..3])` for Jake / `+4..5` for Jenny.
+	// NOTES text offsets are absolute byte offsets into the mystery
+	// buffer; read via mystery.blobAt(noteEntry[+2..3]) for Jake,
+	// +4..5 for Jenny.
 	if (!rec || !isFloppy() || !_font.isLoaded() || count == 0)
 		return;
 
@@ -1039,8 +883,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	if (!notes || !bufBase)
 		return;
 
-	// Snapshot the current screen so each record restores the
-	// briefing background between bubbles.
+	// Snapshot BG for between-bubble restores.
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
@@ -1054,16 +897,9 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	const uint32 dsz       = _mystery.dataSize();
 	const uint32 notesBase = (uint32)(notes - bufBase);
 
-	// Pre-mark every text index across all records as "seen" up front.
-	// `_DisplayHotspotClue_Floppy @ 22dc:05c8` writes
-	// `_TextSeen_Floppy[idx] = 1` inside its render loop, but the
-	// original's `_WaitForClick` blocks until input — so the loop always
-	// runs to completion and every text gets marked. Our `waitForClick`
-	// honours ESC as "skip all" (sets `skipAll`/break), which matches
-	// player expectations for fast-forward but used to drop the seen-bit
-	// for every text after the ESC point. Pre-marking restores parity:
-	// ESC fast-forwards the *visual* but the notebook always reflects
-	// every clue the player would have seen if they'd clicked through.
+	// _DisplayHotspotClue_Floppy @ 22dc:05c8 writes _TextSeen_Floppy[idx]=1
+	// inline per text. Pre-mark all up front so ESC-fast-forward still
+	// records every clue in the notebook.
 	{
 		const byte *r = rec;
 		for (uint i = 0; i < count; i++) {
@@ -1078,14 +914,9 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	}
 
 	auto waitForClick = [&]() -> bool {
-		// Drain pending events first so a previous keystroke's tail
-		// doesn't auto-advance the new page.
+		// Drain pending events so a stale keystroke doesn't auto-advance.
 		Common::Event drain;
 		while (g_system->getEventManager()->pollEvent(drain)) {}
-		// Click during the floppy dialog only dismisses — no
-		// hover-interactive areas. Clear any stuck highlight from the
-		// site loop so the cursor stays a normal pointer over the
-		// balloon.
 		setInteractiveMouseCursor(false);
 		const uint32 minVisibleMs = 250;
 		const uint32 startedAt = g_system->getMillis();
@@ -1125,25 +956,18 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		const uint8  ballY    = rec[8];
 		const uint8  textCount= rec[10];
 
-		// Per-record voice (byte 9 high bit) — see comment in original
-		// header.
+		// byte 9: high bit = play voice slot.
 		const bool playedRecordVoice = (rec[9] & 0x80) != 0 && _audio;
 		if (playedRecordVoice) {
 			const uint slot = rec[9] & 0x7f;
 			_audio->playFloppyVoiceSlot(slot, _partner);
 		}
-		// Suspect-found side effect for byte 9 with high bit clear.
-		// Mirrors `_DisplayHotspotClue_Floppy @ 22dc:0908..0922`:
+		// _DisplayHotspotClue_Floppy @ 22dc:0908..0922 suspect-found:
 		//   if ((rec[9] & 0x80) == 0 && rec[9] != 0)
 		//     _InGallery_Floppy[_GalleryShuffleSeed_Floppy[rec[9]]] = 1;
-		// `_GalleryShuffleSeed_Floppy` is a byte array at DS:0x2d65 that
-		// overlaps `_NewOrder_Floppy` at DS:0x2d66 by one byte: i.e.
-		// `_GalleryShuffleSeed[i+1] == _NewOrder[i]` (verified by
-		// comparing the two writes in `_ReadMystery_Floppy @ 22dc:0178`).
-		// So in our terms the mapping is `_inGallery[_newOrder[b9 - 1]]`.
-		// Skipping `_newOrder` (as we did before) drew the right portrait
-		// only when `_newOrder` happened to be identity — randomized
-		// shuffles dropped one of the two M0 suspects.
+		// _GalleryShuffleSeed (DS:0x2d65) overlaps _NewOrder (DS:0x2d66)
+		// by one byte: GalleryShuffleSeed[i+1] == NewOrder[i]. So:
+		// _inGallery[_newOrder[b9 - 1]].
 		const uint8 b9 = rec[9];
 		if ((b9 & 0x80) == 0 && b9 != 0) {
 			const uint logicalIdx = (uint)b9 - 1;
@@ -1216,14 +1040,9 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		const int textX = ballX + textXIns;
 		const int lineH    = _font.getFontHeight();
 
-		// Pagination state — `FUN_22dc_05c8`'s text-idx loop uses
-		// `local_1c` (set from the PREVIOUS text's flag bit) to decide
-		// between "fresh page" (redraw balloon, restart Y at top) and
-		// "continuation" (append below previous lines). We mirror that
-		// state machine so multi-text records render the same way the
-		// original does — without it our impl concatenates every text
-		// idx into ONE balloon and the result spills out the bottom
-		// (the user's "bubbles aren't large enough" screenshot).
+		// FUN_22dc_05c8 pagination via local_1c (set from previous text's
+		// flag bit): 0 = fresh page (redraw balloon, Y at top),
+		// 1 = continuation (append below previous lines).
 		bool firstPage  = true;
 		int  cursorY    = ballY + textYIns;
 		bool skipAll    = false;
@@ -1255,9 +1074,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			scratch.simpleBlitFrom(*bg.surfacePtr());
 
 			if (firstPage) {
-				// Optional character portrait — only draws once per
-				// fresh page (matches the original which only redraws
-				// the balloon on `local_1c == 0`).
+				// Original only redraws balloon + portrait on local_1c == 0.
 				if (picID != 0 && picID != 0xFFFF) {
 					Picture pic;
 					if (_picsArchive.getPicture(picID, pic)) {
@@ -1285,7 +1102,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 				cursorY = ballY + textYIns;
 			}
 
-			// Wrap text into lines and draw each at cursorY.
 			Common::Array<Common::String> lines;
 			_font.wordWrapText(text, MAX<int>(8, (int)textWidth), lines);
 			for (uint l = 0; l < lines.size(); l++) {
@@ -1295,26 +1111,20 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			}
 			cursorY += (int)lines.size() * lineH;
 
-			// Decide pagination for the NEXT text idx based on THIS
-			// text's high bit.
+			// Pagination for next text idx is driven by this text's high bit.
 			const bool textHighBit = (idxByte & 0x80) != 0;
 			const bool isLastText  = (t + 1 == textCount);
 			const bool isLastRec   = (i + 1 == count);
 
-			// Stamp the "click to continue" indicator (PIC 0xa0
-			// "more" arrow / PIC 0xa1 end indicator) before
-			// flipping to wait. Mirrors `_DisplayHotspotClue_Floppy
-			// @ 22dc:08aa` (mid-page) and `@ 22dc:08c0`
-			// (end-of-record). We skip drawing it on the very last
-			// click of the very last record when the caller passes
-			// `lastIndicator == 0` — that's the original `param_2 ==
-			// 0` "no indicator" case.
+			// "Click to continue" indicator: PIC 0xa0 ("more" arrow) or
+			// PIC 0xa1 (end). _DisplayHotspotClue_Floppy @ 22dc:08aa
+			// (mid-page) / @ 22dc:08c0 (end-of-record). lastIndicator == 0
+			// matches the original param_2 == 0 "no indicator" case.
 			bool waitNeeded   = false;
 			bool drawArrow    = false;
 			bool useEndPic    = false;
 			if (!isLastText) {
 				if (textHighBit) {
-					// continuation — no wait, no indicator
 					firstPage = false;
 				} else {
 					waitNeeded = true;
@@ -1322,15 +1132,12 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					useEndPic  = false;
 				}
 			} else {
-				// Last text in this record.
 				waitNeeded = true;
 				if (!isLastRec) {
-					// More records follow — original passes
-					// `param_2 = 1` → PIC 0xa0.
+					// More records follow → param_2 = 1 → PIC 0xa0.
 					drawArrow = true;
 					useEndPic = false;
 				} else {
-					// Last record. Use caller-supplied indicator.
 					if (lastIndicator == 1) {
 						drawArrow = true;
 						useEndPic = false;
@@ -1373,11 +1180,8 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 }
 
 void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
-	// Floppy briefing — `FUN_19bb_042f @ 19bb:042f` walks
-	// `nDialog = ib[2 + nSubjects]` records starting at
-	// `ib + 3 + nSubjects`. Each record is rendered identically to a
-	// hotspot dialog record (same `FUN_22dc_05c8` callee), so we
-	// share `displayFloppyDialogRecords`.
+	// FUN_19bb_042f @ 19bb:042f: walks nDialog = ib[2 + nSubjects] records
+	// starting at ib + 3 + nSubjects, dispatching each to FUN_22dc_05c8.
 	if (!initBlock || !isFloppy())
 		return;
 	const uint8 nSubjects = initBlock[1];
@@ -1387,19 +1191,12 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 }
 
 void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
-	// Floppy hotspot click — mirrors `FUN_22dc_0b80 @ 22dc:0b80` +
-	// `FUN_1652_00e6 @ 1652:00e6` + `FUN_1652_006c @ 1652:006c`.
-	// Each site stores a per-hotspot dialog list at
-	// `site_data[+6..7]`. The list is laid out as:
-	//   for each hotspot in order:
+	// FUN_22dc_0b80 @ 22dc:0b80 + FUN_1652_00e6 @ 1652:00e6 +
+	// FUN_1652_006c @ 1652:006c. site_data[+6..7] -> per-hotspot list:
+	//   for each hotspot:
 	//     main record (11 + textCount bytes)
-	//     u8 contFlags  (low 7 bits = continuation count, high bit
-	//                    used by FUN_1652_00e6 to drive the partner
-	//                    pose — irrelevant for text rendering)
-	//     contCount × { record (11 + textCount bytes) }
-	// We walk past `hotIdx` hotspots, then dispatch the matched main
-	// record + its continuation chain through the same renderer
-	// `displayFloppyBriefing` uses.
+	//     u8 contFlags  (low 7 = cont count, high bit = partner pose)
+	//     contCount x { record (11 + textCount bytes) }
 	if (!_mystery.isLoaded() || !isFloppy())
 		return;
 	const byte *site = _mystery.siteData(siteNum);
@@ -1411,10 +1208,8 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		return;
 	uint32 off = dlgListOff;
 	for (uint h = 0; h < hotIdx; h++) {
-		// Skip main record.
 		const byte *rec = bufBase + off;
 		off += 11 + rec[10];
-		// Read continuation count and skip those records.
 		const uint contCount = bufBase[off] & 0x7F;
 		off += 1;
 		for (uint c = 0; c < contCount; c++) {
@@ -1424,23 +1219,8 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 	}
 	if (off >= _mystery.dataSize())
 		return;
-	// Layout per hotspot is:
-	//   main record (11 + textCount bytes)
-	//   1 byte: continuation count (high bit = partner-pose flag,
-	//           low 7 bits = number of follow-up records)
-	//   continuation records (each 11 + textCount bytes, tightly packed)
-	// `displayFloppyDialogRecords` walks tightly so we have to call it
-	// twice — once for the main record, once for the continuations —
-	// otherwise the second iteration treats the cont-count byte as a
-	// record header and runs off the buffer.
-	//
-	// Each `displayFloppyDialogRecords` call snapshots the screen to
-	// know what to redraw between bubbles. If we just call it twice
-	// back-to-back the second snapshot includes the first call's last
-	// bubble, so the second bubble draws on top of the first one (the
-	// "background isn't redrawn" glitch the user reported). Capture
-	// the clean site BG here and restore it between the two calls so
-	// every record snapshots a bubble-free background.
+	// Snapshot clean site BG and restore between main + continuation calls
+	// so each displayFloppyDialogRecords sees a bubble-free background.
 	Graphics::ManagedSurface siteBG(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
@@ -1458,11 +1238,10 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		contFlagsByte = bufBase[off + mainLen];
 		contCount = contFlagsByte & 0x7F;
 	}
-	// `_HandleHotspotClick_Floppy @ 1652:00e6` derives the main
-	// record's `param_2` from the continuation byte:
-	//   contFlagsByte == 0  → 0 (no indicator, only one record)
-	//   high bit set         → 1 (PIC 0xa0, "more" arrow)
-	//   low 7 bits non-zero  → 2 (PIC 0xa1, alternate end)
+	// _HandleHotspotClick_Floppy @ 1652:00e6 derives param_2:
+	//   contFlagsByte == 0  -> 0 (no indicator)
+	//   high bit set        -> 1 (PIC 0xa0, "more")
+	//   low 7 bits non-zero -> 2 (PIC 0xa1, alt end)
 	uint mainIndicator = 0;
 	if (contFlagsByte != 0) {
 		mainIndicator = (contFlagsByte & 0x80) ? 1 : 2;
@@ -1473,19 +1252,12 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 	const uint32 contOff = off + mainLen + 1;
 	if (contOff >= _mystery.dataSize())
 		return;
-	// Wipe the main bubble before the continuation chain snapshots the
-	// screen — otherwise the first continuation bubble treats the
-	// post-main-bubble image as its background and the main balloon
-	// pixels persist behind every following balloon.
+	// Wipe main bubble so the continuation chain snapshots a clean BG.
 	g_system->copyRectToScreen(siteBG.getPixels(), siteBG.pitch,
 							   0, 0, 320, 200);
 	g_system->updateScreen();
-	// Continuation chain: `_DisplayDialogContinuations_Floppy @
-	// 1652:006c` passes `param_2 + -1 != 0` (= 1 if more records
-	// follow, 0 on the last). Our renderer maps that automatically
-	// because mid-batch records always get PIC 0xa0; the last record
-	// uses our `lastIndicator` argument (= 0, no indicator on the
-	// final continuation).
+	// _DisplayDialogContinuations_Floppy @ 1652:006c: lastIndicator=0
+	// means no indicator on the final continuation.
 	displayFloppyDialogRecords(bufBase + contOff, contCount, 0);
 }
 
@@ -1498,12 +1270,10 @@ bool EEMEngine::areYouSure() {
 		g_system->unlockScreen();
 	}
 
-	// CD `_AreYouSure @ 1a35:0a5c` and floppy `FUN_19bb_0b43` both:
-	//   * load PIC 0x136 as the dialog body,
-	//   * load PIC 0x1fd / 0x1fe as the pressed YES / NO button images,
-	//   * center the dialog,
-	//   * hit-test YES at (x+0x0c,y+0x23)-(x+0x20,y+0x32),
-	//     and NO at (x+0x60,y+0x23)-(x+0x74,y+0x32).
+	// _AreYouSure @ 1a35:0a5c (CD) / FUN_19bb_0b43 (floppy):
+	//   PIC 0x136 = dialog body; PIC 0x1fd/0x1fe = YES/NO pressed buttons.
+	//   YES hit: (x+0x0c, y+0x23) .. (x+0x20, y+0x32)
+	//   NO  hit: (x+0x60, y+0x23) .. (x+0x74, y+0x32)
 	Picture dialogPic;
 	Picture yesPic;
 	Picture noPic;
@@ -1569,7 +1339,7 @@ bool EEMEngine::areYouSure() {
 				break;
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
-				// Spanish prompt is "S - Si" — accept both Y and S.
+				// Spanish prompt is "S - Si": accept Y and S.
 				if (ev.kbd.keycode == Common::KEYCODE_y ||
 					ev.kbd.keycode == Common::KEYCODE_s ||
 					ev.kbd.keycode == Common::KEYCODE_RETURN) {
@@ -1605,7 +1375,6 @@ bool EEMEngine::areYouSure() {
 		g_system->delayMillis(15);
 	}
 
-	// Restore the screen so the caller's UI is intact.
 	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 	return result;
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 7fe94f56f39..48cf5629a1d 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -56,10 +56,7 @@ const ADGameDescription gameDescriptions[] = {
 		GUI_OPTIONS_EEM_FLOPPY
 	},
 	{
-		// Spanish floppy release — same EEM.EXE binary as the English
-		// floppy (the engine code is identical), with a localised
-		// PICS.DBD that swaps the embedded English image text for
-		// Spanish equivalents.
+		// Spanish floppy: same EEM.EXE as English floppy, localised PICS.DBD.
 		"eem",
 		"Floppy",
 		AD_ENTRY2s("EEM.EXE",   "692a5e6e7f4516d6e40c1f80cbc1b2cc", 109542,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 0af062785e1..b809bf86c41 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -47,28 +47,25 @@
 
 namespace EEM {
 
-const uint kPalSize = 768;     ///< 256 colors * 3 bytes
-const uint kNumSitePals = 40;  ///< SITEPALS holds 40 palettes (40 * 768 = 30720)
+const uint kPalSize = 768;
+const uint kNumSitePals = 40;  // SITEPALS: 40 * 768 = 30720
 
-// Picture / palette IDs from the original code (1-based picture IDs).
-const uint kPicEAKidsLogo      = 0x54;  ///< _ShowEAKids: GetPicture(0x54)
-const uint kPicHighScoreLogo   = 0x20c; ///< _ShowHScoreLogo: GetPicture(0x20c)
-const uint kPicStormLogo       = 0x20b; ///< Floppy storm-logo still: PIC 0x20b
+// 1-based picture/palette IDs.
+const uint kPicEAKidsLogo      = 0x54;  // _ShowEAKids
+const uint kPicHighScoreLogo   = 0x20c; // _ShowHScoreLogo
+const uint kPicStormLogo       = 0x20b; // Floppy storm-logo still
 const uint kPalEAKids          = 0x25;
 const uint kPalHighScore       = 0x27;
-const uint kPalStormLogo       = 0x26;  ///< Floppy `FUN_23d2_0605` palette idx
-const uint kPicMousePointer    = 0x50;  ///< Original startup pointer; 0x51 is the wait cursor
+const uint kPalStormLogo       = 0x26;  // Floppy FUN_23d2_0605
+const uint kPicMousePointer    = 0x50;  // 0x51 is the wait cursor
 
 const byte kSaveBodyVer = 1;
 
-// Internal test switch: populate ScrapBook 1 at startup without exposing a
-// game option or changing save format. Set false before release.
+// Test switch: populate ScrapBook 1 at startup without exposing a game
+// option or changing save format. Set false before release.
 const bool kDebugPopulateScrapbook1AtStartup = false;
 
-// Fallback 11x16 mouse cursor used if the selected PIC pointer cannot be
-// loaded. The original game sets the cursor visible/hidden via
-// _MouseCursor; we leave it on once the screens that need it
-// (ChoosePartner, ActionScreen, CaseSelection, sites) are reached.
+// Fallback 11x16 cursor used if PIC pointer load fails.
 //   0 = transparent, 1 = black outline, 2 = white fill
 const byte kCursorBitmap[11 * 16] = {
 	1,1,0,0,0,0,0,0,0,0,0,
@@ -89,14 +86,14 @@ const byte kCursorBitmap[11 * 16] = {
 	0,0,0,0,0,0,1,2,2,1,0
 };
 const byte kCursorPalette[] = {
-	0x00, 0x00, 0x00, // 0 — transparent (key)
-	0x00, 0x00, 0x00, // 1 — outline
-	0xFF, 0xFF, 0xFF  // 2 — fill
+	0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00,
+	0xFF, 0xFF, 0xFF
 };
 const byte kCursorInteractivePalette[] = {
-	0x00, 0x00, 0x00, // 0 — transparent (key)
-	0xFF, 0x00, 0x00, // 1 — red outline
-	0xFF, 0xFF, 0xFF  // 2 — white fill
+	0x00, 0x00, 0x00,
+	0xFF, 0x00, 0x00,
+	0xFF, 0xFF, 0xFF
 };
 
 static void fadeCurrentPaletteToBlack(uint delayMs = 8) {
@@ -201,10 +198,6 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	ConfMan.registerDefault("hide_highlight_boxes", false);
 	ConfMan.registerDefault("fit_dialog_balloons", false);
 
-	// `ADGameDescription::extra` is set by the matching entry in
-	// `gameDescriptions[]` ("CD" or "Floppy"). Keep variant detection
-	// purely string-based so a future re-release with a different
-	// `extra` tag falls back to CD-style asset paths.
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
 				 ? kVariantFloppy : kVariantCD;
@@ -228,7 +221,7 @@ void EEMEngine::applyStartupTestOverrides() {
 }
 
 Common::Error EEMEngine::run() {
-	// _SetMode13X @ 1000:0358 enters VGA mode 13h (320x200x256).
+	// _SetMode13X @ 1000:0358 — VGA mode 13h.
 	initGraphics(320, 200);
 
 	if (!openArchives())
@@ -237,48 +230,36 @@ Common::Error EEMEngine::run() {
 	if (!loadSitePalettes())
 		return Common::Error(Common::kReadingFailed, "SITEPALS load failed");
 
-	// _LoadFont @ 1b66:023c — main 8 px bitmap font.
+	// _LoadFont @ 1b66:023c.
 	if (!_font.load(Common::Path("FONT.FNT")))
 		warning("FONT.FNT failed to load; text will not render");
 
-	// MIDI music player. Mirrors `_InitMIDI @ 20a2:013a`. Constructed
-	// here (after `initGraphics` so the OSystem's timer/mixer is up).
+	// _InitMIDI @ 20a2:013a.
 	_music = new MusicPlayer(isFloppy());
 
-	// Digital audio (VOC + spool). Mirrors `_InitDrivers @ 1ff1:0368`
-	// which `_AIL_register_driver`s SBDIG.ADV / PASDIG.ADV alongside
-	// the MIDI driver.
+	// _InitDrivers @ 1ff1:0368 (SBDIG.ADV / PASDIG.ADV).
 	_audio = new AudioPlayer(this);
 	_audio->setVoiceEnabled(_voiceOn);
 	syncSoundSettings();
 
-	// CD `_main @ 1a35:0f59` and floppy `_main_Floppy @ 19bb:1012`
-	// both load `_GetPicture(0x50)` as the active mouse pointer before
-	// calling `_InitMouse`. PIC 0x51 is present in both archives but has
-	// no executable xrefs and appears to be the wait cursor.
+	// CD `_main @ 1a35:0f59` and floppy `_main_Floppy @ 19bb:1012` both
+	// load `_GetPicture(0x50)` as the active mouse pointer before
+	// `_InitMouse`. PIC 0x51 is present in both archives but has no
+	// executable xrefs and appears to be the wait cursor.
 	// CD's `_SwitchMouse` supports swapping to a hotspot cursor ID stored
 	// at search record +0x0c, but the shipped CD mystery data only uses
 	// cursor 0; floppy search records have no cursor-id field.
 	installMouseCursor(_picsArchive, false);
 	CursorMan.showMouse(false);
 
-	// _AllBlack @ 172b:0d4b paints the screen black before the first handler.
+	// _AllBlack @ 172b:0d4b.
 	byte black[3 * 256] = { 0 };
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
 
 	debugC(1, kDebugGeneral, "EEM engine starting");
 
-	// If the user chose "Load" before pressing Play, the framework
-	// invokes `loadGameState` which sets up `_playerName`, `_partner`,
-	// `_mysteriesSolved`, and (optionally) `_mystery`. Honour that by
-	// skipping the intros — the player has already typed their name
-	// and picked a partner, so the title chain + profile picker +
-	// partner picker would all be redundant.
-	//
-	//   * Save HAS a mystery in progress → resume at MAP (mirrors the
-	//     original's post-briefing state, handler 0 at 1a35:0e1d).
-	//   * Save has NO mystery → drop into `_ActionScreen`, same as the
-	//     original after partner selection.
+	// Resume from save: mystery in progress → MAP (handler 0 @ 1a35:0e1d);
+	// otherwise → ACTION.
 	const int wantedSave = ConfMan.hasKey("save_slot")
 		? ConfMan.getInt("save_slot") : -1;
 	bool resumed = false;
@@ -303,51 +284,40 @@ Common::Error EEMEngine::run() {
 		}
 	}
 
-	// Skip the entire intro chain (logos + anims + name entry +
-	// partner pick) when resuming a saved profile — the partner is
-	// already known, the player has already named themselves, and the
-	// loaded mystery's site loop is what they want to see again.
 	if (resumed)
 		goto screen_loop;
 
-	// Reproduces _DoOpeningAnims @ 2520:082a:
-	//   EA Kids logo (PIC) -> HighScore Productions logo (PIC) ->
-	//   Storm Software logo (BOLT.ANM) -> [music starts] -> 20
-	//   character-intro animations (ANIM01.A..ANIM20.A) -> [music
-	//   restarts] -> TITLE.ANM. Click / any key skips a single clip;
-	//   ESC skips the rest of the chain (waitForInput / playAnm raise
-	//   `_skipIntro` so each subsequent step bails out).
-	//
-	// Music timing (verified at 2520:0883 and 2520:0918):
-	//   - The three logos and `_InitMysterySounds(0x3c)` all run BEFORE
-	//     any `_MIDIPlayFile` call — those segments are voice-only.
+	// _DoOpeningAnims @ 2520:082a:
+	//   EA Kids logo (PIC) -> HighScore logo (PIC) -> Storm logo
+	//   (BOLT.ANM) -> [music starts] -> 20 character-intro anims
+	//   (ANIM01.A..ANIM20.A) -> [music restarts] -> TITLE.ANM. Click /
+	//   any key skips one clip; ESC raises _skipIntro so each step bails
+	//   out.
+	// Music timing (2520:0883 + 2520:0918):
+	//   - The three logos and `_InitMysterySounds(0x3c)` run BEFORE any
+	//     `_MIDIPlayFile` — those segments are voice-only.
 	//   - Theme starts with `_LoopMIDI = 0x7fff` right before the
 	//     ANIM01..ANIM20 loop (2520:0883).
-	//   - After the loop the original calls `_CleanMysterySounds` and
-	//     then `_MIDIPlayFile("theme.xmi")` again with `_LoopMIDI =
-	//     0xffff` (2520:0918) to restart the theme for TITLE.ANM.
-	//   - `_StopMIDI()` runs on keypress at the title screen
-	//     (2520:094c).
+	//   - After the loop the original calls `_CleanMysterySounds` then
+	//     `_MIDIPlayFile("theme.xmi")` again with `_LoopMIDI = 0xffff`
+	//     (2520:0918) to restart for TITLE.ANM.
+	//   - `_StopMIDI()` runs on keypress at the title screen (2520:094c).
 	_skipIntro = false;
 	if (isFloppy()) {
-		// Floppy opening — driven by `FUN_23d2_039c @ 23d2:039c`:
+		// Floppy opening — `FUN_23d2_039c @ 23d2:039c`:
 		//   FUN_23d2_0170()  — clear palette
 		//   FUN_23d2_004b()  — set up timer
-		//   FUN_23d2_050c()  — show PIC 0x54 (EA Kids logo, palette 0x25)
-		//   FUN_23d2_06c6()  — show PIC 0x20c (High Score logo, palette 0x27)
-		//   FUN_23d2_0605()  — show PIC 0x20b (Storm Software, palette
-		//                      0x26) AND play voice slot 25 (thunder.voc
-		//                      — verified via the Jake voice table at
-		//                      `2608:0f0e` slot 25 → `2608:11ac` =
-		//                      "thunder.voc").
+		//   FUN_23d2_050c()  — PIC 0x54 (EA Kids, palette 0x25)
+		//   FUN_23d2_06c6()  — PIC 0x20c (High Score, palette 0x27)
+		//   FUN_23d2_0605()  — PIC 0x20b (Storm, palette 0x26) AND
+		//                      voice slot 25 = "thunder.voc" (via Jake
+		//                      voice table 2608:0f0e slot 25 →
+		//                      2608:11ac).
 		//   _MIDIPlayFile("theme.xmi", loop=1)
-		//   _PlayANM(idx=0) — CHAT.ANM (filename table at `2608:14fe`
-		//                     index 0 → "chat.anm" at `2608:150a`).
-		//   _PlayANM(idx=1) — MOVIE.ANM (table index 1 → `2608:1513`).
-		// `TITLE.ANM` is shown later by screen-`0xb` handler `@
-		// 19bb:0ebc` once the intro driver returns. The thunder VOC
-		// alongside the storm logo is the "intro voice" the user heard
-		// missing — without it the lightning-bolt logo plays silently.
+		//   _PlayANM(0) — CHAT.ANM (filename table 2608:14fe[0] →
+		//                  "chat.anm" at 2608:150a)
+		//   _PlayANM(1) — MOVIE.ANM (table[1] → 2608:1513)
+		// TITLE.ANM is shown later by screen-0xb handler @ 19bb:0ebc.
 		if (!shouldQuit() && !_skipIntro)
 			showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
@@ -366,11 +336,10 @@ Common::Error EEMEngine::run() {
 		showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
 			showHighScoreLogo();
-		// Storm Software logo: voice + animation. The original at
-		// `_ShowStormLogo @ 2520:0707` calls `_LoadSoundName(
-		// "thunder.voc")` (29be:177d) and passes the buffer to
-		// `OpenDifferenceAnimation_Sound` so the thunder roar plays
-		// alongside the lightning bolt.
+		// Storm Software logo: voice + animation. `_ShowStormLogo @
+		// 2520:0707` calls `_LoadSoundName("thunder.voc")` (29be:177d)
+		// and passes the buffer to `OpenDifferenceAnimation_Sound` so
+		// the thunder roar plays alongside the lightning-bolt BOLT.ANM.
 		if (!shouldQuit() && !_skipIntro) {
 			if (_audio)
 				_audio->playVoc(Common::Path("THUNDER.VOC"));
@@ -381,13 +350,9 @@ Common::Error EEMEngine::run() {
 			if (_audio)
 				_audio->stopVoice();
 		}
-		// `_InitMysterySounds(0x3c)` at 2520:086a — load M60.SDX/SDB
-		// so `_SpoolSound(uVar3 - 1)` between the ANIM01..ANIM20 anims
-		// has data to draw from.
+		// _InitMysterySounds(0x3c) @ 2520:086a — load M60.SDX/SDB.
 		if (!shouldQuit() && !_skipIntro && _audio)
 			_audio->initMysterySounds(60);
-		// Theme begins HERE — after the three silent logos, before
-		// the character-intro reel.
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 		for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
@@ -397,21 +362,17 @@ Common::Error EEMEngine::run() {
 			Common::String name = Common::String::format("ANIM%02d.A", i);
 			playAnm(Common::Path(name), 120,
 					/*holdLastFrame=*/false, fadeIn);
-			// `_SpoolSound(uVar3 - 1)` at 2520:08c2 — per-character
-			// VO after each anim except the last (`if (uVar3 != 0x14)`
-			// at 2520:08a8). Original blocks until done; we run async
-			// and wait so the next anim doesn't start prematurely.
+			// _SpoolSound(uVar3 - 1) @ 2520:08c2 — per-anim VO, skipped
+			// when uVar3 == 0x14 @ 2520:08a8.
 			if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
 				_audio->spoolSound((uint)(i - 1));
 				_audio->waitForSpoolDone();
 			}
 		}
-		// `_CleanMysterySounds` at 2520:0903 — release M60 before the
-		// title.
+		// _CleanMysterySounds @ 2520:0903.
 		if (_audio)
 			_audio->cleanMysterySounds();
-		// Restart the theme for TITLE.ANM — matches the second
-		// `_MIDIPlayFile("theme.xmi")` call at 2520:0918.
+		// _MIDIPlayFile("theme.xmi") @ 2520:0918.
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
 		if (!shouldQuit() && !_skipIntro)
@@ -426,24 +387,13 @@ Common::Error EEMEngine::run() {
 		goto screen_loop;
 	}
 
-	// After the title chain, the original goes Title (B) -> screen 8
-	// (NewPlayer / saved-record selection) -> screen 9 (ChoosePartner) ->
-	// screen C (ActionScreen). Choosing a mystery there enters screen A
-	// (CaseSelection) and then the site loop.
-	// Mouse stays hidden through the opening anims; show it now for
-	// the interactive screens (matches `_MouseCursor = 1` at the tail
-	// of `_NewPlayer`).
+	// Title(B) -> screen 8 (profile) -> 9 (partner) -> C (action) ->
+	// A (case selection) -> site loop.
 	CursorMan.showMouse(true);
 
-	// Stop the title music — the original `_NewPlayer / _DoChoosePartner`
-	// screens have no music until the briefing's `_PlayInSequence` /
-	// per-mystery `_StartTravelMusic` kicks in.
 	if (_music)
 		_music->stop();
-	// Profile pick (or fresh creation) — `screen8_handler @ 1c33:1012`.
-	// `doProfilePicker` lists existing profiles via `listProfiles()`
-	// and falls through to `doNewPlayer` if none exist or the user
-	// picks "[New Player]".
+	// screen8_handler @ 1c33:1012.
 	if (!shouldQuit())
 		doProfilePicker();
 	if (!shouldQuit())
@@ -451,31 +401,16 @@ Common::Error EEMEngine::run() {
 	if (!shouldQuit())
 		doChoosePartner();
 
-	// Now drop into the screen-driver state machine — same pattern as
-	// `_ScreenDriver @ 1a35:0dc1` + the per-screen handlers in the
-	// table at 1a35:0e5e. The original sets `_NextScreen` either
-	// directly (e.g. `_DisplayCorrect` writes 12 = ACTION) or via the
-	// jumptable handlers (e.g. handler 0 calls `_DoInitClues` then
-	// writes 1 = MAP). The handlers here mirror that exactly: each
-	// case body runs the screen and updates `_nextScreen` for the next
-	// iteration. Sentinel `kScreenInvalid` (0xFFFF) ends the loop —
-	// same as the original's table-end marker.
-	//
-	// `_DoChoosePartner @ 1a35:099d` sets `_NextScreen = 0xc` (= the
-	// original `_ActionScreen` — "Choose A Mystery / Practice Mystery /
-	// See ScrapBook 1..3"). That screen is separate from handler 10's
-	// `_DoChooseMystery` / `_CaseSelection`, which is where the "Book N"
-	// title is drawn.
-	//
-	// Mid-mystery profile resume: if the profile picker loaded a
-	// save whose `hasMystery` flag was set, `_mystery.isLoaded()` is
-	// true here and the player just re-picked their partner. Drop
-	// straight to MAP rather than the action menu so they don't have
-	// to walk back through the case picker (which would
-	// `_mystery.load()` fresh and discard their site / clue
-	// progress). The original has no equivalent — it persists only
-	// profile-level state via `_PlayerRecord`, not in-progress
-	// mysteries — so this is a ScummVM-only ergonomics improvement.
+	// Drop into the screen-driver state machine — same pattern as
+	// `_ScreenDriver @ 1a35:0dc1` + the per-screen handler table at
+	// 1a35:0e5e. Sentinel `kScreenInvalid` (0xFFFF) ends the loop.
+	// `_DoChoosePartner @ 1a35:099d` writes `_NextScreen = 0xc` (the
+	// original `_ActionScreen`, which is separate from handler 10's
+	// `_DoChooseMystery` / `_CaseSelection`).
+	// Mid-mystery resume: if the loaded save had `hasMystery` set,
+	// drop straight to MAP rather than the action menu so the player
+	// doesn't walk back through the case picker (which would
+	// `_mystery.load()` fresh and discard site / clue progress).
 	if (!shouldQuit() && !resumed)
 		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
 screen_loop:
@@ -485,11 +420,9 @@ screen_loop:
 
 		switch (current) {
 		case kScreenTitle:
-			// Floppy handler 0xb (`_HandleScreen11_Title_Floppy`) calls
-			// `_DoTitle_Floppy`, whose `_PlayTitleANM_Floppy(1)` file
-			// table entry is `TITLE.ANM`. The opening driver stops after
-			// `MOVIE.ANM`; this live screen owns the title wait and then
-			// writes `_NextScreen = 8` for the profile picker.
+			// Floppy handler 0xb _HandleScreen11_Title_Floppy ->
+			// _DoTitle_Floppy -> _PlayTitleANM_Floppy(1)=TITLE.ANM.
+			// Writes _NextScreen=8 (profile picker).
 			_nextScreen = kScreenProfile;
 			if (isFloppy()) {
 				CursorMan.showMouse(false);
@@ -503,8 +436,8 @@ screen_loop:
 		case kScreenAction:
 			// Top-level post-profile / post-mystery menu. `_ActionScreen
 			// @ 1c33:195b` shows the 5-entry "Choose A Mystery /
-			// Practice / ScrapBook" picker and writes screen 0xa only
-			// when the player picks "Choose A Mystery".
+			// Practice / ScrapBook 1..3" picker; writes _NextScreen=0xa
+			// only when the player picks "Choose A Mystery".
 			_nextScreen = kScreenInvalid;
 			doActionScreen();
 			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
@@ -512,9 +445,8 @@ screen_loop:
 			break;
 
 		case kScreenChooseMystery:
-			// Handler 10 at 1a35:0e0e calls `_DoChooseMystery` which
-			// presets `_NextScreen = 0` (INIT_CLUES) before
-			// `_CaseSelection`.
+			// Handler 10 @ 1a35:0e0e -> _DoChooseMystery (presets
+			// _NextScreen=0 INIT_CLUES) -> _CaseSelection.
 			_nextScreen = kScreenInvalid;
 			doCaseSelection();
 			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
@@ -522,8 +454,8 @@ screen_loop:
 			break;
 
 		case kScreenInitClues:
-			// Handler 0 at 1a35:0e14 runs `_PreLoad` + `_DoInitClues`
-			// then writes `_NextScreen = 1` (MAP).
+			// Handler 0 @ 1a35:0e14 -> _PreLoad + _DoInitClues, writes
+			// _NextScreen=1 (MAP).
 			doInitClues();
 			_nextScreen = _mystery.isLoaded() ? kScreenMap
 											  : kScreenAction;
@@ -531,13 +463,11 @@ screen_loop:
 
 		case kScreenMap:
 		case kScreenMapAlt:
-			// Handler 1/2 at 1a35:0e25 calls `_DoMapScreen @
-			// 20fe:120b` (floppy: 19bb:0ef3 -> 1fed map code),
-			// which manages its own `_NextScreen` writes — 3 (a site
-			// was clicked), 6 (setup), or 0xffff (quit).
-			// Our `doBigMap` keeps the original's "click site, then
-			// enter the site loop" behaviour inline; once it returns
-			// the natural next state is SITE.
+			// Handler 1/2 @ 1a35:0e25 -> `_DoMapScreen @ 20fe:120b`
+			// (floppy 19bb:0ef3 -> 1fed map code), which manages its
+			// own _NextScreen writes: 3 (site clicked), 6 (setup), or
+			// 0xffff (quit). After `doBigMap` returns the natural
+			// next state is SITE.
 			doBigMap();
 			if (!_mystery.isLoaded())
 				_nextScreen = kScreenAction;
@@ -546,21 +476,19 @@ screen_loop:
 			break;
 
 		case kScreenSite:
-			// Handler 3 at 1a35:0e2c calls `_DoSiteLoop @
-			// 168d:03f4` (floppy's equivalent dispatches through
-			// 1652). Site writes `_NextScreen` for PDA / map rather
-			// than entering those screens as nested modals.
+			// Handler 3 @ 1a35:0e2c -> `_DoSiteLoop @ 168d:03f4`
+			// (floppy dispatches through 1652). Site writes _NextScreen
+			// for PDA / map rather than entering those as nested modals.
 			doSiteLoop();
 			if (!_mystery.isLoaded())
 				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
-				_nextScreen = kScreenInvalid;  // user quit
+				_nextScreen = kScreenInvalid;
 			break;
 
 		case kScreenNotebook:
-			// Handler 4 calls the PDA notebook screen. Its button
-			// handler writes 2 (map), 3 (site), 5 (gallery), or 7
-			// (accuse) and then returns to this dispatcher.
+			// Handler 4 — PDA notebook screen. Button handler writes
+			// 2 (map), 3 (site), 5 (gallery), or 7 (accuse).
 			doNotebook();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -569,9 +497,8 @@ screen_loop:
 			break;
 
 		case kScreenGallery:
-			// Handler 5 calls the suspect gallery. Like the original,
-			// ESC and the site button write 3, the map button writes
-			// 2, and the PDA button writes 4.
+			// Handler 5 — suspect gallery. ESC and the site button
+			// write 3, the map button writes 2, the PDA button writes 4.
 			doGallery();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -580,24 +507,15 @@ screen_loop:
 			break;
 
 		case kScreenSetup:
-			// Handler 6 at 1a35:0e48 calls `_DoSetup @ 1f78:044e`.
-			// Reachable via the BigMap setup button which writes
-			// `_NextScreen = 6` (verified at 20fe:0c33). The
-			// original sets `_NextScreen = _LastScreen` on entry,
-			// then the toggle UI returns when ESC / Back is hit;
-			// `doSetup` sets `_nextScreen` itself.
+			// Handler 6 @ 1a35:0e48 -> _DoSetup @ 1f78:044e. Entered
+			// from BigMap setup button (_NextScreen=6 @ 20fe:0c33).
 			doSetup();
 			break;
 
 		case kScreenProfile:
-			// Handler 8 is the player/profile picker. CD
-			// `screen8_handler @ 1c33:1012` loads an existing player
-			// record or runs `_NewPlayer`; floppy
-			// `_HandleScreen8_NewPlayer_Floppy @ 19bb:0ec2` then
-			// writes screen 9. Mirror that route inline: after the
-			// profile is selected, choose a partner, then continue to
-			// the selected profile's loaded case if ScummVM save state
-			// had one, otherwise to case selection.
+			// Handler 8: CD screen8_handler @ 1c33:1012 ->
+			// _NewPlayer; floppy _HandleScreen8_NewPlayer_Floppy @
+			// 19bb:0ec2 writes screen 9.
 			_nextScreen = kScreenInvalid;
 			_mystery.clear();
 			doProfilePicker();
@@ -611,9 +529,8 @@ screen_loop:
 			break;
 
 		case kScreenAccuse:
-			// Handler 7 runs the accusation flow. A failed accusation
-			// returns to `_LastScreen`; a correct solution writes
-			// 0xc (ACTION).
+			// Handler 7 — accusation flow. Failed accusation returns to
+			// `_LastScreen`; a correct solution writes 0xc (ACTION).
 			doAccuse();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -646,7 +563,7 @@ void EEMEngine::setHotspotMouseCursor(bool active) {
 }
 
 bool EEMEngine::openArchives() {
-	// _InitGraphicsSystem @ 172b:0145 opens these five .DBD/.DBX pairs.
+	// _InitGraphicsSystem @ 172b:0145.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
 		warning("PICS archive missing");
 		return false;
@@ -655,8 +572,6 @@ bool EEMEngine::openArchives() {
 		warning("ANI archive missing");
 		return false;
 	}
-	// SITES + BALLOON are optional for the boot path but needed for site
-	// rendering and clue display.
 	if (!_sitesArchive.open(Common::Path("SITES.DBD"), Common::Path("SITES.DBX")))
 		warning("SITES archive missing — site backgrounds disabled");
 	if (!_balloonArchive.open(Common::Path("BALLOON.DBD"), Common::Path("BALLOON.DBX")))
@@ -689,8 +604,8 @@ bool EEMEngine::loadSitePalettes() {
 bool EEMEngine::getSitePalette(uint num, byte *out) const {
 	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize)
 		return false;
-	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects 8-bit
-	// (0..255), so left-shift by 2 like the original VGA hardware did.
+	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects
+	// 8-bit (0..255), so left-shift by 2 like the original VGA hardware.
 	const byte *src = _sitePals.data() + num * kPalSize;
 	for (uint i = 0; i < kPalSize; i++)
 		out[i] = (byte)(src[i] << 2);
@@ -726,12 +641,12 @@ bool EEMEngine::setAnmPalette(const Common::Path &anmPath) {
 
 void EEMEngine::interruptAudio(bool stopMusicToo) {
 	// Mirrors `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @
-	// 20a2:0512` — the original calls both whenever the player aborts
-	// the opening-anim chain or dismisses the title (`_DoOpeningAnims
-	// @ 2520:082a` writes `_LoopMIDI = 0; _StopMIDI();` after the
-	// title-input loop). Conversation / clue-dialog skip paths pass
-	// `stopMusicToo = false` so the site / briefing MIDI keeps going
-	// across an ESC — only the per-line voice + spool need to stop.
+	// 20a2:0512` — both fire when the player aborts the opening-anim
+	// chain or dismisses the title (`_DoOpeningAnims` writes
+	// `_LoopMIDI = 0; _StopMIDI();` after the title-input loop).
+	// Conversation / clue-dialog skip paths pass `stopMusicToo = false`
+	// so the site / briefing MIDI keeps going across an ESC — only the
+	// per-line voice + spool need to stop.
 	if (_audio) {
 		_audio->stopVoice();
 		_audio->stopSpool();
@@ -778,13 +693,8 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 		}
 		g_system->updateScreen();
 
-		// Drain events and let the user skip with click/key. The original
-		// uses _CheckFrameRate / _kbhit; we use a simple fixed delay until
-		// the frame-rate calibration logic from _GetSpeedRating is wired up.
-		// ESC additionally sets `_skipIntro` so the opening-anim chain in
-		// run() bails out of the whole sequence instead of advancing to
-		// the next clip — and stops every active audio channel so the
-		// theme music / voice spool don't bleed past the abort.
+		// Original uses _CheckFrameRate / _kbhit; fixed delay here.
+		// ESC sets _skipIntro and interrupts audio.
 		const uint32 frameStart = g_system->getMillis();
 		bool aborted = false;
 		while (g_system->getMillis() - frameStart < frameDelayMs && !aborted) {
@@ -805,11 +715,10 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 					break;
 				}
 			}
-			// Refresh ScummVM's cursor overlay every tick — without
-			// this the cursor only redraws when the next frame is
-			// blitted (every `frameDelayMs` ms, ~8 Hz at 120 ms),
-			// which the user perceives as choppy / unresponsive
-			// during long animations like SCRAPBK.ANI.
+			// Refresh cursor overlay every tick — otherwise
+			// the cursor only redraws when the next frame is blitted
+			// (~8 Hz at 120 ms), perceived as choppy during long
+			// animations like SCRAPBK.ANI.
 			g_system->updateScreen();
 			g_system->delayMillis(5);
 		}
@@ -818,9 +727,7 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 	}
 
 	if (holdLastFrame && !shouldQuit() && !_skipIntro) {
-		// Mirror the wait-loop at the end of `_DoOpeningAnims`:
-		//   while (!keyDataAvailable) ;
-		// We accept either a click or a key.
+		// _DoOpeningAnims tail: while (!keyDataAvailable).
 		while (!shouldQuit()) {
 			Common::Event ev;
 			bool clicked = false;
@@ -845,10 +752,7 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 			g_system->updateScreen();
 			g_system->delayMillis(20);
 		}
-		// `_DoOpeningAnims @ 2520:0945` writes `_LoopMIDI = 0;
-		// _StopMIDI();` once the title-input loop exits — so the
-		// theme stops the moment the player dismisses the title,
-		// regardless of whether they used ESC or clicked.
+		// _DoOpeningAnims @ 2520:0945: _LoopMIDI=0; _StopMIDI().
 		if (_music)
 			_music->stop();
 	}
@@ -865,22 +769,9 @@ void EEMEngine::blitAt(const Picture &pic, int x, int y) {
 }
 
 void EEMEngine::waitForInput(uint32 maxMs) {
-	// ESC additionally raises `_skipIntro` so the opening-anim chain
-	// can fast-forward past the rest of the sequence, and stops any
-	// active audio so the theme / voice / spool don't bleed past
-	// the abort. Mirrors the `_CleanMysterySounds` + `_StopMIDI`
-	// pair around the title wait in `_DoOpeningAnims`.
-	//
-	// Only Return / KP-Enter / Space / Escape advance — letting any key
-	// dismiss balloons makes typing-while-reading (or a stuck modifier)
-	// blow past dialog the player hasn't finished reading.
-	//
-	// Drop the highlighted-cursor state any caller (site loop, gallery,
-	// notebook hover-handler) may have left on. While a balloon /
-	// intro is showing, click anywhere just dismisses — no
-	// hover-interactive areas — so a "clickable" cursor over the
-	// dialog is misleading. The caller's MOUSEMOVE handler will
-	// re-enable the highlight after we return if appropriate.
+	// ESC: _skipIntro + interruptAudio (matches _CleanMysterySounds +
+	// _StopMIDI around _DoOpeningAnims title wait).
+	// Only Return/KP-Enter/Space/Escape advance.
 	setInteractiveMouseCursor(false);
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
@@ -892,8 +783,6 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 				return;
 			}
 			if (event.type == Common::EVENT_MOUSEMOVE) {
-				// Defensive: keep the cursor non-interactive across
-				// moves while the dialog is up.
 				setInteractiveMouseCursor(false);
 				continue;
 			}
@@ -916,18 +805,17 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 }
 
 void EEMEngine::showEAKidsLogo() {
-	// Mirrors `_ShowEAKids @ 2520:05f0`. The original:
+	// _ShowEAKids @ 2520:05f0:
 	//   1. GetPicture(0x54) + MemoryCopy to VGA + GetPalette(0x25).
 	//   2. FRAME_RATE = 25; for j in 0..1, for u in 0..0x36 (= 55):
 	//        OpenColorCycle(0x01, 0x6e)   // bg / outer ring shimmer
 	//        OpenColorCycle(0x81, 0xee)   // inner gradient shimmer
 	//        every 8 ticks: OpenColorCycle(0x70, 0x80)  // mid band
-	//   3. After the 110-tick loop: 5 more cycles of 0x70..0x80.
-	//   4. Wait 0x23 (= 35) more frames.
+	//   3. Tail: 5 more cycles of 0x70..0x80.
+	//   4. Wait 0x23 (=35) more frames.
 	//   5. _OpenFadeOut.
-	// The cycling is what gives the EA Kids logo its characteristic
-	// shifting glow — it's NOT a static logo. ESC / click skips the
-	// remaining cycle.
+	// The cycling is what gives the EA Kids logo its shifting glow —
+	// it's NOT a static logo.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicEAKidsLogo, pic)) {
 		warning("EA Kids logo (%u) load failed", kPicEAKidsLogo);
@@ -937,12 +825,7 @@ void EEMEngine::showEAKidsLogo() {
 	setSitePalette(kPalEAKids);
 	g_system->updateScreen();
 
-	// 25 fps → 40 ms / tick. Two outer iterations × 55 ticks each = 110
-	// ticks of palette rotation. The first inner-loop iteration of each
-	// outer pass rotates the in-memory palette once *before* applying
-	// to VGA (original gates the wait + setmany on `show != 0`); we
-	// just call the setter every tick — the visible cycle starts
-	// immediately, which is the same end result.
+	// 25 fps -> 40 ms / tick.
 	const uint kFrameMs = 40;
 	int delayCount = 8;
 	bool aborted = false;
@@ -957,7 +840,6 @@ void EEMEngine::showEAKidsLogo() {
 			}
 			g_system->updateScreen();
 
-			// Tick wait + skip detection.
 			const uint32 frameEnd = g_system->getMillis() + kFrameMs;
 			while (g_system->getMillis() < frameEnd && !aborted) {
 				Common::Event ev;
@@ -985,22 +867,19 @@ void EEMEngine::showEAKidsLogo() {
 	if (aborted)
 		return;
 
-	// Tail: 5 more rotations of the mid band, then 35 idle frames.
 	for (uint i = 0; i < 5 && !shouldQuit(); i++)
 		cyclePaletteRangeReverse(0x70, 0x80);
 	g_system->updateScreen();
 	waitForInput(0x23 * kFrameMs);
 
 	// `_OpenFadeOut @ 2520:0093` — 16 linear steps from current palette
-	// to black. Without this, the EA Kids logo cuts hard to the next
-	// screen.
+	// to black.
 	fadeCurrentPaletteToBlack();
 }
 
 void EEMEngine::showHighScoreLogo() {
-	// Mirrors `_ShowHScoreLogo @ 2520:0799`:
-	//   GetPicture(0x20c) + MemoryCopy to VGA + GetPalette(0x27) +
-	//   _OpenFadeIn + 50-tick wait at 25 fps + _OpenFadeOut.
+	// _ShowHScoreLogo @ 2520:0799: PIC 0x20c + palette 0x27;
+	// _OpenFadeIn; 50-tick wait @ 25 fps; _OpenFadeOut.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicHighScoreLogo, pic)) {
 		warning("HighScore logo (%u) load failed", kPicHighScoreLogo);
@@ -1008,9 +887,7 @@ void EEMEngine::showHighScoreLogo() {
 	}
 	blitAt(pic, 0, 0);
 
-	// Load target palette into a buffer, force a black palette, then
-	// fade in — without the explicit black step we'd flash the full
-	// logo briefly between blit and fade.
+	// Force black before fade-in to avoid a 1-frame full-logo flash.
 	byte target[kPalSize];
 	if (!getSitePalette(kPalHighScore, target)) {
 		warning("HighScore palette (%u) load failed", kPalHighScore);
@@ -1021,7 +898,7 @@ void EEMEngine::showHighScoreLogo() {
 	g_system->updateScreen();
 	fadePaletteFromBlack(target);
 
-	// 50 ticks at 25 fps = ~2 s.
+	// 50 ticks @ 25 fps.
 	waitForInput(2000);
 
 	fadeCurrentPaletteToBlack();
@@ -1032,9 +909,8 @@ void EEMEngine::showFloppyStormLogo() {
 	//   GetPicture(0x20b); BlitToVGA;
 	//   if (sound) { LoadVOC(slot 25 = "thunder.voc"); PlayVOC(...); }
 	//   GetPalette(0x26); FadeIn; wait 50 ticks; FadeOut.
-	// CD plays `BOLT.ANM` at this slot with `THUNDER.VOC` overlaid; the
-	// floppy uses a static still + the same VOC. Without the VOC the
-	// lightning logo plays silently — the user noticed.
+	// CD plays `BOLT.ANM` here with `THUNDER.VOC` overlaid; floppy uses
+	// a static still + the same VOC.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicStormLogo, pic)) {
 		warning("Storm logo (%u) load failed", kPicStormLogo);
@@ -1063,34 +939,27 @@ void EEMEngine::showFloppyStormLogo() {
 }
 
 void EEMEngine::doSiteLoop() {
-	// Mirrors the per-mystery site loop. SiteScreen::run() handles
-	// hotspot clicks plus M (map), N (notebook), G (gallery), A (accuse),
-	// Tab (next site), ESC (exit).
+	// Per-mystery site loop. SiteScreen::run() handles hotspot clicks
+	// plus M (map), N (notebook), G (gallery), A (accuse), Tab (next
+	// site), ESC (exit).
 	SiteScreen screen(this, &_mystery);
 	screen.run();
 	setHotspotMouseCursor(false);
 }
 
 void EEMEngine::startTravelMusic() {
-	// Mirrors `_StartTravelMusic @ 20a2:0595`:
-	//
+	// _StartTravelMusic @ 20a2:0595:
 	//   for (num = _SiteNumber; num > 4; num -= 5) {}
 	//   if (_MIDIAvailable && _MusicEnabled) {
 	//       if (_IsMIDIPlaying()) _StopMIDI();
 	//       _MIDIPlay(num);
 	//   }
-	//
-	// Five travel tracks: MUS00000.XMI .. MUS00004.XMI, picked by
-	// `_SiteNumber % 5`. ONE-SHOT — `_DoOpeningAnims @ 2520:0945`
-	// resets `_LoopMIDI = 0` after the title-screen wait, and
-	// `_StartTravelMusic` doesn't write to it; combined with
-	// `_DoSiteLoop @ 168d:06c0` which waits for the track to play
-	// out and then calls `_StopMIDI()` before the interactive phase
-	// begins, the original effectively plays travel music ONCE
-	// during the entrance animation only — the site investigation
-	// itself runs without music. Our previous `loop=true` made the
-	// music never end, leaving travel music droning through site
-	// investigation, accuse, gallery, etc.
+	// Five travel tracks (MUS00000.XMI..MUS00004.XMI), picked by
+	// `_SiteNumber % 5`. ONE-SHOT — `_DoOpeningAnims @ 2520:0945` resets
+	// `_LoopMIDI = 0` after the title-screen wait, and the function
+	// doesn't write it; combined with `_DoSiteLoop @ 168d:06c0` calling
+	// `_StopMIDI()` before the interactive phase, travel music plays
+	// ONCE during the entrance animation only.
 	if (!_music || !_mystery.isLoaded() || !_voiceOn)
 		return;
 	const uint num = _mystery._siteNumber % 5;
@@ -1144,16 +1013,10 @@ bool EEMEngine::hasFeature(EngineFeature f) const {
 }
 
 bool EEMEngine::canLoadGameStateCurrently(Common::U32String *) {
-	// Loading mid-mystery would replace `_mystery._data` while
-	// pointers into it are alive on the stack inside `displayClue`
-	// etc. Profile picking still works via `loadProfile` from the
-	// menu screens before a mystery loads.
 	return !_mystery.isLoaded();
 }
 
 bool EEMEngine::canSaveGameStateCurrently(Common::U32String *) {
-	// Profile saves (no mystery loaded) are always OK; mid-mystery
-	// snapshots only after the active case has fully initialised.
 	return true;
 }
 
@@ -1161,31 +1024,27 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 										 bool isAutosave) {
 	(void)isAutosave;
 
-	// Body header: one byte version. `Common::Serializer::setVersion`
-	// alone doesn't write/read the version, so emit it explicitly and
-	// require an exact match on load.
+	// Body header: explicit 1-byte version.
 	Common::Serializer s(nullptr, stream);
 	s.setVersion(kSaveBodyVer);
 	byte ver = kSaveBodyVer;
 	s.syncAsByte(ver);
 
-	// Profile-level state — mirrors the original `_PlayerRecord` body
-	// at `2d5d:3f6a` (159 bytes, written by `_SavePlayerRecord @
-	// 1c33:034f`). The `_PlayerRecord` layout is:
+	// _PlayerRecord body @ 2d5d:3f6a (159 bytes, written by
+	// _SavePlayerRecord @ 1c33:034f). Layout:
 	//   +0x00..+0x0b : player name (12 chars, null-padded)
-	//   +0x0c..+0x1f : random ID bytes used by `_GenerateFilename`
+	//   +0x0c..+0x1f : random ID bytes for `_GenerateFilename`
 	//                  (29be:0dbf "C:\EEMCDSAV\%s.PLR") — irrelevant to
 	//                  ScummVM saves which key on slot, not filename.
 	//   +0x20..+0x28 : derived 8-char .PLR basename — likewise unused.
-	//   +0x2d        : voice-enable flag (`DAT_2d5d_3f97`, default 1).
-	//   +0x2f        : chain stage (`DAT_2d5d_3f99`, 1=A, 2=B, 3=C —
-	//                  `_DisplayCorrect` advances it once every case
-	//                  in the current set is solved).
+	//   +0x2d        : voice-enable flag (`DAT_2d5d_3f97`, default 1)
+	//   +0x2f        : chain stage (`DAT_2d5d_3f99`, 1=A, 2=B, 3=C;
+	//                  `_DisplayCorrect` advances once every case in the
+	//                  current set is solved)
 	//   +0x31..+0xa6 : `mysteriesSolved[55]` u16 (0=unsolved, 1=solved,
-	//                  2=solved on first try) — `_DisplayCorrect`
-	//                  writes 1 always, 2 when `_FirstTry != 0`.
-	//
-	// We persist the gameplay-meaningful subset and skip the original
+	//                  2=solved on first try — `_DisplayCorrect` writes
+	//                  1 always, 2 when `_FirstTry != 0`)
+	// We persist the gameplay-meaningful subset and skip the original's
 	// filename-derivation bytes.
 	s.syncString(_playerName);
 	s.syncBytes(_mysteriesSolved, sizeof(_mysteriesSolved));
@@ -1193,11 +1052,8 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	s.syncAsByte(_chainStage);
 	s.syncAsByte(_voiceOn);
 
-	// ScummVM-only extension: persist the in-progress mystery so the
-	// player can resume mid-case. The original engine has no such
-	// notion — `_LoadGame @ 2404:0dc7` simply loads a fresh mystery,
-	// it doesn't preserve site progress. The flag lets a profile save
-	// stay valid even when no mystery is loaded (e.g. fresh profile).
+	// Mid-case resume: persist in-progress mystery (no equivalent in
+	// _LoadGame @ 2404:0dc7).
 	bool hasMystery = _mystery.isLoaded();
 	s.syncAsByte(hasMystery);
 	if (hasMystery) {
@@ -1246,13 +1102,13 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 			resetSiteArrivalState();
 			return Common::kReadingFailed;
 		}
-		// `_ReadMystery @ 2404:008f` calls `_InitMysterySounds` at the
-		// tail (2404:0298) so the SDB index is in place for clue and
+		// `_ReadMystery @ 2404:008f` tail-calls `_InitMysterySounds`
+		// (2404:0298) so the SDB index is in place for clue and
 		// partner-speech spool sounds. Floppy ships individual
-		// `M-XXXX.VOC` files instead of the bundled SDB / SDX archive,
-		// so skip the init there to avoid spamming "missing" warnings;
-		// `spoolSound` then silently no-ops via the `_currentMystery <
-		// 0` guard until the per-voice VOC mapping is wired up.
+		// `M-XXXX.VOC` files instead of the bundled SDB/SDX archive,
+		// so we skip the init there to avoid "missing" warnings;
+		// `spoolSound` then no-ops via its `_currentMystery < 0` guard
+		// until the per-voice VOC mapping is wired up.
 		if (_audio && !isFloppy())
 			_audio->initMysterySounds(mysteryNum);
 		_mystery.syncState(s);
@@ -1273,13 +1129,9 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 }
 
 SaveStateList EEMEngine::listProfiles() const {
-	// Mirrors `_findfirst("*.PLR")` in `screen8_handler @ 1c33:1012`.
-	// We disable autosave (`getAutosaveSlot()` returns -1) but a
-	// pre-existing slot-0 file from a previous run, or one written by
-	// another engine using the same target, would still show up here
-	// and pollute the picker. Filter it out so the picker treats slot
-	// 0 as if it didn't exist — matching the original which never
-	// has an autosave concept.
+	// _findfirst("*.PLR") in screen8_handler @ 1c33:1012.
+	// Filter out slot 0 (ScummVM autosave) to match the original which
+	// has no autosave concept.
 	SaveStateList saves = getMetaEngine()->listSaves(_targetName.c_str());
 	for (uint i = 0; i < saves.size(); ) {
 		if (saves[i].getSaveSlot() == 0)
@@ -1296,8 +1148,7 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 
 	const SaveStateList saves = listProfiles();
 
-	// Slot lookup by description: if a save with this profile name
-	// already exists, overwrite it. Same as Wetlands' `saveProfile`.
+	// Overwrite on matching description.
 	int slot = -1;
 	for (auto &s : saves) {
 		if (s.getDescription() == name) {
@@ -1306,13 +1157,8 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 		}
 	}
 
-	// New profile — pick the lowest unused visible slot. Slot 0 is
-	// filtered out by `listProfiles()` because it is ScummVM's
-	// conventional autosave slot, so allocating a new profile there
-	// makes the profile disappear from the picker on the next refresh.
-	// The MetaEngine caps us at 99 by default (`getMaximumSaveSlot`);
-	// 25 was the DOS original's limit (`screen8_handler` walks `*.PLR`
-	// up to 25 entries in `local_8c[0x19][2]`).
+	// New profile: pick lowest unused slot >=1 (slot 0 is autosave;
+	// DOS limit was 25 per screen8_handler local_8c[0x19][2]).
 	if (slot < 0) {
 		const int maxSlot = getMetaEngine()->getMaximumSaveSlot();
 		Common::Array<bool> used(maxSlot + 1);
@@ -1359,8 +1205,6 @@ bool EEMEngine::loadProfile(const Common::String &name) {
 }
 
 void EEMEngine::screenDriver() {
-	// Placeholder for the eventual dispatch table (one entry per ScreenId).
-	// run() currently calls handlers directly until the title path lands.
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 26cb2c8f00d..2d3973a4d97 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -46,45 +46,36 @@ namespace EEM {
 class AudioPlayer;
 class MusicPlayer;
 
-/**
- * Screen IDs used by the original `_ScreenDriver` dispatch table at
- * 1a35:0e5e (and the fallback at 1a35:0e54). 14 entries total: each
- * one is a screen ID + a near function pointer at offset +0x1c. The
- * driver does `JMP word ptr CS:[BX + 0x1c]`, so handlers tail-call —
- * each one runs the screen and updates `_NextScreen` for the next
- * trip through the dispatcher.
- *
- * IDs and their handlers (verified from disassembly at 1a35:0dec..0e4f):
- *
- *   0  INIT_CLUES  → `_PreLoad` + `_DoInitClues`, sets _NextScreen=1
- *   1  MAP         → `_DoMapScreen` @ 20fe:120b (sets _NextScreen
- *                    inside; 3 = a site was clicked, 6 = setup, etc.)
- *   2  MAP         → same handler as 1 (alternate entry, used when
- *                    `_LastScreen == 2` to swap the briefcase anim)
- *   3  SITE        → `_DoSiteLoop` @ 168d:03f4
- *   4  NOTEBOOK    → `_DoNotebook` @ 161e:0500
- *   5  GALLERY     → `_DoGallery` @ 158f:065b
- *   6  SETUP       → `_DoSetup` @ 1f78:044e
- *   7  ACCUSE      → `_DoAccuse` @ 1df2:0bdd (win → 12, lose → last)
- *   8  PROFILE     → `screen8_handler` @ 1c33:1012; tail sets =9
- *   9  PARTNER     → `_DoChoosePartner` @ 1a35:0756; sets =0xc inside
- *   10 (0xa) CHOOSE_MYSTERY → `_DoChooseMystery` + `_CaseSelection`;
- *                    starts with _NextScreen=0 so a successful pick
- *                    falls through to INIT_CLUES.
- *   11 (0xb) TITLE  → floppy `_DoTitle_Floppy` plays TITLE.ANM, waits
- *                    for input, then writes =8. CD shows TITLE.ANM in
- *                    `_DoOpeningAnims`, so it usually never enters this
- *                    handler.
- *   12 (0xc) ACTION → `_ActionScreen` @ 1c33:195b — Choose A Mystery /
- *                    Practice Mystery / See ScrapBook 1..3. Action 1
- *                    sets =10 (CHOOSE_MYSTERY).
- *   0xFFFF SENTINEL → exit loop
- *
- * Screen-driver state writes verified via xrefs to `_NextScreen @
- * 2d5d:3f26`: `_DisplayCorrect` writes 0xc (winner returns to ACTION),
- * `_DisplayAlibi` writes `_LastScreen` (loser snaps back), and
- * `_DoSiteLoop` writes 1/3/4 plus 0xffff on ESC.
- */
+/// _ScreenDriver dispatch table @ 1a35:0e5e (fallback @ 1a35:0e54).
+/// 14 entries total: each is a screen ID + near fn ptr at +0x1c; driver
+/// tail-calls via `JMP word ptr CS:[BX + 0x1c]`. Handler bodies update
+/// _NextScreen before returning. Handlers @ 1a35:0dec..0e4f:
+///
+///   0  INIT_CLUES → `_PreLoad` + `_DoInitClues`, writes _NextScreen=1
+///   1  MAP        → `_DoMapScreen @ 20fe:120b` (writes 3=site clicked,
+///                   6=setup, 0xffff=quit)
+///   2  MAP_ALT    → same handler as 1 (used when `_LastScreen == 2` to
+///                   swap the briefcase anim)
+///   3  SITE       → `_DoSiteLoop @ 168d:03f4`
+///   4  NOTEBOOK   → `_DoNotebook @ 161e:0500`
+///   5  GALLERY    → `_DoGallery @ 158f:065b`
+///   6  SETUP      → `_DoSetup @ 1f78:044e`
+///   7  ACCUSE     → `_DoAccuse @ 1df2:0bdd` (win → 0xc, lose → _LastScreen)
+///   8  PROFILE    → `screen8_handler @ 1c33:1012`; tail sets =9
+///   9  PARTNER    → `_DoChoosePartner @ 1a35:0756`; sets =0xc inside
+///   0xa CHOOSE_MYSTERY → `_DoChooseMystery` + `_CaseSelection`; presets
+///                   _NextScreen=0 so a successful pick falls through to
+///                   INIT_CLUES
+///   0xb TITLE     → floppy `_DoTitle_Floppy` plays TITLE.ANM, writes =8.
+///                   CD shows TITLE.ANM in `_DoOpeningAnims`, so this
+///                   handler is usually unused there.
+///   0xc ACTION    → `_ActionScreen @ 1c33:195b` — Choose A Mystery /
+///                   Practice / See ScrapBook 1..3. Action 1 sets =0xa.
+///   0xFFFF SENTINEL → exit loop
+///
+/// State writes (via xrefs to `_NextScreen @ 2d5d:3f26`): `_DisplayCorrect`
+/// writes 0xc, `_DisplayAlibi` writes `_LastScreen`, `_DoSiteLoop` writes
+/// 1/3/4 plus 0xffff on ESC.
 enum ScreenId {
 	kScreenInvalid        = 0xFFFF,
 	kScreenInitClues      = 0x00,
@@ -102,13 +93,12 @@ enum ScreenId {
 	kScreenAction         = 0x0C
 };
 
-/// Distribution variant. Selected at engine init from the
-/// `ADGameDescription::extra` field set by `gameDescriptions[]` in
-/// `detection.cpp`. Used to gate filename selection (TRAVEL-N.XMI
-/// vs MUS%05u.XMI, FANFARE2.XMI vs MUS00005.XMI, PHONESL.VOC vs
-/// PHONE.VOC), opening-anim flow (MOVIE.ANM vs ANIM01..20.A) and
-/// per-variant sound effects (DING.VOC / NEWSCAN.VOC only ship with
-/// the floppy release).
+/// Distribution variant from `ADGameDescription::extra` (set by
+/// `gameDescriptions[]` in `detection.cpp`). Gates filename selection
+/// (TRAVEL-N.XMI vs MUS%05u.XMI, FANFARE2.XMI vs MUS00005.XMI,
+/// PHONESL.VOC vs PHONE.VOC), opening-anim flow (MOVIE.ANM vs
+/// ANIM01..20.A), and per-variant SFX (DING.VOC / NEWSCAN.VOC ship only
+/// with floppy).
 enum Variant {
 	kVariantCD     = 0,
 	kVariantFloppy = 1,
@@ -130,53 +120,35 @@ public:
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 
-	// Disable ScummVM's periodic autosave. The original engine's
-	// `screen8_handler @ 1c33:1012` builds the profile picker by
-	// walking every `*.PLR` file in the save dir, and we mirror that
-	// via `listProfiles() → MetaEngine::listSaves`. Letting the
-	// framework write a slot-0 autosave creates a phantom profile
-	// that shows up on the picker as a real save. Returning -1 tells
-	// the framework to skip autosave entirely.
+	// Autosave disabled: profile picker (screen8_handler @ 1c33:1012)
+	// lists `*.PLR` and a slot-0 autosave would appear as a profile.
 	int getAutosaveSlot() const override { return -1; }
 
-	// ScummVM extended-save hooks. The base `Engine::saveGameState` /
-	// `loadGameState` write/read the framework header (description,
-	// thumbnail, playtime, version) around our body via these
-	// streams. We keep all per-profile state in the body and only
-	// accept the current private body layout.
 	Common::Error saveGameStream(Common::WriteStream *stream,
 								  bool isAutosave = false) override;
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
-	// Per-profile save helpers. The original `_PlayerRecord` lives at
-	// `2d5d:3f6a` (159 bytes) and is written by `_SavePlayerRecord @
-	// 1c33:034f` to `C:\EEMCDSAV\<name>.PLR`. The DOS launcher screen
-	// `screen8_handler @ 1c33:1012` walks `*.PLR`, lets the player
-	// pick a profile, and calls `_LoadPlayerRecord`. We mirror the
-	// pattern by mapping each ScummVM save slot to one profile (slot
-	// description = player name) — same approach Wetlands uses.
-
-	/// Mirrors `_SavePlayerRecord @ 1c33:034f`. Saves into the slot
-	/// whose description matches @p name, or the lowest unused visible
-	/// slot if no match. Returns the kNoError on success.
+	// Per-profile saves: `_PlayerRecord` @ 2d5d:3f6a (159 bytes), written
+	// by `_SavePlayerRecord @ 1c33:034f` to `C:\EEMCDSAV\<name>.PLR`.
+	// Each save slot maps to one profile (slot description = the
+	// player name) — same approach Wetlands uses. `screen8_handler @
+	// 1c33:1012` walks `*.PLR`, lets the player pick a profile, and calls
+	// `_LoadPlayerRecord`.
+
+	/// `_SavePlayerRecord @ 1c33:034f`.
 	Common::Error saveProfile(const Common::String &name);
 
-	/// Mirrors `_LoadPlayerRecord @ 1c33:03a6`. Returns false if no
-	/// slot has @p name as its description.
+	/// `_LoadPlayerRecord @ 1c33:03a6`.
 	bool loadProfile(const Common::String &name);
 
-	/// Mirrors the `_findfirst("*.PLR")` walk inside
-	/// `screen8_handler`. Sorted by slot.
+	/// `_findfirst("*.PLR")` walk inside `screen8_handler`.
 	SaveStateList listProfiles() const;
 
 	const ADGameDescription *_gameDescription;
 	Variant _variant = kVariantCD;
 	Common::Language _language = Common::EN_ANY;
 
-	/// True for the Spanish floppy release. Used to swap hardcoded
-	/// English UI strings (menus, name prompt, notebook labels,
-	/// fallback hint copy) for their Spanish equivalents extracted
-	/// from EEM.EXE in `eem-full-game/floppy-es/`.
+	/// Spanish floppy release.
 	bool isSpanish() const { return _language == Common::ES_ESP; }
 
 	DBDArchive &getPics()    { return _picsArchive; }
@@ -188,156 +160,113 @@ public:
 	const EEMFont &getFont() const { return _font; }
 	uint8       getPartnerIndex() const { return _partner; }
 
-	/// Switch to the red-outline cursor used to hint at interactive
-	/// regions that do not have their own original highlight art.
+	/// Red-outline cursor for interactive regions without original highlight art.
 	void setInteractiveMouseCursor(bool active);
 
-	/// Switch to the interactive cursor while the mouse is over a
-	/// searchable hotspot.
+	/// Interactive cursor over searchable hotspots.
 	void setHotspotMouseCursor(bool active);
 
-	/// Display one ClueBlock. @p clueBlock points at the u16 frame count
-	/// followed by 62-byte ClueEntries. Mirrors _DisplayClue @ 2404:05e6.
+	/// `_DisplayClue @ 2404:05e6`. @p clueBlock points at the u16 frame
+	/// count followed by 62-byte ClueEntries.
 	void displayClue(const byte *clueBlock);
 
-	/// Floppy hotspot click — locate the dialog records for the
-	/// clicked hotspot in the site's per-hotspot dialog list at
-	/// `site_data[+6]` and dispatch them through
-	/// `displayFloppyDialogRecords`. Mirrors `FUN_22dc_0b80 +
-	/// FUN_1652_00e6 + FUN_1652_006c`.
+	/// Floppy hotspot click. `FUN_22dc_0b80 + FUN_1652_00e6 + FUN_1652_006c`.
+	/// Locates dialog records in site_data[+6] and dispatches them.
 	void displayFloppyHotspotDialog(uint siteNum, uint hotIdx);
 
-	/// Active player name (saved as the profile-save description).
+	/// Active player name (= profile-save description).
 	const Common::String &playerName() const { return _playerName; }
 
-	/// Apply a single ClueEntry's side effects — notebook adds, gallery
-	/// updates, site flags. Called both by `displayClue` after a normal
-	/// click-through and when the player ESC-skips a multi-entry clue.
+	/// Apply a ClueEntry's side effects (notebook, gallery, site flags).
 	void applyClueSideEffects(const byte *entry);
 
-	/// Show clue/notebook screen. Mirrors `_DrawNotes` @ 161e:01d0.
+	/// `_DrawNotes @ 161e:01d0`.
 	void doNotebook();
 
-	/// Show suspect gallery. Mirrors `_DrawGallery` @ 158f:0046.
+	/// `_DrawGallery @ 158f:0046`.
 	void doGallery();
 
-	/// Suspect-detail view inside the gallery. Mirrors
-	/// `MoreInfo @ 158f:0419`: paints PIC 0x3f + suspect picture at
-	/// (0x94, 0x0f), paginates the suspect's found clues inside the
-	/// `_GalleryNoteRect`, and dispatches PDA bottom-bar buttons via
-	/// `_HandleMoreButton @ 158f:027d`. Sets `_nextScreen` and
-	/// returns true when the user picks a button that should exit
-	/// `doGallery` outright (NOTEBOOK / ACCUSE / MAP); returns false
-	/// for plain dismissal (ESC / GALLERY button) so the caller stays
-	/// on the portrait grid.
+	/// `MoreInfo @ 158f:0419`. Suspect-detail view inside the gallery; button
+	/// dispatch via `_HandleMoreButton @ 158f:027d`. Returns true if the
+	/// caller should exit `doGallery` (NOTEBOOK / ACCUSE / MAP).
 	bool moreInfo(const byte *gd, uint suspectIdx,
 				   const Picture &galBg, bool haveBg);
 
-	/// Show big map; click chooses next site. Mirrors `_DoBigMap` @ 20fe:09e7.
+	/// `_DoBigMap @ 20fe:09e7`.
 	void doBigMap();
 
-	/// Run the accuse flow (pick suspect, evaluate chains, show ending).
-	/// Mirrors `_DoAccuseGallery` @ 1df2:0a31 + `_DisplayEnding` @ 1df2:0548.
+	/// Accuse flow. `_DoAccuseGallery @ 1df2:0a31` + `_DisplayEnding @ 1df2:0548`.
 	void doAccuse();
 	void doAccuseFloppy();
 
-	/// Show the accuse-notes screen (PIC 0x1A7, the red "accuse-mode"
-	/// BG with selectable clue list + "N clues" remaining counter).
-	/// Mirrors the outer loop of `_DoAccuse @ 1df2:0bdd`. Returns
-	/// true if the player committed (selected the chain-required
-	/// number of clues and clicked SOLVE), false if they exited via
-	/// ESC / back. Called from `doAccuse` before the evidence gate.
+	/// Accuse-notes screen (PIC 0x1A7). Outer loop of `_DoAccuse @ 1df2:0bdd`.
+	/// Returns true if the player committed (SOLVE clicked), false on ESC.
 	bool doAccuseNotes();
 
-	/// Show a host hint from `KDTextIndex`. Mirrors `_KDHelp` @ 1560:010a +
-	/// `_DisplayHint` @ 1560:0009. Cycles between the two hint slots that
-	/// the original engine tracks via `_SawHelpHint`.
+	/// `_KDHelp @ 1560:010a` + `_DisplayHint @ 1560:0009`. Cycles two
+	/// hint slots tracked via `_SawHelpHint`.
 	void doHelp();
 
-	/// Display the interface-help picture sequence. Mirrors `_InterfaceHelp`
-	/// @ 1560:0205 — walks `HelpData @ 29be:00c8`, blits each pic fullscreen,
-	/// and waits for click / key (ESC ends the cycle).
+	/// `_InterfaceHelp @ 1560:0205`. Walks `HelpData @ 29be:00c8`.
 	void doInterfaceHelp(uint num = 0);
 
-	/// First-char-dispatch balloon picker. Mirrors `_GetKDTextBalloon @
-	/// 1df2:0105`. For digit first chars (0..9) returns balloon from the
-	/// table at `29be:1064`; for any other char returns the constant
-	/// `*(u16*)29be:1068 = 0x17`. The original branch is on Borland's
-	/// ctype-bit-1 (= digit) at `29be:2be1 + char`.
+	/// `_GetKDTextBalloon @ 1df2:0105`. Digits (0..9) → table @ 29be:1064;
+	/// otherwise `*(u16*)29be:1068 = 0x17`.
 	uint16 getKDTextBalloon(byte firstChar) const;
 
-	/// Cleanup for overly tall original speech balloons. Keeps the original
-	/// bubble family and mirror bit, but picks a shorter sibling when the
-	/// wrapped text leaves enough empty lines.
+	/// Pick a shorter balloon sibling when wrapped text leaves empty lines.
 	uint16 fitBalloonToText(uint16 bubNum, const Common::String &text);
 
-	/// Substitute the 0x80..0x89 control bytes the engine uses inside
-	/// `TextBlock` strings. Mirrors `_ParseString @ 1b66:07c3`; jump
-	/// table at 1b66:0cbe. Used by every clue / hint / balloon caller.
+	/// `_ParseString @ 1b66:07c3`. Substitutes 0x80..0x89 control bytes;
+	/// jump table @ 1b66:0cbe.
 	Common::String parseString(const Common::String &raw,
 							   const Common::String &playerName,
 							   uint partner) const;
 
-	/// Play the partner's one-shot reaction animation slot @num. Mirrors
-	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`. The
-	/// per-partner (animId, x, y) come from `_WaitAnims[1+num] @ 29be:0228`,
-	/// and the per-frame timing follows the sequence script that the
-	/// original would index at `_AnimationSequences[seqnum=animId]`. Used
-	/// by `displayClue` when a ClueEntry's KD-anim field (+0x3a) is set —
-	/// e.g. Jenny's "take a picture" gesture when the player searches an
-	/// NPC. Blocks until the script's first 0x80 marker.
+	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`. Per-partner
+	/// (animId, x, y) from `_WaitAnims[1+num] @ 29be:0228`. Blocks until
+	/// the script's first 0x80 marker.
 	void playKdAnim(uint16 num);
 
-	/// Provide a "clean" 320x200 backdrop for the next `playKdAnim` (and
-	/// any future blocking partner-anim playback) to use as the
+	/// Provide a "clean" 320x200 backdrop (site BG + static drops, no
+	/// NPCs / partner) for the next `playKdAnim` to use as the
 	/// background-erase source. Without this, the camera animation would
-	/// composite on top of the static partner sprite from the screen and
-	/// the previous resting frame would bleed through transparent pixels.
-	/// `SiteScreen` calls this with its `_bgSnapshot` (site BG + static
-	/// drops, no NPCs / partner) before invoking `displayClue` from a
-	/// hotspot click. Pass `nullptr` to clear.
+	/// composite on top of the static partner sprite and the previous
+	/// resting frame would bleed through transparent pixels. `SiteScreen`
+	/// passes its `_bgSnapshot` before `displayClue` from a hotspot click.
+	/// Pass `nullptr` to clear.
 	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
 
-	/// Look up balloon-text-inset metadata. Mirrors the 52-entry table at
-	/// `29be:0875` (CD) / `2608:05f9` (floppy), indexed by
-	/// `(bubNum & 0x7F)`. 10 bytes per entry; the first 3 fields
-	/// (x inset, y inset, text width) are used for text wrap, the last
-	/// 2 (indicator dX/dY) by `drawFloppyBubbleIndicator`. Returns
-	/// false if `bubNum` is outside the table.
+	/// Balloon-text-inset metadata. 52-entry table @ 29be:0875 (CD) /
+	/// 2608:05f9 (floppy), indexed by `(bubNum & 0x7F)`. 10 bytes per
+	/// entry: the first 3 fields (x inset, y inset, text width) are used
+	/// for text wrap; the last 2 (indicator dX/dY) by
+	/// `drawFloppyBubbleIndicator`. Returns false if `bubNum` is outside
+	/// the table.
 	bool getBalloonInsets(uint16 bubNum, uint16 &xInset, uint16 &yInset,
 						  uint16 &textW) const;
 	bool getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
 								 uint16 &dy) const;
 
-	/// Stamp the floppy "click to continue" indicator (PIC 0xa0 for
-	/// `endIndicator==false`, PIC 0xa1 otherwise) onto @p dst at the
-	/// position derived from the balloon-inset table. Mirrors
-	/// `FUN_22dc_05c8 @ 22dc:08aa` (mid-page) and `@ 22dc:08c0`
-	/// (end-of-record).
+	/// `FUN_22dc_05c8 @ 22dc:08aa` (mid-page) / `@ 22dc:08c0` (end).
+	/// PIC 0xa0 if !endIndicator else PIC 0xa1.
 	void drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
 								   uint16 bubNum, int ballX, int ballY,
 								   bool endIndicator);
 
-	/// "Are you sure?" yes/no dialog. Mirrors `_AreYouSure` @ 1a35:0a5c.
-	/// Returns true if the user picked YES.
+	/// `_AreYouSure @ 1a35:0a5c`. Returns true on YES.
 	bool areYouSure();
 
 private:
 	void applyStartupTestOverrides();
 
-	/**
-	 * Central dispatch loop matching the original _ScreenDriver @ 1a35:0dc1.
-	 * Each iteration calls the screen handler that matches _nextScreen.
-	 * Handlers update _lastScreen / _nextScreen and return; the loop exits
-	 * when _nextScreen == kScreenInvalid.
-	 */
+	/// Central dispatch loop matching `_ScreenDriver @ 1a35:0dc1`. Each
+	/// iteration calls the handler that matches `_nextScreen`; handlers
+	/// update `_lastScreen` / `_nextScreen` and return. Loop exits when
+	/// `_nextScreen == kScreenInvalid`.
 	void screenDriver();
 
-	/// Re-render helpers used by the corresponding `doX()` modal screens.
-	/// Each replaces what would otherwise be a `[&]()` capture-everything
-	/// lambda inside the `doX()` body; called from the screen's redraw
-	/// triggers (input changes, frame ticks). The state these need is
-	/// passed via reference parameters or read off engine members.
+	/// Re-render helpers for the corresponding `doX()` modal screens.
 	void drawNotebookFrame(int &page);
 	void drawGalleryFrame(const byte *gd, uint8 numSuspects,
 						  Common::Array<Common::Rect> &slotRects,
@@ -352,34 +281,22 @@ private:
 						   Common::Array<Common::Rect> &slotRects,
 						   Common::Array<int> &slotSuspect);
 
-	/**
-	 * Open the five .DBD/.DBX archive pairs the way _InitGraphicsSystem
-	 * @ 172b:0145 does at boot.
-	 */
+	/// Open the five .DBD/.DBX archive pairs. `_InitGraphicsSystem @ 172b:0145`.
 	bool openArchives();
 
-	/** Slurp SITEPALS into @c _sitePals. Mirrors _ReadPalettes @ 172b:0d89. */
+	/// `_ReadPalettes @ 172b:0d89`. Slurps SITEPALS into `_sitePals`.
 	bool loadSitePalettes();
 
-	/**
-	 * Upload palette index @p num (one of 40 stored in SITEPALS) to the
-	 * screen, with the VGA-DAC 6-bit-to-8-bit shift. Mirrors _GetPalette
-	 * @ 172b:0e80 followed by _setmany @ 1000:0930.
-	 */
+	/// `_GetPalette @ 172b:0e80` + `_setmany @ 1000:0930`. Uploads one of
+	/// 40 SITEPALS palettes with the VGA-DAC 6→8 bit shift.
 	void setSitePalette(uint num);
 
-	/// Fill @p out (256 × 3 bytes) with the SITEPALS palette at index
-	/// @p num, expanded from VGA's 6-bit DAC range to 8-bit. Returns
-	/// false if the index is out of range. Used when callers need the
-	/// palette for fade-in (set black first, then fade) without
-	/// flashing the target on screen.
+	/// Fill @p out (256 × 3 bytes) with SITEPALS palette @p num, expanded
+	/// from 6-bit DAC to 8-bit. False if out of range.
 	bool getSitePalette(uint num, byte *out) const;
 
-	/**
-	 * Upload a 6-bit VGA palette read from the head of an .ANM file (the
-	 * first 0x300 bytes per Load_Sequence @ 2503:0006). Used until the
-	 * full title-page animation chain is wired in.
-	 */
+	/// Upload the 6-bit VGA palette from the head of an .ANM file
+	/// (first 0x300 bytes per `Load_Sequence @ 2503:0006`).
 	bool setAnmPalette(const Common::Path &anmPath);
 
 public:
@@ -387,256 +304,168 @@ public:
 	void setSitePaletteForSite(uint siteNum) { setSitePalette(siteNum + 1); }
 private:
 
-	/** Blit @p pic to @p x, @p y on screen. */
 	void blitAt(const Picture &pic, int x, int y);
 
-	/** Hold the current frame for up to @p maxMs or until the user inputs. */
 public:
 	void waitForInput(uint32 maxMs);
 private:
 
-	/**
-	 * Play a difference-encoded animation file (.ANM / .A) on the full
-	 * 320x200 screen. Mirrors the data flow of `OpenDifferenceAnimation`
-	 * @ 2520:0337 → `Load_Sequence` + `Play_Sequence`. Audio cues are
-	 * skipped for now. The default frame delay is 120 ms to match the
-	 * original FRAME_RATE = 0x78 used by `_DoOpeningAnims`.
-	 *
-	 * If @p holdLastFrame is true the call blocks on the final frame
-	 * until the user clicks or hits a key — used for the title screen.
-	 * If @p fadeIn is true the first decoded frame is copied while the
-	 * palette is black, then the animation palette is ramped in like
-	 * `_OpenFadeIn`.
-	 */
+	/// Play a difference-encoded animation file (.ANM / .A) on the full
+	/// 320x200 screen. Mirrors the data flow of `OpenDifferenceAnimation
+	/// @ 2520:0337` → `Load_Sequence` + `Play_Sequence`. Audio cues are
+	/// skipped for now. The default 120 ms frame delay matches the
+	/// original `FRAME_RATE = 0x78` used by `_DoOpeningAnims`.
+	/// If @p holdLastFrame is true the call blocks on the final frame
+	/// until the user clicks or hits a key — used for the title screen.
+	/// If @p fadeIn is true the first decoded frame is copied while the
+	/// palette is black, then ramped in like `_OpenFadeIn`.
 	void playAnm(const Common::Path &path, uint frameDelayMs = 120,
 				 bool holdLastFrame = false, bool fadeIn = false);
 
-	/// Stop every active audio channel — voice, sound spool, and
-	/// MIDI. Mirrors the `_CleanMysterySounds @ 202f:05a5` +
-	/// `_StopMIDI @ 20a2:0512` pair the original triggers when the
-	/// player aborts the opening-anim chain or dismisses the title
-	/// (`_DoOpeningAnims @ 2520:082a` writes `_LoopMIDI = 0;
-	/// _StopMIDI();` after the title-input loop, and
-	/// `_CleanMysterySounds` is called twice — once after the loop
-	/// and once before TITLE.ANM). Called from every ESC handler in
-	/// the intro / title chain so the theme music + voice spool
-	/// don't bleed past the abort.
-	/// Stop currently-playing voice / spooled SFX. Pass `stopMusic =
-	/// true` (the default — matches `_CleanMysterySounds + _StopMIDI`)
-	/// to also halt the MIDI track; conversation / dialog skip paths
-	/// pass `false` so the site / briefing music keeps going across an
-	/// ESC.
+	/// `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @ 20a2:0512`.
+	/// `stopMusicToo=false` keeps MIDI playing across dialog skips.
 	void interruptAudio(bool stopMusicToo = true);
 
-	// Screen handlers — port targets in screens/ later.
 	void showEAKidsLogo();
 	void showHighScoreLogo();
 	void showFloppyStormLogo();
 
-	/// Profile selector — mirrors `screen8_handler @ 1c33:1012`.
-	/// Walks `listProfiles()`, draws the list of existing profile
-	/// names plus a "[New Player]" entry, and either calls
-	/// `loadProfile(name)` on a click or falls through to
-	/// `doNewPlayer()` if the user picks "New". When no profiles
-	/// exist, behaves identically to `doNewPlayer()` (the original
-	/// also bypasses the picker when `local_20 == 0` — see
-	/// 1c33:1170: `if (saves == 0) _NewPlayer();`).
+	/// `screen8_handler @ 1c33:1012`. Profile selector — walks
+	/// `listProfiles()`, falls through to `doNewPlayer()` if "New" or
+	/// no profiles exist (1c33:1170: `if (saves == 0) _NewPlayer();`).
 	void doProfilePicker();
-	void doNewPlayer();          ///< Mirrors `_NewPlayer` @ 1c33:0dda
+	void doNewPlayer();          ///< `_NewPlayer @ 1c33:0dda`.
 	void doChoosePartner();
 
-	/// Display the per-mystery ending pages from `E<num>.BIN`.
-	/// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage
-	/// @ 1df2:044c`. The file format is a 2-byte page count followed
-	/// by N pages, each `{ u16 picNum, u16 x1, u16 y1, u16 x2, u16 y2,
-	/// char text[] (null-terminated, ParseString placeholders) }`.
-	/// Blocks until the player exits the ending view or crosses either
-	/// page boundary.
+	/// Display the per-mystery ending pages from `E<num>.BIN`. Mirrors
+	/// `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @ 1df2:044c`.
+	/// File format: u16 page count, then N pages of
+	/// `{ u16 picNum, u16 x1, u16 y1, u16 x2, u16 y2,
+	///    char text[] (null-terminated, ParseString placeholders) }`.
 	/// `_ShowOneScrap @ 1f78:0773` is just `_DisplayEnding(num, 1)`,
-	/// so this same call covers the post-mystery scrapbook view from
-	/// the action menu.
-	///
-	/// `firstPage=true` opens at page 0; `false` opens at the last
-	/// page (used by `doShowScrapbook` when navigating backwards
-	/// between mysteries — mirrors the `local_8 = 0` write at
-	/// `_ShowScrapbook @ 1f78:067e`).
-	///
-	/// Returns the direction the user wants the caller to navigate:
-	///   -1 → previous mystery (LEFT pressed on the first page or
-	///        click in `PrevPageRect` while on first page),
-	///    0 → exit the scrapbook (ESC / quit; central clicks are ignored),
+	/// so this call also covers the post-mystery scrapbook view.
+	/// `firstPage=true` opens at page 0; `false` opens at the last page
+	/// (back-nav from `doShowScrapbook`, mirrors the `local_8 = 0` write
+	/// at `_ShowScrapbook @ 1f78:067e`).
+	/// Returns the caller's nav direction (per `[BP-0x18]` @ 1df2:0723):
+	///   -1 → previous mystery (LEFT on first page or click in PrevPageRect),
+	///    0 → exit scrapbook (ESC / quit),
 	///   +1 → next mystery (RIGHT/SPACE/Enter/click on last page).
-	/// Mirrors `_DisplayEnding`'s `[BP-0x18]` return at 1df2:0723.
 	int doShowEnding(uint num, bool firstPage = true);
 
-	/// Browse the scrapbook tier @p stage (1=Junior, 2=Senior,
-	/// 3=Master), moving between mystery endings with the original
-	/// `_DisplayEnding` return direction.
-	/// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`: the original
-	/// computes the mystery range from `(stage - 1) * 0x18 + 1` to
-	/// `(stage - 1) * 0x18 + 0x18`. If this is the current chain stage,
-	/// unsolved entries are skipped; completed tiers are already solved.
-	/// Used by both the setup-screen ScrapBook buttons and the
-	/// action-menu "See ScrapBook 1/2/3" entries.
+	/// `_ShowScrapbook(stage, 0) @ 1f78:0642`. Mystery range
+	/// `(stage-1)*0x18+1 .. (stage-1)*0x18+0x18`; skips unsolved
+	/// entries in the current chain stage.
 	void doShowScrapbook(uint stage);
 
 	void doActionScreen();
 	void doCaseSelection();
 	void doSiteLoop();
 
-	/// Setup / preferences screen. Mirrors `_DoSetup @ 1f78:044e` —
-	/// per-profile preferences (voice on/off via `DAT_2d5d_3f97`,
-	/// partner pick via SwapColors on Kid1/Kid2 rects). Reachable
-	/// from BigMap's setup button (sets `_NextScreen = 6` per
-	/// `_DoBigMap @ 20fe:0c33`). Returns to whatever
-	/// `_lastScreen` was — typically MAP.
+	/// `_DoSetup @ 1f78:044e`. Voice on/off (`DAT_2d5d_3f97`), partner
+	/// pick via SwapColors on Kid1/Kid2 rects.
 	void doSetup();
 
-	/// Render the case briefing background + game/book decorations and
-	/// display the briefing ClueBlock. Mirrors `_DoInitClues` @ 1a35:0411
-	/// minus the live ANI sequence playback.
+	/// `_DoInitClues @ 1a35:0411` (minus live ANI sequence playback).
 	void doInitClues();
 
-	/// Floppy variant of the briefing dialog renderer. Walks the dialog
-	/// record list at the tail of the floppy InitBlock (per
-	/// `FUN_19bb_042f` and `FUN_22dc_05c8 @ 22dc:05c8`) and renders one
-	/// speech balloon per record. Each record is `11 + textCount` bytes:
-	///   +0..1  picID (character portrait, 0 = none)
-	///   +2..3  picX
-	///   +4     picY
-	///   +5     balloonId | (mirror_flag << 7)
-	///   +6..7  balloonX
-	///   +8     balloonY
-	///   +9     soundFlag (high bit) | sound slot (low 7 bits)
-	///   +10    textCount
-	///   +11..  text indices (1 byte each, low 7 bits = NOTES idx)
+	/// Floppy briefing dialog renderer. Walks the dialog record list at
+	/// the tail of the floppy InitBlock (`FUN_19bb_042f`, `FUN_22dc_05c8
+	/// @ 22dc:05c8`). Record = 11 + textCount bytes:
+	///   +0..1 picID  +2..3 picX  +4 picY
+	///   +5 balloonId|(mirror<<7)  +6..7 balloonX  +8 balloonY
+	///   +9 soundFlag|slot  +10 textCount  +11.. text indices
 	void displayFloppyBriefing(const byte *initBlock);
 
-	/// Render `count` consecutive floppy dialog records starting at
-	/// `rec`. Shared between briefing and hotspot click handlers since
-	/// the original engine uses the same `FUN_22dc_05c8 @ 22dc:05c8`
-	/// renderer in both contexts. `lastIndicator` is the `param_2`
-	/// equivalent for the LAST record in the batch — 0 = no
-	/// indicator, 1 = PIC 0xa0 (red "more" arrow), 2 = PIC 0xa1
-	/// (alternate end indicator). Records before the last always get
-	/// PIC 0xa0.
+	/// `FUN_22dc_05c8 @ 22dc:05c8`. Renders `count` floppy dialog records.
+	/// `lastIndicator`: 0 = none, 1 = PIC 0xa0, 2 = PIC 0xa1 (records
+	/// before the last always get PIC 0xa0).
 	void displayFloppyDialogRecords(const byte *rec, uint count,
 									 uint lastIndicator = 0);
 
 public:
-	/// Mirrors `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI`
-	/// based on `_mystery._siteNumber % 5` and starts it one-shot. The
-	/// site loop calls this each time `enter(siteNum)` runs so the
-	/// music changes as the player travels between sites.
+	/// `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI` from
+	/// `_mystery._siteNumber % 5`, one-shot.
 	void startTravelMusic();
 
-	/// Block until the current MIDI cue finishes or the player skips it,
-	/// then stop/unload it. Mirrors the `_IsMIDIPlaying` spin +
-	/// `_StopMIDI` cleanup in `_DoSiteLoop` after the CD travel cue.
+	/// `_IsMIDIPlaying` spin + `_StopMIDI` cleanup in `_DoSiteLoop`.
 	void waitForMusicDone(uint32 maxMs = 60000);
 
-	/// Stop any currently playing MIDI track. Mirrors `_StopMIDI @
-	/// 20a2:0512` — used by `_DoSiteLoop @ 168d:06c0` after the
-	/// one-shot travel track plays out and by `_DisplayCorrect /
-	/// _DisplayAlibi` between MIDI cues.
+	/// `_StopMIDI @ 20a2:0512`.
 	void stopMusic();
 
-	/// Forwarded from `Engine::syncSoundSettings`. Re-pulls the user's
-	/// `music_volume` slider into the MIDI player's `_masterVolume`,
-	/// otherwise the AdLib output stays at whatever the slider was at
-	/// the moment `_music` was constructed (and the live launcher
-	/// changes to the volume slider have no effect).
+	/// `Engine::syncSoundSettings` override. Re-pulls `music_volume`
+	/// into the MIDI player's `_masterVolume`.
 	void syncSoundSettings() override;
 private:
 
-	Common::String _playerName;  ///< Substituted into 0x80 placeholders
+	Common::String _playerName;  ///< Substituted into 0x80 placeholders.
 
-	/// Per-mystery solved state. 0 = unsolved, 1 = solved, 2 = solved
-	/// on first try. Mirrors `_PlayerRecord.SolvedMysteries[55]` in the
-	/// original `_DisplayCorrect` flow.
+	/// `_PlayerRecord.SolvedMysteries[55]`. 0=unsolved, 1=solved, 2=first-try.
 	uint8 _mysteriesSolved[55] = {};
 
-	/// Current chain/tier the player is at — mirrors `DAT_2d5d_3f99`
+	/// Current chain/tier the player is at — `DAT_2d5d_3f99`
 	/// (`_PlayerRecord +0x2f`):
-	///   1 = Junior detective  (mysteries  1 .. 24, "A chain")
-	///   2 = Senior detective  (mysteries 25 .. 48, "B chain")
-	///   3 = Master detective  (mysteries 49 .. 54, "C chain")
-	/// Initialized to 1 in `_NewPlayer @ 1c33:0fa3` and bumped by
+	///   1 = Junior detective  (mysteries  1..24, "A chain")
+	///   2 = Senior detective  (mysteries 25..48, "B chain")
+	///   3 = Master detective  (mysteries 49..54, "C chain")
+	/// Initialized to 1 in `_NewPlayer @ 1c33:0fa3`; bumped by
 	/// `_DisplayCorrect @ 1df2:0853` once every mystery in the current
-	/// tier is solved (range checks at 1df2:080d / 0824 / 0837). The
-	/// value also gates `_CaseSelection`'s book label and selection
-	/// list (1c33:0a87 onwards).
+	/// tier is solved (range checks @ 1df2:080d / 0824 / 0837). Also
+	/// gates `_CaseSelection`'s book label and selection list
+	/// (1c33:0a87 onwards).
 	uint8 _chainStage = 1;
 
-	/// Voice / digital-audio enable flag. Mirrors `DAT_2d5d_3f97`
-	/// (`_PlayerRecord +0x2d`). Set to 1 by `_NewPlayer @ 1c33:0fa3`,
-	/// toggled by the SoundOn / SoundOff hot-rects in `_DoSetup @
-	/// 1f78:044e` (verified at `_SetupSettings` 1f78:0076 reading
-	/// the same byte to colour the on/off labels). Gates every
-	/// `_PlayVoice` and `_SpoolSound` call site (clue voices,
-	/// partner speech, intro VO etc. — see `_DoChoosePartner`,
-	/// `_DisplayClue`, `_SayKDDigital` xrefs).
+	/// Voice / digital-audio enable flag. `DAT_2d5d_3f97` (`_PlayerRecord
+	/// +0x2d`). Set to 1 by `_NewPlayer @ 1c33:0fa3`, toggled by the
+	/// SoundOn / SoundOff hot-rects in `_DoSetup @ 1f78:044e`. Gates
+	/// every `_PlayVoice` / `_SpoolSound` call site (clue voices,
+	/// partner speech, intro VO).
 	bool _voiceOn = true;
 
 	Common::RandomSource _rng;
 
-	DBDArchive _picsArchive;     ///< PICS.DBD/.DBX (sprites, buttons, frame backgrounds)
-	DBDArchive _aniArchive;      ///< ANI.DBD/.DBX (multi-frame character animations)
-	DBDArchive _sitesArchive;    ///< SITES.DBD/.DBX (one full-screen scene per site)
-	DBDArchive _balloonArchive;  ///< BALLOON.DBD/.DBX (speech-balloon sprites)
-	DBDArchive _buttonArchive;   ///< BUTTON.DBD/.DBX (per-site labeled map buttons; `_GetButton`)
-	Mystery    _mystery;         ///< Currently-loaded case file (M<n>.BIN)
-	EEMFont    _font;            ///< FONT.FNT - main 8 px font
+	DBDArchive _picsArchive;     ///< PICS.DBD/.DBX
+	DBDArchive _aniArchive;      ///< ANI.DBD/.DBX
+	DBDArchive _sitesArchive;    ///< SITES.DBD/.DBX
+	DBDArchive _balloonArchive;  ///< BALLOON.DBD/.DBX
+	DBDArchive _buttonArchive;   ///< BUTTON.DBD/.DBX (`_GetButton`)
+	Mystery    _mystery;         ///< M<n>.BIN
+	EEMFont    _font;            ///< FONT.FNT (8 px)
 
-	Common::Array<byte> _sitePals; ///< 40 x 768 bytes of 6-bit VGA palettes
+	Common::Array<byte> _sitePals; ///< 40 × 768 bytes, 6-bit VGA.
 
-	uint16 _lastScreen;  ///< Mirrors _LastScreen @ 2d5d:3f24
-	uint16 _nextScreen;  ///< Mirrors _NextScreen @ 2d5d:3f26
-	uint8  _partner;     ///< Mirrors _Partner: 0 = boy (Jake), 1 = girl (Jenny)
+	uint16 _lastScreen;  ///< `_LastScreen @ 2d5d:3f24`.
+	uint16 _nextScreen;  ///< `_NextScreen @ 2d5d:3f26`.
+	uint8  _partner;     ///< `_Partner`: 0=Jake, 1=Jenny.
 
-	/// Set when ESC is pressed during an intro animation or logo. Tells
-	/// the opening-anim loop in run() to skip the rest of the chain
-	/// instead of asking the user to click through every screen.
+	/// ESC during intro: skip remaining opening-anim chain.
 	bool _skipIntro = false;
 
-	/// Per-slot rectangles + clue IDs from the most recent notebook
-	/// render, populated by the `draw` lambda inside `doNotebook` and
-	/// consumed by the click handler. The original walks the notes
-	/// inline; we cache the layout to keep click hit-testing simple.
+	/// Cached notebook slot rects + clue IDs for click hit-testing.
 	Common::Array<Common::Rect> _notebookSlotRects;
 	Common::Array<uint>         _notebookSlotClues;
 
-	/// Optional clean BG (no partner / NPC sprites) used by `playKdAnim`
-	/// to erase the partner's resting frame between camera-anim cells.
-	/// Empty when no caller has provided one (PDA / case briefing /
-	/// accuse contexts use their own composed backdrops). See
-	/// `setPartnerEraseBg`.
+	/// Clean BG (no partner/NPC) used by `playKdAnim` between camera-anim
+	/// cells. See `setPartnerEraseBg`.
 	Graphics::ManagedSurface _partnerEraseBg;
 
 	bool _interactiveMouseCursor = false;
 
-	/// Site whose entrance animation has already played in the current
-	/// mystery. Kept on the engine, not SiteScreen, because PDA/gallery
-	/// screens destroy and recreate SiteScreen when returning to the site.
+	/// Site whose entrance animation has already played this mystery.
+	/// Lives on the engine because PDA/gallery destroys+recreates SiteScreen.
 	int _lastSiteArrivalAnim = -1;
 
-	/// XMIDI music player. Mirrors the original `MIDI.C` family
-	/// (`_MIDIPlayFile`, `_MIDIPlay`, `_StopMIDI`, `_StartTravelMusic`
-	/// at 20a2:00e2-05c9). Constructed lazily during `run()` once the
-	/// MIDI driver / timer system is up.
+	/// `MIDI.C` family (`_MIDIPlayFile`/`_MIDIPlay`/`_StopMIDI`/
+	/// `_StartTravelMusic` @ 20a2:00e2-05c9). Constructed lazily in `run()`.
 	MusicPlayer *_music = nullptr;
 
-	/// Digitised audio (voice + SFX). Mirrors `SOUND.C` / `SPOOLSND.C`
-	/// — VOC playback (`_PlayVoice @ 1ff1:023e`) and the per-mystery
-	/// SDB spool (`_SpoolSound @ 202f:068d` / `_InitMysterySounds @
-	/// 202f:05cb`). Constructed alongside `_music` in `run()`.
+	/// `SOUND.C` / `SPOOLSND.C`. `_PlayVoice @ 1ff1:023e`,
+	/// `_SpoolSound @ 202f:068d`, `_InitMysterySounds @ 202f:05cb`.
 public:
 	AudioPlayer *_audio = nullptr;
 
-	/// Public setter for `_nextScreen` so site loop / inline screens
-	/// can drive the screen-driver state machine without making the
-	/// member itself public. Mirrors the original's direct write to
-	/// `_NextScreen @ 2d5d:3f26` from anywhere in the engine.
+	/// `_NextScreen @ 2d5d:3f26` writer for site loop / inline screens.
 	void setNextScreen(ScreenId s) { _nextScreen = s; }
 	bool shouldPlaySiteArrival(uint siteNum) const {
 		return _lastSiteArrivalAnim != (int)siteNum;
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index 36d584c62b5..a5fba38d7f1 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -30,18 +30,13 @@
 
 namespace EEM {
 
-// Character → glyph translation table.
-//
-// FONT.FNT layout (verified by dumping glyph bitmaps):
-//   index 0..26  : ' ' .. ':' (punctuation, digits)
-//   index 27..32 : ';' '<' '=' '>' '?' '@'
-//   index 33..58 : UPPERCASE A..Z
-//   index 59..84 : lowercase a..z
-//
-// The CHR2FNT segment table at 29b6:0000 in the original maps both 'A'
-// and 'a' to the lowercase glyph (so the original engine renders all
-// text in lowercase). We route uppercase ASCII letters to the uppercase
-// glyph slots (33..58) for proper mixed-case rendering.
+// CHR2FNT @ 29b6:0000. FONT.FNT layout:
+//   0..26  : ' ' .. ':'
+//   27..32 : ';' '<' '=' '>' '?' '@'
+//   33..58 : A..Z
+//   59..84 : a..z
+// Original aliases uppercase to lowercase glyphs; we route uppercase
+// ASCII to slots 33..58 for mixed-case rendering.
 const byte kCharToGlyph[128] = {
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
 	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -100,15 +95,10 @@ bool EEMFont::load(const Common::Path &path) {
 			_maxWidth = g.widthBits;
 	}
 
-	// `_LoadFont @ 1b03:0220` sets the line stride to the FIRST
-	// glyph's height (= the space character) — `DAT_28da_30ca =
-	// DAT_28da_30ce` (the first byte of glyph 0 = its height). Tall
-	// descender glyphs ('g', 'j', 'p', 'q', 'y') hang into the next
-	// row by design, which is how the original engine keeps balloon
-	// text dense enough to fit. Using `_maxHeight` (the descender-
-	// inflated value) added 2–3 px per line and made every multi-
-	// line bubble overflow its graphic vertically — verbatim user-
-	// reported "bubbles aren't large enough" symptom.
+	// _LoadFont @ 1b03:0220 sets line stride to the first glyph's
+	// height (DAT_28da_30ca = DAT_28da_30ce, space glyph height).
+	// Descenders ('g','j','p','q','y') intentionally overhang into
+	// the next row.
 	_lineHeight = !_glyphs.empty() ? _glyphs[0].height : _maxHeight;
 	if (_lineHeight == 0)
 		_lineHeight = _maxHeight;
diff --git a/engines/eem/font.h b/engines/eem/font.h
index ae5a0638d7b..56275b8018e 100644
--- a/engines/eem/font.h
+++ b/engines/eem/font.h
@@ -40,18 +40,13 @@ struct FontGlyph {
 };
 
 /**
- * Loader for the engine's `.FNT` files (FONT.FNT, SYSTEM.FNT, TINY.FNT,
- * 8PNTTHIN.FNT). Mirrors `_LoadFont` @ 1b66:023c.
+ * Loader for .FNT files (FONT.FNT, SYSTEM.FNT, TINY.FNT, 8PNTTHIN.FNT).
+ * _LoadFont @ 1b66:023c.
  *
- * File layout:
- *   - u16 numChars
- *   - per char: u8 height, u8 widthBits, u8 sizeBytes, bytes[sizeBytes] bitmap
+ * Layout: u16 numChars, then per char u8 height, u8 widthBits,
+ * u8 sizeBytes, bytes[sizeBytes] bitmap.
  *
- * Subclasses `Graphics::Font` so callers can use the standard
- * `drawString` / `drawStringUnboxed` / `wordWrapText` helpers without
- * us reimplementing them. Lookups go through a 128-byte char→glyph
- * translation table extracted from CHR2FNT (segment 29b6:0000) — the
- * font is uppercase-only with lowercase aliased to uppercase glyphs.
+ * Char → glyph table extracted from CHR2FNT (29b6:0000).
  */
 class EEMFont : public Graphics::Font {
 public:
@@ -70,9 +65,8 @@ public:
 				  uint32 color) const override;
 	using Graphics::Font::drawChar;  // keep ManagedSurface overload
 
-	/// Convenience wrap-and-draw helper that uses the inherited
-	/// `wordWrapText` to break @p s into lines and then `drawString`
-	/// to render each. Returns the total height drawn.
+	/// Wrap @p s to @p width via the inherited `wordWrapText` and draw
+	/// each line at (x, y) downward. Returns total height drawn.
 	int drawWordWrapped(Graphics::ManagedSurface *dst, int x, int y,
 						int width, const Common::String &s, uint32 color) const;
 
@@ -80,7 +74,7 @@ private:
 	Common::Array<FontGlyph> _glyphs;
 	uint16 _maxHeight  = 0;
 	uint16 _maxWidth   = 0;
-	uint16 _lineHeight = 0;  ///< First glyph height (= original line stride)
+	uint16 _lineHeight = 0;  ///< First glyph height (original line stride)
 };
 
 } // End of namespace EEM
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index ca91bcc0349..c11505fa1dd 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -35,29 +35,25 @@
 #include "eem/detection.h"
 #include "eem/eem.h"
 
-// EEM — graphics helpers (KDGRAPH.C + KDHELP.C, the latter merged because
-// it has only three functions). Animation playback, balloon-table lookup,
-// and the help-screen primitives that share the same balloon machinery.
-
 namespace EEM {
 
-// `_InterfaceHelp(num)` @ 1560:0205 reads `HelpData @ 29be:00c8` (5-byte
-// entries: u8 count, then up to 2 u16 picIds). Verified bytes:
+// HelpData @ 29be:00c8, read by _InterfaceHelp @ 1560:0205. 5-byte entries:
+// u8 count, then up to 2 u16 picIds. Verified bytes:
 //   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
-//   entry 1: count=2, picIds = 0x0192, 0x01b1
-// Only entry 0 is reachable from the PDA notebook (rect 1) and the
-// gallery (rect 1) — both call `_InterfaceHelp(0)`.
+//   entry 1:                              count=2, picIds = 0x0192, 0x01b1
+// Only entry 0 is reachable from the PDA notebook (rect 1) and the gallery
+// (rect 1) — both call _InterfaceHelp(0).
 const uint16 kHelpPics[][2] = {
 	{ 0x0063, 0x01ae },
 	{ 0x0192, 0x01b1 },
 };
 
-// 52-entry, 10-bytes-each balloon-metadata table at `29be:0875` (CD)
-// / `2608:05f9` (floppy). Used by `_DisplayClue` to position
-// `_WordWrap` text inside the balloon AND to position the
-// "click to continue" indicator drawn by
-// `_DisplayHotspotClue_Floppy @ 22dc:05c8`. Layout per entry:
-//   +0..1 = text X inset, +2..3 = text Y inset
+// kBalloonInsetTable: 52 entries x 10 bytes @ 29be:0875 (CD) / 2608:05f9
+// (floppy), indexed by (bubNum & 0x7F). Used by _DisplayClue to position
+// _WordWrap text inside the balloon AND by _DisplayHotspotClue_Floppy
+// @ 22dc:05c8 to position the "click to continue" indicator. Layout per entry:
+//   +0..1 = text X inset
+//   +2..3 = text Y inset
 //   +4..5 = wrap width
 //   +6..7 = "more / end" indicator X within the balloon
 //   +8..9 = "more / end" indicator Y within the balloon
@@ -117,12 +113,8 @@ bool findBalloonFamily(uint16 balloonId, uint16 &first, uint16 &last) {
 	return false;
 }
 
-// Lines that fit inside balloon @p balloonId. The metadata-table `indDY`
-// is the designed bottom of the text area, NOT the indicator-drawing
-// position alone — for families 3, 4, 6 and the singletons the bubble
-// graphic continues below `indDY` with shadow / tail decoration that
-// text must not encroach on. Image height is therefore an overestimate;
-// `indDY` is the artist-intended last text line.
+// indDY is the artist-intended last text line, not just the indicator Y:
+// families 3, 4, 6 and singletons have shadow/tail decoration below indDY.
 uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 	const uint idx = balloonId & 0x7F;
 	if (idx >= ARRAYSIZE(kBalloonInsetTable) || lineH <= 0)
@@ -132,10 +124,9 @@ uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 	return MAX<uint>(1, ((int)insets.indDY - (int)insets.y) / lineH + 1);
 }
 
-// Floppy KDHelp hotspot-searched check. Mirrors
-// `FUN_22dc_096c @ 22dc:096c`: walks the per-site dialog records at
-// `site_data[+6]` to skip `hotspotIdx` hotspots, then returns the
-// `_cluesFound` flag for that hotspot's first text index.
+// FUN_22dc_096c @ 22dc:096c: walks per-site dialog records at site_data[+6]
+// to skip hotspotIdx hotspots, returns _cluesFound flag for hotspot's first
+// text index.
 static bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
 								   uint hotspotIdx) {
 	const byte *site = mystery.siteData(siteIdx);
@@ -169,21 +160,14 @@ static bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
 }
 
 void EEMEngine::doHelp() {
-	// Floppy uses a totally different hint mechanism — per-mystery
-	// `H<n>.BIN` data files (one per case). Format verified at
-	// `FUN_1503_0001 @ 1503:0001` (loader, format string at
-	// `2608:0154` = "h%d.bin") + `FUN_1503_01a5 @ 1503:01a5`
-	// (consumer):
-	//   byte 0 = numChainHints
-	//   numChainHints × { byte siteIdx; byte hotspotIdx; }
-	//   byte = numExtraHints
-	//   numExtraHints × { byte siteIdx; byte hotspotIdx; }
-	//   asciiz string 1  ("[balloon-digit]Let's go to <site>...")
-	//   asciiz string 2  (alternate hint)
-	//   asciiz string 3  (post-solve hint, used when score ≥ 100)
-	// Selection logic: if any chain hotspot is unsearched → string 1.
-	// Else if any extra hotspot is unsearched → string 2. Else if
-	// `selectedPoints() ≥ 100` → string 3.
+	// Floppy per-mystery H<n>.BIN hint files. Loader FUN_1503_0001 @ 1503:0001
+	// (format string "h%d.bin" @ 2608:0154), consumer FUN_1503_01a5 @ 1503:01a5.
+	// Format:
+	//   byte numChainHints; numChainHints × { byte siteIdx; byte hotspotIdx; }
+	//   byte numExtraHints; numExtraHints × { byte siteIdx; byte hotspotIdx; }
+	//   asciiz str1, str2, str3 (post-solve, score >= 100)
+	// Selection: any chain hotspot unsearched -> str1; else any extra
+	// unsearched -> str2; else selectedPoints() >= 100 -> str3.
 	if (isFloppy() && _mystery.isLoaded()) {
 		const Common::String filename = Common::String::format("H%u.BIN",
 															   _mystery.number());
@@ -252,18 +236,9 @@ void EEMEngine::doHelp() {
 		if (!chosen || *chosen == 0)
 			return;
 
-		// Strip leading balloon-digit byte. `_GetKDTextBalloon @
-		// 1df2:0105` (= floppy `FUN_1d40_009f`) doesn't take the
-		// digit's *value* — it indexes the per-character table at
-		// `2608:0c14` by the literal byte, so '0'..'9' map to a
-		// non-trivial balloon-id sequence. Verified bytes at
-		// `2608:0c44` (= 0xc14 + '0'):
-		//   '0'→0x15, '1'→0x16, '2'→0x17, '3'→0x18, '4'→0x19,
-		//   '5'→0x1a, '6'→0x1c, '7'→0x1d, '8'→0x1e, '9'→0x0a.
-		// Without this map the previous (digit - '0') version asked
-		// `getBalloonInsets` for balloon 0 (text width 142) instead of
-		// the correct balloon 21 (text width 155), which is why the
-		// hint bubble rendered narrower than the original.
+		// _GetKDTextBalloon @ 1df2:0105 (floppy FUN_1d40_009f) indexes the
+		// per-character table at 2608:0c14 by the literal byte. Bytes at
+		// 2608:0c44 (= 0xc14 + '0') give the '0'..'9' -> balloon-id mapping:
 		static const uint8 kFloppyDigitToBalloon[10] = {
 			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
 		};
@@ -317,7 +292,6 @@ void EEMEngine::doHelp() {
 		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
 		g_system->updateScreen();
 
-		// Wait for click — KD hint dismisses on any input.
 		while (!shouldQuit()) {
 			Common::Event ev;
 			bool advance = false;
@@ -338,9 +312,9 @@ void EEMEngine::doHelp() {
 		return;
 	}
 
-	// Mirrors `_KDHelp @ 1560:010a`. The original walks the first two
-	// entries of `_AChain` (the puzzle's required-clue chain — the
-	// "spine" of evidence the player must collect):
+	// Mirrors _KDHelp @ 1560:010a. Walks the first two _AChain entries
+	// (the puzzle's required-clue chain — the "spine" of evidence the
+	// player must collect):
 	//
 	//   for (i = 0; i < 2; i++) {
 	//       if (_AChain[i] != -1 && _HintBlock[i] != -1 &&
@@ -351,17 +325,16 @@ void EEMEngine::doHelp() {
 	//       if (_HintBlock[i] != -1) defined++;
 	//   }
 	//   if (!shown) {
-	//       // Fall back to the generic KD hint: KDTextIndex[+0xe]
-	//       // (first time) / KDTextIndex[+0x10] (second time, toggled
-	//       // by _SawHelpHint). If neither chain entry had a hint
-	//       // defined, show the global "no hints" sentinel instead.
+	//       // Generic KD hint: KDTextIndex[+0xe] (first time) /
+	//       // KDTextIndex[+0x10] (second time, toggled by _SawHelpHint).
+	//       // If no chain hint was ever defined, render the "no hints"
+	//       // sentinel instead.
 	//       _DisplayHint(...);
 	//   }
 	//
-	// So this is a SMART per-puzzle hint: the partner points the
-	// player at whichever chain clue they haven't yet found, only
-	// falling back to the generic "let's keep looking" line when
-	// every chain hint has been triggered already.
+	// SMART per-puzzle hint: partner points at whichever chain clue the
+	// player hasn't found yet, only falling back to the generic line once
+	// every chain hint has been triggered.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
@@ -393,10 +366,7 @@ void EEMEngine::doHelp() {
 	}
 
 	if (chosenText == 0xFFFF) {
-		// No unfound chain clue had a hint to give — fall back to the
-		// generic KD hint (or the "no hints defined" sentinel if the
-		// chain has no hints at all). Mirrors the second arm of
-		// `_KDHelp` (1560:0152-019b).
+		// Second arm of _KDHelp (1560:0152-019b): generic KD hint fallback.
 		if (anyHintDefined) {
 			const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
 			const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
@@ -409,9 +379,7 @@ void EEMEngine::doHelp() {
 				soundNum   = 8;
 			}
 		}
-		// Else: keep chosenText == 0xFFFF — original would render
-		// `NoHints` (a "There are no hints defined for this Mystery"
-		// string at 29be:00d3); we just bail.
+		// Else: original would render NoHints string @ 29be:00d3; we bail.
 	}
 
 	if (chosenText == 0xFFFF) {
@@ -422,20 +390,19 @@ void EEMEngine::doHelp() {
 	const Common::String raw  = _mystery.textAt(chosenText);
 	Common::String text = parseString(raw, _playerName, _partner);
 
-	// Render as a speech-balloon overlay, exactly mirroring
-	// `_DisplayHint @ 1560:0009`:
+	// Render as a speech-balloon overlay, mirroring _DisplayHint @ 1560:0009:
 	//
 	//   _GetKDTextBalloon(text, &bub);             // first-char dispatch
-	//   _GetBalloon(bub);                           // load balloon pic
-	//   y = (h < 0x4e) ? (0x50 - h) >> 1 : 1;       // vertical centre
-	//   _AddPicBackground(balloon, 0x21, y);        // overlay on screen
+	//   _GetBalloon(bub);                          // load balloon pic
+	//   y = (h < 0x4e) ? (0x50 - h) >> 1 : 1;      // vertical centre
+	//   _AddPicBackground(balloon, 0x21, y);       // overlay on screen
 	//   _WordWrap(0x21+tbl[bub].x, y+tbl[bub].y,   // text inside balloon
 	//             tbl[bub].w, text, -1, color=0);
-	//   _SayKDDigital(snd);                          // partner voice
+	//   _SayKDDigital(snd);                        // partner voice
 	//   _Wait();
 	//
-	// The balloon BG is the caller's CURRENT screen — site / PDA /
-	// gallery — not a cleared scratch.
+	// BG is the caller's CURRENT screen (site / PDA / gallery), not a cleared
+	// scratch.
 	Graphics::ManagedSurface ms(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	ms.clear();
@@ -447,16 +414,13 @@ void EEMEngine::doHelp() {
 		}
 	}
 
-	// Balloon shape dispatch via `_GetKDTextBalloon @ 1df2:0105` —
-	// based on the first char of the parsed text. Digits select a
-	// specific balloon variant; non-digit defaults to `0x17`. The
-	// digit, when present, is THEN consumed from the displayed
-	// text — mirrors `_DisplayAlibi @ 1df2:0145`'s `str = pbVar7 + 1`
-	// advance after using `*str` for `bindx`. Without this the hint
-	// renders like "1Try checking the kitchen..." with a stray
-	// leading digit. `_GetKDTextBalloon` itself doesn't strip it
-	// (verified at 1df2:0105 — it just reads `*str`), so the caller
-	// has to.
+	// Balloon shape dispatch via _GetKDTextBalloon @ 1df2:0105 — based on
+	// the first char of the parsed text. Digits select a specific balloon
+	// variant; non-digit defaults to 0x17. The digit, when present, is
+	// THEN consumed from the displayed text — mirrors _DisplayAlibi
+	// @ 1df2:0145's `str = pbVar7 + 1` advance after reading `*str` for
+	// bindx. _GetKDTextBalloon itself doesn't strip it (1df2:0105 just
+	// reads `*str`), so the caller has to.
 	const byte firstChar =
 		text.empty() ? (byte)0 : (byte)text[0];
 	uint16 bubNum = getKDTextBalloon(firstChar);
@@ -480,8 +444,6 @@ void EEMEngine::doHelp() {
 						 (uint32)transp);
 	}
 
-	// Balloon-relative text insets from the table at `29be:0875`
-	// (10 bytes per entry: x, y, max-width, ...).
 	uint16 tx = 5, ty = 4, tw = 155;
 	getBalloonInsets(bubNum, tx, ty, tw);
 	_font.drawWordWrapped(&ms, balloonX + tx, balloonY + ty, tw, text,
@@ -491,9 +453,10 @@ void EEMEngine::doHelp() {
 							   0, 0, 320, 200);
 	g_system->updateScreen();
 
-	// `_DisplayHint @ 1560:0009` plays `_SayKDDigital(soundnum)` —
-	// partner-specific voice line keyed to which hint type fired (10
-	// = first chain hint, 11 = second, 7 / 8 = generic KD).
+	// _DisplayHint @ 1560:0009 plays _SayKDDigital(soundnum) — a
+	// partner-specific voice line keyed to which hint type fired:
+	//   10 = first chain hint, 11 = second chain hint,
+	//    7 = generic KD (first), 8 = generic KD (second).
 	if (_audio && _mystery.kdTextIndex() && soundNum > 0)
 		_audio->sayKDDigital(_mystery.kdTextIndex(), (uint)soundNum,
 							 _partner);
@@ -502,29 +465,22 @@ void EEMEngine::doHelp() {
 }
 
 void EEMEngine::doInterfaceHelp(uint num) {
-	// Mirrors `_InterfaceHelp(num)` @ 1560:0205. The original walks
-	// `HelpData @ 29be:00c8` (5-byte entries: u8 count, then up to 2
-	// u16 picIds), `_GetPicture`s each one, blits it via
-	// `_Rect_Move_Mask(0, 0, ...)` (a MASKED blit on top of the
-	// existing screen — transparent pixels show the caller's BG), and
-	// waits for click / key. ESC at `1560:02b3` skips to end. The
-	// function also hides the cursor at the top (`MOV [0x3a00], 0` at
-	// 1560:0216 + `_RemoveMouse @ 1000:542f` at 1560:021c) and
-	// restores it at the tail (`_DrawMouse @ 1000:5429` at 1560:02e8).
-	//
-	// `kHelpPics` lives at file scope above; see comment there for the
-	// HelpData decoding.
+	// Mirrors _InterfaceHelp(num) @ 1560:0205. The original walks
+	// HelpData @ 29be:00c8 (5-byte entries: u8 count, then up to 2 u16
+	// picIds), _GetPictures each one, blits via _Rect_Move_Mask(0, 0, ...)
+	// (a MASKED blit on top of the existing screen — transparent pixels
+	// show the caller's BG), and waits for click / key. ESC at 1560:02b3
+	// skips to the end. The function hides the cursor at the top
+	// (MOV [0x3a00], 0 @ 1560:0216 + _RemoveMouse @ 1000:542f at
+	// 1560:021c) and restores it at the tail (_DrawMouse @ 1000:5429
+	// at 1560:02e8). See kHelpPics comment for HelpData decoding.
 	if (num >= ARRAYSIZE(kHelpPics))
 		return;
 
 	debugC(1, kDebugScript, "doInterfaceHelp(%u): showing pics 0x%x, 0x%x",
 		   num, kHelpPics[num][0], kHelpPics[num][1]);
 
-	// Snapshot the caller's screen ONCE so each help PIC overlays the
-	// same clean BG. Without this, after the first PIC is dismissed the
-	// second snapshot would include the first PIC's pixels and the two
-	// would composite together — same gotcha as the setup-screen help
-	// loop fix in `doSetup`.
+	// Snapshot caller's screen once: each PIC overlays the same clean BG.
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
@@ -548,17 +504,8 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
 			   picId, pic.surface.w, pic.surface.h, pic.flags);
 
-		// Compose a 320x200 frame from the clean BG snapshot and overlay
-		// the help pic with `transBlitFrom` — `Graphics::ManagedSurface`'s
-		// masked blit (transparent colour = the pic's `flags >> 8`,
-		// matching `_Rect_Move_Mask`'s param_10 at 1000:03fc). Pass an
-		// explicit `destPos` of (0, 0) — the no-destPos overload at
-		// managed_surface.cpp:738 scales src to fill `this`'s rect,
-		// stretching the help PIC to 320x200 instead of placing it at
-		// native size. The original `_Rect_Move_Mask` passes destX=0,
-		// destY=0 with copy-width = pic[+4] (= `pic.surface.w`) and
-		// copy-height = pic[+2] (= `pic.surface.h`) — i.e. native size,
-		// not stretched.
+		// transBlitFrom transp = pic.flags >> 8 matches _Rect_Move_Mask param_10
+		// @ 1000:03fc. Explicit (0,0) destPos: no-arg overload stretches to fill.
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
@@ -617,10 +564,7 @@ void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
 
 uint16 EEMEngine::fitBalloonToText(uint16 bubNum,
 								   const Common::String &text) {
-	// Opt-in via the "Better fit for dialog balloons" game option, and
-	// CD-only — the floppy build's balloon archive / inset table hasn't
-	// been validated for shrinking yet, so leave it on the original
-	// artist-chosen bubble.
+	// Opt-in via "fit_dialog_balloons", CD only (floppy table unvalidated).
 	if (isFloppy() || !ConfMan.getBool("fit_dialog_balloons"))
 		return bubNum;
 
@@ -683,7 +627,6 @@ uint16 EEMEngine::fitBalloonToText(uint16 bubNum,
 
 bool EEMEngine::getBalloonInsets(uint16 bubNum, uint16 &xInset,
 								  uint16 &yInset, uint16 &textW) const {
-	// `kBalloonInsetTable` lives at file scope above; see comment there.
 	const uint idx = bubNum & 0x7F;
 	if (idx >= ARRAYSIZE(kBalloonInsetTable))
 		return false;
@@ -706,12 +649,12 @@ bool EEMEngine::getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
 void EEMEngine::drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
 										   uint16 bubNum, int ballX, int ballY,
 										   bool endIndicator) {
-	// Mirrors `_DisplayHotspotClue_Floppy @ 22dc:08c0` (end-of-record)
-	// and `@ 22dc:08aa` (mid-pagination). Both grab a pre-loaded PIC
-	// (`DAT_28da_3034 = PIC 0xa0` for "more pages",
-	//  `DAT_28da_3030 = PIC 0xa1` for "end indicator") and stamp it at
-	// `(ballX + insetTable[bubNum].indDX,
-	//   ballY + insetTable[bubNum].indDY)` via `_AddPicBackground`.
+	// Mirrors _DisplayHotspotClue_Floppy @ 22dc:08c0 (end-of-record) and
+	// @ 22dc:08aa (mid-pagination). Both grab a pre-loaded PIC:
+	//   DAT_28da_3034 = PIC 0xa0  "more pages" indicator
+	//   DAT_28da_3030 = PIC 0xa1  "end" indicator
+	// and stamp it at (ballX + insetTable[bubNum].indDX,
+	//                  ballY + insetTable[bubNum].indDY) via _AddPicBackground.
 	uint16 dx = 0;
 	uint16 dy = 0;
 	if (!getBalloonIndicatorPos(bubNum, dx, dy))
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
index 1bd50f83cce..c1fd4bf26b0 100644
--- a/engines/eem/music.cpp
+++ b/engines/eem/music.cpp
@@ -39,11 +39,9 @@ const int kMidiDriverFlags = MDT_MIDI | MDT_ADLIB | MDT_PREFER_MT32;
 } // End of anonymous namespace
 
 MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
-	// Mirrors `_InitMIDI @ 20a2:013a` which used `_AIL_register_driver`
-	// to walk the .ADV files (ADLIB.ADV, SBFM.ADV, MT32MPU.ADV, etc.)
-	// and pick a backend. We honour the launcher's "Music driver"
-	// setting while preferring MT-32 when no concrete device was chosen,
-	// like other ScummVM engines with native MT-32 scores.
+	// _InitMIDI @ 20a2:013a — `_AIL_register_driver` against
+	// ADLIB.ADV / SBFM.ADV / MT32MPU.ADV. We honour the launcher's
+	// "Music driver" setting and prefer MT-32 when unset.
 	const MidiDriver::DeviceHandle dev =
 		MidiDriver::detectDevice(kMidiDriverFlags);
 	MusicType musicType = MidiDriver::getMusicType(dev);
@@ -52,22 +50,21 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 
 	switch (musicType) {
 	case MT_ADLIB:
-		// `_MIDIPlayFile` (20a2:024c) opens "SAMPLE.AD" (29be:14d6) and
-		// installs every patch the sequence requests via
+		// _MIDIPlayFile @ 20a2:024c opens SAMPLE.AD (string at 29be:14d6)
+		// and installs every patch the sequence requests via
 		// `_AIL_install_timbre`. ScummVM's Miles AdLib driver does the
-		// same: it reads SAMPLE.AD on construction and serves timbres
-		// out of that bank, which is what makes the notes match the
-		// 1993 release. SAMPLE.OPL would be the OPL3 variant; the game
-		// only ships SAMPLE.AD, so an empty path falls back to OPL2.
+		// same on-demand install from SAMPLE.AD, which is what makes
+		// the notes match the 1993 release; the generic AdLib fallback
+		// would use ScummVM's built-in timbres instead. SAMPLE.OPL would
+		// be the OPL3 variant — the game only ships SAMPLE.AD, so the
+		// empty second path falls back to OPL2.
 		_milesAudioMode = true;
 		_driver = Audio::MidiDriver_Miles_AdLib_create(
 			Common::Path("SAMPLE.AD"), Common::Path());
 		break;
 	case MT_MT32:
-		// `MT32MPU.ADV` was the original MT-32 driver; ScummVM has no
-		// Miles MT-32 instrument bank for EEM, so we use the standard
-		// MT-32 driver and let the XMIDI's own program changes drive
-		// the patch selection.
+		// MT32MPU.ADV in the original. No Miles MT-32 bank ships with
+		// EEM, so use the standard MT-32 driver.
 		_milesAudioMode = true;
 		_driver = Audio::MidiDriver_Miles_MT32_create(Common::Path());
 		break;
@@ -84,10 +81,7 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 			delete _driver;
 			_driver = nullptr;
 		} else {
-			// No GM/MT-32 reset for AdLib (Miles AdLib handles its own
-			// state); for MT-32/GM the original would've sent its own
-			// initialisation patches via `_AIL_install_timbre`, but we
-			// don't have those banks for non-AdLib devices.
+			// Miles AdLib handles its own reset.
 			if (musicType != MT_ADLIB) {
 				if (musicType == MT_MT32 || _nativeMT32)
 					_driver->sendMT32Reset();
@@ -103,12 +97,12 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 
 void MusicPlayer::send(uint32 b) {
 	// Miles drivers (both AdLib and MT-32) implement their own per-
-	// source-channel mixing and timbre installation, so just forward
-	// the raw event. Going through `MidiPlayer::send` would re-wrap
-	// CC 7 against `_masterVolume` AND remap the source channel via
-	// `sendToChannel`/`allocateChannel`, both of which the Miles driver
-	// already handles internally (and break the timbre selection if
-	// double-applied).
+	// source-channel mixing and timbre installation, so forward the raw
+	// event. Going through `MidiPlayer::send` would re-wrap CC 7 against
+	// `_masterVolume` AND remap the source channel via
+	// `sendToChannel` / `allocateChannel`, both of which the Miles
+	// driver already handles internally (double-applying breaks the
+	// timbre selection).
 	if (_milesAudioMode) {
 		_driver->send(b);
 		return;
@@ -123,7 +117,7 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 	Common::StackLock lock(_mutex);
 	stop();
 
-	// Mirrors `_MIDIPlayFile`'s `_fopen` + `_fread` (20a2:024c-029e).
+	// _MIDIPlayFile @ 20a2:024c-029e (_fopen + _fread).
 	Common::File f;
 	if (!f.open(xmiPath)) {
 		warning("MusicPlayer: %s missing", xmiPath.toString().c_str());
@@ -157,17 +151,15 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 		return;
 	}
 
-	// Mirrors `_LoopMIDI = 0xFFFF` (the count register the original
-	// engine uses for indefinite looping in `_DoOpeningAnims`).
+	// _LoopMIDI = 0xFFFF in _DoOpeningAnims.
 	_isLooping = loop;
 	_parser->property(MidiParser::mpAutoLoop, loop ? 1 : 0);
 	_parser->setTrack(0);
 
 	// Pull the launcher's music_volume slider into `_masterVolume` so
 	// the non-Miles `Audio::MidiPlayer::send` path scales correctly.
-	// (Miles drivers do their own volume handling on the Multisource
-	// path, but they also honour `MidiDriver::syncSoundSettings` which
-	// `Engine::syncSoundSettings` triggers.)
+	// (Miles drivers handle volume themselves but also honour
+	// `MidiDriver::syncSoundSettings` via `Engine::syncSoundSettings`.)
 	syncVolume();
 	_isPlaying = true;
 	debugC(1, kDebugSound, "MusicPlayer: playing %s (%u bytes, loop=%d, miles=%d)",
@@ -175,15 +167,14 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 }
 
 void MusicPlayer::playMus(uint num, bool loop) {
-	// CD format string verified at `29be:1525` ("mus%05d.xmi").
-	// Floppy maps the same numeric slots to its own filenames:
-	//   0..4 → travel music. The floppy table at 2608:1399-13cd holds
-	//          5 entries (Travel-6, Travel-4, Travel-7, Travel-1,
-	//          Travel-8) used by `_StartTravelMusic` via
-	//          `siteNumber % 5`.
+	// CD format string "mus%05d.xmi" at 29be:1525. Floppy maps the same
+	// numeric slots to different filenames:
+	//   0..4 → travel music. Table at 2608:1399-13cd holds 5 entries
+	//          (Travel-6, Travel-4, Travel-7, Travel-1, Travel-8) used by
+	//          `_StartTravelMusic` via `siteNumber % 5`.
 	//   5    → FANFARE2.XMI (winner). String at 2608:0c64.
-	//   6    → no equivalent in floppy install (the loser sting in
-	//          `_DisplayAlibi` is CD-only); skip.
+	//   6    → no equivalent on floppy (loser sting in `_DisplayAlibi`
+	//          is CD-only); skip.
 	if (_isFloppy) {
 		static const char *const kTravelTracks[5] = {
 			"Travel-6.XMI", "Travel-4.XMI", "Travel-7.XMI",
diff --git a/engines/eem/music.h b/engines/eem/music.h
index 7e8c1fc817d..59dac57e30d 100644
--- a/engines/eem/music.h
+++ b/engines/eem/music.h
@@ -31,59 +31,37 @@
 namespace EEM {
 
 /**
- * MIDI music player. Mirrors the original `MIDI.C` source file in
- * `EEMCD.EXE` (the `_MIDIPlayFile / _MIDIPlay / _StopMIDI /
- * _IsMIDIPlaying / _StartTravelMusic` family at `20a2:00e2-05c9`).
+ * MIDI music player. Mirrors MIDI.C in EEMCD.EXE
+ * (_MIDIPlayFile / _MIDIPlay / _StopMIDI / _IsMIDIPlaying /
+ * _StartTravelMusic family at 20a2:00e2-05c9).
  *
- * The original engine uses Miles Audio Interface Library (AIL):
+ * Original uses Miles AIL: _InitMIDI @ 20a2:013a registers
+ * ADLIB.ADV / SBFM.ADV / MT32MPU.ADV; _MIDIPlayFile @ 20a2:024c
+ * installs AdLib timbres from SAMPLE.AD (29be:14d6) via
+ * _AIL_install_timbre before _AIL_start_sequence. We use
+ * Audio::MidiDriver_Miles_AdLib_create (loads SAMPLE.AD) for AdLib
+ * and the Miles MT-32 driver for MT-32.
  *
- *   - `_InitMIDI @ 20a2:013a` calls `_AIL_register_driver` against
- *     `ADLIB.ADV` / `SBFM.ADV` / `MT32MPU.ADV` and reserves a timbre
- *     cache via `_AIL_define_timbre_cache`.
- *   - `_MIDIPlayFile @ 20a2:024c` opens the .XMI, calls
- *     `_AIL_register_sequence`, then loops over the sequence's
- *     `_AIL_timbre_request` results. For every (bank, patch) pair the
- *     driver asks for, it pulls the AdLib instrument definition from
- *     **`SAMPLE.AD`** (string at `29be:14d6`) via `_load_global_timbre`
- *     and installs it through `_AIL_install_timbre`. Only after every
- *     patch is loaded does it call `_AIL_start_sequence`.
- *
- * Without those custom timbres, ScummVM's generic AdLib synth falls
- * back to its built-in default timbre table — same notes, very
- * different timbres. ScummVM ships a Miles AdLib driver
- * (`Audio::MidiDriver_Miles_AdLib_create`) that loads `SAMPLE.AD` and
- * implements the same install-on-demand workflow, so we use it for
- * AdLib output. MT-32 uses ScummVM's Miles MT-32 driver path, and any
- * other MIDI output falls back to `Audio::MidiPlayer::createDriver`.
- *
- * Available music files in the game directory:
- *   - THEME.XMI   — opening anims (looping) + title screen
- *   - MUS00000.XMI..MUS00004.XMI — per-site travel music
- *     (`_StartTravelMusic` picks one via `_SiteNumber % 5`)
- *   - MUS00005.XMI — winner ending (`_DisplayCorrect` @ 1df2:0789)
- *   - MUS00006.XMI — loser ending (`_DisplayAlibi` @ 1df2:018a)
+ * Music files:
+ *   THEME.XMI — opening anims + title.
+ *   MUS00000..MUS00004 — travel music (siteNumber % 5).
+ *   MUS00005 — winner (_DisplayCorrect @ 1df2:0789).
+ *   MUS00006 — loser  (_DisplayAlibi  @ 1df2:018a).
  */
 class MusicPlayer : public Audio::MidiPlayer {
 public:
 	explicit MusicPlayer(bool isFloppy = false);
 
-	/// Mirrors `_MIDIPlayFile @ 20a2:024c`. Reads the .XMI from the game
-	/// directory and starts playing. `loop=true` mirrors the
-	/// `_LoopMIDI = 0xFFFF` writes inside `_DoOpeningAnims` (theme music).
+	/// _MIDIPlayFile @ 20a2:024c. loop=true mirrors
+	/// _LoopMIDI = 0xFFFF in _DoOpeningAnims.
 	void playFile(const Common::Path &xmiPath, bool loop = false);
 
-	/// Mirrors `_MIDIPlay(num) @ 20a2:047d`. Composes the filename
-	/// "MUS%05u.XMI" (CD) or maps to TRAVEL-N.XMI / FANFARE2.XMI
-	/// (floppy) and plays it. Used by `_StartTravelMusic`,
-	/// `_DisplayCorrect` (winner), `_DisplayAlibi` (loser).
+	/// _MIDIPlay(num) @ 20a2:047d. CD: "MUS%05u.XMI";
+	/// floppy: TRAVEL-N.XMI / FANFARE2.XMI.
 	void playMus(uint num, bool loop = false);
 
-	// In Miles AdLib mode the driver allocates its own AdLib voice
-	// pool and consumes the XMIDI's source-channel byte directly, so we
-	// must NOT route through `Audio::MidiPlayer::sendToChannel` (which
-	// remaps every source channel through `allocateChannel()` and
-	// breaks the timbre selection / volume scaling the Miles driver
-	// performs internally). Same workaround Toltecs / SAGA use.
+	// Miles drivers handle source-channel routing themselves; bypass
+	// Audio::MidiPlayer::sendToChannel. Same workaround as Toltecs / SAGA.
 	void send(uint32 b) override;
 
 private:
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 5982e5c5fc5..06892c0735a 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -92,63 +92,23 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_data = staging;
 	_number = num;
 
-	// Floppy `M*.BIN` uses a completely different header layout from
-	// the CD release. Verified via Ghidra of `EEM.EXE` floppy:
-	//
-	//   `_ReadMystery_Floppy @ 22dc:0178` parses M0..M54 into pointers
-	//    held in a global table at 28da:3c87+. The header offsets are:
-	//
-	//     header[+0..+1]   ???
-	//     header[+2..+3]   ???
-	//     header[+4..+5]   pointer → SUSPECTS section
-	//                      (count byte, then 0xb-byte entries; entry[+4] =
-	//                       pic ID, entry[+10] = recolor flag)
-	//                      (`_FloppySuspectsPtr` @ 28da:3c8b)
-	//     header[+6..+7]   pointer → ???       (28da:3c9f)
-	//     header[+8..+9]   pointer → NOTES section
-	//                      (7-byte entries indexed by clue ID; used by
-	//                       floppy `_DrawNotes` @ 15e0:01e8)
-	//                      (`DAT_28da_3c9b`)
-	//     header[+0xa..+b] pointer → GALLERY-PORTRAITS section
-	//                      (count byte, then variable-length entries
-	//                       `5 + name_len` bytes; entry[+0..+1] = u16
-	//                       picID, entry[+4] = name length)
-	//                      (`_FloppyGalleryPtr` @ 28da:3c87, count =
-	//                       `_FloppyNumSuspects` @ 28da:004b)
-	//     header[+0xc..+d] pointer → TEXT block (alibi text base; used in
-	//                       floppy `_DisplayAlibi` @ 1d40:00df)
-	//     header[+0x10..1] pointer → KDTextIndex (`_FloppyKDTextIndexPtr`
-	//                       @ 28da:3c93)
-	//     header[+0x12..3] pointer → ???       (28da:3c8f)
-	//
-	//   There is NO fixed-offset numSites / numSuspects / numCONSITEs
-	//   field — counts are stored as the FIRST byte of each section.
-	//   The CD release refactored this into a flat header at fixed
-	//   offsets; our `Mystery::load` here parses the CD layout.
-	//
-	// Detect the variant from the first u16: CD M0 starts with `0x003e`
-	// (initOffset = 62), floppy M0 starts with `0x2286` (a section
-	// pointer near end-of-file). When the first u16 is too high to be
-	// a CD `_initOffset`, parse as floppy.
+	// Floppy M*.BIN uses a different header layout from CD.
+	// _ReadMystery_Floppy @ 22dc:0178. Section-pointer header:
+	//   header[+0]    InitBlock byte offset (caseType byte at *(buf+initOff))
+	//   header[+4]    SITES: count byte + 11-byte entries
+	//                 (entry[+4]=picID, [+6..7]=u16 X, [+8..9]=u16 Y, [+10]=recolor)
+	//   header[+6]    SITE INDEX (array of u16 offsets to per-site structs)
+	//   header[+8]    NOTES (7-byte entries / clue ID; _DrawNotes_Floppy @ 15e0:01e8)
+	//   header[+0xa]  GALLERY portraits: count byte + variable `5+nameLen` entries
+	//                 (entry[+0..1]=u16 picID, [+4]=nameLen)
+	//   header[+0xc]  TEXT block (alibi base; _DisplayAlibi_Floppy @ 1d40:00df)
+	//   header[+0x10] KDTextIndex
+	//   header[+0x12] SOLVED CLUE CHAIN
+	// Counts live in the FIRST BYTE of each section, not the header.
+	// Detect via first u16: CD M0 = 0x003e (initOffset), floppy M0 = 0x2286
+	// (section pointer near EOF).
 	if (readU16(0) > 0x100) {
 		_isFloppy = true;
-		// Section-pointer header verified via Ghidra of floppy
-		// `_ReadMystery_Floppy @ 22dc:0178`,
-		// `_DoSiteLoop_Floppy @ 1652:03a3`,
-		// and `FUN_1fed_07ed` (BigMap site iteration):
-		//
-		//   header[+4]   → SITES section
-		//                  count byte + 0xb-byte entries; entry[+4] =
-		//                  pic ID for BigMap marker, [+6..7] = u16 X,
-		//                  [+8..9] = u16 Y, [+10] = recolor flag.
-		//   header[+6]   → SITE INDEX (array of u16 offsets to per-site
-		//                  data structs; site[+0]=picOff,
-		//                  site[+2]=clueBlockOff, site[+8]=speakerInfo)
-		//   header[+8]   → NOTES (7-byte entries / clue ID)
-		//   header[+0xa] → SUSPECTS / GALLERY portraits
-		//   header[+0xc] → TEXT block base
-		//   header[+0x10] → KDTextIndex
-		//   header[+0x12] → SOLVED CLUE CHAIN
 		_floppySuspectsOff  = readU16(0x04);  // SITES
 		_floppyHintBlockOff = readU16(0x06);  // SITE INDEX
 		_floppyNoteIndexOff = readU16(0x08);
@@ -156,16 +116,8 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_floppyTextOff      = readU16(0x0c);
 		_floppyKDTextOff    = readU16(0x10);
 		_floppySolvedOff    = readU16(0x12);
-
-		// header[+0] (the first u16) holds the InitBlock byte offset on
-		// floppy too — verified at `FUN_19bb_042f` where `*DAT_28da_3ca5`
-		// (deref'd as int *) reads the first u16 of the buffer and uses
-		// it as `cVar1 = *(buffer + initOffset)` (caseType byte).
 		_initOffset = readU16(0x00);
 
-		// Counts: first byte of each section. Verified at
-		// `FUN_1fed_07ed` (`uVar3 = *_FloppySuspectsPtr` then iterates)
-		// and `FUN_154e_0045` (`DAT_28da_004b = *DAT_28da_3c87`).
 		const byte *sitesSec = (_floppySuspectsOff < _data.size())
 								? _data.data() + _floppySuspectsOff : nullptr;
 		const byte *susSec   = (_floppyGalleryOff < _data.size())
@@ -175,16 +127,9 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_numCONSITEs = 0;
 		_numCOFFSITEs = 0;
 
-		// Point CD-shaped accessor offsets at the floppy equivalents
-		// so existing accessors return the right base for floppy:
-		//   siteIndexEntry() → floppy site index (header[+6])
-		//   noteIndex()       → floppy notes (header[+8])
-		//   galleryData()     → floppy suspects (header[+0xa])
-		//   textAt()          → floppy text block (header[+0xc])
-		//   kdTextIndex()     → floppy KDTextIndex (header[+0x10])
-		//   solvedClueBlock() → floppy solved chain (header[+0x12])
-		// Per-section LAYOUTS still differ from CD, so consumers
-		// walking entries need `isFloppy()` branches.
+		// Point CD-shaped accessors at the floppy equivalents.
+		// Per-section LAYOUTS still differ, so consumers walking entries
+		// need `isFloppy()` branches.
 		_siteIndexOffset = _floppyHintBlockOff;
 		_noteOffset      = _floppyNoteIndexOff;
 		_galleryOffset   = _floppyGalleryOff;
@@ -193,16 +138,9 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_solvedOffset    = _floppySolvedOff;
 		_hintOffset      = _floppyHintBlockOff;
 
-		// Per-mystery runtime state — mirror `_ReadMystery_Floppy @
-		// 22dc:0178` zeroing `_TextSeen_Floppy` / `_InGallery_Floppy` and
-		// seeding `_NewOrder_Floppy[0]` (random) + the `+1`-shift loop
-		// that fills the rest. We use the identity mapping `[0..N-1]` so
-		// dialog `byte9` (1-based logical idx) maps to `_inGallery[idx -
-		// 1]` and the gallery render iterates the same indices. Without
-		// this init the floppy load path returns with `_newOrder` left
-		// at whatever `clear()` zeroed it to, so every byte9>0 dialog
-		// resolves through `_newOrder[logicalIdx]==0` and the second
-		// suspect overwrites the first slot.
+		// Per-mystery runtime state — mirrors _ReadMystery_Floppy @ 22dc:0178.
+		// _newOrder uses identity mapping [0..N-1] so dialog byte9 (1-based
+		// logical idx) maps to _inGallery[idx - 1].
 		memset(_cluesFound, 0, sizeof(_cluesFound));
 		memset(_noteSelected, 0, sizeof(_noteSelected));
 		memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
@@ -245,13 +183,10 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_numCONSITEs = (uint8)readU16(14 * 2);
 	_numCOFFSITEs = (uint8)readU16(15 * 2);
 
-	// Defensive clamp. The floppy mystery file format uses a different
-	// header layout (verified by comparing M0.BIN: CD has `numSites =
-	// readU16(0x14) = 3`; floppy has `readU16(0x14) = 0x1925`,
-	// obviously not a site count). Without a clamp, downstream loops
-	// over `_onSites` / `_visitedSite` (capacity 20) blow past the
-	// array end. Until the floppy format is fully supported, cap at
-	// the array capacity so the engine fails gracefully.
+	// Defensive clamp: the floppy header layout differs (M0.BIN CD has
+	// numSites=readU16(0x14)=3; floppy has readU16(0x14)=0x1925, clearly
+	// not a count). Cap to `_onSites` / `_visitedSite` array capacity so
+	// downstream loops don't walk off the end on malformed/floppy files.
 	if (_numSites > kVisitedSiteCap)
 		_numSites = kVisitedSiteCap;
 
@@ -261,17 +196,14 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_cChain[i] = readU16((26 + i) * 2);
 	}
 
-	// Per-mystery runtime state — `_ReadMystery` zeroes these at load.
+	// Per-mystery runtime state — _ReadMystery zeroes these at load.
+	// _newOrder uses identity mapping; original randomly cycles gallery
+	// positions but requires matching changes in both clue side-effect
+	// and rendering paths.
 	memset(_cluesFound, 0, sizeof(_cluesFound));
 	memset(_noteSelected, 0, sizeof(_noteSelected));
 	memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
 	memset(_inGallery, 0, sizeof(_inGallery));
-	// `_NewOrder` in the original randomly cycles the gallery positions
-	// per playthrough. For consistency between clue side-effects (which
-	// write to `_inGallery[_newOrder[galIdx]]`) and gallery rendering
-	// (which iterates logical indices), we keep the identity mapping.
-	// If the original's randomized positioning is required later, both
-	// the side-effect path AND the rendering path need to use it together.
 	(void)rng;
 	for (uint i = 0; i < kGalleryCap; i++)
 		_newOrder[i] = (uint8)i;
@@ -280,7 +212,7 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
 	_firstTry = true;
 	_searchLocationNumber = _siteNumber = 0xFFFF;
-	_lastSite = 0x1B; // Sentinel matching _ReadMystery's `_LastSite = 0x1b`.
+	_lastSite = 0x1B; // _ReadMystery _LastSite sentinel.
 
 	debugC(1, kDebugMystery, "Loaded %s (%d B): %u sites, %u suspects, "
 		   "CON=%u COFF=%u, init=0x%04x site=0x%04x text=0x%04x",
@@ -293,9 +225,8 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 const byte *Mystery::siteIndexEntry(uint siteNum) const {
 	if (!isLoaded() || siteNum >= _numSites)
 		return nullptr;
-	// Floppy site index uses 2-byte (u16) entries — verified at
-	// `_DoSiteLoop_Floppy @ 1652:03d2` reading `*(int *)
-	// ((int)_FloppySiteIndexPtr + siteNum * 2)`. CD uses 6-byte rows.
+	// Floppy site index: 2-byte u16 entries (_DoSiteLoop_Floppy @ 1652:03d2).
+	// CD: 6-byte rows.
 	const uint stride = _isFloppy ? 2 : 6;
 	const uint off = _siteIndexOffset + siteNum * stride;
 	if (off + stride > _data.size())
@@ -383,13 +314,9 @@ void Mystery::loadFloppySiteAnimData() {
 
 const byte *Mystery::hotspots(uint siteNum) const {
 	if (_isFloppy) {
-		// Floppy: hotspot table sits inside the per-site sub-blob.
-		// `site_data[+4..5]` is a u16 file offset to a header byte
-		// (count) + N×8-byte rectangles (x1, y1, x2, y2 as u16s) —
-		// verified at `FUN_22dc_0b80 @ 22dc:0b80` (the click hit-test
-		// loop reads `*(byte *)(buf + site_data[+4])` for the count
-		// then `FUN_14c9_0039(... buf + site_data[+4] + 1 + i*8)`
-		// for each rectangle).
+		// Floppy hotspot table inside per-site sub-blob (FUN_22dc_0b80 @ 22dc:0b80).
+		// site_data[+4..5] = u16 file offset to count byte + N x 8-byte rects
+		// (x1, y1, x2, y2 as u16s).
 		const byte *site = siteData(siteNum);
 		if (!site || (size_t)(site - _data.data()) + 6 > _data.size())
 			return nullptr;
@@ -453,11 +380,10 @@ const byte *Mystery::noteIndex() const {
 uint16 Mystery::noteIndexCount() const {
 	if (!isLoaded())
 		return 0;
-	// NoteIndex runs from _noteOffset to the start of GalleryData.
-	// CD entries are 4 bytes (`u16 textOff; u16 points`); floppy
-	// entries are 7 bytes (`u16 ?; u16 jakeOff; u16 jennyOff; u8
-	// score`) — verified at `FUN_22dc_05c8 @ 22dc:0843` reading
-	// `*(int *)(notes + idx*7 + 2)` (Jake) / `+4` (Jenny).
+	// NoteIndex runs from _noteOffset to start of GalleryData.
+	// CD entries: 4 bytes (u16 textOff; u16 points).
+	// Floppy entries: 7 bytes (u16 ?; u16 jakeOff; u16 jennyOff; u8 score)
+	// per FUN_22dc_05c8 @ 22dc:0843.
 	if (_galleryOffset <= _noteOffset)
 		return 0;
 	const uint stride = _isFloppy ? 7 : 4;
@@ -473,11 +399,8 @@ bool Mystery::noteHasNotebookText(uint clueId) const {
 	if (!_isFloppy)
 		return true;
 
-	// `_DrawNotes_Floppy @ 15e0:01e8` first checks `_TextSeen[idx]`,
-	// then skips the row when `*(u16 *)(notes + idx * 7) == 0`.
-	// Many floppy dialog records are spoken-only lines: they must still
-	// be marked seen so site dialog does not repeat, but they are not
-	// notebook clues and should not render fallback "note N" labels.
+	// _DrawNotes_Floppy @ 15e0:01e8 skips rows with notes[idx*7..idx*7+1] == 0.
+	// Spoken-only dialog records are marked seen but have no notebook text.
 	return READ_LE_UINT16(ni + clueId * 7) != 0;
 }
 
@@ -491,12 +414,9 @@ const byte *Mystery::mapEntry(uint siteNum) const {
 	if (!isLoaded() || siteNum >= _numSites)
 		return nullptr;
 	if (_isFloppy) {
-		// Floppy SITES section: byte[0] = count, then 11-byte entries.
-		// Verified at `FUN_1fed_07ed` (BigMap site iteration) where
-		// `pcVar2 = _FloppySuspectsPtr` (header[+4]) and the loop reads
-		// `*(int *)(pcVar2 + i*0xb + 7)` (X) and `*(int *)(pcVar2 + i*0xb
-		// + 9)` (Y) — the +7/+9 offsets are 1-based because pcVar2[0]
-		// holds the count, so entry stride 11 starts at byte 1.
+		// Floppy SITES section (FUN_1fed_07ed): byte[0]=count, then 11-byte
+		// entries starting at byte 1.
+		//   +4 picID, +6..7 u16 X, +8..9 u16 Y, +10 recolor.
 		const uint off = _floppySuspectsOff + 1 + siteNum * 11;
 		if (off + 11 > _data.size())
 			return nullptr;
@@ -509,15 +429,12 @@ const byte *Mystery::mapEntry(uint siteNum) const {
 }
 
 const byte *Mystery::floppySuspectEntry(uint suspectIdx) const {
-	// Floppy gallery section: byte[0] = numSuspects, then per suspect a
-	// variable-size record of `5 + nameLen` bytes (verified at
-	// `_DrawGallery_Floppy @ 154e:00b6` advancing `iVar7 += pbVar4[4]
-	// + 5`):
-	//   u16 +0  picID (gallery portrait, BUTTON.DBD entry)
-	//   u16 +2  alibi marker (0xFFFF = guilty; else high byte = index
-	//           into TEXT_BLOCK alibi-offset table at header[+0xc])
-	//   u8  +4  name length
-	//   u8  +5..+5+nameLen-1  name string
+	// Floppy gallery section (_DrawGallery_Floppy @ 154e:00b6).
+	// byte[0]=numSuspects, then per suspect a `5 + nameLen` record:
+	//   u16 +0 picID (BUTTON.DBD entry)
+	//   u16 +2 alibi marker (0xFFFF=guilty; else hi byte indexes TEXT_BLOCK)
+	//   u8  +4 nameLen
+	//   u8  +5.. name string
 	if (!_isFloppy || !isLoaded())
 		return nullptr;
 	const byte *gd = _data.data() + _galleryOffset;
@@ -539,11 +456,9 @@ const byte *Mystery::floppySuspectEntry(uint suspectIdx) const {
 }
 
 bool Mystery::isGuilty(uint suspectIdx) const {
-	// `_WITCH @ 1df2:089f` (CD): `if (GalleryData[i*0x46 + 0x02] == -1)
-	// _DisplayCorrect(); else _DisplayAlibi(...)`. Innocent suspects
-	// store their alibi-text TextBlock offset at +0x02; the guilty
-	// one stores the sentinel 0xFFFF. Floppy uses the same convention
-	// at suspect entry +2..3 but with variable-stride entries.
+	// _WITCH @ 1df2:089f (CD): GalleryData[i*0x46 + 0x02] == 0xFFFF marks
+	// guilty; innocent suspects store their alibi TextBlock offset there.
+	// Floppy uses same convention at suspect entry +2..3 (variable stride).
 	if (_isFloppy) {
 		const byte *e = floppySuspectEntry(suspectIdx);
 		return e && READ_LE_UINT16(e + 2) == 0xFFFF;
@@ -557,13 +472,9 @@ bool Mystery::isGuilty(uint suspectIdx) const {
 
 uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
 	if (_isFloppy) {
-		// Floppy alibi: u16 at suspect +2..3 carries TWO things:
-		// 0xFFFF = guilty, otherwise the HIGH BYTE indexes the
-		// TEXT_BLOCK table (header[+0xc]), each entry u16 = absolute
-		// alibi-text offset in the buffer. Verified at
-		// `_DisplayAlibi_Floppy @ 1d40:0145` reading
-		// `*(int *)(textBlock + ((byte *)entry)[3] * 2)`. The result
-		// is an ABSOLUTE offset; caller reads via `blobAt(off)`.
+		// Floppy alibi (_DisplayAlibi_Floppy @ 1d40:0145): u16 at suspect +2..3.
+		// 0xFFFF = guilty; else high byte indexes the TEXT_BLOCK table at
+		// header[+0xc], each entry u16 = absolute alibi-text offset.
 		const byte *e = floppySuspectEntry(suspectIdx);
 		if (!e)
 			return 0xFFFF;
@@ -583,10 +494,8 @@ uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
 }
 
 const byte *Mystery::hintBlock() const {
-	// Header word at index 9 (`_hintOffset`) — used by `_KDHelp @
-	// 1560:010a`'s per-chain-clue hint table. Each pair-of-bytes is
-	// a TextBlock offset for the corresponding `_AChain` entry, or
-	// `0xFFFF` if no hint is defined for that chain position.
+	// Header word[9] _hintOffset (_KDHelp @ 1560:010a). Each u16 is a
+	// TextBlock offset for the corresponding _AChain entry, or 0xFFFF.
 	if (!isLoaded() || _hintOffset == 0 || _hintOffset >= _data.size())
 		return nullptr;
 	return _data.data() + _hintOffset;
@@ -604,10 +513,8 @@ int Mystery::selectedPoints() const {
 	if (!ni || cnt == 0)
 		return 0;
 	if (_isFloppy) {
-		// Floppy `_GetSelectedPoints_Floppy @ 1d40:0c23`: collect the
-		// per-note score (note +6 byte) for every clue with TextSeen
-		// set, sort descending, sum the top 5. Mirrors
-		// `_SortNotesByScore_Floppy @ 1d40:000e` + the top-5 fold.
+		// _GetSelectedPoints_Floppy @ 1d40:0c23: per-note score (note +6)
+		// for each TextSeen clue, sort descending, sum top 5.
 		uint8 scores[Mystery::kCluesFoundCap] = {};
 		uint scoreCount = 0;
 		const uint maxIdx = MIN<uint>(cnt, kCluesFoundCap);
@@ -616,7 +523,7 @@ int Mystery::selectedPoints() const {
 				continue;
 			scores[scoreCount++] = ni[i * 7 + 6];
 		}
-		// Partial selection sort for top 5.
+		// Partial selection sort for the top 5 scores.
 		const uint topN = MIN<uint>(5u, scoreCount);
 		for (uint k = 0; k < topN; k++) {
 			uint best = k;
@@ -639,7 +546,7 @@ int Mystery::selectedPoints() const {
 	for (uint i = 0; i < cnt && i < kCluesFoundCap; i++) {
 		if (!_noteSelected[i])
 			continue;
-		// Each NoteIndex entry is 4 bytes: u16 textOff + u16 points.
+		// CD NoteIndex entry: 4 bytes = u16 textOff + u16 points (at +2).
 		const uint16 pts = READ_LE_UINT16(ni + i * 4 + 2);
 		total += (int)(int16)pts;
 	}
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 8ef1c584715..07545cf6e23 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -30,31 +30,23 @@
 
 namespace EEM {
 
-/**
- * One mystery (case file) loaded from `M<n>.BIN`.
- *
- * Mirrors the layout established by `_ReadMystery` @ 2404:008f:
- *
- *   word[0]  = InitBlock byte offset
- *   word[2]  = MapData byte offset
- *   word[3]  = SiteIndex byte offset
- *   word[4]  = TextBlock byte offset
- *   word[5]  = NoteIndex byte offset
- *   word[6]  = GalleryData byte offset
- *   word[7]  = KDTextIndex byte offset
- *   word[8]  = SolvedClues byte offset
- *   word[9]  = HintBlock byte offset
- *   word[10] = NumSites + start of MysteryStats
- *   word[13] = NumSuspects (low byte)
- *   word[14] = NumCONSITEs
- *   word[15] = NumCOFFSITEs
- *   word[16..20] = AChain (5 words)
- *   word[21..25] = BChain
- *   word[26..30] = CChain
- *
- * Per-mystery state is reset every time a mystery is loaded; chains and
- * indices are pointers into the in-memory `_data` blob.
- */
+/// Mystery file M<n>.BIN. CD header layout from _ReadMystery @ 2404:008f:
+///   word[0]      InitBlock offset
+///   word[2]      MapData offset
+///   word[3]      SiteIndex offset
+///   word[4]      TextBlock offset
+///   word[5]      NoteIndex offset
+///   word[6]      GalleryData offset
+///   word[7]      KDTextIndex offset
+///   word[8]      SolvedClues offset
+///   word[9]      HintBlock offset
+///   word[10]     NumSites
+///   word[13]     NumSuspects (low byte)
+///   word[14]     NumCONSITEs
+///   word[15]     NumCOFFSITEs
+///   word[16..20] AChain (5 words)
+///   word[21..25] BChain
+///   word[26..30] CChain
 class Mystery {
 public:
 	static const uint kNumChains       = 3;
@@ -67,14 +59,11 @@ public:
 	Mystery() = default;
 	~Mystery() = default;
 
-	/// Load `M<num>.BIN` and reset per-mystery state. Returns false on error.
+	/// Load M<num>.BIN and reset per-mystery state. Returns false on error.
 	bool load(uint num, class Common::RandomSource *rng = nullptr);
 
-	/// Drop the loaded mystery and zero per-mystery state. Safe to call
-	/// at any time; `isLoaded()` returns false afterward.
 	void clear();
 
-	/// True once `load()` succeeded and offsets are valid.
 	bool isLoaded() const { return !_data.empty(); }
 
 	uint number() const { return _number; }
@@ -83,120 +72,93 @@ public:
 	uint8  numCONSITEs() const { return _numCONSITEs; }
 	uint8  numCOFFSITEs() const { return _numCOFFSITEs; }
 
-	/// Pointer to the InitBlock (case briefing).
-	/// _InitBlock @ 2d5d:?? = mystery + word[0] in `_ReadMystery`.
+	/// InitBlock (case briefing) at mystery + word[0].
 	const byte *initBlock() const;
 
-	/// Pointer to the GalleryData; one 0x46-byte entry per suspect.
-	/// First u16 of each entry is the PIC picture ID for that suspect.
+	/// GalleryData: 0x46-byte entry per suspect; first u16 = PIC picture ID.
 	const byte *galleryData() const;
 
 	/// Floppy variable-stride suspect record. Returns nullptr on CD or
-	/// when @p suspectIdx is out of range. Walks the gallery section
-	/// (`5 + nameLen` bytes per suspect) to land on the requested entry.
-	/// Layout: u16 picID, u16 alibiMarker (0xFFFF = guilty),
+	/// out-of-range. Layout: u16 picID, u16 alibiMarker (0xFFFF=guilty),
 	/// u8 nameLen, nameLen bytes of name.
 	const byte *floppySuspectEntry(uint suspectIdx) const;
 
-	/// Pointer to the NoteIndex array (4 bytes per entry: u16 textOff + u16 pts).
+	/// NoteIndex array (4 bytes per entry: u16 textOff + u16 pts).
 	const byte *noteIndex() const;
 
-	/// Number of entries in NoteIndex.
 	uint16 noteIndexCount() const;
 
-	/// True when @p clueId has a visible notebook/accuse text entry.
-	/// Floppy dialog text indices may be spoken-only records with a
-	/// zero notebook text offset; those are marked seen but skipped by
-	/// `_DrawNotes_Floppy`.
+	/// True when clueId has a notebook text entry. Floppy dialog records
+	/// may be spoken-only with zero notebook offset, skipped by _DrawNotes_Floppy.
 	bool noteHasNotebookText(uint clueId) const;
 
-	/// Pointer to the KDTextIndex; first u16s are TextBlock offsets for
-	/// host hint lines.
+	/// KDTextIndex; first u16s are TextBlock offsets for host hint lines.
 	const byte *kdTextIndex() const;
 
-	/// Pointer to the HintBlock; per-clue hint TextBlock offsets indexed
-	/// by `_aChain[i]` (the Nth required clue). Mirrors the
-	/// `_HintBlock` global read in `_KDHelp @ 1560:010a`.
+	/// HintBlock (_KDHelp @ 1560:010a). Per-clue hint TextBlock offsets
+	/// indexed by _aChain[i].
 	const byte *hintBlock() const;
 
-	/// Read entry @p i from `_aChain` (the required-clue chain). Returns
-	/// 0xFFFF when no entry exists. Used by `_KDHelp` to walk unfound
-	/// clues for hints.
+	/// Entry @p i of the required-clue chain. Returns 0xFFFF when out of
+	/// range. Walked by `_KDHelp` to find unfound clues for hints.
 	uint16 aChain(uint i) const {
 		return i < kChainLen ? _aChain[i] : 0xFFFF;
 	}
 
-	/// Pointer to the MapData entry for site @p siteNum (14 bytes per
-	/// entry; first u16 = sitepic, +4..7 = (x, y) on the big map).
+	/// MapData entry for siteNum: 14 bytes; first u16 = sitepic, +4..7 = (x, y).
 	const byte *mapEntry(uint siteNum) const;
 
-	/// Pointer to the SiteIndex entry for site @p siteNum (6 bytes per site).
+	/// SiteIndex entry for siteNum (6 bytes per site on CD).
 	const byte *siteIndexEntry(uint siteNum) const;
 
-	/// Pointer to the SiteData (sitepic, travel, hotspot count, ...)
-	/// referenced by SiteIndex[@p siteNum].
+	/// SiteData (sitepic, travel, hotspot count, ...) per SiteIndex[siteNum].
 	const byte *siteData(uint siteNum) const;
 
-	/// Floppy-only pointer to the matching `ANI.BIN` per-site animation
-	/// block. Layout: u8 cycleCount, cycleCount × {u8 start, u8 end},
-	/// u8 animCount, animCount × {u8 animId, u16 x, u8 y}.
+	/// Floppy ANI.BIN per-site animation block. Layout:
+	/// u8 cycleCount, cycleCount x {u8 start, u8 end},
+	/// u8 animCount, animCount x {u8 animId, u16 x, u8 y}.
 	const byte *floppySiteAnimData(uint siteNum) const;
 
-	/// Pointer to the hotspot rectangle array for site @p siteNum.
-	/// Each rect is 14 bytes: x1, y1, x2, y2, then 6 bytes of clue data.
+	/// Hotspot rectangle array for siteNum (14 bytes each: x1,y1,x2,y2 + clue).
 	const byte *hotspots(uint siteNum) const;
 
-	/// Number of hotspots in site @p siteNum.
 	uint16 hotspotCount(uint siteNum) const;
 
-	/// Pointer to a NUL-terminated string at TextBlock+ at p offset.
+	/// NUL-terminated string at TextBlock + offset.
 	const char *textAt(uint16 offset) const;
 
-	/// Pointer at byte offset @p offset within the mystery blob, or null
-	/// if out of range. Used to chase ClueBlock pointers stored in
-	/// hotspot data.
+	/// Pointer at byte @p offset within the mystery blob, or null if out
+	/// of range. Used to chase ClueBlock pointers stored in hotspot data.
 	const byte *blobAt(uint32 offset) const {
 		return offset < _data.size() ? _data.data() + offset : nullptr;
 	}
 
-	/// Total mystery blob size in bytes (for bounds checks).
 	uint32 dataSize() const { return (uint32)_data.size(); }
 
-	/// Synchronize the per-mystery runtime state for save/load. The fixed
-	/// arrays serialize first, then the booleans and counters.
 	void syncState(Common::Serializer &s);
 
-	/// Sum of point values of every selected notebook entry. Mirrors
-	/// `_GetSelectedPoints` @ 1df2:00bd.
+	/// _GetSelectedPoints @ 1df2:00bd.
 	int selectedPoints() const;
 
-	/// Sum of the top five point values among found notebook entries.
-	/// Mirrors `_GetFoundPoints` @ 1df2:0098.
+	/// _GetFoundPoints @ 1df2:0098 — sum of top 5 found notebook entries.
 	int foundPoints() const;
 
-	/// True when `selectedPoints() > 99`. Mirrors `_SolvedCheck`.
 	bool solvedCheck() const { return selectedPoints() > 99; }
 
-	/// True iff suspect @p suspectIdx is the case's guilty party. The
-	/// guilty marker is `GalleryData[suspectIdx * 0x46 + 0x02] ==
-	/// 0xFFFF` — innocent suspects store their alibi text offset there;
-	/// the guilty suspect uses the sentinel. Verified at `_WITCH @
-	/// 1df2:089f` (`if (psVar1->field_0x2 == -1) _DisplayCorrect();
-	/// else _DisplayAlibi(...)`).
+	/// _WITCH @ 1df2:089f. GalleryData[i*0x46 + 0x02] == 0xFFFF marks the
+	/// guilty suspect; innocent suspects store their alibi TextBlock offset.
 	bool isGuilty(uint suspectIdx) const;
 
-	/// TextBlock offset of suspect @p suspectIdx's alibi text. Returns
-	/// 0xFFFF for the guilty suspect (no alibi).
+	/// TextBlock offset of suspect's alibi text. 0xFFFF for guilty suspect.
 	uint16 alibiTextOffset(uint suspectIdx) const;
 
-	/// Pointer to the win-clueblock (`MysteryIndex[+0x10]` =
-	/// `_solvedOffset`). Mirrors `_DisplayCorrect`'s
-	/// `_DisplayClue(_Mystery + MysteryIndex[+0x10], 0)` at 1df2:0769.
+	/// Win-clueblock at `MysteryIndex[+0x10]` = `_solvedOffset`. Used by
+	/// `_DisplayCorrect` @ 1df2:0769 (`_DisplayClue(_Mystery + MysteryIndex[+0x10], 0)`).
 	const byte *solvedClueBlock() const;
 
 	/// Per-mystery runtime state, zeroed at load time.
 	uint8  _cluesFound[kCluesFoundCap]   = {};
-	uint8  _noteSelected[kCluesFoundCap] = {};  ///< Mirror `_NoteSelected`
+	uint8  _noteSelected[kCluesFoundCap] = {};  ///< _NoteSelected
 	uint16 _hotSpotsSeen[kHotSpotsCap]   = {};
 	uint16 _inGallery[kGalleryCap]       = {};
 	uint8  _newOrder[kGalleryCap]        = {};
@@ -234,19 +196,15 @@ private:
 	uint16 _bChain[kChainLen] = {};
 	uint16 _cChain[kChainLen] = {};
 
-	// Floppy variant uses a completely different header (see comment
-	// in `Mystery::load`). When `_isFloppy` is true, the CD-shaped
-	// `_initOffset / _siteIndexOffset / etc.` fields are unset and the
-	// floppy section pointers below are populated from the floppy
-	// header offsets verified at `_ReadMystery_Floppy @ 22dc:0178`.
+	// Floppy variant — see Mystery::load. _ReadMystery_Floppy @ 22dc:0178.
 	bool   _isFloppy = false;
-	uint16 _floppySuspectsOff = 0;   ///< header[+4]  → suspects
-	uint16 _floppyHintBlockOff = 0;  ///< header[+6]  → hint→clue table
-	uint16 _floppyNoteIndexOff = 0;  ///< header[+8]  → notes (7B/clue)
-	uint16 _floppyGalleryOff = 0;    ///< header[+0xa] → gallery portraits
-	uint16 _floppyTextOff = 0;       ///< header[+0xc] → text block
-	uint16 _floppyKDTextOff = 0;     ///< header[+0x10] → KDTextIndex
-	uint16 _floppySolvedOff = 0;     ///< header[+0x12] → solved clue chain
+	uint16 _floppySuspectsOff = 0;   ///< header[+4]    suspects
+	uint16 _floppyHintBlockOff = 0;  ///< header[+6]    hint -> clue table
+	uint16 _floppyNoteIndexOff = 0;  ///< header[+8]    notes (7B/clue)
+	uint16 _floppyGalleryOff = 0;    ///< header[+0xa]  gallery portraits
+	uint16 _floppyTextOff = 0;       ///< header[+0xc]  text block
+	uint16 _floppyKDTextOff = 0;     ///< header[+0x10] KDTextIndex
+	uint16 _floppySolvedOff = 0;     ///< header[+0x12] solved clue chain
 	Common::Array<byte> _floppySiteAnimData;
 	uint16 _floppySiteAnimSiteOff[kVisitedSiteCap] = {};
 
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index a08ce19f85f..deb49700d57 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -53,7 +53,7 @@ bool DBDArchive::open(const Common::Path &dbdName, const Common::Path &dbxName)
 		return false;
 	}
 
-	// _InitGraphicsSystem @ 172b:0145 reads 10 bytes per entry until EOF.
+	// _InitGraphicsSystem @ 172b:0145: 10-byte entries until EOF.
 	const int32 dbxSize = dbx.size();
 	_index.reserve(dbxSize / 10);
 	while (dbx.pos() + 10 <= dbxSize) {
@@ -76,10 +76,8 @@ void DBDArchive::close() {
 	_index.clear();
 }
 
-/**
- * Read one 12-byte frame header + payload at the current stream position.
- * Shared between picture and animation loaders since the layout is the same.
- */
+/// Read one 12-byte frame header + payload at the current stream position.
+/// Shared between picture and animation loaders since the layout is identical.
 bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out) {
 	out.flags             = stream.readUint16LE();
 	const uint16 height   = stream.readUint16LE();
@@ -116,11 +114,10 @@ bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out
 bool DBDArchive::loadEntry(uint num, Picture &out) {
 	if (num >= _index.size()) {
 		// Out-of-range picture IDs are non-fatal — every caller already
-		// checks the return value (e.g., `haveDone`/`haveCrime` in
-		// `drawBigMapOverview`). The floppy's PICS.DBD ships fewer
-		// entries than the CD (e.g., the BigMap done-marker `0x20D` is
-		// CD-only), so this fires routinely on floppy and shouldn't
-		// be a `warning`.
+		// checks the return value (e.g. `haveDone` / `haveCrime` in
+		// `drawBigMapOverview`). Floppy PICS.DBD ships fewer entries than
+		// CD (e.g. BigMap done-marker 0x20D is CD-only), so this fires
+		// routinely on floppy and stays at debug level rather than warning.
 		debugC(2, kDebugGfx,
 			   "DBDArchive::loadEntry: %u out of range (max %u)",
 			   num, (uint)_index.size());
@@ -133,8 +130,7 @@ bool DBDArchive::loadEntry(uint num, Picture &out) {
 		return false;
 	}
 
-	// Mirrors _GetFromDB @ 172b:105d. The 2-byte word read first matches
-	// loadAnimation's frame-count read; for picture entries it is always 1.
+	// _GetFromDB @ 172b:105d. Leading u16 = frame count (always 1 for pictures).
 	(void)_dbd.readUint16LE();
 	return readFrame(_dbd, entry.compressed != 0, out);
 }
@@ -151,7 +147,7 @@ bool DBDArchive::loadAnimation(uint num, Animation &out) {
 		return false;
 	}
 
-	// Mirrors _GetAnimation @ 172b:163a: u16 frame count, then N frames.
+	// _GetAnimation @ 172b:163a: u16 frame count, then N frames.
 	const uint16 frameCount = _dbd.readUint16LE();
 	if (frameCount == 0 || frameCount > 256) {
 		warning("DBDArchive::loadAnimation: %u has implausible frame count %u",
diff --git a/engines/eem/resource.h b/engines/eem/resource.h
index 846bf0f1135..a984f323d35 100644
--- a/engines/eem/resource.h
+++ b/engines/eem/resource.h
@@ -31,79 +31,47 @@
 
 namespace EEM {
 
-/**
- * Index entry for a .DBD/.DBX archive pair (10 bytes on disk).
- *
- * Mirrors the original `dbi` struct read by _InitGraphicsSystem @ 172b:0145
- * in 10-byte chunks until EOF. Each entry locates one resource blob in the
- * companion .DBD container.
- */
+/// dbi struct from _InitGraphicsSystem @ 172b:0145 (10 bytes per entry).
 struct DBEntry {
-	uint32 offset;     ///< Byte offset of the entry in the .DBD file.
-	uint16 compressed; ///< Non-zero if the payload is PKWARE DCL ("Implode") packed.
-	uint32 size;       ///< Total size of the entry on disk (including 14-byte header).
+	uint32 offset;     ///< Byte offset in the .DBD file.
+	uint16 compressed; ///< Non-zero = PKWARE DCL ("Implode") packed payload.
+	uint32 size;       ///< Total entry size on disk (incl. 14-byte header).
 };
 
-/**
- * 8-bit indexed picture decoded from a .DBD entry.
- *
- * The original engine's PicData is a 16-byte struct; we keep the descriptive
- * fields here and let `Graphics::ManagedSurface` own the pixel data so the
- * rest of the engine can blit/scale/clip with the standard API.
- */
+/// 8-bit indexed picture decoded from a .DBD entry. Original PicData is
+/// 16 bytes; pixels live in Graphics::ManagedSurface.
 struct Picture {
-	uint16 flags     = 0; ///< +0  high byte = sub-mode used by some sprites
-	uint16 rowoff    = 0; ///< +6  row offset (used by some clipped sprites)
-	uint16 miscflags = 0; ///< +8  high byte = transparent-mask flag
+	uint16 flags     = 0; ///< +0  high byte: sub-mode
+	uint16 rowoff    = 0; ///< +6  row offset (clipped sprites)
+	uint16 miscflags = 0; ///< +8  high byte: transparent-mask flag
 	uint16 compsize  = 0; ///< +10 packed payload size on disk
 	Graphics::ManagedSurface surface;
 };
 
-/// Multi-frame animation as stored in ANI.DBD — a sequence of Pictures.
+/// Multi-frame animation from ANI.DBD.
 typedef Common::Array<Picture> Animation;
 
-/**
- * Reader for a .DBD + .DBX archive pair.
- *
- * The original engine has five such pairs: PICS, SITES, ANI, BALLOON, BUTTON.
- * Each .DBX is parsed once into an in-memory `_index`; reads of individual
- * entries seek into the .DBD on demand and (when flagged) decompress with
- * `Common::decompressDCL`.
- */
+/// .DBD + .DBX archive pair (PICS, SITES, ANI, BALLOON, BUTTON).
 class DBDArchive {
 public:
 	DBDArchive();
 	~DBDArchive();
 
-	/**
-	 * Open both halves of an archive. @p dbdName / @p dbxName are looked up
-	 * via SearchMan, so case is normalized for us. Returns false if either
-	 * file is missing or the index is malformed.
-	 */
+	/// Open both halves; returns false if either file is missing/malformed.
 	bool open(const Common::Path &dbdName, const Common::Path &dbxName);
 	void close();
 
-	/** Number of entries in the index. */
 	uint32 size() const { return _index.size(); }
 
-	/**
-	 * Load entry @p num (0-based index), decompressing if needed.
-	 * Returns true on success. Mirrors _GetFromDB @ 172b:105d.
-	 */
+	/// Load entry num (0-based), decompressing if needed.
+	/// _GetFromDB @ 172b:105d.
 	bool loadEntry(uint num, Picture &out);
 
-	/**
-	 * Convenience wrapper that mirrors the engine's 1-based picture API:
-	 * `_GetPicture(num)` calls `_GetFromDB(..., num - 1)`. Use this when
-	 * porting code that references picture IDs by their original number.
-	 */
+	/// 1-based wrapper matching the engine's _GetPicture(num) -> _GetFromDB(num - 1).
 	bool getPicture(uint num, Picture &out) { return loadEntry(num - 1, out); }
 
-	/**
-	 * Load a multi-frame animation entry. Mirrors _GetAnimation @ 172b:163a:
-	 * read u16 frameCount, then for each frame read a 12-byte header and
-	 * decompress the payload. Used for ANI.DBD entries.
-	 */
+	/// Multi-frame entry. _GetAnimation @ 172b:163a: u16 frameCount,
+	/// then per frame a 12-byte header + payload.
 	bool loadAnimation(uint num, Animation &out);
 
 private:
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index f309a2d76bc..167f9d0d1cd 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -39,28 +39,20 @@ constexpr Common::Rect kSitePdaRect(Common::Point(35, 111), 21, 25);
 constexpr Common::Rect kSitePartnerFootMapRect(Common::Point(7, 177), 50, 23);
 constexpr Common::Rect kSitePartnerHeadHintRect(Common::Point(5, 80), 39, 30);
 
-// Masked blit a Picture into a ManagedSurface. Pixels equal to `transp`
-// (the high byte of `pic.flags`, per `_Rect_Move_Mask @ 1000:03fc`) are
-// skipped. Used by `enterSiteAnim` for both skateboard + KD slide-in
-// passes; the surface is the in-memory frame buffer that gets pushed
-// to the screen each tick.
+// Masked blit using `transp` = high byte of `pic.flags` (`_Rect_Move_Mask @ 1000:03fc`).
 void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 			   int x, int y, byte transp) {
 	dst.transBlitFrom(p.surface, Common::Point(x, y), (uint32)transp);
 }
 
-// Mask-aware blit from a Picture into a `Graphics::Surface` (the
-// locked framebuffer). Same pixel-mask semantics as `blitFrame`.
-// Used by hotspot/NPC rendering inside `SiteScreen::renderHotspots`
-// and `renderStaticDrops`.
+// Top-left masked blit. `_AddDrop @ 172b:1a77` calls
+// `_Rect_Move_Mask(..., x, y, ...)` with the raw (x, y) and IGNORES
+// per-frame anchor offsets — so this is the correct path for static
+// drops and any non-animated overlay. Animations must route through
+// `blitAnimFrameAnchored` instead so per-frame anchor offsets
+// (miscflags = X, rowoff = Y) apply correctly.
 void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 					   int x, int y) {
-	// Top-left semantics. Used for static drops (`_AddDrop @
-	// 172b:1a77` which calls `_Rect_Move_Mask(..., x, y, ...)` with
-	// the raw (x, y) and ignores per-frame anchors) and any other
-	// non-animated overlay. Animation rendering should go through
-	// `blitAnimFrameAnchored` instead so per-frame anchor offsets
-	// (miscflags = X, rowoff = Y) apply correctly.
 	if (!screen)
 		return;
 	const byte transp = (byte)(p.flags >> 8);
@@ -82,17 +74,10 @@ void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY) {
-	// `_UpdateAnimations @ 172b:09c1` blits each animation frame at
-	// `(anchor_x - puVar5[4], anchor_y - puVar5[3])` where puVar5[3]/[4]
-	// are the per-frame `rowoff` / `miscflags` values from the
-	// 16-byte PicData header. Both are SIGNED 16-bit anchor offsets
-	// — when frames have varying anchors (anim 0x14 BigMap walk-
-	// cycle has miscflags = -2 per cell, anim 0x07 has rowoff up to
-	// 61), the sprite actually translates across the screen as it
-	// cycles through cells. Without this, the partner "shakes in
-	// place" instead of walking. (Transparency still comes from
-	// `flags >> 8`, verified at the `_Rect_Move_Mask(..., *thePic >>
-	// 8)` call — NOT from miscflags as an earlier comment claimed.)
+	// `_UpdateAnimations @ 172b:09c1`: blit at
+	//   (anchor_x - puVar5[4], anchor_y - puVar5[3])
+	// where puVar5[3]/[4] are per-frame rowoff/miscflags (signed int16)
+	// from the 16-byte PicData header. Transparency = flags >> 8.
 	if (!screen)
 		return;
 	const int blitX = anchorX - (int)(int16)p.miscflags;
@@ -114,9 +99,11 @@ void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 	}
 }
 
-// Rotate one VGA palette range by one slot. Mirrors `_ColorCycle @
-// 172b:2015` — used by both the per-site Loop-1 ColorCycle entries and
-// the always-on hotspot marching-ants range 0xF9..0xFE.
+// `_ColorCycle @ 172b:2015` — rotate `_fpal[start..end]` by one slot:
+// save [start], shift [start..end-1] = [start+1..end], restore saved at
+// [end], then re-upload via `_Set_Palette`. We do the same against
+// ScummVM's palette manager. Used by per-site Loop-1 ColorCycle entries
+// and the always-on hotspot marching-ants range 0xF9..0xFE.
 void cyclePaletteRange(uint8 start, uint8 end) {
 	if (end <= start)
 		return;
@@ -138,11 +125,10 @@ void cyclePaletteRange(uint8 start, uint8 end) {
 }
 
 void cyclePaletteRangeReverse(uint8 start, uint8 end) {
-	// Mirrors `_OpenColorCycle @ 2520:04f7`: save the END color, shift
-	// every entry up by one (END-1 → END, ...), wrap saved END to
-	// START. Visually colors march from start toward end — opposite
-	// direction from `cyclePaletteRange`. Used by the EA Kids /
-	// HighScore logo cycles.
+	// `_OpenColorCycle @ 2520:04f7`: save END, shift every entry up by
+	// one (END-1 → END, ...), wrap saved END to START. Visually colors
+	// march from start toward end — opposite direction from
+	// `cyclePaletteRange`. Used by the EA Kids / HighScore logo cycles.
 	if (end <= start)
 		return;
 	const uint count = (uint)end - (uint)start + 1;
@@ -163,13 +149,11 @@ void cyclePaletteRangeReverse(uint8 start, uint8 end) {
 	g_system->getPaletteManager()->setPalette(buf, start, count);
 }
 
-// Per-speaker partner-position table verified against `_WaitAnims @
-// 29be:021c`. 12 bytes per entry, indexed by `siteData[+8]`. Layout:
+// `_WaitAnims @ 29be:021c`. 12 bytes per entry, indexed by `siteData[+8]`:
 //   +0..1 anim Jake, +2..3 anim Jenny,
 //   +4..5 x    Jake, +6..7 x    Jenny,
 //   +8..9 y    Jake, +10..11 y    Jenny.
-// Seven valid entries — anything past entry 6 in the binary is
-// `_SiteButtons` rect data that follows the table in memory.
+// 7 entries; past entry 6 is `_SiteButtons` rect data.
 const uint16 kWaitAnims[7][6] = {
 	{ 0x00, 0x0a, 0x06, 0x06, 0x50, 0x50 }, // 0
 	{ 0x03, 0x0c, 0x06, 0x06, 0x50, 0x50 }, // 1
@@ -180,9 +164,8 @@ const uint16 kWaitAnims[7][6] = {
 	{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
 };
 
-// `_DoKDAnim` lookup table. Six valid kdAnimNum entries (0..5)
-// verified from `29be:0228`. Layout per entry: { animJake, animJenny,
-// xJake, xJenny, yJake, yJenny }. Position is (6, 80) in every entry.
+// `_DoKDAnim` table @ 29be:0228. 6 entries (kdAnimNum 0..5).
+// Layout: { animJake, animJenny, xJake, xJenny, yJake, yJenny }.
 const uint16 kKdAnimTable[6][6] = {
 	{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
 	{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
@@ -192,29 +175,27 @@ const uint16 kKdAnimTable[6][6] = {
 	{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
 };
 
-// Animation script table. Mirrors `_AnimationSequences @ 29be:22d4`
-// (a 55-entry table of far ptrs, each pointing to a u16-frame-index
-// stream terminated by 0x80; 0x81 marks a jump that we don't see in
-// the partner subset and so don't yet implement).
-//
-// `_NewAnimation @ 172b:06e1` reads the script via
+// Animation script table — mirrors `_AnimationSequences @ 29be:22d4`
+// (55-entry table of far ptrs, each pointing to a u16-frame-index
+// stream). `_NewAnimation @ 172b:06e1` reads the script via
 // `_AnimationSequences[anim_id]` and stores the pointer in
-// `DAT_2d5d_3eaf[i*0xb]`. `_UpdateAnimations @ 172b:09c1` then walks
-// it one entry per `_CheckFrameRate` tick (~100 ms): the value at
-// `script[index]` is the frame to render; 0x80 resets index to 0
-// (loop). So a script like `[0,0,0,0,0,0,0,0,0,2]` renders nine ticks
-// of frame 0 then one tick of frame 2 → the natural "blink with long
-// idle hold" cadence.
+// `DAT_2d5d_3eaf[i*0xb]`; `_UpdateAnimations @ 172b:09c1` then walks it
+// one entry per `_CheckFrameRate` tick (~140 ms).
 //
-// We use the same scripts for the wait anims (`renderPartner`) AND
-// the kd-clue reaction anims (`playKdAnim`), since both call
-// `_NewAnimation` in the original — only the state field differs (1
-// = looping, 4 = one-shot). seqnum == animId per `_PlayAnimation`
-// 172b:1f5d push order.
+// Script byte format:
+//   0x80         = restart (loop back to index 0; terminator for one-shots)
+//   0x81 N       = jump to byte N (not used in partner subset)
+//   other        = frame index to render this tick
 //
-// Each entry was read directly from the EXE via Ghidra; cross-checked
-// against `_NewAnimation`'s read. Frame counts include only the
-// playable frames (the trailing 0x80 is the terminator, not a frame).
+// Repeated frames are the original's "frame-hold" mechanism: per-tick
+// walk advances exactly one entry, so K repeats hold the frame for
+// K * `kFramePeriodMs` ≈ K * 140 ms (e.g. [0,0,0,0,0,0,0,0,0,2] →
+// nine ticks of frame 0, one tick of frame 2 = "blink with long
+// idle hold"). Same scripts serve wait anims (looping) and kd-clue
+// reactions (state-4 one-shot — see `_PlayAnimation`); state field
+// in the slot differentiates them. `seqnum == animId` per
+// `_PlayAnimation @ 172b:1f5d` push order.
+// Length counts only playable frames; trailing 0x80 is not a frame.
 struct AnimScript {
 	uint16 seqnum;
 	uint8 len;
@@ -225,9 +206,7 @@ const AnimScript kAnimScripts[] = {
 	{ 0x00, 10, { 0,0,0,0,0,0,0,0,0,2 } },
 	// 0x01 (29be:188a) — Jake PDA idle: alternating head bob with peak
 	{ 0x01, 15, { 0,1,2,0,1,0,2,1,0,1,0,1,2,1,0 } },
-	// 0x02 (29be:18aa) — Jake gallery: brief wave, long hold, second
-	// wave, hold (CONFIRMED 26 frames — earlier table was truncated to
-	// 16 which dropped the second wave cycle).
+	// 0x02 (29be:18aa) — Jake gallery: brief wave, long hold, second wave, hold.
 	{ 0x02, 26, { 0,1,2,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0,0,0,0 } },
 	// 0x03 (29be:18e0) — Jake "lift, hold, lower" gesture
 	{ 0x03,  9, { 0,1,2,3,2,2,2,1,0 } },
@@ -235,8 +214,7 @@ const AnimScript kAnimScripts[] = {
 	{ 0x04, 13, { 0,1,2,3,4,5,4,4,4,3,2,1,0 } },
 	// 0x05 (29be:1910) — Jake/Jenny shared (speaker 5): held idle, peak
 	{ 0x05, 13, { 0,0,0,1,2,3,2,1,0,0,0,0,0 } },
-	// 0x06 (29be:192c) — speaker 6 partner: empty (immediate END,
-	// renders nothing — verified at 29be:192c byte 0 = 0x80)
+	// 0x06 (29be:192c) — empty (byte 0 = 0x80, immediate END).
 	{ 0x06,  0, { 0 } },
 	// 0x07 (29be:192e) — Jake walk-cycle (10 frames: 0..9)
 	{ 0x07, 10, { 0,1,2,3,4,5,6,7,8,9 } },
@@ -256,33 +234,21 @@ const AnimScript kAnimScripts[] = {
 	{ 0x0e,  0, { 0 } },
 	// 0x0f — alias of 0x09
 	{ 0x0f,  9, { 0,0,0,1,0,0,0,0,0 } },
-	// 0x10 — Jenny gallery wait (alias of 0x09 — verified at 29be:1956)
+	// 0x10 (29be:1956, alias of 0x09) — Jenny gallery wait.
 	{ 0x10,  9, { 0,0,0,1,0,0,0,0,0 } },
-	// 0x11 (29be:1992) — Jenny entrance count-up: 0..7. Used by
-	// `_DoBigMap` when `_LastScreen == 2` (BigMap entrance one-shot).
+	// 0x11 (29be:1992) — Jenny BigMap entrance count-up 0..7 (one-shot).
 	{ 0x11,  8, { 0,1,2,3,4,5,6,7 } },
-	// 0x12 (29be:197e) — Jake entrance count-down 8..0. Used by
-	// `_DoBigMap` (entrance one-shot, partner-specific exit cell).
+	// 0x12 (29be:197e) — Jake BigMap entrance count-down 8..0 (one-shot).
 	{ 0x12,  9, { 8,7,6,5,4,3,2,1,0 } },
-	// 0x13 (29be:1992, alias of 0x11) — Jake walk-cycle 0..7,
-	// looped during BigMap idle.
+	// 0x13 (29be:1992, alias of 0x11) — Jake BigMap idle walk-cycle.
 	{ 0x13,  8, { 0,1,2,3,4,5,6,7 } },
-	// 0x14 (29be:196a) — BigMap idle walk-cycle 0..8 (9 cells),
-	// partner shifts feet while you pick a site.
+	// 0x14 (29be:196a) — BigMap idle walk-cycle 0..8.
 	{ 0x14,  9, { 0,1,2,3,4,5,6,7,8 } },
-	// 0x15 (29be:185e, alias of 0x00) — Jake CaseSelection greeter:
-	// nine idle, one blink, loop. Same blink cadence as the site
-	// loop's wait anim (animID 0x00).
+	// 0x15 (29be:185e, alias of 0x00) — Jake CaseSelection greeter.
 	{ 0x15, 10, { 0,0,0,0,0,0,0,0,0,2 } },
-	// 0x16 (29be:185e, alias of 0x00) — Jenny CaseSelection greeter,
-	// same blink script as 0x15.
+	// 0x16 (29be:185e, alias of 0x00) — Jenny CaseSelection greeter.
 	{ 0x16, 10, { 0,0,0,0,0,0,0,0,0,2 } },
-	// Site / drop scripts ≤28 frames — see `_AnimationSequences @
-	// 29be:22d4`. Many of these are short count-ups used by ambient
-	// animations (people walking, vehicles passing) that the original
-	// drives one entry per `_CheckFrameRate` tick (~140 ms). Without
-	// these, our generic fallback cycles through every animation cell
-	// at one entry per tick and the ambient anims look 2-3× too fast.
+	// Site / drop scripts ≤28 frames (`_AnimationSequences @ 29be:22d4`).
 	// 0x1b (29be:192e, alias of 0x07) — walk-cycle 0..9.
 	{ 0x1b, 10, { 0,1,2,3,4,5,6,7,8,9 } },
 	// 0x1c (29be:21a8) — short 6-frame count-up.
@@ -312,36 +278,20 @@ const AnimScript kAnimScripts[] = {
 	{ 0x34,  5, { 0,1,2,3,4 } },
 	// 0x35 (29be:21b6) — count-up 0..10 (11 frames).
 	{ 0x35, 11, { 0,1,2,3,4,5,6,7,8,9,10 } },
-	// Briefing animations — `_DoInitClues @ 1a35:0411` calls
-	// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), 1, ...)`
-	// for the game animation (always anim ID 0x17 — even Jenny's
-	// briefing reuses Jake's SCRIPT, even though the loaded ANI.DBD
-	// cells come from her partner-specific entry 0x3b). Same pattern
-	// for book (0x18 always) and nancy (0x19 always).
-	//
-	// AnimScript len was 28 — these scripts overflow that. Bump
-	// `frames[]` is fine because we just need to fit 30 frames per
-	// briefing entry. We size `frames` to 36 so all five scripts fit
-	// (longest is 0x18 at 30 frames).
 };
-static_assert(true, "see kAnimScriptsLong below for >28-frame scripts");
 
-// Scripts longer than 28 frames live here. The lookup in
-// `findAnimScript` checks both arrays. Stored as
-// `(seqnum, len, ptr)` so each script can be any length without
-// bloating every entry — the longest (0x22) runs 115 frames.
+// Scripts longer than 28 frames (>fits-inline limit). `findAnimScript`
+// checks both this array and `kAnimScripts`. Longest is 0x22 (115 frames).
 struct AnimScriptLong {
 	uint16 seqnum;
 	uint16 len;
 	const uint8 *frames;
 };
 
-// Briefing animations — `_DoInitClues @ 1a35:0411` calls
-// `_NewAnimation(..., (PicData *)CONCAT22(0x17, ...), 1, ...)` for the
-// game animation (always anim ID 0x17 — even Jenny's briefing reuses
-// Jake's SCRIPT, even though the loaded ANI.DBD cells come from her
-// partner-specific entry 0x3b). Same pattern for book (0x18) / nancy
-// (0x19).
+// Briefing animations — `_DoInitClues @ 1a35:0411` always uses anim ID
+// 0x17 (game) / 0x18 (book) / 0x19 (nancy) regardless of partner; the
+// per-partner ANI.DBD cells come from a separate entry (e.g. 0x3b for
+// Jenny's briefing).
 static const uint8 kScript17[] = {
 	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	20,21,22,23,24,25,26,27,28,29
@@ -355,17 +305,10 @@ static const uint8 kScript19[] = {
 	1,2,3,4,5,6,7,8,9,10,11,12
 };
 
-// Site / NPC drop scripts (29be:22d4 entries 0x1a..0x36 minus the
-// short ones that fit in `kAnimScripts`). Many entries deliberately
-// repeat the same frame several times — that's the original's
-// "frame-hold" mechanism (the per-tick walk advances exactly one
-// entry, so K repeats hold the frame for K * `kFramePeriodMs` ≈
-// K * 140 ms). Without these scripts our generic fallback cycles
-// through every animation cell at one entry per tick, which is the
-// "site animations run too fast" symptom.
-
-// 0x1a (29be:19a4) — count-up 0..7, long idle hold, repeat 1..7,
-// idle, mirror 7..0, idle (77 entries).
+// Site / NPC drop scripts (29be:22d4 entries 0x1a..0x36). Repeated
+// frames are the original's frame-hold mechanism (one entry per tick).
+
+// 0x1a (29be:19a4) — count-up 0..7, idle hold, repeat 1..7, idle, mirror 7..0, idle.
 static const uint8 kScript1a[] = {
 	0,1,2,3,4,5,6,7,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
@@ -552,8 +495,9 @@ const AnimScriptLong kAnimScriptsLong[] = {
 
 // `_PatientSequence` and `_ImpatientSequence` are standalone script
 // pointers, not entries in `_AnimationSequences`. CD has the data but
-// never calls the switchers; floppy calls them from `_DoSiteLoop_Floppy`.
-// We intentionally enable the same switch for both builds.
+// never calls the switchers; floppy calls them from `_DoSiteLoop_Floppy`
+// (via `_Switch2Patient` / `_Switch2Impatient`). We intentionally enable
+// the same switch for both builds.
 static const uint8 kPatientSequence[]   = { 0,0,0,0,0,0,0,0,0,2 };
 static const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 
@@ -562,10 +506,6 @@ static const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 // behavior but makes the feature observable during normal testing.
 static const uint32 kImpatienceDelayMs = 60 * 1000;
 
-// Look up the script for `seqnum`. Returns the frame array + length,
-// or `(nullptr, 0)` if no script is known — caller falls back to
-// flipbook cycling so unknown anims still animate (just without idle
-// holds).
 struct AnimScriptRef {
 	const uint8 *frames;
 	uint16 len;
@@ -594,12 +534,11 @@ static AnimScriptRef findAnimScript(uint16 seqnum) {
 }
 
 // Original frame period from `_InitFrameCounter @ 1a35:01ae`:
-// `LastFrame = (cs_within_hour) + 0xe`, with `cs_within_hour` =
-// `((ti_min * 60) + ti_sec) * 100 + ti_hund` (Borland C `struct time`
-// memory order is min, hour, hund, sec). The `+ 0xe` is 14
-// centiseconds → ~140 ms per frame, matching `_CheckFrameRate @
-// 1a35:0204`. Earlier 100 ms ran the partner / hotspot animations
-// roughly 1.4× faster than the original.
+//   LastFrame    = cs_within_hour + 0xe
+//   cs_within_hour = ((ti_min * 60) + ti_sec) * 100 + ti_hund
+// (Borland C `struct time` memory order is min, hour, hund, sec.)
+// `+ 0xe` is 14 centiseconds → ~140 ms per frame, matching
+// `_CheckFrameRate @ 1a35:0204`.
 static const uint kFramePeriodMs = 140;
 
 static uint frameFromScriptAtTick(const uint8 *frames, uint len,
@@ -612,17 +551,12 @@ static uint frameFromScriptAtTick(const uint8 *frames, uint len,
 }
 
 void auditPartnerAnims(EEMEngine *vm) {
-	// Cross-check every registered partner-subset script against the
-	// underlying ANI.DBD entry it references. If the script asks for
-	// a frame past the anim's actual frame count, the visible result
-	// is "missing frames" — that's the user-reported symptom we
-	// want to catch and fix here, not paper over with a clamp.
+	// Cross-check every script against the ANI.DBD entry it references;
+	// warn on out-of-range frame requests.
 	if (!vm)
 		return;
 	DBDArchive &ani = vm->getAni();
 
-	// Helper: audit one (id, frames, len) tuple — log a warning if
-	// the script asks for a frame the ANI.DBD entry doesn't have.
 	struct Walker {
 		static void check(DBDArchive &ani, uint16 id, const uint8 *frames, uint8 len) {
 			if (len == 0)
@@ -639,10 +573,8 @@ void auditPartnerAnims(EEMEngine *vm) {
 					maxRequested = frames[j];
 			if (maxRequested >= a.size()) {
 				warning("anim 0x%02x: script wants frame %u but ANI.DBD has "
-						"only %u — frames will be clamped (verify script "
-						"reading from `_AnimationSequences[0x%02x]` against "
-						"Ghidra)",
-						id, maxRequested, (uint)a.size(), id);
+						"only %u — frames will be clamped",
+						id, maxRequested, (uint)a.size());
 			} else {
 				debugC(2, kDebugSite,
 					   "anim 0x%02x: %u cells, script max=%u, len=%u",
@@ -658,20 +590,9 @@ void auditPartnerAnims(EEMEngine *vm) {
 		Walker::check(ani, kAnimScriptsLong[i].seqnum,
 					  kAnimScriptsLong[i].frames, kAnimScriptsLong[i].len);
 
-	// Per-frame anchor-offset audit. The original `_UpdateAnimations
-	// @ 172b:09c1` blits each frame at `(anchor_x - frame.miscflags,
-	// anchor_y - frame.rowoff)`. Our `blitMaskedSurface` ignores
-	// those offsets — it treats the WaitAnims (anchor_x, anchor_y)
-	// as the top-left. That's fine when every frame has
-	// `miscflags == 0 && rowoff == 0`; if any frame has a non-zero
-	// anchor, the partner sprite jumps between cells. Log the
-	// offending IDs so we know whether to plumb anchors through
-	// `partnerFrameAtTick` callers.
-	// Audit covers every animID we register a script for — the
-	// partner-subset (0x00..0x16, used by the wait anims, kd-clue
-	// reactions, BigMap, Notebook, Gallery, CaseSelection greeter)
-	// AND the briefing-subset (0x17..0x19, the
-	// game/book/nancy anims driven by `_DoInitClues @ 1a35:0411`).
+	// Per-frame anchor-offset audit (`_UpdateAnimations @ 172b:09c1`
+	// applies (anchor_x - miscflags, anchor_y - rowoff)). Log non-zero
+	// anchors so we know which anims need `blitAnimFrameAnchored`.
 	const uint16 partnerIds[] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
 								   0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
 								   0x0d, 0x0f, 0x10, 0x11, 0x12, 0x13,
@@ -701,10 +622,7 @@ void auditPartnerAnims(EEMEngine *vm) {
 			}
 		}
 		if (anyAnchor) {
-			// `_UpdateAnimations @ 172b:09c1` reads these as signed
-			// 16-bit values via `puVar5[3]/[4]`, so log them with
-			// the sign preserved — earlier the log printed unsigned
-			// 65534 instead of -2.
+			// Signed int16 (puVar5[3]/[4] in `_UpdateAnimations`).
 			debugC(1, kDebugSite,
 				   "anim 0x%02x: per-frame anchor (rowoff [%d..%d], "
 				   "miscflags [%d..%d]) — handled by "
@@ -714,31 +632,27 @@ void auditPartnerAnims(EEMEngine *vm) {
 	}
 }
 
-// Pick the frame index to render at `tickMs` for the looping
-// animation `seqnum` whose underlying ANI.DBD entry has `numFrames`
-// frames. Mirrors the looping path of `_UpdateAnimations`: walk the
-// script one entry per ~100 ms `_CheckFrameRate` tick, wrap on the
-// 0x80 terminator. Exposed (non-static) so the BigMap, CaseSelection
-// greeter, Notebook, and Gallery render paths in `ui.cpp` can use the
-// same cadence — without this, every off-site partner rendering
-// flipbook-cycles ALL cells of the ANI entry (no idle holds, no
-// timing variations) which is the user-reported "constantly looping"
-// symptom.
+// Looping path of `_UpdateAnimations`: walk the script one entry per
+// `_CheckFrameRate` tick (`kFramePeriodMs` ~= 140 ms), wrap on 0x80.
+// If `seqnum` has no registered script, falls back to flipbook
+// (`tick % numFrames`) so unknown anims still move. The script can in
+// theory request a frame past the asset's actual cell count (misencoded
+// script) — `frameFromScriptAtTick` clamps to `numFrames - 1` so the
+// caller doesn't read past `anim[]`. Exported (non-static) so BigMap,
+// CaseSelection greeter, Notebook, and Gallery render paths in
+// `ui.cpp` use the same cadence.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	const AnimScriptRef s = findAnimScript(seqnum);
-	// The script can in theory request a frame that's outside the
-	// animation's actual frame count (a misencoded script). Clamp so
-	// we don't read past `anim[]` in the caller.
 	return frameFromScriptAtTick(s.frames, s.len, numFrames, tickMs);
 }
 
-// Generic "play `unfold` once, then loop `waitSeq` forever" walker.
-// Mirrors the original's slot-script-swap idiom: the entrance script
-// runs to its 0x80 terminator, then the slot's script pointer is
-// rewritten to a looping wait sequence (e.g. `_BigMapWaitSeq @
-// 29be:1574`, `_SmallMapWaitSeq @ 29be:1548`). `partnerFrameAtTick`
-// can't model that swap on its own (it always wraps on the same
-// script), hence this helper.
+// Play `unfold` once, then loop `waitSeq` forever. Mirrors the
+// original's slot-script-swap idiom: the entrance script runs to its
+// 0x80 terminator, then the slot's script pointer is rewritten to a
+// looping wait sequence (e.g. `_BigMapWaitSeq @ 29be:1574`,
+// `_SmallMapWaitSeq @ 29be:1548`). `partnerFrameAtTick` can't model
+// that swap on its own (it always wraps on the same script), hence
+// this helper.
 static uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 									   const uint8 *waitSeq, uint waitSeqLen,
 									   uint numFrames, uint32 elapsedMs) {
@@ -750,9 +664,10 @@ static uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 }
 
 uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
-	// Script 0x14 @ 29be:196a (count-up 0..8, 0x80) → on terminator,
-	// `_DoBigMap` swaps to `_BigMapWaitSeq` @ 29be:1574
-	// (9,9,9,9,10,9,9,9,9, 0x80) — open-map hold with a fidget.
+	// Slot starts on script 0x14 (count-up 0..8 @ 29be:196a). On 0x80
+	// terminator, `_DoBigMap` rewrites the slot's script pointer to
+	// `_BigMapWaitSeq @ 29be:1574` = (9,9,9,9,10,9,9,9,9, 0x80) — the
+	// open-map hold with a fidget.
 	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
 	static const uint8 kWaitSeq[] = { 9, 9, 9, 9, 10, 9, 9, 9, 9 };
 	return oneShotThenLoopFrameAtTick(kUnfold, ARRAYSIZE(kUnfold),
@@ -761,10 +676,10 @@ uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
 }
 
 uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
-	// Script 0x13 @ 29be:1992 (count-up 0..7, 0x80) → on terminator,
-	// `_DoMapScreen @ 20fe:1390` swaps to `_SmallMapWaitSeq` @ 29be:1548
-	// (18 entries: hold cell 7 with a single cell-10 fidget) — fidget
-	// every ~1.8 s.
+	// Slot starts on script 0x13 (count-up 0..7 @ 29be:1992). On 0x80
+	// terminator, `_DoMapScreen @ 20fe:1390` rewrites the slot pointer
+	// (`MOV [BX+0x789f],0x1548`) to `_SmallMapWaitSeq @ 29be:1548` =
+	// 18 entries holding cell 7 with a single cell-10 fidget (~1.8 s).
 	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7 };
 	static const uint8 kWaitSeq[] = {
 		7, 7, 7, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7
@@ -785,21 +700,16 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		return;
 	}
 
-	// Reset the wait-anim phase so the partner starts fresh from
-	// script[0] when entering. Mirrors `_DoSiteLoop @ 168d:0436`
-	// where `_NewAnimation` sets the new slot's frame index to
-	// 0xffff (= -1, becomes 0 on the first `_UpdateAnimations`
-	// tick).
+	// `_DoSiteLoop @ 168d:0436`: `_NewAnimation` sets the new slot's
+	// frame index to 0xffff (-1) → starts at script[0].
 	_waitPhaseAnchor = g_system->getMillis();
 	if (resetPartnerMood) {
 		_partnerWaitMood = kPartnerWaitDefault;
 		initImpatienceCounter();
 	}
 
-	// Capture whether this is the first time the player enters this
-	// site BEFORE we mark it visited — `_DoSiteLoop @ 168d:03f4`
-	// uses the same check to decide whether to play the arrival
-	// dialog: `if (_VisitedSite[_SiteNumber] == 0) _DisplayClue(...)`.
+	// `_DoSiteLoop @ 168d:03f4`:
+	//   if (_VisitedSite[_SiteNumber] == 0) _DisplayClue(...);
 	const bool firstVisit = (siteNum < Mystery::kVisitedSiteCap)
 							 && (_mystery->_visitedSite[siteNum] == 0);
 
@@ -809,22 +719,14 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 
 	const bool playArrival = _vm->shouldPlaySiteArrival(siteNum);
 
-	// `_DoTravel @ 168d:02da` calls `_StartTravelMusic` after the
-	// destination is set. We do the same here so the music swaps as
-	// the player moves between sites.
+	// `_DoTravel @ 168d:02da` calls `_StartTravelMusic`.
 	if (playArrival)
 		_vm->startTravelMusic();
 
-	// Palette: original `_BuildBackground` calls `GetPalette(sitenum + 1)`
-	// where sitenum is the global SITES.DBD index (= the per-mystery
-	// `sitepic` field), not the per-mystery site index.
-	//
-	// Floppy site_data layout differs (per `_DoSiteLoop_Floppy @
-	// 1652:03f4`): the FIRST u16 is the *offset* to a drops sub-struct
-	// whose byte 0 is the small SITES.DBD picID. Without this branch
-	// we'd dereference the offset directly and call setSitePalette
-	// with a value in the thousands — the user reported `index 9275
-	// out of range` for site 2.
+	// `_BuildBackground` calls `GetPalette(sitenum + 1)` — sitenum is the
+	// global SITES.DBD index (per-mystery `sitepic` field).
+	// Floppy (`_DoSiteLoop_Floppy @ 1652:03f4`): first u16 of site_data is
+	// an offset to a drops sub-struct whose byte 0 is the SITES.DBD picID.
 	const byte *sd = _mystery->siteData(siteNum);
 	uint16 sitepic = 0;
 	if (sd) {
@@ -839,18 +741,11 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	}
 	_vm->setSitePaletteForSite(sitepic);
 
-	// SITEPALS ships with palette indices 0xF9..0xFE all set to a
-	// uniform yellow (`3F 3E 00` = R=63 G=62 B=0 for every site palette
-	// in the file), so the original `_DrawRect`'s cycling colour
-	// pattern + `_ColorCycle(0xF9, 0xFE)` produced a uniformly-coloured
-	// outline with no visible movement — the "marching ants" pattern
-	// was a placeholder that never lit up. Override those entries with
-	// a 6-step yellow ramp here so the existing per-tick rotation
-	// (`applyColorCycles → cyclePaletteRange(0xF9, 0xFE)`) creates a
-	// pulsing glow on unsearched hotspots. Done after `setSitePalette`
-	// so the per-site palette load doesn't clobber the ramp.
+	// SITEPALS ships palette 0xF9..0xFE as uniform yellow (3F 3E 00),
+	// so original marching-ants was a placeholder. Override with a
+	// 6-step yellow ramp so `cyclePaletteRange(0xF9, 0xFE)` produces a
+	// visible pulse on unsearched hotspots.
 	{
-		// 6-step yellow glow: dark → bright → dim → ...
 		static const byte kAntsGlow[6 * 3] = {
 			0x40, 0x40, 0x00, // F9 — dim
 			0x80, 0x80, 0x00, // FA
@@ -864,14 +759,11 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 
 	renderBackground(siteNum);
 
-	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` whenever
-	// `_LastSite != _SiteNumber`. Keep our guard on the engine rather
-	// than on SiteScreen, because PDA/gallery returns recreate
-	// SiteScreen and must not replay the arrival.
+	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` when
+	// `_LastSite != _SiteNumber`. Guard lives on the engine so PDA/gallery
+	// re-entry doesn't replay arrival.
 	if (playArrival) {
-		// `_EnterSiteAnim` snapshots the current screen, so populate
-		// that temporary background with the same site layers that
-		// should already be visible behind the arriving partner.
+		// `_EnterSiteAnim` snapshots the screen, so populate the BG first.
 		if (_vm->isFloppy())
 			renderFloppyDrops(siteNum);
 		else
@@ -885,34 +777,21 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 			else
 				_vm->waitForMusicDone();
 		}
-		// Re-paint the BG; the normal snapshot below should contain
-		// only the static layers, while animated NPCs are redrawn per
-		// tick by the frame pump.
 		renderBackground(siteNum);
 	}
 
-	// Static drops (Loop 2 from `_DoSiteLoop`) — no animation, baked
-	// into the BG snapshot the run() pump uses to restore. Floppy
-	// stores them in a different shape (drops sub-struct after the
-	// site_data offset), so dispatch on `isFloppy()`.
+	// Loop 2 from `_DoSiteLoop`: static drops (baked into snapshot).
 	if (_vm->isFloppy())
 		renderFloppyDrops(siteNum);
 	else
 		renderStaticDrops(siteNum);
 
-	// Snapshot the static layers so per-tick animation re-blits don't
-	// have to re-load PIC 0x43, the SITES.DBD scene, or each
-	// `_AddDrop` PIC every frame.
 	captureBgSnapshot();
 	_snapshotSite = (int)siteNum;
 
-	// Cache ColorCycle palette ranges for this site so the per-tick
-	// frame pump can rotate them. Mirrors the init scan at the top of
-	// `_DoSiteLoop @ 168d:03f4`.
 	scanColorCycles(siteNum);
 
-	// Animated NPCs (Loop 1) and the persistent partner sit on top of
-	// the snapshot. Initial frame goes at tickMs=now.
+	// Loop 1 (animated NPCs) + partner on top of the snapshot.
 	const uint32 now = g_system->getMillis();
 	renderAnimatedDrops(siteNum, now);
 	renderPartner(siteNum, now);
@@ -920,15 +799,12 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	renderHotspots(siteNum);
 	g_system->updateScreen();
 
-	// First-visit dialog. `_DoSiteLoop @ 168d:03f4` does:
+	// First-visit dialog. `_DoSiteLoop @ 168d:03f4`:
 	//   if (_VisitedSite[_SiteNumber] == 0) {
 	//       _DisplayClue(_Mystery + SiteIndex[siteNum*6 + 2], 1);
 	//       _VisitedSite[_SiteNumber] = 1;
 	//   }
-	// `SiteIndex[+2..+3]` is the byte offset (within the mystery
-	// buffer) of a ClueBlock that holds the partner's arrival
-	// dialogue. We've kept the +2 field undocumented up to now —
-	// this confirms it's the entry-clue offset.
+	// SiteIndex[+2..+3] = byte offset of entry-clue ClueBlock.
 	if (firstVisit) {
 		const byte *idx = _mystery->siteIndexEntry(siteNum);
 		if (idx) {
@@ -936,10 +812,7 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 			if (clueOff != 0xFFFF) {
 				const byte *clueBlock = _mystery->blobAt(clueOff);
 				if (clueBlock) {
-					// See onHotspotClicked — supply a partner-less BG
-					// so KD-anim playback (e.g. the partner's arrival
-					// camera gesture) doesn't ghost over the resting
-					// idle frame.
+					// Partner-less BG so KD-anim doesn't ghost over the idle.
 					_vm->setPartnerEraseBg(&_bgSnapshot);
 					_vm->displayClue(clueBlock);
 					_vm->setPartnerEraseBg(nullptr);
@@ -948,9 +821,7 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		}
 		if (siteNum < Mystery::kVisitedSiteCap)
 			_mystery->_visitedSite[siteNum] = 1;
-		// The dialog overlay will have left the screen with portrait /
-		// balloon residues; refresh the site so the player returns to
-		// a clean state. Re-build the snapshot too.
+		// Dialog overlay leaves portrait/balloon residue; refresh & re-snapshot.
 		renderBackground(siteNum);
 		if (_vm->isFloppy())
 			renderFloppyDrops(siteNum);
@@ -981,10 +852,8 @@ bool SiteScreen::checkImpatienceCounter() {
 }
 
 void SiteScreen::notePartnerActivity() {
-	// Mirrors `_Switch2Patient(WaitHandle)` plus
-	// `_InitImpatientCounter()` in the floppy site loop, but only for
-	// deliberate actions in the port: clicks and key presses. Passive
-	// mouse movement should not make impatience impossible to see.
+	// `_Switch2Patient(WaitHandle)` + `_InitImpatientCounter()` from
+	// the floppy site loop. Triggered on clicks/keys only (not mouse-move).
 	const bool wasImpatient = _partnerWaitMood == kPartnerWaitImpatient;
 	_partnerWaitMood = kPartnerWaitPatient;
 	initImpatienceCounter();
@@ -996,11 +865,7 @@ void SiteScreen::run() {
 	if (!_mystery || !_mystery->isLoaded())
 		return;
 
-	// The caller (run() in eem.cpp) is responsible for bringing the
-	// player into a site via the map first, so `_siteNumber` is
-	// already set to the destination they picked. Resuming a save
-	// also restores `_siteNumber`. Start there instead of forcing
-	// site 0 each time.
+	// Caller seeds `_siteNumber` (via map pick or save restore).
 	uint cur = _mystery->_siteNumber;
 	if (cur >= _mystery->numSites())
 		cur = 0;
@@ -1023,27 +888,14 @@ void SiteScreen::run() {
 				break;
 
 			case Common::EVENT_LBUTTONDOWN: {
-				// On-screen UI buttons. `_DoSiteLoop @ 168d:03f4` calls
+				// `_DoSiteLoop @ 168d:03f4` calls
 				//   _FindButton(&SiteButtons, 2, MouseX, MouseY)
-				// where `SiteButtons` is two 8-byte rectangles at
-				// 29be:0x274 (verified via 168d:0729-0848):
-				//   Button 0: (35, 111) - (56, 136)  -> notebook
-				//                                       (`_NextScreen = 4`)
-				//   Button 1: (7, 177)  - (57, 200)  -> map
-				//                                       (CD `_NextScreen = 1`,
-				//                                       floppy = 2)
-				// Test the buttons before falling through to hotspots so
-				// a click on the PDA / partner foot doesn't accidentally
-				// trigger a hotspot underneath.
-				// Partner head is a port-only enhancement so the player
-				// can click the host sprite for a hint, mirroring the
-				// PDA's rect-3 / gallery's rect-3 behaviour. The
-				// original site loop's `_FindButton(&SiteButtons, 2,
-				// ...)` only checks notebook + map, but the same
-				// partner-click -> `_KDHelp` shortcut is wired in
-				// `_HandleNoteButton[3]` (0x0403) and
-				// `_HandleGalleryButton[3]` (0x061e). Rect matches
-				// the PDA / gallery `kBtnPartner` (5, 80, 44, 110).
+				// `SiteButtons` @ 29be:0274 — two 8-byte rects:
+				//   Button 0: (35,111)-(56,136)  notebook (_NextScreen=4)
+				//   Button 1: (7,177)-(57,200)   map (CD=1, floppy=2)
+				// Partner-head click is port-only: `_KDHelp` shortcut
+				// mirroring `_HandleNoteButton[3]` (0x0403) /
+				// `_HandleGalleryButton[3]` (0x061e). Rect = (5,80,44,110).
 				if (kSitePdaRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
@@ -1054,7 +906,7 @@ void SiteScreen::run() {
 				if (kSitePartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
-					// CD writes `_NextScreen = 1`; floppy writes 2.
+					// CD: _NextScreen=1, floppy=2.
 					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 													   : kScreenMap);
 					_vm->stopMusic();
@@ -1065,10 +917,7 @@ void SiteScreen::run() {
 					_vm->doHelp();
 					notePartnerActivity();
 					enter(cur, false);
-					// Re-evaluate cursor against the CURRENT pointer
-					// position, not the click that opened the help.
-					// The player may have moved off the partner-head
-					// area during the dialog.
+					// Re-evaluate cursor against CURRENT pointer position.
 					mouse = g_system->getEventManager()->getMousePos();
 					updateHotspotCursor(cur, mouse.x, mouse.y);
 					break;
@@ -1077,13 +926,9 @@ void SiteScreen::run() {
 				if (idx >= 0) {
 					_vm->setHotspotMouseCursor(false);
 					onHotspotClicked(cur, (uint)idx);
-					// Restore the site BG after the clue overlay.
 					notePartnerActivity();
 					enter(cur, false);
-					// Use CURRENT pointer position — the click pos is
-					// still inside the hotspot rect, so reusing it
-					// would leave the "clickable" cursor stuck after
-					// the conversation even if the player moved off.
+					// Use CURRENT pointer position (click pos still in rect).
 					mouse = g_system->getEventManager()->getMousePos();
 					updateHotspotCursor(cur, mouse.x, mouse.y);
 				} else {
@@ -1094,12 +939,8 @@ void SiteScreen::run() {
 
 			case Common::EVENT_KEYDOWN:
 				notePartnerActivity();
-				// `_DoSiteLoop @ 168d:07e1` originally routed ESC
-				// through `_ESCHit` -> "Are you sure?" -> MAP. The
-				// site has visible MAP / SETUP / etc. controls along
-				// the top bar, so ESC now opens the ScummVM in-game
-				// menu (save / load / quit) instead of the back-nav
-				// shortcut.
+				// `_DoSiteLoop @ 168d:07e1` routes ESC via `_ESCHit` →
+				// "Are you sure?" → MAP. Port reroutes to ScummVM menu.
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_vm->setHotspotMouseCursor(false);
 					_vm->openMainMenuDialog();
@@ -1114,21 +955,15 @@ void SiteScreen::run() {
 			}
 		}
 
-		// Hotspot side effects can invalidate the active mystery; exit
-		// immediately rather than tick another frame against stale BG
-		// snapshots / hotspot tables.
+		// Hotspot side effects can invalidate the mystery; exit before
+		// ticking another frame against stale snapshots.
 		if (!_mystery || !_mystery->isLoaded()) {
 			_vm->stopMusic();
 			return;
 		}
 
-		// Per-tick frame pump (mirrors `_CheckFrameRate` +
-		// `_UpdateAnimations` at the top of `_DoSiteLoop`'s main loop).
-		// Restore the static BG snapshot, redraw animated NPCs +
-		// partner at the current frame, then re-render hotspots on
-		// top. The original ticks at 14 cs (~140 ms, see
-		// `kFramePeriodMs` above) — we matched 100 ms before, which
-		// ran site animations ~1.4× too fast.
+		// Per-tick frame pump: `_CheckFrameRate` + `_UpdateAnimations`
+		// at the top of `_DoSiteLoop`'s main loop. 14 cs (~140 ms).
 		const uint32 now = g_system->getMillis();
 		if (_snapshotSite == (int)cur &&
 			now - _lastTickMs >= kFramePeriodMs) {
@@ -1140,9 +975,7 @@ void SiteScreen::run() {
 			renderAnimatedDrops(cur, now);
 			renderPartner(cur, now);
 			renderHotspots(cur);
-			// Per-tick palette rotation for ColorCycle entries +
-			// hotspot marching ants. Matches `_ColorCycle(start, end)`
-			// calls inside `_DoSiteLoop @ 168d:03f4`'s main loop.
+			// `_ColorCycle(start, end)` per tick (`_DoSiteLoop @ 168d:03f4`).
 			applyColorCycles();
 			_lastTickMs = now;
 		}
@@ -1152,17 +985,12 @@ void SiteScreen::run() {
 }
 
 bool SiteScreen::enterSiteAnim() {
-	// Mirrors `_EnterSiteAnim @ 1000:9b21`. Two phases, both partner
-	// dependent:
-	//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny). Sprite
-	//             starts at (320 - sprite_w, 199 - sprite_h) and slides
-	//             left until off-screen.
-	//   Phase 2 — KD slide-in: anim 7 (Jake) / 0xf (Jenny). Sprite enters
-	//             from x = -sprite_w at y = 0x8b (Jake) / 0x8e (Jenny)
-	//             and slides until x = 0.
-	// Original cycles frames every `_MoveSkateBoardPixels` worth of
-	// motion (a runtime-calibrated speed value); we use a fixed 4 px
-	// per tick which feels close to the DOS pacing.
+	// `_EnterSiteAnim @ 1000:9b21`. Two phases (partner-dependent):
+	//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny).
+	//             Slides from (320-w, 199-h) leftward off-screen.
+	//   Phase 2 — KD slide-in: anim 7 (Jake) / 0xf (Jenny).
+	//             Slides from x=-w at y=0x8b/0x8e until x=0.
+	// Frame rate uses `_MoveSkateBoardPixels` (runtime); we use 4 px/tick.
 	if (!_vm || !_mystery)
 		return false;
 	const uint8 partner = _vm->getPartnerIndex();
@@ -1170,7 +998,6 @@ bool SiteScreen::enterSiteAnim() {
 	const uint kKDAni    = (partner == 0) ? 7  : 0xf;
 	const int  kKDY      = (partner == 0) ? 0x8b : 0x8e;
 
-	// Snapshot the current screen so we can restore between frames.
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return false;
@@ -1179,11 +1006,10 @@ bool SiteScreen::enterSiteAnim() {
 	bg.simpleBlitFrom(*screen);
 	g_system->unlockScreen();
 
-	// Phase 1 — skateboard scroll. `_GetAnimation(6 | 0xe)`.
+	// Phase 1 — skateboard scroll.
 	Animation skate;
 	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
-		// `iVar4 = 199 - sprite_h`, `uVar5 = 320 - sprite_w` from the
-		// original; sprite_h/w come from the FIRST frame.
+		// `iVar4 = 199 - sprite_h`, `uVar5 = 320 - sprite_w` (frame 0).
 		const int spriteH = skate[0].surface.h;
 		const int spriteW = skate[0].surface.w;
 		int x = (320 - spriteW) & ~3;            // 4-px aligned (mode-X)
@@ -1221,20 +1047,11 @@ bool SiteScreen::enterSiteAnim() {
 		}
 	}
 
-	// Phase 2 — KD slide-in. From `_EnterSiteAnim` each frame is blitted
-	// at its OWN anchor offsets (the sprite "walks in" because the
-	// frame-by-frame anchors decrease as the animation progresses):
-	//   destX = -frame.miscflags    (on-disk byte 8 = anchor X)
-	//   destY = kKDY - frame.rowoff (on-disk byte 6 = anchor Y)
-	// Both anchors are SIGNED int16 (mirrors `blitAnimFrameAnchored` /
-	// `_UpdateAnimations @ 172b:09c1`). The original's `-pPVar8->width`
-	// negation wraps in 16-bit on DOS, so a frame with `miscflags = -2`
-	// (0xFFFE) lands at destX = +2 in the original. Without the int16
-	// re-cast our 32-bit negation produces destX = -65534 and the
-	// second-to-last frame (which has a non-zero anchor in anim 7/0xf)
-	// clips entirely off-screen, leaving a one-tick partner-less gap.
-	// Each frame waits one `_CheckFrameRate` tick — we use 80 ms which
-	// matches the original's ~12 FPS pacing.
+	// Phase 2 — KD slide-in. `_EnterSiteAnim` per-frame:
+	//   destX = -frame.miscflags    (signed int16; byte 8 = anchor X)
+	//   destY = kKDY - frame.rowoff (signed int16; byte 6 = anchor Y)
+	// 16-bit negation: miscflags=-2 (0xFFFE) → destX=+2.
+	// ~80 ms per frame (~12 FPS, one `_CheckFrameRate` tick).
 	Animation kd;
 	if (_vm->getAni().loadAnimation(kKDAni, kd) && !kd.empty()) {
 		for (uint frameIdx = 0;
@@ -1268,13 +1085,9 @@ bool SiteScreen::enterSiteAnim() {
 
 void SiteScreen::renderStaticDrops(uint siteNum) {
 	// Loop 2 from `_DoSiteLoop @ 168d:03f4`:
-	//   bound: siteData[+0x4]   (verified at 168d:05c0:
-	//          `MOV ES:[BX+0x4], DI; CMP ES:[BX+0x4], DI`)
-	//   per entry at siteData[+0xc + i*6]: {picId, x, y}
-	//   each → `_AddDrop(picId, x, y)` (`_AddDrop @ 172b:1a77`):
-	//   loads PIC `picId-1` from PICS.DBD and blits with miscflags
-	//   high-byte as the transparent colour. These NEVER cycle, so
-	//   they belong to the BG snapshot.
+	//   count @ siteData[+0x4], entries @ siteData[+0xc + i*6]: {picId, x, y}.
+	//   `_AddDrop @ 172b:1a77` loads PIC picId-1 from PICS.DBD,
+	//   blits with miscflags high-byte as transparency.
 	if (!_mystery)
 		return;
 	const byte *site = _mystery->siteData(siteNum);
@@ -1305,19 +1118,11 @@ void SiteScreen::renderStaticDrops(uint siteNum) {
 }
 
 void SiteScreen::renderFloppyDrops(uint siteNum) {
-	// Floppy drops live inside the drops sub-struct pointed to by
-	// `*site_data` (u16 offset). Verified from the call site in
-	// `_DoSiteLoop_Floppy @ 1652:0418`:
-	//   FUN_16e2_18eb(
-	//     *(u16 *)(local_1a + i*5),          // arg1 = u16 picID @ +0..1
-	//     *(u16 *)(local_1a + i*5 + 2),      // arg2 = u16 X     @ +2..3
-	//     local_1a[i*5 + 4]                  // arg3 = u8  Y     @ +4
-	//   );
-	// Inside `FUN_16e2_18eb @ 16e2:18eb`, `arg1 - 1` indexes the
-	// PICS.DBX table at `2608:4537` (loaded from `PICS.DBX` by
-	// `FUN_16e2_0149 @ 16e2:0149`); `arg2/arg3` become destX/destY.
-	// drops_struct[0] = BG picID (rendered separately by
-	// `renderBackground`), drops_struct[1] = drop count.
+	// Floppy drops: drops sub-struct pointed to by `*site_data` u16 offset.
+	// `_DoSiteLoop_Floppy @ 1652:0418` → `FUN_16e2_18eb @ 16e2:18eb`:
+	//   entry stride 5: u16 picID @ +0, u16 X @ +2, u8 Y @ +4.
+	//   picID-1 indexes PICS.DBX table @ 2608:4537.
+	// drops_struct[0] = BG picID (separate), drops_struct[1] = drop count.
 	if (!_mystery)
 		return;
 	const byte *site = _mystery->siteData(siteNum);
@@ -1340,9 +1145,7 @@ void SiteScreen::renderFloppyDrops(uint siteNum) {
 		const int16  y     = (int16)e[4];
 		if (picID == 0)
 			continue;
-		// `getPicture(num)` already does `loadEntry(num - 1)` (see
-		// `resource.h:100`), matching the `picID - 1` index the
-		// original passes to PICS.DBD.
+		// `getPicture(num)` does `loadEntry(num - 1)` (resource.h:100).
 		Picture pic;
 		if (!_vm->getPics().getPicture((uint)picID, pic))
 			continue;
@@ -1353,29 +1156,19 @@ void SiteScreen::renderFloppyDrops(uint siteNum) {
 
 void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 	// Loop 1 from `_DoSiteLoop @ 168d:03f4`:
-	//   bound: siteData[+0xa]
-	//   per entry at siteData[+0x48 + i*6]: {animId, x, y}
-	//   animId == -1 → `_ColorCycle(x, y)` palette range (handled
-	//                  in the run() loop's frame pump as palette
-	//                  rotation).
-	//   else → `_GetAnimation(animId)` + `_NewAnimation` then
-	//          `_UpdateAnimations @ 172b:09c1` walks a sequence
-	//          script (entries are frame indices; 0x80 = end-of-loop,
-	//          0x81 = jump command). We don't have the sequence-script
-	//          structure decoded yet, so for now we cycle through the
-	//          raw animation frames in order using a global tick.
+	//   count @ siteData[+0xa], entries @ siteData[+0x48 + i*6]: {animId, x, y}.
+	//   animId == -1 → `_ColorCycle(x, y)` (palette rotation, separate path).
+	//   else → `_GetAnimation` + `_NewAnimation` + `_UpdateAnimations @ 172b:09c1`
+	//          walks a sequence script (0x80=loop, 0x81=jump).
 	if (!_mystery)
 		return;
 
 	if (_vm && _vm->isFloppy()) {
-		// Floppy extra site anims live in `ANI.BIN`, not in the
-		// mystery's site_data. `_ReadMystery_Floppy` asks
-		// `_GetSiteAnimData_Floppy(mystery)` for the case block, then
-		// walks one entry per site:
+		// Floppy site anims live in ANI.BIN (per-case block from
+		// `_GetSiteAnimData_Floppy`). Per-site:
 		//   u8 cycleCount, cycleCount × {u8 start, u8 end},
 		//   u8 animCount, animCount × {u8 animId, u16 x, u8 y}.
-		// `_DoSiteLoop_Floppy` registers at most four of these in local
-		// animation slots, so cap the draw loop the same way.
+		// `_DoSiteLoop_Floppy` caps at 4 slots.
 		const byte *siteAnim = _mystery->floppySiteAnimData(siteNum);
 		if (!siteAnim)
 			return;
@@ -1432,9 +1225,6 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 			continue;
 		const uint frameIdx = partnerFrameAtTick((uint16)animId,
 												  (uint)anim.size(), tickMs);
-		// Animated drops go through `_NewAnimation` in the original,
-		// so `_UpdateAnimations` applies per-frame anchor offsets —
-		// route through the anchored blitter.
 		blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
 	}
 
@@ -1442,14 +1232,8 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 }
 
 void SiteScreen::scanColorCycles(uint siteNum) {
-	// `_DoSiteLoop @ 168d:03f4` walks Loop 1 entries (siteData[+0xa]
-	// count, 6-byte entries at siteData[+0x48]) and stores each entry
-	// with `animId == -1` into a 5-slot table:
-	//   start palette idx = entry +2
-	//   end   palette idx = entry +4
-	// We mirror the layout exactly. Up to 5 entries are tracked (the
-	// original's `[unaff_BP + -0x12]` and `[unaff_BP + -0x1c]` arrays
-	// are 5 × u16 each).
+	// `_DoSiteLoop @ 168d:03f4`: Loop 1 entries with animId==-1 are
+	// ColorCycle palette ranges (start @ +2, end @ +4). Max 5 slots.
 	_colorCycles.clear();
 	if (!_mystery)
 		return;
@@ -1488,16 +1272,11 @@ void SiteScreen::scanColorCycles(uint siteNum) {
 }
 
 void SiteScreen::applyColorCycles() {
-	// `_ColorCycle @ 172b:2015` rotates `_fpal[start..end]` by one
-	// palette slot — saves [start], shifts [start..end-1] = [start+1..
-	// end], restores saved at [end] — then re-uploads via `_Set_Palette`.
-	// We do the same against ScummVM's palette manager. Always rotate
-	// 0xf9..0xfe for hotspot marching ants (the `_ColorCycle(0xf9,
-	// 0xfe)` call at the bottom of `_DoSiteLoop`'s main loop).
+	// `_ColorCycle @ 172b:2015` per range. Always rotate 0xF9..0xFE
+	// (hotspot marching ants — `_ColorCycle(0xf9, 0xfe)` in `_DoSiteLoop`).
 	for (uint i = 0; i < _colorCycles.size(); i++) {
 		cyclePaletteRange(_colorCycles[i].start, _colorCycles[i].end);
 	}
-	// Hotspot marching ants — always cycled.
 	cyclePaletteRange(0xF9, 0xFE);
 }
 
@@ -1521,18 +1300,13 @@ void SiteScreen::restoreBgSnapshot() {
 
 void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
-	// table index, then for each (speaker × partner) loads
-	//   anim  = WaitAnims[speakerIdx].anim[partner]
-	//   x     = WaitAnims[speakerIdx].x[partner]
-	//   y     = WaitAnims[speakerIdx].y[partner]
-	// from the table at `_WaitAnims @ 29be:021c`. Each entry is
-	// 12 bytes / 6 u16:
-	//   +0..1 anim Jake, +2..3 anim Jenny,
-	//   +4..5 x    Jake, +6..7 x    Jenny,
-	//   +8..9 y    Jake, +10..11 y    Jenny.
-	// `kWaitAnims` lives at file scope above; we cap rendering at
-	// `speaker < 7` since anything past entry 6 is the `_SiteButtons`
-	// rect data that follows the table in the binary.
+	// table index, then for each (speaker x partner) loads:
+	//   anim = WaitAnims[speakerIdx].anim[partner]
+	//   x    = WaitAnims[speakerIdx].x[partner]
+	//   y    = WaitAnims[speakerIdx].y[partner]
+	// from `_WaitAnims @ 29be:021c` (see `kWaitAnims` at file scope for
+	// 12-byte / 6-u16 entry layout). Rendering caps at speaker < 7
+	// (entries past 6 are `_SiteButtons` rect data in the binary).
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -1541,14 +1315,10 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	int    x;
 	int    y;
 	if (_vm->isFloppy()) {
-		// Floppy: site_data+8 is a u16 OFFSET to a 10-byte
-		// speakerInfo struct (per `_DoSiteLoop_Floppy @ 1652:042b`):
-		//   bytes 0..1  Jake anim ID  (u16)
-		//   bytes 2..3  Jake X        (u16)
-		//   byte  4     Jake Y        (u8)
-		//   bytes 5..6  Jenny anim ID (u16)
-		//   bytes 7..8  Jenny X       (u16)
-		//   byte  9     Jenny Y       (u8)
+		// `_DoSiteLoop_Floppy @ 1652:042b`: site_data+8 is a u16 OFFSET
+		// to a 10-byte speakerInfo struct:
+		//   +0..1 Jake anim, +2..3 Jake X, +4 Jake Y,
+		//   +5..6 Jenny anim, +7..8 Jenny X, +9 Jenny Y.
 		const uint16 spkOff = READ_LE_UINT16(site + 8);
 		const byte *spk = _mystery->blobAt(spkOff);
 		if (!spk)
@@ -1578,20 +1348,9 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
 		return;
 
-	// `_UpdateAnimations @ 172b:09c1` walks the per-anim script (from
-	// `_AnimationSequences[seqnum]`) one entry per `_CheckFrameRate`
-	// tick (~100 ms): render `script[index]`, advance, wrap on 0x80.
-	// That's how the original gets long idle holds with brief blinks
-	// — naive flipbook cycling (`tick % nFrames`) loses those pauses
-	// and makes the partner constantly fidget. `partnerFrameAt` picks
-	// the right frame; if no script is registered for this anim it
-	// falls back to flipbook so unknown anims still move.
-	// Use the relative phase anchor instead of the raw `tickMs` so
-	// the wait anim resumes from script[0] after each kdAnim
-	// one-shot ends — matching the original's `_PlayAnimation @
-	// 172b:1f5d` resetting the resumed slot's frame index to
-	// 0xffff. Without this, the wait anim snaps mid-cycle every
-	// time we return from a clue display.
+	// Relative phase anchor (not raw tickMs) so wait anim resumes
+	// from script[0] after each kdAnim one-shot — matches
+	// `_PlayAnimation @ 172b:1f5d` reset to frame 0xffff.
 	const uint32 elapsed = (tickMs >= _waitPhaseAnchor)
 							? (tickMs - _waitPhaseAnchor)
 							: tickMs;
@@ -1611,24 +1370,17 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
-	// Partner sprite — anchor-aware blit so per-frame `miscflags` /
-	// `rowoff` apply (e.g. the BigMap walk-cycle's -2 px shift).
 	blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
 	g_system->unlockScreen();
 }
 
 void SiteScreen::renderBackground(uint siteNum) {
-	// Mirrors `_BuildBackground(sitepic, 0x42, 0x14)` as called from
-	// `_DoSiteLoop @ 168d:03f4` and `_DisplayCorrect`. The original:
-	//   1. Loads frame via `_GetFromDB(_PicIndex, 0x3d)` — that's
-	//      DBI entry 0x3d (0-based). Note this is NOT the same as
-	//      `_GetPicture(0x3d)` which would index entry 0x3c. Our
-	//      `loadEntry(0x3d)` matches the original directly.
-	//   2. Loads SITES.DBD entry by `sitepic` (0-based, from
-	//      SiteData[+0..+1]).
-	//   3. `_Rect_Move(... x, y, 48000, ...)` composes the scene at
-	//      (x, y) = (0x42, 0x14) = (66, 20) on top of the frame.
-	//   4. `_GetPalette(sitepic + 1)` — per-site palette.
+	// `_BuildBackground(sitepic, 0x42, 0x14)` from `_DoSiteLoop @
+	// 168d:03f4` / `_DisplayCorrect`:
+	//   1. `_GetFromDB(_PicIndex, 0x3d)` — DBI entry 0x3d (0-based).
+	//   2. SITES.DBD entry `sitepic` (0-based, SiteData[+0..+1]).
+	//   3. `_Rect_Move(..., 0x42, 0x14, 48000, ...)` composes at (66, 20).
+	//   4. `_GetPalette(sitepic + 1)`.
 	Picture frame;
 	if (_vm->getPics().loadEntry(0x3d, frame)) {
 		g_system->copyRectToScreen(frame.surface.getPixels(),
@@ -1636,21 +1388,11 @@ void SiteScreen::renderBackground(uint siteNum) {
 								   0, 0, frame.surface.w, frame.surface.h);
 	}
 
-	// `_BuildBackground @ 172b:13e2` calls
-	//   `_GetFromDB(_siteFile, &_SiteDBIndex, sitenum)`
-	// with the value at SiteData[+0] passed straight through. The
-	// `_GetFromDB` callee uses that as a **0-based** index into
-	// SITES.DBD (verified at 172b:14c8: `MOV BX, [BP+0x6]; IMUL BX, BX, 0xa`
-	// — the dbi entry stride is 10 bytes, no -1 adjustment). Our
-	// previous `loadEntry(sitepic - 1)` was off by one, which is why
-	// the tutorial mystery rendered scenes from neighbouring cases.
-	// Floppy site_data stores the picID one indirection deeper:
-	// `*site_data` (u16) → drops sub-struct, `drops[0]` (byte) is the
-	// SITES.DBD entry. CD uses the u16 directly. Verified at
-	// `FUN_16e2_12fd @ 16e2:12fd` (called as
-	// `FUN_16e2_12fd(*local_12, 0x42, 0x14)` from
-	// `_DoSiteLoop_Floppy`, where `*local_12` is the byte at
-	// drops_struct+0 via the `(undefined1 *)` cast).
+	// `_BuildBackground @ 172b:13e2` → `_GetFromDB(_siteFile,
+	// &_SiteDBIndex, sitenum)` uses SiteData[+0] as a 0-based SITES.DBD
+	// index (stride 10, asm `IMUL BX,BX,0xa` @ 172b:14c8; no -1 adjust).
+	// Floppy: site_data[0] is u16 offset to drops sub-struct; drops[0]
+	// byte is the SITES.DBD index (`FUN_16e2_12fd @ 16e2:12fd`).
 	const byte *site = _mystery->siteData(siteNum);
 	uint16 sitepic = 0;
 	if (site) {
@@ -1670,8 +1412,7 @@ void SiteScreen::renderBackground(uint siteNum) {
 	if (!haveScene)
 		haveScene = _vm->getPics().getPicture(sitepic + 1, scene);
 	if (haveScene) {
-		// Hard-coded composition position from `_BuildBackground`:
-		//   `_Rect_Move(0, 0, h, ..., 0x42, 0x14, 48000, h, w)`.
+		// `_Rect_Move(0, 0, h, ..., 0x42, 0x14, 48000, h, w)`.
 		const int x = 0x42;
 		const int y = 0x14;
 		const int w = MIN<int>(scene.surface.w, 320 - x);
@@ -1683,9 +1424,7 @@ void SiteScreen::renderBackground(uint siteNum) {
 }
 
 void SiteScreen::renderHotspots(uint siteNum) {
-	// Hotspot outlines (`_DrawSearchButtons`). The original always
-	// draws these; the port exposes an optional game setting to hide
-	// them for players who do not want location hints.
+	// `_DrawSearchButtons`. Port adds optional "hide hint" setting.
 	if (ConfMan.getBool("hide_highlight_boxes"))
 		return;
 
@@ -1698,31 +1437,31 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	if (!screen)
 		return;
 
-	// Mirrors `_DrawSearchButtons @ 2404:0a8f`:
+	// `_DrawSearchButtons @ 2404:0a8f`:
 	//   for each hotspot:
-	//     if `_Sawit(theSite, loc) == 0` (NOT seen yet):
-	//       `_DrawRect(rect)`       — outline in cycling colors
-	//                                 0xF9..0xFE; `_ColorCycle(0xF9,
-	//                                 0xFE)` rotates them every tick →
-	//                                 "marching ants" glow that draws
-	//                                 the player's eye to unsearched
-	//                                 spots.
+	//     if _Sawit(theSite, loc) == 0 (NOT seen yet):
+	//       _DrawRect(rect)       — outline in cycling colors
+	//                                0xF9..0xFE; `_ColorCycle(0xF9, 0xFE)`
+	//                                rotates them every tick → "marching
+	//                                ants" glow on unsearched spots.
 	//     else (seen):
-	//       `_DrawSolidRect(rect)` — outline in solid colour 0xFF.
-	// (Verified at the actual asm `2404:0af6 OR AX,AX; 2404:0af8 JZ` —
-	// the C-level decompile mis-reordered the if/else branches.)
-	//
-	// We don't need per-pixel colour cycling here because palette
-	// `0xF9..0xFE` is already rotated by `applyColorCycles` each tick;
-	// drawing the outline with any single colour in that range will
-	// pulse on its own. We pick a phased start so adjacent hotspots
-	// don't all glow in lock-step.
+	//       _DrawSolidRect(rect)  — outline in solid colour 0xFF.
+	// (Branch order per asm `2404:0af6 OR AX,AX; 2404:0af8 JZ` — the
+	// C-level decompile mis-reordered the if/else.)
+	// Palette 0xF9..0xFE is already rotated by `applyColorCycles` each
+	// tick, so drawing a single colour from that range pulses on its
+	// own. Phase the start colour by hotspot index so adjacent spots
+	// don't glow in lock-step.
 	const uint32 tickMs = g_system->getMillis();
 
-	// CD hotspot rows are 14 bytes each (rect + 6 bytes of clue
-	// metadata). The seen key is the zero-based mystery-wide ordinal
-	// at +0xa, not this site's local row index. Floppy stores plain
-	// 8-byte rectangles only, so its seen key remains the row index.
+	// CD hotspot row = 14 bytes:
+	//   +0..1 x1, +2..3 y1, +4..5 x2, +6..7 y2   (rect, screen coords)
+	//   +8..9   clueOffset    (u16 byte offset to ClueBlock in mystery)
+	//   +0xa..b hotspotIndex  (zero-based mystery-wide seen ordinal)
+	//   +0xc..d extra         (CD cursor ID for `_SwitchMouse`; shipped = 0)
+	// Seen key = the +0xa ordinal (so unrelated hotspots on later sites
+	// don't inherit the first site's seen state after travel/reload).
+	// Floppy = 8-byte plain rect only; seen key falls back to row index.
 	const bool floppy = _vm && _vm->isFloppy();
 	const uint stride = floppy ? 8 : 14;
 	for (uint i = 0; i < count; i++) {
@@ -1738,15 +1477,12 @@ void SiteScreen::renderHotspots(uint siteNum) {
 		const bool seen = seenKey < Mystery::kHotSpotsCap &&
 						   _mystery->_hotSpotsSeen[seenKey];
 		if (seen) {
-			// `_DrawSolidRect` (172b:0506) — outline in palette
-			// index 0xFF (a fixed, non-cycling colour) so already-
-			// found hotspots visually retreat into the BG.
+			// `_DrawSolidRect @ 172b:0506` — solid 0xFF (non-cycling).
 			screen->frameRect(rect, 0xFF);
 		} else {
-			// `_DrawRect` (172b:03e2) — outline in palette indices
-			// 0xF9..0xFE which `_ColorCycle(0xF9, 0xFE)` rotates
-			// every tick. Walk all four edges incrementing the
-			// colour per pixel, exactly like the original.
+			// `_DrawRect @ 172b:03e2` — walk all four edges incrementing
+			// the colour per pixel through palette indices 0xF9..0xFE,
+			// which `_ColorCycle(0xF9, 0xFE)` rotates every tick.
 			byte color = (byte)(0xF9 + ((i + (tickMs / 80)) & 0x07) % 6);
 			auto bumpColor = [&]() {
 				const byte next = (byte)(color + 1);
@@ -1813,21 +1549,16 @@ void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
-	// Floppy: hotspot rectangles are plain 8-byte rects (no clue
-	// metadata at +0xa or +8); the dialog records live in a separate
-	// per-hotspot list at `site_data[+6]`. Dispatch through the
-	// floppy-specific renderer and mark the click rectangle seen by
-	// array index (the only ordinal we have on floppy).
+	// Floppy: 8-byte rects only (no clue metadata @ +0xa/+8). Dialog
+	// records live in a separate list @ `site_data[+6]`. Seen key =
+	// array index (only ordinal available).
 	if (_vm->isFloppy()) {
 		if (hotIdx < Mystery::kHotSpotsCap)
 			_mystery->_hotSpotsSeen[hotIdx] = 1;
 		_mystery->_searchLocationNumber = (uint16)hotIdx;
-		// Snapshot `_cluesFound` BEFORE the floppy dialog so we can
-		// auto-save only when a new note (= clue text idx) is added.
-		// The floppy clue-side-effect path lives in
-		// `displayFloppyDialogRecords` (see clues.cpp), not in
-		// `displayClue` / `applyClueSideEffects`, so the CD-side
-		// autosave below would never trigger for floppy.
+		// Snapshot `_cluesFound` before dialog → autosave on new clue.
+		// Floppy side-effect path is `displayFloppyDialogRecords`
+		// (clues.cpp), not `displayClue` → autosave must be duplicated.
 		byte before[Mystery::kCluesFoundCap];
 		memcpy(before, _mystery->_cluesFound, sizeof(before));
 		_vm->setPartnerEraseBg(&_bgSnapshot);
@@ -1852,10 +1583,7 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 
 	// `_DoSiteLoop @ 168d:03f4` (after _DisplayClue):
 	//   _HotSpotsSeen[hotspot[+0xa] * 2] = _HotSpotComplete;
-	// The seen key is the hotspotIndex field (+0xa) — a zero-based
-	// mystery-wide ordinal — NOT the site's local row index. Using the
-	// local index makes unrelated hotspots on later sites inherit the
-	// first site's seen state after travel or reload.
+	// Seen key = hotspotIndex @ +0xa (mystery-wide ordinal, NOT local row).
 	const byte *spots = _mystery->hotspots(siteNum);
 	uint hotOrdinal = hotIdx; // fallback to array index
 	if (spots) {
@@ -1865,40 +1593,24 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 		_mystery->_hotSpotsSeen[hotOrdinal] = 1;
 	_mystery->_searchLocationNumber = (uint16)hotIdx;
 
-	// Bytes 8..9 of each 14-byte hotspot rect = byte offset within the
-	// mystery blob pointing at a ClueBlock. Verified against M0.BIN:
-	// site 0 hotspot 0 -> 0x0502 -> "Hi! I think somebody's playing a
-	// trick on us...". `displayClue` runs the entry's side effects
-	// (`_AddNotebook` for ClueEntry +0x30..+0x39, gallery +0x26..+0x2f,
-	// onsite +0x1c..+0x25) so we don't need to touch `_cluesFound` here.
+	// Bytes 8..9 of each 14-byte hotspot rect = byte offset to ClueBlock.
+	// `displayClue` handles side effects (`_AddNotebook` for ClueEntry
+	// +0x30..+0x39, gallery +0x26..+0x2f, onsite +0x1c..+0x25).
 	if (spots) {
 		const uint16 clueOff = READ_LE_UINT16(spots + hotIdx * 14 + 8);
 		debugC(2, kDebugSite, "  hotspot %u -> clue offset 0x%04x",
 			   hotIdx, clueOff);
 		const byte *clueBlock = _mystery->blobAt(clueOff);
 		if (clueBlock) {
-			// Snapshot `_cluesFound` BEFORE the clue display so we
-			// can detect if any new clue was actually collected
-			// (vs. a re-read of an already-found clue) — only worth
-			// auto-saving when the player makes progress.
+			// Snapshot `_cluesFound` → detect new-clue 0→1 → autosave.
 			byte before[Mystery::kCluesFoundCap];
 			memcpy(before, _mystery->_cluesFound, sizeof(before));
-			// Hand the engine our partner-less backdrop so that
-			// `_DoKDAnim` / `playKdAnim` (the camera-style reaction
-			// animation that fires when a ClueEntry has +0x3a != -1)
-			// can erase the partner's resting frame between cells
-			// instead of compositing over it. Cleared after the call
-			// so accuse / briefing contexts fall back to their own
-			// snapshots.
+			// Partner-less BG for `_DoKDAnim` / `playKdAnim` to erase
+			// the resting idle (fires when ClueEntry +0x3a != -1).
 			_vm->setPartnerEraseBg(&_bgSnapshot);
 			_vm->displayClue(clueBlock);
 			_vm->setPartnerEraseBg(nullptr);
-			// Auto-save when a new clue is found. The original
-			// engine has no autosave (saving is a manual SETUP
-			// button, `_SaveGame @ 2404:0c87`); we add the autosave
-			// here so the player never loses mystery progress.
-			// Detected via 0→1 transition in `_cluesFound[]` (set
-			// by `applyClueSideEffects` inside `displayClue`).
+			// Port-only autosave (original = manual `_SaveGame @ 2404:0c87`).
 			bool foundNewClue = false;
 			for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
 				if (!before[i] && _mystery->_cluesFound[i]) {
@@ -1915,26 +1627,23 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 			}
 		}
 	}
-	// Caller (`SiteScreen::run`) re-renders the site after this returns.
 }
 
 void EEMEngine::playKdAnim(uint16 num) {
 	// Mirrors `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
 	//   _SuspendAnimation(WaitHandle);
-	//   anim   = WaitAnims[1+num].anim[partner]   (table @ 29be:0228)
-	//   x      = WaitAnims[1+num].x[partner]
-	//   y      = WaitAnims[1+num].y[partner]
+	//   anim = kKdAnimTable[num].anim[partner]   (@ 29be:0228)
+	//   x    = kKdAnimTable[num].x[partner]
+	//   y    = kKdAnimTable[num].y[partner]
 	//   _PlayAnimation(anim, x, y, WaitHandle)
-	//     → registers a state-4 (one-shot) animation slot and lets
-	//       `_UpdateAnimations` walk the sequence script until 0x80,
-	//       then frees this slot and re-activates `WaitHandle`.
-	// Our port renders the partner's idle inline in each redraw rather
-	// than via a slot system, so we play the one-shot synchronously here
+	//     -> registers a state-4 (one-shot) animation slot and lets
+	//        `_UpdateAnimations` walk the script until 0x80, then
+	//        frees the slot and re-activates `WaitHandle`.
+	// Port renders the partner's idle inline in each redraw rather
+	// than via a slot system, so we play the one-shot synchronously
 	// (blocking) and resume normal idle rendering when the caller
-	// returns. That matches the user-visible effect: the partner's
-	// gesture (Jenny taking a picture, etc.) finishes before the
-	// speaker portrait + speech balloon appear.
-	//
+	// returns — matches the visible effect (partner gesture finishes
+	// before the speaker portrait + speech balloon appear).
 	// `kKdAnimTable` and `kAnimScripts` live at file scope above.
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
@@ -1950,28 +1659,17 @@ void EEMEngine::playKdAnim(uint16 num) {
 		return;
 	}
 
-	// `_DoKDAnim` (168d:028a) calls `_PlayAnimation` with state=4 (one-
-	// shot), which `_UpdateAnimations` walks until it sees the 0x80
-	// terminator and then frees the slot. The same script the
-	// site-loop wait anim uses (looping) is what the one-shot plays
-	// through ONCE here.
+	// State-4 one-shot walks the same (looping) script through once.
 	const AnimScriptRef s = findAnimScript(animId);
 	const uint8 *frames = s.frames;
 	uint frameCount     = s.len;
 	if (frameCount == 0) {
-		// Fallback: linear playback through anim cells (better than
-		// nothing if a future kdAnim references an unscripted anim).
+		// Fallback: linear playback through anim cells.
 		frameCount = (uint)anim.size();
 	}
 
-	// Erase-source for between-frame redraw. Prefer the partner-less
-	// backdrop the caller stashed via `setPartnerEraseBg` (e.g. the
-	// site's `_bgSnapshot`, which has the static drops + frame but no
-	// partner sprite). Without that, fall back to whatever's currently
-	// on screen — which works for full-screen contexts (PDA / accuse /
-	// briefing) where there is no separate idle partner overlay to
-	// erase, but produces visible "ghosting" against the site's idle
-	// partner cell at (6, 80) because it has the resting pose baked in.
+	// Erase-source: caller-stashed partner-less BG (via `setPartnerEraseBg`)
+	// or fall back to current screen (works for full-screen contexts).
 	Graphics::ManagedSurface bg(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
@@ -1991,35 +1689,27 @@ void EEMEngine::playKdAnim(uint16 num) {
 		const Picture &fr = anim[frameIdx];
 		const byte transp = (byte)(fr.flags >> 8);
 
-		// Restore BG, then masked-blit the next frame.
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
 		// Anchor-aware: kdAnim cells (0x03/0x04/0x0c/0x0d ...) have
-		// non-zero per-frame `miscflags`/`rowoff` (anim 0x03 has
-		// rowoff up to 9, anim 0x04 has miscflags = -2). Without
-		// applying those, the camera-flash gesture pop-up appears
-		// at a fixed pixel rather than translating across cells.
+		// non-zero per-frame `miscflags`/`rowoff` (anim 0x03 has rowoff
+		// up to 9, anim 0x04 has miscflags = -2). Routes through
+		// `blitAnimFrameAnchored` so the gesture translates across
+		// cells instead of pinning to a fixed pixel.
 		(void)transp;  // anchored blitter recomputes from p.flags
 		blitAnimFrameAnchored(scratch.surfacePtr(), fr, px, py);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 
-		// One frame per `_CheckFrameRate` tick. The original calibrates
-		// this to ~10 fps; 100 ms matches what the rest of the engine
-		// uses for partner / NPC frame cycling. Pump `updateScreen`
-		// inside the inner wait so ScummVM's cursor overlay refreshes
-		// at the same rate as the wait granularity (10 ms ≈ 100 Hz)
-		// rather than only on the per-frame redraw — without this the
-		// cursor only refreshes once every 100 ms during the anim.
+		// 100 ms per frame (~10 fps). Pump updateScreen inside the wait
+		// so cursor overlay refreshes at 100 Hz.
 		const uint32 wakeup = g_system->getMillis() + 100;
 		while (g_system->getMillis() < wakeup && !shouldQuit()) {
 			Common::Event ev;
 			while (g_system->getEventManager()->pollEvent(ev)) {
-				// Drain events but don't allow skipping mid-animation —
-				// the speaker portrait + balloon haven't been drawn yet
-				// and a click would otherwise eat the upcoming clue.
+				// Drain events; don't skip mid-anim (would eat upcoming clue).
 				if (ev.type == Common::EVENT_QUIT ||
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 					return;
@@ -2029,7 +1719,6 @@ void EEMEngine::playKdAnim(uint16 num) {
 		}
 	}
 
-	// Restore BG so the next caller (speaker portrait blit) starts clean.
 	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 }
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 606edf14ffe..895d2124a9f 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -34,57 +34,46 @@ namespace EEM {
 class EEMEngine;
 class Mystery;
 
-/// Pick the frame index to render at `tickMs` for animation
-/// `seqnum`. Walks the script registered in `kAnimScripts` (mirrors
-/// `_UpdateAnimations @ 172b:09c1`'s looping path) at one entry per
-/// 100 ms tick, wrapping on the script's 0x80 terminator. Falls
-/// back to flipbook (`tick % numFrames`) when no script is
-/// registered. `numFrames` is the underlying ANI.DBD entry's cell
-/// count — used both for the fallback path and to clamp script
-/// values that point past the asset.
+/// partnerFrameAtTick: frame index for `seqnum` at `tickMs`. Walks `kAnimScripts`
+/// at `kFramePeriodMs` (~140 ms = `_CheckFrameRate` cadence) per entry; wraps
+/// on the script's 0x80 terminator. Falls back to flipbook (`tick % numFrames`)
+/// when no script is registered. `numFrames` is the ANI.DBD entry's cell count,
+/// used both for the fallback and to clamp script values past the asset.
+/// Mirrors the looping path of `_UpdateAnimations @ 172b:09c1`.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
-/// BigMap-overview partner frame: plays the count-up 0..8 once (the
-/// "unfold the map" pose), then loops the `_BigMapWaitSeq` hold
-/// (9,9,9,9,10,9,9,9,9). Mirrors `_DoBigMap @ 20fe:09e7`'s two-phase
-/// dispatch — the slot starts on script 0x14 (count-up @ 29be:196a)
-/// and on the 0x80 terminator the slot's script pointer is rewritten
-/// to `_BigMapWaitSeq @ 29be:1574`. `partnerFrameAtTick` can't model
-/// that swap on its own (it always loops), so without this helper the
-/// unfold cycles forever instead of resting on the open-map pose.
-/// `elapsedMs` is the time since the BigMap was opened.
+/// bigMapPartnerFrameAtTick: count-up 0..8 once, then loop `_BigMapWaitSeq`
+/// (9,9,9,9,10,9,9,9,9). Mirrors `_DoBigMap @ 20fe:09e7` two-phase swap from
+/// script 0x14 (count-up @ 29be:196a) to `_BigMapWaitSeq @ 29be:1574`.
 uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
 
-/// BigMap-detail (zoomed view) partner frame. Same two-phase shape as
-/// `bigMapPartnerFrameAtTick`, but the original `_DoMapScreen @ 20fe:120b`
-/// uses script 0x13 (count-up 0..7 @ 29be:1992) for the unfold and
-/// swaps the slot pointer to `_SmallMapWaitSeq @ 29be:1548` (an
-/// 18-frame hold of cell 7 with a single cell-10 fidget) on the
-/// terminator (`MOV [BX+0x789f],0x1548` at 20fe:1390). `elapsedMs`
-/// is the time since the detail screen was opened.
+/// bigMapDetailPartnerFrameAtTick: zoomed-view partner frame. Same two-phase shape
+/// as `bigMapPartnerFrameAtTick`. `_DoMapScreen @ 20fe:120b` runs script 0x13
+/// (count-up 0..7 @ 29be:1992) then swaps to `_SmallMapWaitSeq @ 29be:1548`
+/// (`MOV [BX+0x789f],0x1548` at 20fe:1390).
 uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
 
-/// Anchor-aware masked blit. Mirrors the per-frame anchor offset
-/// math in `_UpdateAnimations @ 172b:09c1`:
-/// `blit_x = anchor_x - frame.miscflags`, `blit_y = anchor_y -
-/// frame.rowoff`. Use for any animation rendered through the
-/// `_NewAnimation` path in the original (partner sprites, animated
-/// drops, briefing animations) — without it, frames with non-zero
-/// per-cell anchors (e.g. anim 0x14 BigMap walk-cycle's miscflags
-/// = -2 shift) "shake in place" instead of translating across
-/// the screen as they're meant to.
+/// blitAnimFrameAnchored: mask-blit at (anchorX - frame.miscflags,
+/// anchorY - frame.rowoff). Mirrors per-frame anchor math in
+/// `_UpdateAnimations @ 172b:09c1`. Both anchors are SIGNED int16
+/// (e.g. anim 0x14 BigMap walk-cycle has miscflags = -2 per cell, so
+/// the sprite translates across the screen as it cycles). Use for any
+/// animation rendered through `_NewAnimation` in the original — partner
+/// sprites, animated drops, briefing animations. Transparency comes
+/// from `flags >> 8` (NOT miscflags).
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY);
 
-/// Rotate one VGA palette range by one slot. Mirrors `_ColorCycle`
-/// and is used by site color cycles, hotspot marching ants, and the
-/// BigMap marker shine.
+/// Rotate one VGA palette range by one slot (START→END direction).
+/// Mirrors `_ColorCycle`. Used by per-site Loop-1 ColorCycle entries,
+/// hotspot marching ants (0xF9..0xFE), and the BigMap marker shine.
 void cyclePaletteRange(uint8 start, uint8 end);
 
-/// Rotate one VGA palette range by one slot in the OPPOSITE direction.
-/// Mirrors `_OpenColorCycle @ 2520:04f7` (CD) / `_ReverseColorCycle_Floppy`
-/// — used by the opening-anim logos (EA Kids, etc.) where the cycle
-/// shifts END→START rather than START→END.
+/// Rotate one VGA palette range by one slot in the OPPOSITE direction
+/// (END→START): save END, shift every entry up by one (END-1 → END, ...),
+/// wrap saved END to START. Mirrors `_OpenColorCycle @ 2520:04f7` (CD) /
+/// `_ReverseColorCycle_Floppy`. Used by opening-anim logos (EA Kids, etc.)
+/// where the cycle shifts END→START rather than START→END.
 void cyclePaletteRangeReverse(uint8 start, uint8 end);
 
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
@@ -97,14 +86,8 @@ struct Hotspot {
 	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }
 };
 
-/**
- * Site / scene controller.
- *
- * Walks the mystery's SiteIndex to render one site at a time, polls hotspots
- * for the player's search clicks, and dispatches clue display. Mirrors the
- * site loop driven by `_DrawSearchButtons` @ 2404:0a8f and `_SearchButtons`
- * @ 2404:0bfb.
- */
+/// Site / scene controller. Mirrors `_DrawSearchButtons @ 2404:0a8f` /
+/// `_SearchButtons @ 2404:0bfb` site loop.
 class SiteScreen {
 public:
 	SiteScreen(EEMEngine *vm, Mystery *mystery)
@@ -126,40 +109,28 @@ private:
 	bool checkImpatienceCounter();
 	void notePartnerActivity();
 
-	/// Play the partner's site-arrival sequence once `_LastSite !=
-	/// _SiteNumber`. Mirrors `_EnterSiteAnim @ 1000:9b21` — animation
-	/// 6 (Jake) / 14 (Jenny) skateboards in from the right edge along
-	/// the bottom, then animation 7 / 15 slides KD in from the left.
-	/// Returns true when the player skipped it with input.
+	/// Partner site-arrival sequence (when `_LastSite != _SiteNumber`).
+	/// Mirrors `_EnterSiteAnim @ 1000:9b21`: anim 6/14 (Jake/Jenny) skateboards
+	/// in from right, then anim 7/15 slides KD in from left. Returns true if skipped.
 	bool enterSiteAnim();
 
-	/// Draw the persistent in-site partner sprite (Jake or Jenny
-	/// standing/idling) at the position from `_WaitAnims` @ 29be:021c.
-	/// Mirrors the `_GetAnimation` + `_NewAnimation` block at the tail
-	/// of `_DoSiteLoop @ 168d:03f4`. `tickMs` selects which frame of
-	/// the partner's animation to render; in the original, frames
-	/// advance per `_CheckFrameRate` tick via `_UpdateAnimations`.
+	/// renderPartner: persistent in-site partner sprite at `_WaitAnims @ 29be:021c`.
+	/// Mirrors `_GetAnimation` + `_NewAnimation` tail of `_DoSiteLoop @ 168d:03f4`.
 	void renderPartner(uint siteNum, uint32 tickMs);
 
-	/// Draw the per-site `_AddDrop` static decorations (Loop 2).
-	/// `_DoSiteLoop` runs this loop with bound siteData[+0x4] and
-	/// 6-byte entries at siteData[+0xc]: {picId, x, y}. These never
-	/// animate so they go in the BG snapshot.
+	/// renderStaticDrops: `_AddDrop` static decorations (Loop 2).
+	/// siteData[+0x4] count, siteData[+0xc] entries (6 bytes: {picId, x, y}).
 	void renderStaticDrops(uint siteNum);
 
-	/// Floppy variant: drops live inside the drops sub-struct
-	/// (`*site_data` → drops; `drops[1]` = count; entries at
-	/// `drops + 2` are 5 bytes each: {u16 X, u16 Y, byte picID}).
-	/// PIC entries are loaded from PICS.DBD with `picID - 1`. Per
-	/// `_DoSiteLoop_Floppy @ 1652:0418` and `FUN_16e2_18eb`.
+	/// renderFloppyDrops: floppy variant. `*site_data`→drops; drops[1]=count;
+	/// entries at drops+2 are 5 bytes ({u16 X, u16 Y, byte picID}); PIC loaded as picID-1.
+	/// Per `_DoSiteLoop_Floppy @ 1652:0418` and `FUN_16e2_18eb`.
 	void renderFloppyDrops(uint siteNum);
 
-	/// Draw the per-site animated NPCs (Loop 1) at the current tick.
-	/// `_DoSiteLoop` registers each via `_NewAnimation` (siteData[+0xa]
-	/// entries at siteData[+0x48]: {animId (-1 = ColorCycle), x, y})
-	/// and `_UpdateAnimations @ 172b:09c1` advances frame indices each
-	/// tick. We use a millis-based frame index so all NPCs cycle in
-	/// step with the global clock.
+	/// renderAnimatedDrops: per-site animated NPCs (Loop 1) at current tick.
+	/// `_DoSiteLoop` registers each via `_NewAnimation` (siteData[+0xa] count,
+	/// siteData[+0x48] entries: {animId (-1=ColorCycle), x, y}); frames advance via
+	/// `_UpdateAnimations @ 172b:09c1`.
 	void renderAnimatedDrops(uint siteNum, uint32 tickMs);
 
 	/// Snapshot the post-BG, post-static-drops screen so the per-tick
@@ -169,16 +140,12 @@ private:
 	/// Restore the snapshot taken at `captureBgSnapshot` time.
 	void restoreBgSnapshot();
 
-	/// Scan the site's Loop 1 entries for ColorCycle entries (animId
-	/// == -1) and cache their (start, end) palette ranges. Called from
-	/// `enter()`. Mirrors `_DoSiteLoop @ 168d:03f4`'s init scan.
+	/// scanColorCycles: scan Loop 1 for ColorCycle entries (animId == -1),
+	/// cache (start, end) palette ranges. Mirrors `_DoSiteLoop @ 168d:03f4` init scan.
 	void scanColorCycles(uint siteNum);
 
-	/// Rotate cached ColorCycle palette ranges (and 0xf9..0xfe for
-	/// hotspot marching ants) one step. Mirrors the original's per-tick
-	/// `_ColorCycle(start, end)` calls inside `_DoSiteLoop`'s main
-	/// loop. ScummVM's palette manager grabs current 8-bit RGB and
-	/// writes back the rotated values.
+	/// applyColorCycles: rotate cached ColorCycle ranges + 0xf9..0xfe (hotspot ants)
+	/// one step. Mirrors per-tick `_ColorCycle(start, end)` calls in `_DoSiteLoop`.
 	void applyColorCycles();
 
 	EEMEngine *_vm;
@@ -194,24 +161,13 @@ private:
 	uint32 _impatientDeadlineMs = 0; ///< Test-shortened impatience deadline.
 	PartnerWaitMood _partnerWaitMood = kPartnerWaitDefault;
 
-	/// Wall-clock timestamp at which the partner's wait animation
-	/// "started" (or last restarted). The site loop renders the
-	/// wait sprite at `partnerFrameAtTick(animId, ..., now -
-	/// _waitPhaseAnchor)` so the script position is RELATIVE to
-	/// this anchor, not the global clock. Bump it on:
-	///   - entry to a new site (mirrors `_NewAnimation` setting
-	///     the slot's frame index to 0xffff at site setup, see
-	///     `_DoSiteLoop @ 168d:0436`)
-	///   - return from a one-shot kdAnim (mirrors `_PlayAnimation
-	///     @ 172b:1f5d` writing 0xffff to the resumed slot's frame
-	///     index when state=4 chains back to WaitHandle)
-	/// Without this, the wait anim "snaps" to wherever the global
-	/// clock dictates each time the partner reappears, instead of
-	/// resuming from script[0].
+	/// Wall-clock anchor for partner wait anim phase: rendered as
+	/// `partnerFrameAtTick(animId, ..., now - _waitPhaseAnchor)`.
+	/// Bumped on site entry (`_NewAnimation` writes 0xffff at `_DoSiteLoop @ 168d:0436`)
+	/// and on return from a one-shot kdAnim (`_PlayAnimation @ 172b:1f5d` state=4).
 	uint32 _waitPhaseAnchor = 0;
 
-	/// Per-site cached ColorCycle ranges. Up to 5 (matching the
-	/// original's 5-slot animation table).
+	/// Per-site cached ColorCycle ranges (up to 5, matching original 5-slot anim table).
 	struct ColorCycleRange { uint8 start, end; };
 	Common::Array<ColorCycleRange> _colorCycles;
 };
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6e9fd72df76..f8c888d1843 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -36,16 +36,10 @@
 #include "eem/music.h"
 #include "eem/site.h"
 
-// EEM — UI screens (NOTE.C, GALLERY.C, ACCUSE.C, MAP.C, CHOOSE.C combined).
-// Each function is a self-contained modal `EEMEngine::doX()` reachable from
-// the site loop. They share the same wait-for-input idiom and PIC 0x3f /
-// 0x41 / 0x42 / 0x43 backdrops.
-
 namespace EEM {
 
-// Five fixed gallery slot positions verified at `29be:0x116`. Used by
-// both `_DrawGallery @ 158f:0046` (notebook gallery) and the accuse
-// portrait grid; the layout is identical so we share the table.
+// Gallery slot positions @ 29be:0x116. Used by `_DrawGallery @ 158f:0046`
+// and the accuse portrait grid.
 struct GallerySlot { int x; int y; };
 const GallerySlot kGallerySlots[5] = {
 	{  83,  14 }, // 0
@@ -206,11 +200,8 @@ const byte *advanceFloppyDialogRecords(const byte *rec, uint count,
 	return rec;
 }
 
-// Floppy gallery slot positions verified at `2608:0x16c` (5 ×
-// {u16 x, u16 y}) — read by `_DrawGallery_Floppy @ 154e:0045`'s
-// `[BX + 0x16c]` (x) and `[BX + 0x16e]` (y) loads. The floppy
-// layout is shifted left ~0x30 px relative to CD: row 0 starts at
-// x=0x53 (vs 0x53→0x83 on CD) and the bottom row at x=0x77 / 0xbf.
+// Floppy gallery slot positions @ 2608:0x16c. Read by `_DrawGallery_Floppy
+// @ 154e:0045`'s [BX + 0x16c] (x) and [BX + 0x16e] (y) loads.
 const GallerySlot kFloppyGallerySlots[5] = {
 	{ 0x53, 0x0e }, // 0
 	{ 0x9b, 0x0e }, // 1
@@ -219,17 +210,15 @@ const GallerySlot kFloppyGallerySlots[5] = {
 	{ 0xbf, 0x5a }  // 4
 };
 
-// `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ `29be:1064`:
+// `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ 29be:1064:
 //   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
 //   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
 const uint16 kDigitBalloons[10] = {
 	0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
 };
 
-// Return the next non-empty slot in `slotRects` starting from `from`,
-// stepping by `dir` (+1 or -1) with wraparound. Used by the accuse
-// gallery's keyboard-cycle (TAB / arrow keys) — mirrors the way
-// `_PutMouseInRect` skips eliminated suspects in the original.
+// Next non-empty slot in `slotRects` from `from`, stepping by `dir` with
+// wraparound. Mirrors `_PutMouseInRect` skipping eliminated suspects.
 int nextLiveSlot(const Common::Array<Common::Rect> &slotRects,
 				 int from, int dir) {
 	const int n = (int)slotRects.size();
@@ -252,9 +241,7 @@ void copyToScreen(Graphics::ManagedSurface &scratch) {
 }
 
 void cycleChooserPalette() {
-	// `_DoChoose` / `_DoListPicker_Floppy` rotate 0x6f..0x73 on each
-	// frame tick while waiting for input. These colors are used by the
-	// animated chooser surface baked into the menu backgrounds.
+	// `_DoChoose` / `_DoListPicker_Floppy` rotate 0x6f..0x73 per tick.
 	cyclePaletteRange(kChooserCycleStart, kChooserCycleEnd);
 }
 
@@ -445,9 +432,6 @@ void drawProfilePickerFrame(const ProfilePickerView &v) {
 	copyToScreen(scratch);
 }
 
-// Snapshot of `doActionScreen`'s captured locals, used by
-// `drawActionMenuFrame`. Lives on the stack inside `doActionScreen`;
-// never escapes.
 struct ActionMenuView {
 	EEMEngine *vm;
 	const Picture *bg;
@@ -460,13 +444,9 @@ struct ActionMenuView {
 	uint pick;
 };
 
-// Mystery list shown in the "Choose A Mystery" sub-screen. Mirrors
-// `_DoChooseMystery @ 1a35:02b7`: opens BOOK%u.NME (CRLF-separated
-// ASCII strings, last entry is whitespace = sentinel), reads up to 25
-// lines × 40 bytes each, hands the array to `_CaseSelection`. We
-// preserve the trailing whitespace line as the original sentinel
-// since `_DoChoose @ 1c33:0514` walks until `*piVar3 == 0 && piVar3[1]
-// == 0` — but for our renderer we just keep the names array.
+// `_DoChooseMystery @ 1a35:02b7` — opens BOOK%u.NME (CRLF strings, up
+// to 25 × 40 bytes, whitespace-line sentinel). `_DoChoose @ 1c33:0514`
+// walks until {0, 0}.
 Common::StringArray loadBookNames(uint book) {
 	Common::StringArray names;
 	const Common::String fname = Common::String::format("BOOK%u.NME", book);
@@ -479,10 +459,7 @@ Common::StringArray loadBookNames(uint book) {
 		Common::String line = f.readLine();
 		if (f.eos() && line.empty())
 			break;
-		// `_fgets` in the original reads CRLF terminators with the line;
-		// `Common::File::readLine` strips them, so `line` here is the
-		// text only. Trim trailing whitespace so the sentinel "        "
-		// last entry doesn't render as a blank scrollable row.
+		// Trim trailing whitespace so the sentinel line doesn't render.
 		while (!line.empty() &&
 			   (line.lastChar() == ' ' || line.lastChar() == '\t' ||
 				line.lastChar() == '\r'))
@@ -504,14 +481,8 @@ void clampCaseTopRow(uint &topRow, uint listLen, uint visibleRows) {
 		topRow = maxTop;
 }
 
-// Per-mystery sub-chooser ("Choose A Mystery") view.
-//
-// `names` are the entries from BOOK%d.NME (in display order — index 0
-// = first case in the tier, mystery number = `tierLo + index`).
-// `solvedFlags` is a parallel bool array indicating which entries are
-// already solved (greyed and unselectable in `_DoChoose`).
-// `topRow` is the scroll position; up to 12 entries are visible.
-// `selRow` is the highlighted row (0-based within the names array).
+// "Choose A Mystery" sub-chooser view. names = BOOK%d.NME entries
+// (mystery number = tierLo + index). solvedFlags marks greyed entries.
 struct CaseSubmenuView {
 	EEMEngine *vm;
 	const Picture *caseBg;
@@ -535,10 +506,8 @@ void drawCaseGreeter(Graphics::ManagedSurface &scratch,
 	if (!haveKdAnim || !kdAnim || kdAnim->empty())
 		return;
 
-	// `_CaseSelection` registers the partner-specific ANI slot, but
-	// drives it with script 0x15 regardless of partner. The chooser
-	// loop advances it through `_UpdateAnimations` after each
-	// `_CheckFrameRate` tick.
+	// `_CaseSelection` drives the partner ANI with script 0x15 regardless
+	// of partner.
 	const uint32 now = g_system->getMillis();
 	const uint frameIdx = partnerFrameAtTick(0x15, (uint)kdAnim->size(), now);
 	blitAnimFrameAnchored(scratch.surfacePtr(), (*kdAnim)[frameIdx],
@@ -595,12 +564,9 @@ bool animateCaseSelectionReveal(EEMEngine *vm, const Picture *caseBg,
 	return false;
 }
 
-// Mirrors `_DoChoose`'s `DrawList @ 1c33:040d`. 12 visible rows × 10 px
-// at (61, 35); colour palette: 0x13 = highlighted (selected), 0x1B =
-// greyed (already solved), 0x5C = default. We approximate with the
-// closest indices of site palette 0 — 0xF (white) / 0x7 (medium grey)
-// / 0x8 (dark grey) — since we don't decode the original CLUT byte
-// ramp.
+// `_DoChoose`'s `DrawList @ 1c33:040d`. 12 rows × 10 px at (61, 35).
+// Original colours 0x13 sel / 0x1B greyed / 0x5C default approximated
+// here as 0xF / 0x8 / 0x7 from site palette 0.
 void drawCaseSubmenu(const CaseSubmenuView &v) {
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -635,18 +601,14 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 			kListX, kListY0 + r * kLineH, kListW, color);
 	}
 
-	// Selection arrow at the left edge of the highlighted row — the
-	// original highlights via colour change but adding an arrow makes
-	// the keyboard-driven path obvious.
+	// Selection arrow (ScummVM-only — original uses colour change).
 	if (v.selRow >= v.topRow && v.selRow < v.topRow + (uint)kVisible) {
 		const int r = (int)(v.selRow - v.topRow);
 		v.vm->getFont().drawString(&scratch, ">",
 			kListX - 6, kListY0 + r * kLineH, 6, 0xF);
 	}
 
-	// Scrollbar thumb. `DrawThumb @ 1c33:????` renders a thumb at
-	// (240, 45..146) proportional to scroll position. We draw a
-	// 1-px outlined block to indicate the same range.
+	// Scrollbar thumb at (240, 45..146), proportional to scroll position.
 	if (count > (uint)kVisible) {
 		const int trackY0 = 45;
 		const int trackH  = 146 - 45;
@@ -674,22 +636,14 @@ void drawActionMenuFrame(const ActionMenuView &v) {
 					   kActionScreenDecorX, kActionScreenDecorY);
 
 	if (v.vm->getFont().isLoaded()) {
-		// `DrawList` @ 1c33:040d coordinates: `_TextBox + 3` for x
-		// and `DAT_29be_0d02` for y. `_TextBox` @ 29be:0d00 holds
-		// {x=58, y=35, x2=238, y2=158}. Matches the blue panel.
+		// `DrawList @ 1c33:040d`. _TextBox @ 29be:0d00 = {58, 35, 238, 158}.
 		const int kListX  = 58 + 3;
 		const int kListW  = 238 - kListX;
 		const int kListY0 = 35;
 		const int kLineH  = 10;
 
-		// Render 11 list rows: separator + menu item pairs.
-		//   row 0  separator
-		//   row 1  Choose A Mystery
-		//   row 2  separator
-		//   row 3  Practice Mystery
-		//   ...
-		//   row 9  See ScrapBook 3
-		//   row 10 separator
+		// 11 rows: separator/item pairs (0=sep, 1=Choose A Mystery, ...,
+		// 9=See Scrapbook 3, 10=sep).
 		for (int r = 0; r < 11; r++) {
 			const int y = kListY0 + r * kLineH;
 			if ((r & 1) == 0) {
@@ -709,21 +663,11 @@ void drawActionMenuFrame(const ActionMenuView &v) {
 }
 
 void EEMEngine::doProfilePicker() {
-	// Mirrors `screen8_handler @ 1c33:1012`. The original walks
-	// `*.PLR` files in `C:\EEMCDSAV\` (max 25), reads the first 12
-	// bytes of each (the player-name field of `_PlayerRecord`), and
-	// hands the list to `_DoChoose`. If no profiles exist (loop hits
-	// `local_20 == 0` at 1c33:1170), it falls straight into
-	// `_NewPlayer`. Selecting an entry calls `_LoadPlayerRecord` and
-	// returns; the 0xfffe / 0xffff chooser sentinels both enter
-	// `_NewPlayer` in this screen.
-
-	// Palette reset. `screen8_handler` runs `_FadeOut(); _GetPalette(0);
-	// _GetBackground(0x104);` before the picker, so the BG always
-	// renders against SITEPALS index 0 regardless of which intro
-	// palette was active last. Without this, skipping out of an intro
-	// anim (THEME / ANIM01..20 / TITLE) leaves the previous video's
-	// palette in place and the picker draws with the wrong colours.
+	// `screen8_handler @ 1c33:1012` — walks *.PLR (max 25), reads 12-byte
+	// player-name field, hands list to `_DoChoose`. No profiles or
+	// 0xfffe/0xffff sentinel enters `_NewPlayer`.
+
+	// `screen8_handler` does `_FadeOut(); _GetPalette(0); _GetBackground(0x104)`.
 	setSitePalette(0);
 
 	const SaveStateList saves = listProfiles();
@@ -733,16 +677,12 @@ void EEMEngine::doProfilePicker() {
 	}
 
 	if (!_font.isLoaded()) {
-		// No font means we can't render the picker — fall through.
 		doNewPlayer();
 		return;
 	}
 
-	// Build the visible list: existing profile names + "[New Player]".
-	// The DOS picker also has a bottom click area at 29be:0d08 that
-	// returns 0xfffe and immediately enters `_NewPlayer`; keeping the
-	// explicit row makes that affordance visible in ScummVM while the
-	// bottom rect remains active too.
+	// Existing profiles + "[New Player]". DOS bottom click area @ 29be:0d08
+	// returns 0xfffe → `_NewPlayer`; rect remains active too.
 	Common::Array<ProfilePickerEntry> entries;
 	for (const SaveStateDescriptor &s : saves) {
 		ProfilePickerEntry e;
@@ -765,12 +705,8 @@ void EEMEngine::doProfilePicker() {
 	const bool haveReveal =
 		_picsArchive.getPicture(kProfilePickerRevealPic, reveal);
 
-	// Picker geometry: `DrawList @ 1c33:040d` is called from
-	// `screen8_handler @ 1c33:1012` with `(_TextBox + 3, DAT_29be_0d02)`.
-	// `_TextBox @ 29be:0d00` holds {x1=58, y1=35, x2=238, y2=158} so
-	// the list origin is (61, 35), 10 px per row, max 12 visible
-	// rows. `screen8_handler` slides PIC 0x105 into the lower strip
-	// before calling `_DoChoose`; it does not draw that caption as text.
+	// Geometry: list origin (61, 35), 10 px/row, 12 visible. PIC 0x105
+	// slides into lower strip via `screen8_handler` before `_DoChoose`.
 	ProfilePickerView view;
 	view.vm = this;
 	view.bg = &bg;
@@ -866,8 +802,7 @@ void EEMEngine::doProfilePicker() {
 					break;
 				}
 				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `screen8_handler` sets `Chelp = 0`, so the
-					// original ignores this chooser button here.
+					// `screen8_handler` sets Chelp=0 — button ignored.
 					break;
 				}
 				if (kChooserListRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -915,9 +850,7 @@ void EEMEngine::doProfilePicker() {
 	if (e.slot < 0) {
 		doNewPlayer();
 	} else {
-		// Mirrors `_LoadPlayerRecord` at 1c33:1281 — slot found,
-		// load it. The save body re-fills `_playerName`, partner,
-		// chain stage, mysteriesSolved.
+		// `_LoadPlayerRecord @ 1c33:1281`.
 		if (!loadProfile(e.label)) {
 			warning("doProfilePicker: failed to load profile '%s' at slot %d",
 					e.label.c_str(), e.slot);
@@ -927,9 +860,8 @@ void EEMEngine::doProfilePicker() {
 }
 
 void EEMEngine::doNewPlayer() {
-	// Mirrors `_NewPlayer` @ 1c33:0dda. The original draws background
-	// 0x104 + character peek pic 0x107, then shows "Please type your
-	// name" and accepts up to 12 characters until Enter.
+	// `_NewPlayer @ 1c33:0dda` — BG 0x104 + peek pic 0x107, prompt for
+	// up to 12 chars.
 	if (!_font.isLoaded()) {
 		_playerName = "Detective";
 		return;
@@ -938,24 +870,18 @@ void EEMEngine::doNewPlayer() {
 	Common::String name;
 	const int maxChars = 12;
 
-	// Mirror the original: load PIC 0x104 as the name-entry backdrop.
-	// The original also slides in PIC 0x107 (a peeking character).
 	Picture bg;
 	const bool haveBG = _picsArchive.getPicture(0x104, bg);
 	Picture peek;
 	const bool havePeek = _picsArchive.getPicture(kNameEntryPeekPic, peek);
 
-	// Localized name-entry prompt. Spanish text is taken from the
-	// Spanish floppy EEM.EXE ("Teclea tu nombre"). The colon suffix is
-	// our own — the original DOS prompt has none.
+	// Spanish from EEM.EXE ("Teclea tu nombre"); colon added.
 	const char *prompt = isSpanish()
 		? "Teclea tu nombre:" : "Please type your name:";
 
 	if (animateNameEntryPeek(this, &bg, haveBG, havePeek ? &peek : nullptr))
 		return;
-	// Match the original `_NewPlayer`: `_Show_String(rw=0x28, cl=0x50)`
-	// for the prompt, then `_ShowChar(0x50, x, …)` for typed input.
-	// (rw=row=y, cl=col=x.) Prompt at (y=40, x=80), input at (y=80, x=80).
+	// Prompt (y=40, x=80), input (y=80, x=80).
 	drawNameEntryFrame(this, &bg, haveBG, havePeek ? &peek : nullptr,
 					   name, prompt);
 
@@ -977,20 +903,14 @@ void EEMEngine::doNewPlayer() {
 			if (k == Common::KEYCODE_RETURN) {
 				if (name.empty())
 					name = "Detective";
-				// Mirrors `_NewPlayer @ 1c33:0dda` tail (1c33:0fa0+):
-				// after the name is committed, try `_LoadPlayerRecord`
-				// — if it returns 0 (no existing .PLR), zero out the
-				// per-profile state and call `_SavePlayerRecord` to
-				// create a fresh profile file. Same flow here, mapped
-				// onto ScummVM save slots via name → description.
+				// `_NewPlayer @ 1c33:0fa0+`: `_LoadPlayerRecord` → if
+				// missing, zero state and `_SavePlayerRecord`.
 				if (!loadProfile(name)) {
 					_playerName = name;
 					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
 					_mystery.clear();
 					_partner = 0;
-					// `_NewPlayer @ 1c33:0fa3` writes
-					// `DAT_2d5d_3f99 = 1` — fresh profiles always
-					// start at the Junior tier.
+					// `_NewPlayer @ 1c33:0fa3`: DAT_2d5d_3f99 = 1 (Junior).
 					_chainStage = 1;
 					saveProfile(name);
 				}
@@ -1027,38 +947,24 @@ void EEMEngine::doNewPlayer() {
 }
 
 int EEMEngine::doShowEnding(uint num, bool firstPage) {
-	// Mirrors `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @
-	// 1df2:044c` on CD and `FUN_1d40_05b7` + `FUN_1d40_031e` on
-	// floppy. CD ending file format:
+	// `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @ 1df2:044c`
+	// (CD); `FUN_1d40_05b7` + `FUN_1d40_031e` (floppy).
+	// CD E<num>.BIN format:
 	//   u16 pageCount
-	//   for each page:
-	//     u16 picNum
-	//     u16 x1, y1, x2, y2  (story rect — passed to WordWrap)
-	//     char text[]        (null-terminated, ParseString opcodes)
-	// Floppy files prepend a small title header and use a shared
-	// newspaper background (PIC 0x8b) plus per-page overlay pictures.
-	//
-	// The original swaps the font: `_FreeFont(); _LoadFont("tiny.fnt")`
-	// at 1df2:055f-1df2:0563, calls `_GetPalette(0)` (site palette 0),
-	// then for each page `_GetBackground(picNum)` +
-	// `_WordWrap2(x1, y1, x2-x1, text, fontColor=0, dropColor=-1)`. The
-	// fontColor=0 draws in palette index 0 (the newspaper's body-text
-	// colour), with no drop shadow. Verified at the call site asm
-	// 1df2:04cf-1df2:04f4 (Ghidra mis-paired the two trailing args).
-	//
-	// Keyboard page navigation mirrors the original handlers
-	// (1df2:0689 / 1df2:06a0): LEFT decrements pageIdx, RIGHT (or
-	// SPACE / Enter) increments it. Hitting the boundary (LEFT on page
-	// 0, RIGHT on last page) sets `[BP-0x18]` to -1 / 1 respectively
-	// and exits — that return value is what `_ShowScrapbook` uses to
-	// walk forward / backward through solved mysteries (see
-	// 1f78:0664-1f78:069c). Mouse navigation is intentionally limited
-	// to the red-highlighted edge rects so central page clicks are ignored.
-	//
-	// `firstPage=false` opens the ending at the LAST page (used by
-	// `doShowScrapbook` after a "previous mystery" navigation —
-	// matches `local_8 = 0` written before the back-step at
-	// 1f78:067e).
+	//   per page: u16 picNum
+	//             u16 x1, y1, x2, y2   (story rect — passed to WordWrap2)
+	//             char text[]          (null-terminated, ParseString opcodes)
+	// Floppy: small title header + shared newspaper BG (PIC 0x8b) +
+	//   per-page overlay pictures.
+	// Render: _FreeFont; _LoadFont("tiny.fnt") @ 1df2:055f; _GetPalette(0);
+	// per-page _GetBackground(picNum) + _WordWrap2(x1, y1, x2-x1, text,
+	// fontColor=0, dropColor=-1). fontColor=0 is the newspaper body-text
+	// colour (asm 1df2:04cf-04f4 — Ghidra mis-paired the trailing args).
+	// LEFT/RIGHT page nav (1df2:0689 / 06a0): boundary sets [BP-0x18] to
+	// -1 / +1 — that return value drives `_ShowScrapbook` walking forward/
+	// back through solved mysteries (1f78:0664-069c).
+	// firstPage=false opens at LAST page (used by doShowScrapbook after
+	// "previous mystery"; matches `local_8 = 0` @ 1f78:067e).
 	const Common::String fname = Common::String::format("E%u.BIN", num);
 	Common::File f;
 	if (!f.open(Common::Path(fname))) {
@@ -1077,19 +983,13 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		return 0;
 	}
 
-	// Mirrors 1df2:0558-1df2:056a — `_FreeFont(); _LoadFont(tiny.fnt)`.
-	// The newspaper layout uses TINY.FNT (smaller glyphs) so the body
-	// copy fits in the columns. `_LoadFont(font.fnt)` is restored at
-	// 1df2:0625 after the page loop.
+	// 1df2:0558-056a: TINY.FNT; restored at 1df2:0625.
 	EEMFont tinyFont;
 	const bool haveTinyFont = tinyFont.load(Common::Path("TINY.FNT"));
 	if (!haveTinyFont)
 		warning("doShowEnding: TINY.FNT failed to load — falling back");
 
-	// Mirrors 1df2:055f `_GetPalette(0)` — site palette 0 is the
-	// shared "newspaper" CLUT for ending pages. The newspaper body
-	// text in particular is palette index 0 (= newspaper black) so we
-	// MUST switch palettes before rendering.
+	// 1df2:055f `_GetPalette(0)` — newspaper CLUT (body text = idx 0).
 	setSitePalette(0);
 	CursorMan.showMouse(true);
 
@@ -1227,15 +1127,10 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 									  kFirstTryBadgePos, transp);
 			}
 
-			// Story text. The bytes are a null-terminated string with
-			// `_ParseString` placeholders (0x80 = player name, 0x82
-			// = partner first name, etc.).
+			// `_ParseString` placeholders (0x80=name, 0x82=partner first).
 			const Common::String text = parseString(raw, _playerName, _partner);
 
-			// Use TINY.FNT (`_LoadFont(@29be:10a5)` at 1df2:055f-0563)
-			// and color 0 (`_WordWrap2(...,0,-1)` per asm at 1df2:04cf,
-			// not 0xF as Ghidra's decompile output suggests). Falls
-			// back to the main font if TINY.FNT failed to load.
+			// TINY.FNT + color 0 (asm 1df2:04cf — not 0xF as Ghidra shows).
 			const EEMFont &renderFont = haveTinyFont ? tinyFont : _font;
 			if (renderFont.isLoaded() && x2 > x1) {
 				const int textW = MIN<int>((int)x2 - (int)x1, 320 - (int)x1);
@@ -1330,15 +1225,9 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 }
 
 void EEMEngine::doShowScrapbook(uint stage) {
-	// Mirrors `_ShowScrapbook(stage, 0) @ 1f78:0642`. The original
-	// splits the cases into 24-entry tiers, then feeds each ending
-	// viewer return direction back into the tier walk:
-	//   -1 -> previous mystery, opened at its last page
-	//    0 -> close scrapbook
-	//   +1 -> next mystery, opened at its first page
-	// When the requested tier is the player's current chain stage, the
-	// original skips unsolved entries; completed tiers are already solved,
-	// so they are shown as a full range.
+	// `_ShowScrapbook(stage, 0) @ 1f78:0642`. 24-entry tiers; ending
+	// viewer returns -1/0/+1 for prev/close/next. Current tier skips
+	// unsolved entries.
 	if (stage < 1 || stage > 3)
 		return;
 	const int solvedCount =
@@ -1389,31 +1278,13 @@ void EEMEngine::doShowScrapbook(uint stage) {
 }
 
 void EEMEngine::doSetup() {
-	// Mirrors `_DoSetup @ 1f78:044e` (CD) and `_DoSetup_Floppy @
-	// 1ee2:0387` (floppy). Both variants share the same PIC 0x40 BG
-	// with text labels baked in, the same 13 button rectangles at
-	// `_SetupButtons @ 29be:1218` (CD) / `2608:0d8c` (floppy), and the
-	// same 4 highlight rectangles at `0xe94..0xeb3` (Kid1 / Kid2 /
-	// SoundOn / SoundOff). Floppy additionally pre-loads a few overlay
-	// PICs (0x9b..0x9e + 0x1fa) that the CD uses on demand, but the
-	// behaviour is identical — colour-key swap on `0xFE` to indicate
-	// active state, click dispatch via the same 12-entry handler
-	// jumptable. So a single shared handler covers both variants.
-	//
-	// The setup screen is BG `PIC 0x40` (loaded once on entry) with
-	// every label baked in — "Setup", "Partner", "Sound", "Music",
-	// the "Jake"/"Jenny"/"On"/"Off" option strings, etc. — all
-	// rendered in palette key `0xFE`. The original then runs
-	// `_SetupSettings @ 1f78:000d` which uses `_SwapColors @
-	// 172b:1d2a` to recolour those `0xFE` pixels per label rect:
-	// `0x15` for the active state, `0` for the inactive one. So
-	// nothing is drawn as text; the visible state of each toggle is
-	// purely a per-rect colour swap on top of `PIC 0x40`.
-	//
-	// Click hit-tests go through `_SetupButtons @ 29be:1218` — 13×
-	// 8-byte rects. Each click runs `HandleSetupButton @ 1f78:0158`,
-	// which dispatches via the 12-entry jumptable at `1f78:0436`.
-	// Verified handler map (decompiled at each jumptable target):
+	// `_DoSetup @ 1f78:044e` (CD) / `_DoSetup_Floppy @ 1ee2:0387`.
+	// PIC 0x40 BG with labels baked in palette key 0xFE; `_SetupSettings
+	// @ 1f78:000d` runs `_SwapColors @ 172b:1d2a` per label rect
+	// (0x15 active, 0x00 inactive — nothing drawn as text). 13× 8-byte
+	// click rects at `_SetupButtons @ 29be:1218` (CD) / 2608:0d8c (floppy);
+	// `HandleSetupButton @ 1f78:0158` dispatches via 12-entry jumptable
+	// at 1f78:0436. Rect map (x1, y1, x2, y2 — handler):
 	//   [0]  ( 20, 44, 39, 61)   Partner toggle (1f78:017a)
 	//   [1]  ( 20, 87, 39,104)   Voice toggle   (1f78:0196 → DAT_2d5d_3f97)
 	//   [2]  ( 20,127, 39,144)   back to profile (NextScreen=8)
@@ -1427,9 +1298,8 @@ void EEMEngine::doSetup() {
 	//   [10] (212,153,266,184)   Quit          (_AreYouSure → NextScreen=0xffff)
 	//   [11] ( 81, 25,238, 37)   Credits       (PIC 0x208 fullscreen)
 	//   [12] ( 11,  1,  3,  3)   debug placeholder
-	// Highlight rects (`Kid1 @ 29be:1320` / `Kid2 @ 29be:1328` /
-	// `SoundOn @ 29be:1330` / `SoundOff @ 29be:1338`) drive
-	// `_SwapColors`; they're not click targets in the original.
+	// Highlight rects (Kid1/Kid2/SoundOn/SoundOff @ 29be:1320..1338) drive
+	// `_SwapColors` only — not click targets in the original.
 	if (!_font.isLoaded()) {
 		_nextScreen = (ScreenId)_lastScreen;
 		return;
@@ -1454,11 +1324,9 @@ void EEMEngine::doSetup() {
 	const Common::Rect kSoundOnRect  (106,  86, 125,  94);
 	const Common::Rect kSoundOffRect (106,  96, 125, 104);
 
-	// Pixel-level color-key swap. Mirrors `_SwapColors @ 172b:1d2a`:
-	// for each pixel in `r` whose value equals `from`, replace with
-	// `to`. `0xFE` is the BG's text-key color; `0x15` is the active
-	// (bright) palette index, `0x00` the inactive one — both verified
-	// in `_SetupSettings @ 1f78:000d`.
+	// `_SwapColors @ 172b:1d2a` — replace pixels in r where value==from
+	// with to. 0xFE = BG text-key; 0x15 = active palette index, 0x00 =
+	// inactive (set by `_SetupSettings @ 1f78:000d`).
 	auto swapColors = [](Graphics::ManagedSurface &dst,
 						 const Common::Rect &r, byte from, byte to) {
 		const int x1 = MAX<int>(0, r.left);
@@ -1500,15 +1368,9 @@ void EEMEngine::doSetup() {
 	};
 	draw();
 
-	// Render `picId` and block until click/key. Returns the pressed
-	// keycode (KEYCODE_ESCAPE for an explicit bail, KEYCODE_INVALID
-	// for a click or any other key). When `transparent` is true,
-	// preserve the current screen behind and overlay `picId` with
-	// its transparent colour key — mirrors `_InterfaceHelp @
-	// 1560:0205` calling `_Rect_Move_Mask` with the pic's
-	// `miscflags >> 8` as the transp byte. When false, do a raw
-	// fullscreen blit — mirrors the credits handler at 1f78:0281
-	// using `_vga_fbuffvid`.
+	// Render picId and block until input. transparent=true: overlay via
+	// `_Rect_Move_Mask` (`_InterfaceHelp @ 1560:0205`). false: raw fullscreen
+	// blit via `_vga_fbuffvid` (credits @ 1f78:0281).
 	auto showFullscreenPic = [&](uint16 picId,
 								  bool transparent) -> Common::KeyCode {
 		Picture pic;
@@ -1519,18 +1381,13 @@ void EEMEngine::doSetup() {
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		if (transparent) {
-			// Preserve the current screen so the help PIC's
-			// transparent pixels show the setup BG underneath.
 			Graphics::Surface *cur = g_system->lockScreen();
 			if (cur) {
 				scratch.simpleBlitFrom(*cur);
 				g_system->unlockScreen();
 			}
 			const byte transp = (byte)(pic.flags >> 8);
-			// Explicit destPos — the no-destPos overload of
-			// `transBlitFrom` (managed_surface.cpp:738) stretches
-			// src to dst dimensions, scaling the PIC to 320x200.
-			// The original `_Rect_Move_Mask` blits at native size.
+			// Explicit destPos — no-destPos overload stretches to dst.
 			scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
 								  (uint32)transp);
 		} else {
@@ -1557,10 +1414,8 @@ void EEMEngine::doSetup() {
 	};
 
 	auto leaveSetup = [&]() {
-		// `_DoSetup`'s entry writes `_NextScreen = _LastScreen`. We
-		// honor any handler that has already overridden `_nextScreen`
-		// (Credits / Save don't, but New Case / Quit do). Otherwise
-		// fall back to `_lastScreen`.
+		// `_DoSetup` entry: _NextScreen = _LastScreen. Fall back to it
+		// unless a handler already overrode _nextScreen.
 		if (_nextScreen == kScreenSetup) {
 			_nextScreen = (ScreenId)_lastScreen;
 			if (_nextScreen == kScreenSetup ||
@@ -1570,7 +1425,7 @@ void EEMEngine::doSetup() {
 		saveProfile(_playerName);
 	};
 
-	_nextScreen = kScreenSetup;  // sentinel — leaveSetup picks the real target
+	_nextScreen = kScreenSetup;  // sentinel — leaveSetup picks target
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
@@ -1592,10 +1447,8 @@ void EEMEngine::doSetup() {
 			const int mx = ev.mouse.x;
 			const int my = ev.mouse.y;
 
-			// Partner toggle (button [0]) — original has no symmetric
-			// right-side button (the [3] rect is ScrapBook 1, not a
-			// partner arrow). Direct clicks on the Jake/Jenny labels
-			// are accepted as a more intuitive fallback.
+			// Partner toggle [0]. Direct Jake/Jenny label clicks are a
+			// ScummVM-only fallback.
 			if (kPartnerBtn.contains(mx, my)) {
 				_partner = _partner == 0 ? 1 : 0;
 				dirty = true;
@@ -1610,7 +1463,7 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Voice toggle (button [1]).
+			// Voice toggle [1].
 			if (kVoiceBtn.contains(mx, my)) {
 				_voiceOn = !_voiceOn;
 				if (_audio)
@@ -1637,33 +1490,26 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// New Case (button [7]). Original handler at 1f78:01ad
-			// sets `_NextScreen = 0xa` (CHOOSE_MYSTERY) and exits the
-			// dispatch loop with SI=1.
+			// New Case [7] @ 1f78:01ad → NextScreen=0xa (CHOOSE_MYSTERY).
 			if (kNewCaseBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				_nextScreen = kScreenChooseMystery;
 				return;
 			}
 
-			// Save (button [6]). Original calls `_SaveGame @
-			// 2404:0c87` and stays in the setup loop. Our save is
-			// profile-scoped (one slot per player name) — same effect.
+			// Save [6] — `_SaveGame @ 2404:0c87`.
 			if (kSaveBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				continue;
 			}
 
-			// Done (button [8]). Original handler is just `MOV SI,1;
-			// JMP exit` — `_NextScreen` stays at whatever entry set it
-			// to (= `_LastScreen`).
+			// Done [8] — MOV SI,1; JMP exit (NextScreen stays = LastScreen).
 			if (kDoneBtn.contains(mx, my)) {
 				leaveSetup();
 				return;
 			}
 
-			// Quit (button [10]). Original: `_AreYouSure(0)` →
-			// confirmed → `_NextScreen = 0xffff` (sentinel quit).
+			// Quit [10] — `_AreYouSure(0)` → NextScreen=0xffff.
 			if (kQuitBtn.contains(mx, my)) {
 				if (areYouSure()) {
 					_nextScreen = kScreenInvalid;
@@ -1673,28 +1519,17 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Help (button [9]). Original `_InterfaceHelp(1) @
-			// 1560:0205` walks the help-pic table at `29be:00c8`:
-			// each `num` slot is 5 bytes — count + two u16 PIC IDs.
-			// For num=1: count=2, pics = {0x0192, 0x01B1}. The
-			// original blits each pic with `_Rect_Move_Mask` — a
-			// MASKED blit whose transparent colour is the pic's
-			// `miscflags >> 8`, so the setup BG shows through. It
-			// also hides the cursor (`MOV [0x3a00], 0` + `_RemoveMouse`
-			// at the top of `_InterfaceHelp`, 1560:0216-021c). ESC
-			// at any point breaks out (1560:02b3 sets uVar5 = count).
+			// Help [9] — `_InterfaceHelp(1) @ 1560:0205`. Help-pic table
+			// @ 29be:00c8: each `num` slot = 5 bytes (count + two u16
+			// PIC IDs). num=1 → count=2, pics = {0x0192, 0x01B1}. Each
+			// pic blitted via `_Rect_Move_Mask` (transparent = pic
+			// `miscflags >> 8`, so setup BG shows through). Also hides
+			// cursor (1560:0216-021c). ESC breaks (1560:02b3).
 			if (kHelpBtn.contains(mx, my)) {
 				static const uint16 kHelp1Pics[] = { 0x0192, 0x01B1 };
 				CursorMan.showMouse(false);
 				for (uint i = 0; i < ARRAYSIZE(kHelp1Pics); i++) {
-					// Re-render the setup BG before each help PIC so
-					// each one overlays a clean canvas. Without this,
-					// `showFullscreenPic`'s `lockScreen` snapshot would
-					// pick up the previous PIC and the two help cards
-					// would composite together. Mirrors the original's
-					// `_vga_fvidvid(0)` call at the tail of every
-					// `_InterfaceHelp` iteration (1560:02e5), which
-					// restores the back-buffer BG between cards.
+					// Restore BG between cards (1560:02e5 `_vga_fvidvid(0)`).
 					draw();
 					const Common::KeyCode k =
 						showFullscreenPic(kHelp1Pics[i], /*transparent=*/true);
@@ -1706,37 +1541,25 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Credits (button [11]). Original handler at 1f78:025a
-			// loads PIC 0x208, hides the cursor (`MOV [0x3a00], 0`
-			// at 1f78:0269 + `_RemoveMouse @ 1000:542f` at 1f78:026F),
-			// blits it fullscreen via `_vga_fbuffvid` (raw copy, no
-			// mask), then waits for any input.
+			// Credits [11] @ 1f78:025a — PIC 0x208 fullscreen.
 			if (kCreditsBtn.contains(mx, my)) {
 				CursorMan.showMouse(false);
 				showFullscreenPic(0x208, /*transparent=*/false);
 				CursorMan.showMouse(true);
-				// PIC 0x208 has its own palette baked into the BG
-				// dump via `_GetPicture`; the original restores via
-				// `_GetPalette` on return. Reset to setup palette
-				// (SITEPALS index 0) so the setup BG renders right.
+				// PIC 0x208 has its own baked palette; restore site 0.
 				setSitePalette(0);
 				dirty = true;
 				continue;
 			}
 
-			// Profile (button [2]). Original handler writes
-			// `_NextScreen = 8`, returning to the player/profile
-			// picker. Save the current profile settings first, then
-			// let the screen driver run profile → partner → case/map.
+			// Profile [2] — NextScreen=8.
 			if (kProfileBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				_nextScreen = kScreenProfile;
 				return;
 			}
 
-			// ScrapBook 1 / 2 / 3 (buttons [3] / [4] / [5]). Original
-			// handlers call `_ShowScrapbook(stage, 0)`, with stages 2
-			// and 3 gated by chain progress.
+			// ScrapBook [3]/[4]/[5] — `_ShowScrapbook(stage, 0)`.
 			if (kScrap1Btn.contains(mx, my)) {
 				doShowScrapbook(1);
 				setSitePalette(0);
@@ -1764,17 +1587,9 @@ void EEMEngine::doSetup() {
 }
 
 void EEMEngine::doActionScreen() {
-	// Mirrors `_ActionScreen` @ 1c33:195b. The original draws background
-	// PIC 0x104 plus PIC 9 at (10, 0x87), then calls `_DoChoose` with
-	// `ActionNames @ 29be:0d6a`. The "Book N" heading belongs only to
-	// `_CaseSelection`, so this top-level menu intentionally does not
-	// draw one.
-	// Layout:
-	//   list[0]  = "----------------------------------"
-	//   list[1]  = "         Choose A Mystery"
-	//   list[2..10] = alternating menu items + separators
-	// Five selectable items: Choose A Mystery / Practice Mystery /
-	// See ScrapBook 1/2/3.
+	// `_ActionScreen @ 1c33:195b` — BG PIC 0x104 + PIC 9 @ (10, 0x87),
+	// `_DoChoose` with ActionNames @ 29be:0d6a. 5 picks alternating with
+	// separators.
 	enum MenuPick {
 		kPickChoose = 0,
 		kPickPractice,
@@ -1783,9 +1598,7 @@ void EEMEngine::doActionScreen() {
 		kPickScrap3,
 		kNumPicks
 	};
-	// Localized action-menu labels. Spanish text is taken verbatim
-	// from the Spanish floppy EEM.EXE (`eem-full-game/floppy-es/`):
-	// "Elegir Misterio" / "Caso de Practica" / "Ver Recortes  1..3".
+	// Spanish from EEM.EXE floppy-es.
 	const char *kPickLabelEN[kNumPicks] = {
 		"         Choose A Mystery",
 		"         Practice Mystery",
@@ -1801,17 +1614,11 @@ void EEMEngine::doActionScreen() {
 		"         Ver Recortes  3"
 	};
 	const char * const *kPickLabel = isSpanish() ? kPickLabelES : kPickLabelEN;
-	// Menu entry gating per `_ActionScreen @ 1c33:195b` — the asm at
-	// 1c33:19d1-1a70 sets greys[] based on chain stage AND per-tier
-	// solve count:
-	//   stage 1 → grey ScrapBook 2/3; grey ScrapBook 1 if no tier-1 solves
-	//   stage 2 → grey Practice + ScrapBook 3; grey ScrapBook 2 if no tier-2 solves
-	//   stage 3 → grey Practice; grey ScrapBook 3 if no tier-3 solves
-	//   stage 4 → grey Choose + Practice (post-completion read-only state)
-	// In other words: each tier's ScrapBook unlocks as soon as you've
-	// solved your first case in that tier. Practice Mystery is only
-	// available at stage 1. Choose A Mystery is greyed once every case
-	// in every tier is solved (stage 4).
+	// Gating @ 1c33:19d1-1a70 by chain stage + per-tier solves:
+	//   stage 1: grey SB2/3; SB1 needs any tier-1 solve
+	//   stage 2: grey Practice + SB3; SB2 needs tier-2 solve
+	//   stage 3: grey Practice; SB3 needs tier-3 solve
+	//   stage 4: grey Choose + Practice
 	bool anySolved1 = false;
 	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
 		if (_mysteriesSolved[i]) { anySolved1 = true; break; }
@@ -1833,8 +1640,7 @@ void EEMEngine::doActionScreen() {
 	const bool kPickEnabled[kNumPicks] = {
 		chooseOn, practiceOn, scrap1On, scrap2On, scrap3On
 	};
-	// Seed selection on the first enabled entry — at stage 4 the
-	// `Choose A Mystery` default is greyed, so we land on ScrapBook 1.
+	// Seed selection on first enabled entry.
 	uint pick = 0;
 	for (uint i = 0; i < kNumPicks; i++) {
 		if (kPickEnabled[i]) { pick = i; break; }
@@ -1842,27 +1648,15 @@ void EEMEngine::doActionScreen() {
 
 	const char *kSeparator = "----------------------------------";
 
-	// Click rectangles from the original `_DoChoose` @ 1c33:0514 — each
-	// `_InRect(_MouseX, _MouseY, addr, 0x29be)` reads one 4×u16 rect at
-	// the listed offset in segment 29be ({x1, y1, x2, y2}). We use
-	// `Common::Rect` (left/top/right/bottom) which also gives us
-	// `contains(x, y)` for hit testing.
+	// `_DoChoose @ 1c33:0514` click rects (4×u16 {x1,y1,x2,y2} in seg 29be):
 	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
 	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
 	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
 	const Common::Rect kListRect    ( 58,  35, 238, 158); // 29be:0d00 list panel
 
-	// The original `_NewPlayer` set `_MouseCursor = 1` on exit; the
-	// chain of screens after it expects the cursor to stay visible.
-	// Reassert here in case anything between hid it.
 	CursorMan.showMouse(true);
 
-	// Reassert site palette 0 (the chooser CLUT). In
-	// the normal flow `doProfilePicker` (or the post-screen reset paths
-	// at lines 1402 / 1147 / 1121) leaves us on palette 0 already, but
-	// the launcher-resume path jumps straight here from `_AllBlack`
-	// (palette = all-zero) — without this the BG renders into a black
-	// CLUT and the player sees an empty screen.
+	// Launcher-resume path can enter from `_AllBlack` palette.
 	setSitePalette(0);
 
 	Picture bg;
@@ -1893,11 +1687,8 @@ void EEMEngine::doActionScreen() {
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// OK / EXIT / HELP buttons (rectangles from `_DoChoose`).
 				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Greyed entries can't be confirmed (mirrors
-					// `_DoChoose @ 1c33:0635` — clicks on a `_Greys[i]
-					// != 0` row are ignored before `select` is set).
+					// Greyed entries ignored (`_DoChoose @ 1c33:0635`).
 					if (kPickEnabled[pick])
 						confirmed = true;
 					break;
@@ -1908,12 +1699,9 @@ void EEMEngine::doActionScreen() {
 					break;
 				}
 				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `_ActionScreen` sets `Chelp = 0`, so `_DoChoose`
-					// ignores this middle button on the top-level menu.
+					// `_ActionScreen` sets Chelp=0.
 					continue;
 				}
-				// List panel: click on a non-separator row selects the
-				// menu entry under the cursor.
 				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
 					const int kLineH = 10;
 					const int row = (ev.mouse.y - kListRect.top) / kLineH;
@@ -1942,10 +1730,7 @@ void EEMEngine::doActionScreen() {
 				break;
 			}
 			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
-				// Cycle backwards through enabled picks (mirrors the
-				// `_DoChoose` arrow handlers @ 1c33:0514). Loop is
-				// bounded by `kNumPicks` so a row of all-disabled picks
-				// can't spin forever.
+				// `_DoChoose` arrow handlers @ 1c33:0514, bounded loop.
 				for (int i = 0; i < (int)kNumPicks; i++) {
 					pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
 					if (kPickEnabled[pick])
@@ -2010,12 +1795,8 @@ void EEMEngine::doActionScreen() {
 	}
 
 	if (pick == kPickScrap1 || pick == kPickScrap2 || pick == kPickScrap3) {
-		// `_ActionScreen` handlers at 1c33:1B13 / 1B26 / 1B40 each
-		// call `_ShowScrapbook(0, stage)` for the matching tier
-		// (verified at the action-handler jumptable bytes
-		// `01 03 05 07 09 ff` paired with handlers at 1c33:1be1).
-		// Viewing the scrapbook never starts a new case; return to
-		// the same action menu afterwards.
+		// `_ActionScreen` handlers @ 1c33:1B13 / 1B26 / 1B40 →
+		// `_ShowScrapbook(0, stage)`. Returns here afterwards.
 		const uint stage = (pick == kPickScrap1) ? 1
 						 : (pick == kPickScrap2) ? 2 : 3;
 		doShowScrapbook(stage);
@@ -2029,11 +1810,8 @@ void EEMEngine::doActionScreen() {
 }
 
 void EEMEngine::doCaseSelection() {
-	// Mirrors `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @
-	// 1c33:0a87`. `_DoChooseMystery` loads BOOK<stage>.NME and
-	// `_CaseSelection` draws PIC 0x41 plus the centered "Book N" /
-	// "Challenge Book" title. This screen is entered only after the
-	// player selects "Choose A Mystery" on `_ActionScreen`.
+	// `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @ 1c33:0a87` —
+	// loads BOOK<stage>.NME, draws PIC 0x41 + centered "Book N" title.
 	const uint kMaxMystery = 54;
 
 	CursorMan.showMouse(true);
@@ -2047,9 +1825,7 @@ void EEMEngine::doCaseSelection() {
 	const bool haveRevealPic =
 		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
 
-	// KD greeter sprite. `_CaseSelection @ 1c33:0a87` loads anim 0x15
-	// (Jake-paired) or 0x16 (Jenny-paired), then runs it through the
-	// chooser loop via `_UpdateAnimations`.
+	// `_CaseSelection @ 1c33:0a87` greeter ANI 0x15 (Jake) / 0x16 (Jenny).
 	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
 	Animation kdAnim;
 	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
@@ -2057,10 +1833,8 @@ void EEMEngine::doCaseSelection() {
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
 
-	// Stage roster:
-	//   stage 1 (Junior, BOOK1.NME) -> mysteries  1..24
-	//   stage 2 (Senior, BOOK2.NME) -> mysteries 25..48
-	//   stage 3 (Master, BOOK3.NME) -> mysteries 49..54
+	// stage 1 (Junior, BOOK1.NME) = 1..24, stage 2 (Senior, BOOK2.NME) =
+	// 25..48, stage 3 (Master, BOOK3.NME) = 49..54.
 	uint stageLo = 1, stageHi = 0x18;
 	uint book = 1;
 	switch (_chainStage) {
@@ -2078,8 +1852,7 @@ void EEMEngine::doCaseSelection() {
 	}
 	const uint listLen = MIN<uint>((uint)names.size(), stageHi - stageLo + 1);
 
-	// Per-row solved flags. `_DoChoose @ 1c33:0521` skips solved entries
-	// when seeding the initial selection and ignores clicks on them.
+	// Solved-row gating @ `_DoChoose @ 1c33:0521`.
 	Common::Array<bool> solvedFlags;
 	solvedFlags.resize(listLen);
 	for (uint i = 0; i < listLen; i++) {
@@ -2143,10 +1916,8 @@ void EEMEngine::doCaseSelection() {
 					return;
 				}
 				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Original `_CaseSelection` returns 0xfffe here,
-					// then `_ChooseSavedGame` runs. ScummVM stores
-					// in-progress cases in the profile, so route to
-					// the profile picker instead.
+					// Original returns 0xfffe → `_ChooseSavedGame`. ScummVM
+					// stores in-progress cases per profile.
 					saveProfile(_playerName);
 					_mystery.clear();
 					_nextScreen = kScreenProfile;
@@ -2170,9 +1941,7 @@ void EEMEngine::doCaseSelection() {
 					const uint idx = topRow + (uint)row;
 					if (idx >= listLen || solvedFlags[idx])
 						continue;
-					// Second click on the already-selected row counts as
-					// the OK button — saves the player a trip down to the
-					// bottom-bar after picking a mystery.
+					// Second click on selected row = OK (ScummVM ergonomic).
 					if (idx == selRow) {
 						confirmed = true;
 						break;
@@ -2277,51 +2046,25 @@ void EEMEngine::doCaseSelection() {
 }
 
 void EEMEngine::doNotebook() {
-	// Mirrors `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
-	// `_HandleNoteButton @ 161e:03cb`.
-	//
-	// Layout (verified from Ghidra labels in 29be:013f / 29be:0147):
-	//   _NotebookRect = (78, 12, 288, 152)   — note display rectangle.
-	//   _NoteButtons (11 entries, 8 bytes each, at 29be:0147):
-	//     [0]  (134, 174, 155, 190)  decorative — `_HandleNoteButton(0)`
-	//                                returns immediately (i-1 unsigned > 9).
-	//     [1]  (93,  174, 115, 190)  → `_InterfaceHelp(0)` (handler 0x3f9)
-	//     [2]  (157, 174, 178, 190)  → handler 0x477   (page nav)
-	//     [3]  (5,   80,  44, 110)   → `_KDHelp` (host hint, 0x403)
-	//     [4]  (180, 174, 201, 190)  → solve / accuse  (0x436)
-	//     [5]  (204, 174, 224, 190)  → `_NextScreen = 5` (gallery, 0x489)
-	//     [6]  (226, 174, 247, 190)  → handler 0x4ab
-	//     [7]  (7,   177,  57, 200)  → handler 0x480   (back to map)
-	//     [8]  (35,  111,  56, 136)  → `_NextScreen = 3` (site)
-	//     [9]  (0, 0, 0, 0)          → same exit as [8]
-	//     [10] (66,  79, 267, 174)   → `_InterfaceHelp(0)` (note area)
-	//   Background: PIC 0x3f.
-	//   Partner anim: anim 1 (Jake) / 0xb (Jenny) at (5, 80).
+	// `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
+	// `_HandleNoteButton @ 161e:03cb`. _NotebookRect = (78, 12, 288, 152).
+	// _NoteButtons @ 29be:0147 — 11 rects × 8 bytes. Jumptable @ 161e:04ec
+	// dispatches handler[i-1] (rect 0's i-1 underflows = decorative slot):
+	//   [0] (134,174,155,190)  decorative — no handler
+	//   [1] ( 93,174,115,190)  HELP → `_InterfaceHelp(0)`           (0x3f9)
+	//   [2] (157,174,178,190)  GALLERY → `_NextScreen = 5`          (0x477)
+	//   [3] (  5, 80, 44,110)  host hint → `_KDHelp`                (0x403)
+	//   [4] (180,174,201,190)  SOLVE → `_SolvedCheck` → NextScreen=7 (0x436)
+	//   [5] (204,174,224,190)  PAGE NEXT → `_EraseNotes` + redraw   (0x489)
+	//   [6] (226,174,247,190)  PAGE PREV → CurrentPage-- + redraw   (0x4ab)
+	//   [7] (  7,177, 57,200)  MAP → `_NextScreen = 2`              (0x480)
+	//   [8] ( 35,111, 56,136)  SITE → `_NextScreen = 3`             (0x3ed)
+	//   [9] (  0,  0,  0,  0)  same exit as [8]
+	//   [10] (267,174,288,190) → `_InterfaceHelp(0)` again          (0x3f9)
+	// BG PIC 0x3f; partner ANI 1 (Jake) / 0xb (Jenny) at (5, 80).
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
-	// Button rects from `_NoteButtons @ 29be:0147` matched to handler
-	// addresses via the jump table at `_HandleNoteButton + 0xec` (i.e.
-	// 161e:04ec). Decoded handlers (i = rect_index, dispatch = handler[i-1]):
-	//   rect 0 (134,155) → no handler (i-1 underflows; original treats
-	//                      this as a decorative/no-op slot)
-	//   rect 1 (93,115)  → 0x03f9 = `_InterfaceHelp(0)`           (HELP)
-	//   rect 2 (157,178) → 0x0477 = `_NextScreen = 5`             (GALLERY)
-	//   rect 3 (5,80)    → 0x0403 = `_KDHelp`                     (host hint)
-	//   rect 4 (180,201) → 0x0436 = `_SolvedCheck` -> NextScreen=7 (SOLVE)
-	//   rect 5 (204,224) → 0x0489 = `_EraseNotes` + `_DrawNotes`  (PAGE NEXT)
-	//   rect 6 (226,247) → 0x04ab = decrement CurrentPage + redraw (PAGE PREV)
-	//   rect 7 (7,177)   → 0x0480 = `_NextScreen = 2`             (MAP)
-	//   rect 8 (35,111)  → 0x03ed = `_NextScreen = 3`             (SITE)
-	//   rect 9 (0,0)     → 0x03ed = same as rect 8
-	//   rect 10 (66,79)  → 0x03f9 = `_InterfaceHelp(0)`           (note-area help)
-	// (`_NoteButtons @ 29be:0147` actually has rect [10] at
-	// (267,174,288,190) — small button on the right of the bottom
-	// bar that the original handler dispatch table at 161e:04ec
-	// routes to `_InterfaceHelp(0)` again. Earlier this rect was
-	// mis-noted as a "note area" of (66,79,267,174) — that
-	// rectangle exists nowhere in the binary's button table.)
-
 	CursorMan.showMouse(true);
 
 	int page = 0;
@@ -2372,10 +2115,7 @@ void EEMEngine::doNotebook() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Test buttons in the order the original would —
-				// button 0 / 9 are dead zones, so check the actionable
-				// rects directly. Earlier rects "win" when overlapping
-				// (matches `_FindButton`).
+				// Earlier rects win on overlap (matches `_FindButton`).
 				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenSite;
 					exitFlag = true;
@@ -2403,8 +2143,7 @@ void EEMEngine::doNotebook() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// rect 1 → `_InterfaceHelp(0)`: walks `HelpData[0]` and
-					// blits PICs 0x63 / 0x1ae fullscreen for click-through.
+					// rect 1 → `_InterfaceHelp(0)` (PICs 0x63 / 0x1ae).
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
@@ -2422,22 +2161,14 @@ void EEMEngine::doNotebook() {
 					continue;
 				}
 				if (kPdaHelp2Rect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `_NoteButtons[10]` → handler 0x03f9 = same
-					// `_InterfaceHelp(0)` as button [1].
+					// _NoteButtons[10] → handler 0x03f9 = same as [1].
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
 					continue;
 				}
-				// Click on a clue's slot rect → toggle selection. The
-				// original `_DoNotebook` doesn't do this — note
-				// selection lives in the accuse screen there — but
-				// keyboard 1..9 toggling is awkward, and the resulting
-				// `_NoteSelected` state is what `_SolvedCheck` reads
-				// either way. Slot rects are the per-clue rectangles
-				// `drawNotebookFrame` publishes, so this just
-				// reproduces the visible-text-bbox click without the
-				// previous bogus outer-area gate.
+				// ScummVM-only: click on clue slot toggles _NoteSelected
+				// (original toggles only in accuse screen).
 				for (uint i = 0; i < _notebookSlotRects.size(); i++) {
 					if (_notebookSlotRects[i].contains(ev.mouse.x,
 													   ev.mouse.y)) {
@@ -2453,7 +2184,7 @@ void EEMEngine::doNotebook() {
 			break;
 
 		const uint32 now = g_system->getMillis();
-		// Re-render every 100 ms so the partner sprite cycles frames.
+		// Re-render every 100 ms for partner sprite cycle.
 		if (dirty || now - lastDraw >= 100) {
 			drawNotebookFrame(page);
 			lastDraw = now;
@@ -2469,50 +2200,31 @@ void EEMEngine::doNotebook() {
 }
 
 void EEMEngine::drawNotebookFrame(int &page) {
-	// PDA notebook redraw — formerly the `draw` lambda inside `doNotebook`.
-	// Mirrors `_DrawNotes @ 161e:01d0` for the per-page note layout, plus
-	// the partner-sprite blit at (5, 80) (`_NewAnimation` from
-	// `_DoNotebook @ 161e:0500`). Uses `_notebookSlotRects` /
-	// `_notebookSlotClues` to publish the per-page slot layout to the
-	// click handler in `doNotebook`.
+	// `_DrawNotes @ 161e:01d0` per-page layout + partner sprite at (5,80)
+	// from `_DoNotebook @ 161e:0500`.
 	const Common::Rect kNotebookRect(78, 12, 288, 152);
 
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
-	// PIC 0x3f frame.
 	Picture frame;
 	if (_picsArchive.getPicture(0x3f, frame))
 		scratch.simpleBlitFrom(frame.surface);
 
-	// Partner sprite at (5, 80). Anim 1 for Jake, 0xb (11) for Jenny
-	// for CELLS, but the original `_DoNotebook @ 161e:0500` always
-	// uses script 0x01 (verified by `CONCAT22(1, ...)` in its
-	// `_NewAnimation` call at 161e:054c). Both 0x01 and 0x0b have
-	// the SAME script in `kAnimScripts` (alias), so both lookups
-	// produce identical results — but routing through 0x01
-	// matches the original verbatim.
+	// Partner ANI 1/0xb (cells); script 0x01 (`_NewAnimation @ 161e:054c`).
 	const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
 	Animation partnerAni;
 	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
 		const uint32 now = g_system->getMillis();
 		const uint frameIdx = partnerFrameAtTick(0x01,
 												  (uint)partnerAni.size(), now);
-		// Anchor-aware blit. The PDA partner (anim 0x01/0x0b) cells
-		// have miscflags = rowoff = 0 in the audit, but routing
-		// through `blitAnimFrameAnchored` is harmless and keeps the
-		// rendering path consistent with the BigMap partner.
 		blitAnimFrameAnchored(scratch.surfacePtr(),
 							  partnerAni[frameIdx], 5, 80);
 	}
 
-	// Notes — `_DrawNotes` walks `_NoteIndex` for the current page,
-	// rendering each found clue's text inside `_NotebookRect` with
-	// word-wrap. Selected clues are highlighted (color 0x3c in the
-	// original's case-briefing palette).
-	// Build a list of found-clue indices, identical ordering to the
-	// original's iteration through `_CluesFound[]`.
+	// `_DrawNotes` walks `_NoteIndex` for current page; word-wraps each
+	// found clue in `_NotebookRect`. Selected = color 0x3c.
 	Common::Array<uint> found;
 	for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
 		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
@@ -2526,29 +2238,14 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	const int kRectW = kNotebookRect.width();
 	const int kRectH = kNotebookRect.height();
 
-	// Walk forward to the start clue of the current page.
-	// Each page renders as many clues as fit in `kRectH`.
+	// Walk forward to start clue of current page; fits as many as kRectH.
 	int clueCursor = 0;
 	Common::Array<int> pageStarts;
 	pageStarts.push_back(0);
-	// Floppy NoteIndex entries are 7 bytes:
-	//   +0..1  clue text offset (ABSOLUTE byte offset into the
-	//          mystery blob; partner-agnostic clue statement —
-	//          this is what the notebook displays).
-	//   +2..3  Jake's spoken line offset (used by
-	//          `_DisplayHotspotClue_Floppy` when Jake narrates a
-	//          dialog record — NOT for the notebook).
-	//   +4..5  Jenny's spoken line offset (same, for Jenny).
-	//   +6     score.
-	// Verified at `_DrawNotes_Floppy / FUN_15e0_01e8`'s
-	// `*(int *)(notes + idx * 7)` access (no `+2`/`+4` shift —
-	// always reads byte +0). The earlier port used the dialog
-	// spoken line for the notebook, which showed the partner's
-	// "Hi, Jake! Hi, Jenny!" intro instead of the actual clue
-	// statement ("We received a note that says..."). CD entries
-	// are 4 bytes with offsets relative to the TextBlock at
-	// header[+0xc]. Resolve the right text for the active variant
-	// once per render.
+	// Floppy NoteIndex entry (7 bytes): +0 clueTextOff (absolute, what
+	// the notebook displays), +2 Jake spoken, +4 Jenny spoken, +6 score.
+	// `_DrawNotes_Floppy / FUN_15e0_01e8` reads only byte +0.
+	// CD entries are 4 bytes, offsets relative to TextBlock @ header[+0xc].
 	const bool floppyNb = isFloppy();
 	const byte *bufBase = _mystery.blobAt(0);
 	const uint32 mysSz  = _mystery.dataSize();
@@ -2594,8 +2291,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 			page = 0;
 	}
 
-	// Track per-slot rectangles so the click handler can map a
-	// click in `kNoteArea` back to a clue index.
+	// Per-slot rects published to the click handler.
 	Common::Array<Common::Rect> slotRects;
 	Common::Array<uint> slotClues;
 
@@ -2611,12 +2307,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		if (txt.empty())
 			txt = Common::String::format(
 				isSpanish() ? "nota %u" : "clue %u", clueId);
-		// Per `_DrawNotes @ 161e:01d0`: text uses
-		// `_NoteUnselectedColor` (0x5c=cyan) for unselected and 0x3c
-		// (light yellow-white) for selected. Both contrast cleanly
-		// with the PDA screen's natural blue, so we draw text
-		// directly on PIC 0x3f without an extra fill rectangle —
-		// matches the original design.
+		// `_DrawNotes @ 161e:01d0`: 0x5c unselected, 0x3c selected.
 		Common::Array<Common::String> wrapped;
 		_font.wordWrapText(txt, kRectW, wrapped);
 		const int lineH = _font.getFontHeight();
@@ -2632,12 +2323,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		y += h + 7;
 	}
 
-	// Page indicator only — the original `_DrawNotes @ 161e:01d0`
-	// has no points display in the PDA notebook (the per-clue point
-	// values are SPOILERS for the chain weighting that the engine
-	// uses internally for `_GetSelectedPoints`). Showing the running
-	// total tells the player exactly when they have enough evidence
-	// to solve, which deflates the deduction step.
+	// Page indicator only (original has no points display).
 	_font.drawString(&scratch, Common::String::format("p%d/%d",
 							   page + 1, (int)pageStarts.size()),
 					 270, 4, 320, 0x5C);
@@ -2652,27 +2338,11 @@ void EEMEngine::drawNotebookFrame(int &page) {
 }
 
 void EEMEngine::doGallery() {
-	// Mirrors `_DoGallery @ 158f:065b` and `_DrawGallery @ 158f:0046`.
-	// Verified directly from the disassembly:
-	//   * Background: PIC 0x3f (same as PDA).
-	//   * Partner sprite at (5, 0x50): anim 2 (Jake) / 0x10 (Jenny).
-	//     `_NewAnimation(5, 0x50, ...)`. NOTE: gallery uses anim 2/0x10,
-	//     PDA uses 1/0xb — different sprites.
-	//   * Five fixed slot positions at `29be:0x116` (4 bytes per slot,
-	//     `{u16 x, u16 y}`):
-	//         slot 0 = ( 83,  14)   slot 3 = (119,  90)
-	//         slot 1 = (155,  14)   slot 4 = (191,  90)
-	//         slot 2 = (227,  14)
-	//   * For each logical suspect i in 0..NumSuspects-1:
-	//         picId   = `*(u16 *)(_GalleryData + i * 0x46)` (entry +0).
-	//         visible = `_InGallery[_NewOrder[i]] != 0`.
-	//         drawX   = positions[_NewOrder[i]].x
-	//         drawY   = positions[_NewOrder[i]].y + (0x48 - pic.height)
-	//     So portraits are BOTTOM-aligned to baselines 0x48 + pos.y.
-	//   * Click on portrait via `_SearchSuspects` → `MoreInfo(i)` shows
-	//     the suspect detail page. ESC returns to PDA.
-	//   * Frame-cycled @ 100ms via `_CheckFrameRate` + `_UpdateAnimations`
-	//     + `_GizmoColorCycle`.
+	// `_DoGallery @ 158f:065b` + `_DrawGallery @ 158f:0046`. BG PIC 0x3f.
+	// Partner ANI 2 (Jake) / 0x10 (Jenny) at (5, 0x50). 5 slot positions
+	// @ 29be:0x116. Per suspect i: picId = *(u16*)(_GalleryData + i*0x46);
+	// visible = _InGallery[_NewOrder[i]]; drawY = pos.y + (0x48 - pic.h)
+	// (bottom-aligned to baseline). Click → `_SearchSuspects` → moreInfo.
 	if (!_mystery.isLoaded())
 		return;
 
@@ -2732,21 +2402,17 @@ void EEMEngine::doGallery() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// PDA bottom-bar buttons mirror `_NoteButtons @ 29be:0147`.
-				// `_DoGallery @ 158f:065b` shares the SAME button table
-				// with `_DoNotebook` (both call `_FindButton` against the
-				// 11-entry table at 0x147). `_HandleGalleryButton @
-				// 158f:05c0` dispatches via a different jump table
-				// (158f:0645). Verified gallery button mapping:
-				//   rect 0 (134,155) → 0x05ef = `_NextScreen = 4` (NOTEBOOK)
-				//   rect 1 (93,115)  → 0x0625 = `_InterfaceHelp` (HELP)
-				//   rect 2 (157,178) → 0x0638 = generic exit (no-op)
-				//   rect 3 (5,80)    → 0x061e = `_KDHelp` (host hint)
-				//   rect 4 (180,201) → 0x05ff = `_SolvedCheck` -> SOLVE
-				//   rect 5 (204,224) → 0x0638 = generic exit
-				//   rect 6 (226,247) → 0x0638 = generic exit
-				//   rect 7 (7,177)   → 0x05f7 = `_NextScreen = 2` (MAP)
-				//   rect 8 (35,111)  → 0x05e4 = `_NextScreen = 3` (SITE)
+				// Shares _NoteButtons @ 29be:0147 with doNotebook;
+				// _HandleGalleryButton @ 158f:05c0 jumptable @ 158f:0645:
+				//   [0] (134,155) → NOTEBOOK = NextScreen=4   (0x5ef)
+				//   [1] ( 93,115) → HELP = _InterfaceHelp(0)  (0x625)
+				//   [2] (157,178) → generic exit (no-op)      (0x638)
+				//   [3] (  5, 80) → _KDHelp (host hint)       (0x61e)
+				//   [4] (180,201) → _SolvedCheck → SOLVE      (0x5ff)
+				//   [5] (204,224) → generic exit              (0x638)
+				//   [6] (226,247) → generic exit              (0x638)
+				//   [7] (  7,177) → MAP = NextScreen=2        (0x5f7)
+				//   [8] ( 35,111) → SITE = NextScreen=3       (0x5e4)
 				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y)) {
 					_nextScreen = kScreenSite;
 					exitFlag = true;
@@ -2768,9 +2434,7 @@ void EEMEngine::doGallery() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Gallery rect 1 → `_InterfaceHelp(0)` per jmp table at
-					// 158f:0625 (HandleGalleryButton). Same picture sequence
-					// as the notebook HELP button.
+					// rect 1 → `_InterfaceHelp(0)` (158f:0625).
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					lastDraw = 0;
@@ -2782,8 +2446,7 @@ void EEMEngine::doGallery() {
 					lastDraw = 0;
 					continue;
 				}
-				// `_SearchSuspects` walks the per-slot rects and returns
-				// the suspect index. We mirror that with cached rects.
+				// `_SearchSuspects` — per-slot rect → suspect index.
 				bool clicked = false;
 				for (uint i = 0; i < slotRects.size(); i++) {
 					if (slotSuspect[i] < 0)
@@ -2792,9 +2455,6 @@ void EEMEngine::doGallery() {
 						if (moreInfo(gd, (uint)slotSuspect[i],
 									 galBg, haveBg))
 							exitFlag = true;
-						// Force gallery redraw immediately so the
-						// player isn't left looking at the dismissed
-						// MoreInfo screen until the next 100 ms tick.
 						drawGalleryFrame(gd, num, slotRects, slotSuspect);
 						lastDraw = g_system->getMillis();
 						mouse = g_system->getEventManager()->getMousePos();
@@ -2823,11 +2483,6 @@ void EEMEngine::doGallery() {
 									  gallerySlotAt(slotRects, slotSuspect,
 													mouse.x, mouse.y));
 		}
-		// `g_system->updateScreen()` is what tells the framework to
-		// re-render the cursor at its current mouse position; without
-		// it here, the cursor only refreshes when `drawGalleryFrame`
-		// runs (every 100 ms) and visibly lags the mouse. Match
-		// `doNotebook`'s per-tick `updateScreen()` cadence (line 1548).
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -2836,28 +2491,13 @@ void EEMEngine::doGallery() {
 
 bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 						  const Picture &galBg, bool haveBg) {
-	// Suspect-detail page. Mirrors `MoreInfo @ 158f:0419`:
-	//   _RefreshGalleryBackground();
-	//   _GetPicture(*(u16*)(gd + i*0x46));
-	//   _AddPicBackground(pic, 0x94, 0xf);
-	//   _DrawGalleryNotes(gd + i*0x46);
-	//   loop until ESC or button click.
-	// Suspect data layout differs by variant:
-	//   * CD (`158f:0419`): fixed 0x46-byte stride.
-	//     +0..1   picId
-	//     +8..9   clue count (u16)
-	//     +0xa..  array of u16 clue IDs (max 30,
-	//             terminated by 0xFFFF if short).
-	//   * Floppy (`_MoreInfo_Floppy = 154e:042b` →
-	//     `FUN_154e_0201` → `FUN_15e0_01e8`):
-	//     variable-stride.
-	//     +0..1   picId
-	//     +2..3   alibi (0xFFFF = guilty)
-	//     +4      clue count (u8)
-	//     +5..    u8 clue IDs (per the asm at
-	//             154e:020e..0282 which calls
-	//             `FUN_15e0_01e8(rect, entry+5,
-	//                            entry[4], NULL)`).
+	// `MoreInfo @ 158f:0419` — suspect detail. PIC = entry+0,
+	// _AddPicBackground at (0x94, 0xf), notes via _DrawGalleryNotes.
+	// CD layout (0x46-byte stride): +0 picId, +8 clueCount(u16), +0xa
+	// clue IDs(u16, 0xFFFF terminator, max 30).
+	// Floppy (`_MoreInfo_Floppy = 154e:042b` → `FUN_15e0_01e8`):
+	// variable stride. +0 picId, +2 alibi (0xFFFF = guilty), +4 count(u8),
+	// +5 clue IDs(u8).
 	const bool floppyMI = isFloppy();
 	const byte *suspect = floppyMI
 							  ? _mystery.floppySuspectEntry(suspectIdx)
@@ -2871,10 +2511,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 
 	setInteractiveMouseCursor(false);
 
-	// Suspect's clue notes inside _GalleryNoteRect
-	// = (78, 93, 288, 152), per 29be:0100. Cyan text
-	// renders directly on the PDA's natural blue
-	// screen — matches `_DrawGalleryNotes @ 158f:01f4`.
+	// _GalleryNoteRect = (78, 93, 288, 152) @ 29be:0100.
+	// `_DrawGalleryNotes @ 158f:01f4`.
 	const int rx = 78, ry = 93;
 	const int rw = 288 - 78, rh = 152 - 93;
 	const int lineH = _font.getFontHeight();
@@ -2882,14 +2520,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 	const byte *ni = _mystery.noteIndex();
 	const uint16 niCount = _mystery.noteIndexCount();
 
-	// Pagination matches `_DrawNotes @ 161e:01d0` / `MoreInfo @
-	// 158f:0419`: the original tracks `_NextClue` across redraws and
-	// only emits clues whose wrapped height fits the rect, deferring
-	// the rest to the next page. `_PageBreaks[]` stores the
-	// entry-point for each page so PREV (button 6 / case 6 at
-	// 158f:03b8 — `SUB _CurrentPage, 2; _NextClue = _PageBreaks[...]`)
-	// can step back. We mirror that with an explicit stack of
-	// page-start indices: forward push, back pop.
+	// Pagination via `_NextClue` + `_PageBreaks[]` (case 6 @ 158f:03b8).
 	uint pageStart = 0;
 	Common::Array<uint> pageStack;
 	bool back = false;
@@ -2902,13 +2533,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		ms.clear();
 		if (haveBg)
 			ms.simpleBlitFrom(galBg.surface);
-		// Partner sprite at (5, 0x50). The original `MoreInfo @
-		// 158f:0419` calls `_RefreshGalleryBackground` (clears the
-		// portrait grid) but the partner anim slot registered by
-		// `_DoGallery` keeps painting at every `_UpdateAnimations`
-		// tick — the suspect detail pic covers the right side only
-		// (drawn at 0x94, 0xf), so the partner stays visible on the
-		// left. Re-blit per page so the frame stays current.
+		// Partner sprite at (5, 0x50). Re-blitted per page.
 		{
 			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 			Animation partnerAni;
@@ -2921,7 +2546,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 									  partnerAni[frameIdx], 5, 0x50);
 			}
 		}
-		// Full suspect picture at (0x94, 0xf).
 		Picture detail;
 		if (_picsArchive.getPicture(detailPic, detail)) {
 			const byte transp = (byte)(detail.flags >> 8);
@@ -2929,12 +2553,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 							 Common::Point(0x94, 0x0f), transp);
 		}
 
-		// Walk the clue list from `pageStart`. Skip clues that aren't
-		// found / lack a note entry, then measure the wrapped height
-		// before drawing. If a clue would overflow and we've already
-		// drawn at least one, defer it to the next page; if it's the
-		// FIRST clue on this page (one clue too tall to ever fit),
-		// draw it anyway so progress is guaranteed.
+		// Walk clues from pageStart; defer overflow to next page unless
+		// first clue is too tall to ever fit.
 		int yPos = ry;
 		bool drewAny = false;
 		uint k = pageStart;
@@ -2952,10 +2572,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 				continue;
 			if (!ni || clueId >= niCount)
 				continue;
-			// Floppy notes: 7-byte entries (+0..1 clue text, +2..3 Jake
-			// spoken, +4..5 Jenny spoken, +6 score). Notebook always
-			// uses +0 (partner-agnostic statement) per `FUN_15e0_01e8`.
-			// CD notes are 4-byte: u16 textOff + u16 score.
+			// Floppy: 7-byte entry (+0 text, +2 Jake, +4 Jenny, +6 score).
+			// CD: 4-byte entry (u16 textOff + u16 score).
 			Common::String txt;
 			if (floppyMI) {
 				const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
@@ -3006,9 +2624,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 					: "No clues yet for this suspect.",
 				rx, ry, MAX<int>(8, rw), 0x5C);
 		}
-		// Header / footer text. The PDA's NEXT (case 5) and PREV
-		// (case 6) icons are visible on the bottom bar — the footer
-		// just covers dismissal here.
+		// Header / footer text.
 		if (_font.isLoaded()) {
 			_font.drawString(&ms,
 				isSpanish() ? "EXPEDIENTE" : "SUSPECT FILE",
@@ -3021,9 +2637,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 			0, 0, 320, 200);
 		g_system->updateScreen();
 
-		// Drain the queued LBUTTONDOWN that brought us into MoreInfo
-		// so it doesn't get caught by the input loop below. Subsequent
-		// pages don't need this.
+		// Drain the LBUTTONDOWN that opened MoreInfo (first page only).
 		if (isFirstShow) {
 			isFirstShow = false;
 			g_system->delayMillis(150);
@@ -3037,22 +2651,10 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 			}
 		}
 
-		// Wait for input. Each PDA button maps to a distinct action,
-		// mirroring `_HandleMoreButton @ 158f:027d`:
-		//   case 0 NOTEBOOK    -> NextScreen=4
-		//   case 1 HELP        -> _InterfaceHelp(0)
-		//   case 2 GALLERY     -> close MoreInfo
-		//   case 3 PARTNER head-> _KDHelp + redraw
-		//   case 4 ACCUSE      -> NextScreen=7
-		//   case 5 PAGE NEXT   -> next page
-		//   case 6 PAGE PREV   -> prev page
-		//   case 7 MAP         -> NextScreen=2
-		//   case 8 SITE        -> no-op
-		//   case 10 HELP (alt) -> _InterfaceHelp(0)
-		// Clicks outside any known button rect are no-ops, matching
-		// the original (which short-circuits on `_FindButton == -1`).
-		// ESC closes; PgDn / PgUp / Return / Backspace also paginate
-		// as a modern accessibility convenience.
+		// `_HandleMoreButton @ 158f:027d`:
+		//   [0] NOTEBOOK→4, [1] HELP→_InterfaceHelp, [2] GALLERY (close),
+		//   [3] _KDHelp, [4] ACCUSE→7, [5] PAGE_NEXT, [6] PAGE_PREV,
+		//   [7] MAP→2, [8] SITE noop, [10] HELP alt.
 		bool advance = false;
 		bool prev = false;
 		bool redraw = false;
@@ -3096,16 +2698,13 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 					}
 					if (kPdaHelpRect.contains(mx, my) ||
 						kPdaHelp2Rect.contains(mx, my)) {
-						// Help overlay; re-render the current page
-						// after it dismisses.
 						setInteractiveMouseCursor(false);
 						doInterfaceHelp(0);
 						redraw = true;
 						break;
 					}
 					if (kPdaGalleryRect.contains(mx, my)) {
-						// Case 2: close MoreInfo and return to the
-						// portrait grid.
+						// Case 2: close MoreInfo.
 						back = true;
 						break;
 					}
@@ -3120,16 +2719,13 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 						break;
 					}
 					if (kPdaPartnerHeadHintRect.contains(mx, my)) {
-						// Case 3: ask KD for a hint then redraw the
-						// current page over the clean BG.
+						// Case 3: _KDHelp.
 						setInteractiveMouseCursor(false);
 						doHelp();
 						redraw = true;
 						break;
 					}
-					// Case 8 SITE and click outside any known button
-					// = no-op. The original `_FindButton`
-					// short-circuits when no rect matches.
+					// Case 8 SITE / non-button = no-op.
 					break;
 				}
 				if (e2.type == Common::EVENT_KEYDOWN && hasMore &&
@@ -3149,9 +2745,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 					break;
 				}
 			}
-			// Per-tick `updateScreen()` so the SDL cursor follows the
-			// mouse — without it the cursor freezes on entry to the
-			// MoreInfo screen.
 			g_system->updateScreen();
 			g_system->delayMillis(20);
 		}
@@ -3163,9 +2756,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 			pageStart = pageStack.back();
 			pageStack.pop_back();
 		}
-		// `redraw` falls through with the same `pageStart` and
-		// re-iterates the outer while, repainting on top of whatever
-		// the help overlay left behind.
 	}
 
 	return exitGallery;
@@ -3293,32 +2883,15 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 }
 
 void EEMEngine::doBigMap() {
-	// Two-stage flow that mirrors the original screen-1 wrapper at
-	// 20fe:120b and `_DoBigMap @ 20fe:09e7`:
-	//
-	//   STAGE 1 — Overview. PIC 0x42 + site icons drawn via the
-	//   `_DrawBigMapButtons` algorithm at BigMap coords MapData[+4/+6].
-	//   The original `_DoBigMap` returns sx/sy = (mouseX*2 - 0x74,
-	//   mouseY*2 - 0x55) when the player clicks inside `BigMapWindow`,
-	//   which is the scroll position into the SmallMap.
-	//
-	//   STAGE 2 — Detail zoom. PIC 0x43 frame + a 0xe9 × 0xab viewport
-	//   into BIGMAP.PIC at (2, 2), drawn by `DrawMap @ 20fe:1058` with
-	//   the (sx, sy) returned from stage 1. Site icons are stamped at
-	//   SmallMap coords MapData[+8/+0xa] via `_StampButtons`. Click on
-	//   a site icon → travel.
-	//
-	// MapData entry layout (14 bytes), verified directly from the
-	// disassembly of `_DrawBigMapButtons @ 20fe:0877` (`PUSH ES:[BX+4]`
-	// for X, `PUSH ES:[BX+6]` for Y, `CMP ES:[BX+0xc], 0` for crime)
-	// and `_StampButtons @ 20fe:0d2f` (`MOV AX, ES:[BX+8]`,
-	// `MOV AX, ES:[BX+0xa]`):
-	//   +0..3   ??? (not yet decoded)
-	//   +4..5   BigMap X
-	//   +6..7   BigMap Y
-	//   +8..9   SmallMap X
-	//   +0xa..b SmallMap Y
-	//   +0xc..d crime-flag
+	// `_DoBigMap @ 20fe:09e7` two stage:
+	//   Stage 1 (Overview): PIC 0x42 + site icons at MapData[+4/+6]
+	//     (`_DrawBigMapButtons @ 20fe:0877`). Click in BigMapWindow
+	//     returns scroll (mouseX*2 - 0x74, mouseY*2 - 0x55).
+	//   Stage 2 (Detail): PIC 0x43 frame + 0xe9×0xab BIGMAP.PIC viewport
+	//     at (2,2). Icons stamped at MapData[+8/+0xa] (`_StampButtons @
+	//     20fe:0d2f`). Click icon = travel.
+	// MapData entry (14 bytes): +0..3 ???, +4 BigMapX, +6 BigMapY,
+	//   +8 SmallMapX, +0xa SmallMapY, +0xc crime-flag.
 
 	if (!_mystery.isLoaded())
 		return;
@@ -3327,35 +2900,20 @@ void EEMEngine::doBigMap() {
 
 	while (!shouldQuit()) {
 		setInteractiveMouseCursor(false);
-		setSitePalette(0x24); // `_GetPalette(0x24)` per `_DoBigMap @ 20fe:09e7`.
+		setSitePalette(0x24); // `_GetPalette(0x24)` @ `_DoBigMap`.
 
-		// ------------------------------------------------------------------
-		// STAGE 1 — Overview: PIC 0x42 + clickable site icons.
-		// ------------------------------------------------------------------
-
-		// Anchor for the partner-sprite timeline. `_DoBigMap`'s
-		// `_NewAnimation` call seeds the slot's frame index to 0xffff so
-		// the first `_UpdateAnimations` tick starts at script[0]; we mirror
-		// that by passing elapsed-since-open (zero on the first paint) into
-		// `bigMapPartnerFrameAtTick`, which plays the unfold once and then
+		// Stage 1: Overview. mapStartTick anchors partner timeline;
+		// `_NewAnimation` seeds frame to 0xffff so unfold plays once then
 		// loops the wait sequence.
 		const uint32 mapStartTick = g_system->getMillis();
 		drawBigMapOverview(0);
 		uint32 mapLastTick = mapStartTick;
 
-		// Static rectangles read directly from the binary at the labelled
-		// addresses (CD `29be:0x1596` / floppy `2608:0x13fe..0x143e`).
-		// Format is {x1, y1, x2, y2}. The floppy click table at
-		// `2608:1436` (verified at `_BigMapInteractionLoop_Floppy @
-		// 1fed:0a3a`) puts the setup button at (251, 3, 315, 42) — 1 px
-		// up and 1 px left of the CD's (252, 4, 315, 42). The floppy
-		// PIC 0x42 BG paints the visible button border at the same
-		// pixels, so use the variant-specific rect to match the
-		// hit-test region the original uses for that variant.
-		const Common::Rect kBigMapWindow(0, 0, 247, 192); // 29be:1596
+		// Rects @ CD 29be:1596 / floppy 2608:13fe (1 px diff on Setup).
+		const Common::Rect kBigMapWindow(0, 0, 247, 192);
 		const Common::Rect kSetupBtnRect = isFloppy()
-			? Common::Rect(251, 3, 315, 42)   // 2608:1436
-			: Common::Rect(252, 4, 315, 42);  // 29be:15ce
+			? Common::Rect(251, 3, 315, 42)
+			: Common::Rect(252, 4, 315, 42);
 
 		bool wantZoom = false;
 		int zoomX = 0;
@@ -3372,18 +2930,12 @@ void EEMEngine::doBigMap() {
 					continue;
 				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					// SetupButtonRect → `_NextScreen = 6` (the original's
-					// settings screen, mirrors `_DoBigMap @ 20fe:0c33`
-					// where it pushes `_PressButton` then writes
-					// `_NextScreen = 6`). Now wired to the actual
-					// `doSetup` handler instead of dropping the player
-					// out to the launcher.
+					// Setup → NextScreen=6 (`_DoBigMap @ 20fe:0c33`).
 					if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
 						_nextScreen = kScreenSetup;
 						return;
 					}
-					// Click in the BigMapWindow → zoom. Original formula:
-					//   sx = mouseX*2 - 0x74; sy = mouseY*2 - 0x55
+					// BigMapWindow click → zoom (sx=x*2-0x74, sy=y*2-0x55).
 					if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
 						int sx = ev.mouse.x * 2;
 						int sy = ev.mouse.y * 2;
@@ -3399,8 +2951,7 @@ void EEMEngine::doBigMap() {
 			if (wantZoom)
 				break;
 
-			// Cycle the partner-sprite frame every 100 ms (matching the
-			// original's `_CheckFrameRate` cadence inside `_DoBigMap`).
+			// `_CheckFrameRate` cadence — 100 ms.
 			const uint32 now = g_system->getMillis();
 			if (now - mapLastTick >= 100) {
 				mapLastTick = now;
@@ -3415,11 +2966,7 @@ void EEMEngine::doBigMap() {
 		if (!wantZoom)
 			return;
 
-		// ------------------------------------------------------------------
-		// STAGE 2 — Detail zoom: PIC 0x43 frame + scrollable BIGMAP.PIC
-		// viewport at (2, 2), 0xe9 × 0xab. Click on a stamped icon → travel.
-		// ------------------------------------------------------------------
-
+		// Stage 2: Detail. PIC 0x43 + BIGMAP.PIC viewport.
 		Common::File f;
 		if (!f.open(Common::Path("BIGMAP.PIC"))) {
 			warning("doBigMap: BIGMAP.PIC missing for detail view");
@@ -3443,19 +2990,13 @@ void EEMEngine::doBigMap() {
 		int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 		int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
-		// Anchor the detail-screen partner timeline (mirrors `_DoMapScreen`'s
-		// `_NewAnimation` seeding the slot's frame index to 0xffff). The
-		// unfold (script 0x13) plays once, then `_SmallMapWaitSeq` loops.
+		// Detail partner timeline (script 0x13 unfold, then wait seq).
 		const uint32 detailStartTick = g_system->getMillis();
 		drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
 		uint32 detailLastTick = detailStartTick;
 		bool returnToOverview = false;
 
-		// `SmallMapButtons[4]` is the large right-side panel below setup:
-		// CD/floppy both store `(252, 43, 320, 200)`. Its handler at
-		// CD `20fe:156c` kills the zoom animation, sets `_NextScreen = 1`
-		// and calls `_DoBigMap` again, so mouse players can return to the
-		// overview without leaving the map screen.
+		// `SmallMapButtons[4]` @ 20fe:156c — return to overview.
 		const Common::Rect kBigMapReturnRect(252, 43, 320, 200);
 		const Common::Rect kArrowYUp(237, 2, 247, 11);
 		const Common::Rect kArrowYDown(237, 163, 247, 172);
@@ -3464,8 +3005,8 @@ void EEMEngine::doBigMap() {
 		const Common::Rect kXSlider(15, 175, 221, 185);
 		const Common::Rect kYSlider(237, 14, 247, 160);
 		const Common::Rect kDetailSetupBtn = isFloppy()
-			? Common::Rect(251, 3, 315, 42)   // 2608:1436
-			: Common::Rect(252, 4, 315, 42);  // 29be:15ce
+			? Common::Rect(251, 3, 315, 42)
+			: Common::Rect(252, 4, 315, 42);
 		const int kArrowStep = 16;
 		const int kSliderRange = mapW - kMapWinW;
 		const int kSliderRangeY = mapH - kMapWinH;
@@ -3516,17 +3057,7 @@ void EEMEngine::doBigMap() {
 						kBigMapReturnRect.contains(ev.mouse.x, ev.mouse.y) ||
 						kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y));
 					if (kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
-						// `_DoMapScreen @ 20fe:1560` writes `_NextScreen
-						// = 6` (= kScreenSetup) and `INC [BP-8]` to bail
-						// out of the detail loop — verified via the byte
-						// search for `c7 06 16 79 06 00`, which finds the
-						// imm at exactly this site and `_DoBigMap @
-						// 20fe:0c33`. Same `SetupButtonRect @ 29be:15ce`
-						// rect used by both the overview and the detail
-						// (no per-screen rect duplication in the binary).
-						// The detail/zoom state is lost on return because
-						// the screen driver re-enters BigMap at stage 1 —
-						// this matches the original behaviour.
+						// `_DoMapScreen @ 20fe:1560` — NextScreen=6.
 						_nextScreen = kScreenSetup;
 						setInteractiveMouseCursor(false);
 						return;
@@ -3549,8 +3080,6 @@ void EEMEngine::doBigMap() {
 							scrollX + kArrowStep);
 						dirty = true;
 					} else if (kXSlider.contains(ev.mouse.x, ev.mouse.y)) {
-						// Click on X slider track → jump scrollX so the
-						// click position maps proportionally into the map.
 						if (kSliderRange > 0) {
 							const int t = ev.mouse.x - kXSlider.left;
 							const int tw = kXSlider.width();
@@ -3570,9 +3099,7 @@ void EEMEngine::doBigMap() {
 							   ev.mouse.x < kMapWinX + kMapWinW &&
 							   ev.mouse.y >= kMapWinY &&
 							   ev.mouse.y < kMapWinY + kMapWinH) {
-						// Hit-test the per-site button at its actual bbox
-						// (`_StampButtons` records the rect at SmallMap +8/+0xa
-						// with the button PIC's width/height).
+						// Per-site bbox from `_StampButtons` (SmallMap +8/+0xa).
 						const bool fmap = _mystery.isLoaded() && isFloppy();
 						for (uint i = 0; i < _mystery.numSites(); i++) {
 							if (!_mystery._onSites[i] &&
@@ -3585,10 +3112,8 @@ void EEMEngine::doBigMap() {
 							uint16 my;
 							uint16 buttonId;
 							if (fmap) {
-								// Floppy detail view: click rect on
-								// BIGMAP.PIC at (+0, +2), labelled BUTTON.DBD
-								// entry ID at entry+4 (per
-								// `FUN_1fed_0c3e @ 1fed:0c3e`).
+								// Floppy: rect +0/+2; BUTTON.DBD id at +4
+								// (`FUN_1fed_0c3e @ 1fed:0c3e`).
 								mx = READ_LE_UINT16(entry + 0x0);
 								my = READ_LE_UINT16(entry + 0x2);
 								buttonId = (uint16)entry[0x4];
@@ -3620,8 +3145,7 @@ void EEMEngine::doBigMap() {
 			if (returnToOverview)
 				break;
 
-			// Cycle the partner sprite at 100 ms ticks (same cadence as
-			// `_DoMapScreen`'s `_CheckFrameRate` + `_UpdateAnimations` loop).
+			// 100 ms partner cycle.
 			const uint32 now = g_system->getMillis();
 			if (now - detailLastTick >= 100) {
 				detailLastTick = now;
@@ -3639,20 +3163,9 @@ void EEMEngine::doBigMap() {
 }
 
 void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
-	// Map-overview redraw — formerly the `drawOverview` lambda inside
-	// `doBigMap`. PIC 0x42 frame + per-site marker (Done / Crime / Site
-	// per `_DrawBigMapButtons @ 20fe:0877`) + the partner idle sprite.
-	// `_DoBigMap @ 20fe:09e7` (`_NewAnimation` block at 20fe:0a44-0a99)
-	// registers the partner: when `_LastScreen == 2` it plays an
-	// entrance one-shot at (0x102, 0x50) and on END swaps to the idle
-	// at (0xfd, 0x50). We don't track LastScreen finely enough so we
-	// always render the IDLE pose at (0xfd, 0x50). Idle anim ID:
-	// Jake = 0x14 (20), Jenny = 0x12 (18).
-	//
-	// `elapsedMs` is the time since `doBigMap` opened — the partner-sprite
-	// timeline anchor. `bigMapPartnerFrameAtTick` uses it to play the
-	// unfold script (0..8) once, then loop `_BigMapWaitSeq` (the open-map
-	// hold). Without that anchor the unfold would loop indefinitely.
+	// PIC 0x42 + per-site Done/Crime/Site marker (`_DrawBigMapButtons @
+	// 20fe:0877`) + partner idle at (0xfd, 0x50). Idle ANI: Jake=0x14,
+	// Jenny=0x12. elapsedMs anchors unfold→wait timeline.
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
@@ -3661,14 +3174,9 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	if (_picsArchive.getPicture(0x42, frame))
 		scratch.simpleBlitFrom(frame.surface);
 
-	// Marker PICs from `_main @ 1a35:0f59`. Three globals are filled
-	// once at boot via `_GetPicture` (1-based IDs):
-	//   _DoneMarker  = PIC 0x20d  (already-searched site)
-	//   _SiteMarker  = PIC 0xc5   (default available site)
-	//   _CrimeMarker = PIC 0xc6   (crime-scene flag set)
-	// Floppy's `PICS.DBX` has 524 entries, so PIC 0x20d is CD-only.
-	// Its pixels are the same 7x8 marker as PIC 0xc5, with the animated
-	// 0xfb/0xfc interior remapped to static 0x1b/0x19 blue.
+	// Marker PICs from `_main @ 1a35:0f59`:
+	//   _DoneMarker = 0x20d, _SiteMarker = 0xc5, _CrimeMarker = 0xc6.
+	// 0x20d is CD-only (floppy PICS.DBX has 524 entries).
 	Picture done;
 	Picture normal;
 	Picture crimeM;
@@ -3715,27 +3223,19 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 			blitBigMapMarker(scratch, *m, (int)mx, (int)my,
 							  useVisitedColors);
 		} else {
-			// Fallback if the markers couldn't be loaded.
 			const Common::Rect mark(mx - 3, my - 3, mx + 4, my + 4);
 			scratch.fillRect(mark, 0x0F);
 		}
 	}
 
-	// Partner idle sprite at (0xfd, 0x50). Jake = anim 0x14, Jenny = 0x12
-	// for the loaded CELLS, but the original `_DoBigMap @ 20fe:0a47`
-	// always passes `CONCAT22(0x14, ...)` to `_NewAnimation` so the
-	// SCRIPT key is 0x14 (`[0..8]` count-up) regardless of partner.
-	// Without this, Jenny was running 0x12's count-DOWN script
-	// `[8..0]` over her cells — visually backwards from the original.
+	// Partner idle at (0xfd, 0x50). `_DoBigMap @ 20fe:0a47` always passes
+	// script 0x14 (count-up) to `_NewAnimation` regardless of partner.
 	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
 		const uint frameIdx = bigMapPartnerFrameAtTick((uint)mapAnim.size(),
 													   elapsedMs);
-		// Anchor-aware: the BigMap walk-cycle has miscflags = -2 per
-		// cell, so the partner shifts left as it cycles — without the
-		// anchor adjustment the sprite "shakes in place" instead of
-		// walking forward.
+		// BigMap walk-cycle miscflags = -2 (anchor shift left).
 		blitAnimFrameAnchored(scratch.surfacePtr(), mapAnim[frameIdx],
 							  0xfd, 0x50);
 	}
@@ -3749,15 +3249,9 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 								 const Common::Array<byte> &mapPixels,
 								 uint16 mapW, uint16 mapH,
 								 uint32 elapsedMs) {
-	// Map-detail redraw — formerly the `drawDetail` lambda inside
-	// `doBigMap`. PIC 0x43 frame + a 0xe9 × 0xab BIGMAP.PIC viewport at
-	// (2, 2), stamped site buttons, and the partner sprite at (0x101,
-	// 0x50) — `_DoMapScreen @ 20fe:120b` (`_NewAnimation` at
-	// 20fe:12cd-12f0, anim 0x13 Jake / 0x11 Jenny, seqnum 0x13).
-	//
-	// `elapsedMs` is the time since the detail screen was opened —
-	// `bigMapDetailPartnerFrameAtTick` uses it to play the unfold once
-	// and then loop `_SmallMapWaitSeq`.
+	// PIC 0x43 + 0xe9×0xab BIGMAP.PIC viewport at (2,2), stamped buttons,
+	// partner at (0x101, 0x50). `_DoMapScreen @ 20fe:120b` (ANI 0x13 Jake
+	// / 0x11 Jenny, seq 0x13). elapsedMs anchors unfold→wait.
 	const int kMapWinW = 0xe9;
 	const int kMapWinH = 0xab;
 	const int kMapWinX = 2;
@@ -3776,17 +3270,8 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	scratch.copyRectToSurface(mapPixels.data() + scrollY * mapW + scrollX,
 							  mapW, kMapWinX, kMapWinY, copyW, copyH);
 
-	// Stamped site buttons. `_StampButtons @ 20fe:0d2f` (CD):
-	//   button = _GetButton(MapData[+0])
-	//   destX  = MapData[+8]
-	//   destY  = MapData[+0xa]
-	// Floppy uses `FUN_1fed_0c3e @ 1fed:0c3e`: for each SITES row, the
-	// byte at entry+4 is a BUTTON.DBD entry ID (loaded via
-	// `FUN_16e2_1838 @ 16e2:1838`, which opens `button.dbd` — string
-	// at `2608:0558`). The labelled button is stamped at
-	// `(entry+0..1, entry+2..3)` on BIGMAP.PIC. These are the same
-	// per-site labelled buttons the CD uses, just keyed off a
-	// different field offset.
+	// `_StampButtons @ 20fe:0d2f` (CD): button=MapData[+0], dst=+8/+0xa.
+	// Floppy `FUN_1fed_0c3e @ 1fed:0c3e`: button.dbd id at +4, rect +0/+2.
 	const bool floppyMap = _mystery.isLoaded() && isFloppy();
 	for (uint i = 0; i < _mystery.numSites(); i++) {
 		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
@@ -3830,12 +3315,7 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 		}
 	}
 
-	// Partner sprite on the detail map (drawn last to sit over the
-	// frame and the BIGMAP.PIC viewport). The original always passes
-	// `CONCAT22(0x13, ...)` to `_NewAnimation` (i.e. script ID 0x13)
-	// regardless of partner — verified at `_DoBigMap @ 20fe:0a47`.
-	// So we look up script 0x13 for both partners while still
-	// loading the partner-specific CELLS via `kDetailAniId`.
+	// Always script 0x13 (`_NewAnimation @ _DoBigMap 20fe:0a47`).
 	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
 	Animation detailAnim;
 	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
@@ -3852,46 +3332,24 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 }
 
 uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
-	// Mirrors `_GetKDTextBalloon @ 1df2:0105`:
+	// `_GetKDTextBalloon @ 1df2:0105`:
 	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
 	//   else                              bub = *(u16*)(29be:0fe6+0x1e+c*2)
-	// `ctype` is Borland's `_ctype_` array at `29be:2be1`. Bit 1 (0x02) is
-	// set only for digits '0'..'9' (verified by reading the table — '0'..'9'
-	// each map to byte 0x02; everything else has bit 1 clear).
-	// Lookup table at 29be:1064 (= 29be:0fe6 + 0x1e + '0'*2):
-	//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
-	//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
-	// Note `*(u16*)29be:1068` (= entry for '2') is the same byte the
-	// non-digit fallback returns — the original encodes the constant by
-	// reusing the digit-2 slot.
+	// `ctype` is Borland's `_ctype_` at 29be:2be1; bit 1 (0x02) is set
+	// only for '0'..'9'. Digit table @ 29be:1064 (see kDigitBalloons).
+	// `*(u16*)29be:1068` = entry for '2' = 0x17 — original reuses the
+	// digit-2 slot as the non-digit fallback constant.
 	if (firstChar < '0' || firstChar > '9')
 		return 0x17;
-	// `kDigitBalloons` lives at file scope above.
 	return kDigitBalloons[firstChar - '0'];
 }
 
 bool EEMEngine::doAccuseNotes() {
-	// Mirrors the accuse-notes screen at the head of `_DoAccuse @
-	// 1df2:0bdd`:
-	//   * BG: PIC 0x1A7 (the red "accuse-mode" backdrop).
-	//   * `_AccuseNoteRect @ 29be:1048` = (79, 27, 304, 159) holds
-	//     the rendered clue list.
-	//   * Counter at `(0xd1, 0xb)` = `(209, 11)` shows "N clue(s)"
-	//     remaining (`_UpdateSelectionCount @ 1df2:08dd`,
-	//     `_Show_String(0xb, 0xd1, ...)`).
-	//   * Expected count = `6 - DAT_2d5d_3f99`:
-	//       chainStage 1 → 5 clues, 2 → 4 clues, 3 → 3 clues.
-	//   * `_NoteUnselectedColor = 1` (red) for unselected, `0x3c`
-	//     for selected (1df2:0c2c sets it on entry).
-	//   * Click on a clue toggles its selection
-	//     (`_SearchNoteAreas` + `_SwapColors`).
-	//   * Click `_NoteButtons[4]` (rect at `(180, 174, 201, 190)`,
-	//     the original's solve button) jumps to the evidence check;
-	//     `_HandleAccuseNoteButton(4)` returns 2 and the outer loop
-	//     forces `uStack_8 = uStack_a` to trigger `_SolvedCheck`.
-	//   * CD and floppy originals only cancel this screen through ESC.
-	//     ScummVM also wires the visible PDA navigation buttons so the
-	//     accusation can be abandoned without a keyboard.
+	// `_DoAccuse @ 1df2:0bdd` head. BG PIC 0x1A7. `_AccuseNoteRect @
+	// 29be:1048` = (79, 27, 304, 159). Counter @ (209, 11) shows
+	// `6 - chainStage` (stage 1=5, 2=4, 3=3 clues). Unselected color 1
+	// (red), selected 0x3c. Click toggles selection; `_NoteButtons[4]`
+	// (180,174,201,190) SOLVE → `_HandleAccuseNoteButton` returns 2.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return false;
 	const byte *ni = _mystery.noteIndex();
@@ -3902,49 +3360,31 @@ bool EEMEngine::doAccuseNotes() {
 	Picture accuseBg;
 	const bool haveBg = _picsArchive.getPicture(0x1a7, accuseBg);
 
-	// Reset selection on entry. `FUN_1d40_0e07 @ 1d40:0e34` (floppy)
-	// explicitly zeroes the 0x7f-byte `_NoteSelected_Floppy` array
-	// before drawing, so the player always starts with a clean board.
-	// Without this, leftover selections from a failed attempt persist
-	// and the post-selection 100-point gate gets the wrong sum.
+	// `FUN_1d40_0e07 @ 1d40:0e34` zeroes _NoteSelected_Floppy on entry.
 	memset(_mystery._noteSelected, 0, sizeof(_mystery._noteSelected));
 
-	// Required count for solving — `6 - chainStage`.
 	const uint expected = (_chainStage >= 1 && _chainStage <= 3)
 		? (uint)(6 - _chainStage)
 		: 5;
 
-	// Build the list of FOUND clue IDs (in clue-ID order; the
-	// original's `_DrawNotes(NULL, 100, ...)` walks `_CluesFound[]`
-	// the same way).
+	// `_DrawNotes(NULL, 100, ...)` walks `_CluesFound[]`.
 	Common::Array<uint> found;
 	for (uint i = 0; i < niCount && i < Mystery::kCluesFoundCap; i++) {
 		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
 			found.push_back(i);
 	}
 
-	// `_AccuseNoteRect` (79, 27, 304, 159) — text wrap area.
 	const int rectX = 79;
 	const int rectY = 27;
 	const int rectW = 304 - 79;
 	const int rectH = 159 - 27;
 
-	// `_NoteButtons` rects (verified at `29be:0147`). `_DoAccuse`
-	// re-uses the same table as `_DoNotebook`, but the original
-	// handler only routes SOLVE / PAGE NEXT / PAGE PREV; ScummVM
-	// additionally routes the visible site/map/notebook/gallery
-	// buttons below for pointer-only cancellation.
-	// `_HandleAccuseNoteButton @ 1df2:0990` returns `DI` (initialised
-	// to 0) and only sets `DI = 2` in the `i == 4` branch (asm:
-	// `1df2:09b2: MOV DI, 0x2`). The outer loop's `iVar6 == 2` test
-	// at `1df2:0db2` is checking the HANDLER'S RETURN VALUE, not the
-	// button INDEX — earlier comment had this backwards. So the
-	// SOLVE rect is `[4]` (180, 174, 201, 190), the same icon the
-	// PDA uses to trigger the accuse flow in the first place.
-	const Common::Rect kBtnSolve   (180, 174, 201, 190); // [4] SOLVE
-	const Common::Rect kBtnPageNext(204, 174, 224, 190); // [5] PAGE NEXT
-	const Common::Rect kBtnPagePrev(226, 174, 247, 190); // [6] PAGE PREV
-	const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3] KD HELP
+	// `_NoteButtons` @ 29be:0147. `_HandleAccuseNoteButton @ 1df2:0990`
+	// returns DI=2 for i==4 (SOLVE).
+	const Common::Rect kBtnSolve   (180, 174, 201, 190); // [4]
+	const Common::Rect kBtnPageNext(204, 174, 224, 190); // [5]
+	const Common::Rect kBtnPagePrev(226, 174, 247, 190); // [6]
+	const Common::Rect kBtnPartner (  5,  80,  44, 110); // [3]
 
 	// Per-page slot rects + their clue IDs (for click hit-testing).
 	Common::Array<Common::Rect> slotRects;
@@ -3955,20 +3395,9 @@ bool EEMEngine::doAccuseNotes() {
 	int numPages = 1;
 	pageBreaks[0] = 0;
 
-	// Variant-aware text resolver. CD note entries are 4 bytes
-	// (u16 textOff RELATIVE to TextBlock, u16 score), so the offset
-	// is added to `_textOffset` via `Mystery::textAt`. Floppy
-	// entries are 7 bytes:
-	//   +0..1  clue text offset (ABSOLUTE byte offset into the
-	//          mystery blob — partner-agnostic clue statement;
-	//          this is what the notebook displays).
-	//   +2..3  Jake spoken-line offset (`FUN_22dc_05c8 @ 22dc:0843`).
-	//   +4..5  Jenny spoken-line offset.
-	//   +6     score (`FUN_1d40_0c48`).
-	// `_DrawNotes_Floppy / FUN_15e0_01e8` reads `*(int *)(notes +
-	// idx * 7)` — always byte +0 — for the notebook text.
-	// Reading +2/+4 here showed the partner's "Hi, Jake! Hi,
-	// Jenny!" intro instead of the actual clue statement.
+	// CD note: 4 bytes (u16 textOff rel TextBlock, u16 score).
+	// Floppy: 7 bytes (+0 abs textOff, +2 Jake, +4 Jenny, +6 score).
+	// Notebook always uses +0 (`FUN_15e0_01e8`).
 	const bool floppyNote = isFloppy();
 	const byte *bufBaseNotes = _mystery.blobAt(0);
 	auto noteText = [&](uint clueId) -> Common::String {
@@ -4020,14 +3449,8 @@ bool EEMEngine::doAccuseNotes() {
 		if (haveBg)
 			scratch.simpleBlitFrom(accuseBg.surface);
 
-		// Partner sprite at (5, 0x50). The original `_DoAccuse @
-		// 1df2:0c2c` does `_NewAnimation(5, 0x50, partnerCells,
-		// script=2, prior=1)` — same anim cells as the gallery
-		// (anim 2 for Jake / 0x10 for Jenny) with script 0x02. The
-		// `_UpdateAnimations` loop in `_DoAccuse @ 1df2:0bfa` keeps
-		// the slot painting through the entire selection screen;
-		// without an explicit blit here the player sees a partner-
-		// less accuse-mode screen.
+		// Partner at (5, 0x50). `_DoAccuse @ 1df2:0c2c`: ANI 2/0x10,
+		// script 2, prior 1.
 		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 		Animation partnerAni;
 		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
@@ -4039,10 +3462,7 @@ bool EEMEngine::doAccuseNotes() {
 								  partnerAni[frameIdx], 5, 0x50);
 		}
 
-		// Clue list inside `_AccuseNoteRect`. Selected = 0x3c (yellow),
-		// unselected = 1 (red), per `_NoteUnselectedColor = 1` set at
-		// 1df2:0c25 — the red colour is what gives the screen its
-		// "accuse-mode" look together with PIC 0x1A7.
+		// Selected=0x3c, unselected=1 (`_NoteUnselectedColor` @ 1df2:0c25).
 		slotRects.clear();
 		slotClues.clear();
 		const int lineH = _font.getFontHeight();
@@ -4078,11 +3498,11 @@ bool EEMEngine::doAccuseNotes() {
 			y += h + 7;
 		}
 
-		// Counter — `_UpdateSelectionCount(remaining)` at (0xd1, 0xb).
+		// `_UpdateSelectionCount(remaining)` @ (0xd1, 0xb).
 		const uint remaining = (selectedCount < expected)
 			? expected - selectedCount
 			: 0;
-		// Spanish floppy uses "nota/notas" (verified in Spanish EEM.EXE).
+		// Spanish floppy uses "nota/notas".
 		const char *clueWord = isSpanish()
 			? (remaining == 1 ? "nota" : "notas")
 			: (remaining == 1 ? "clue" : "clues");
@@ -4165,12 +3585,7 @@ bool EEMEngine::doAccuseNotes() {
 					dirty = true;
 					continue;
 				}
-				// Page navigation — `_NoteButtons[5]` / `[6]`,
-				// dispatched in `_HandleAccuseNoteButton @
-				// 1df2:0990`. Only effective if there's another
-				// page in that direction; mirrors the
-				// `1 < _CurrentPage` guard at 1df2:09a8 and the
-				// `_NextClue != -1` guard at 1df2:099e.
+				// `_NoteButtons[5]/[6]` — page nav guards @ 1df2:09a8/099e.
 				if (kBtnPageNext.contains(mx, my)) {
 					if (page + 1 < numPages) {
 						page++;
@@ -4185,33 +3600,22 @@ bool EEMEngine::doAccuseNotes() {
 					}
 					continue;
 				}
-				// Partner click — `_NoteButtons[3]`. Original
-				// `_HandleAccuseNoteButton` doesn't dispatch this
-				// (no case for i == 3), but the rect is still in
-				// the table; we wire it to the puzzle hint so the
-				// player can ask the partner what to look for
-				// without leaving accuse mode.
+				// `_NoteButtons[3]` — ScummVM hint, original ignores.
 				if (kBtnPartner.contains(mx, my)) {
 					doHelp();
 					dirty = true;
 					continue;
 				}
 				if (kBtnSolve.contains(mx, my)) {
-					// Count selected.
 					uint selected = 0;
 					for (uint i = 0; i < found.size(); i++) {
 						if (_mystery._noteSelected[found[i]])
 							selected++;
 					}
 					if (selected == expected) {
-						// Commit — let the caller do the
-						// `_SolvedCheck` + suspect picker dance.
+						// `_DoAccuse` gate `uStack_8 == uStack_a`.
 						return true;
 					}
-					// Wrong count — `_DoAccuse` only triggers the
-					// check when `uStack_8 == uStack_a`; we just
-					// stay in the loop so the player can keep
-					// adjusting.
 					continue;
 				}
 				// Toggle clue under cursor.
@@ -4237,9 +3641,7 @@ bool EEMEngine::doAccuseNotes() {
 		}
 		if (dirty)
 			draw();
-		// Per-tick redraw so the partner sprite cycles. Same
-		// 100 ms cadence as `_CheckFrameRate` + `_UpdateAnimations`
-		// in the original (1df2:0bfa).
+		// 100 ms `_CheckFrameRate` cadence @ 1df2:0bfa.
 		static uint32 sLastTick = 0;
 		const uint32 now = g_system->getMillis();
 		if (now - sLastTick >= 100) {
@@ -4256,25 +3658,17 @@ void EEMEngine::doAccuse() {
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
-	// Floppy accusation flow is structurally different from the CD's:
-	// the suspect gallery section uses variable-stride entries (5 +
-	// nameLen bytes, NOT 0x46), the score is computed from the top-5
-	// note scores in `_TextSeen` (not `_NoteSelected`), and the win
-	// path renders the solved-chain dialog records at header[+0x12]
-	// rather than a single ClueBlock. Dispatch to a dedicated handler
-	// that mirrors `_KDHelp_Floppy` + `_HandleNoteButton_Floppy` (button
-	// 4 → `FUN_1d40_11fd` "ready to solve" gate) +
-	// `FUN_1d40_0c79` (gallery picker) + `_DisplayCorrect_Floppy` /
-	// `_DisplayAlibi_Floppy`.
+	// Floppy: variable-stride entries (5 + nameLen, not 0x46), top-5
+	// `_TextSeen` scoring (not `_NoteSelected`), header[+0x12] win-dialog.
+	// Handled by doAccuseFloppy (`FUN_1d40_11fd`, `FUN_1d40_0c79`,
+	// `_DisplayCorrect_Floppy`, `_DisplayAlibi_Floppy`).
 	if (isFloppy()) {
 		doAccuseFloppy();
 		return;
 	}
 
-	// `_AccuseEntry @ 1df2:0ff8` runs before the red accuse-notes
-	// picker. It scores the top five FOUND clues, says how close the
-	// player is, and only lets `_DoAccuse` continue when that score is
-	// at least 0x65.
+	// `_AccuseEntry @ 1df2:0ff8` — gates picker on top-5 found clue
+	// score (≥0x65 required).
 	const byte *entryKdIdx = _mystery.kdTextIndex();
 	if (!entryKdIdx)
 		return;
@@ -4350,36 +3744,19 @@ void EEMEngine::doAccuse() {
 		return;
 	}
 
-	// Mirrors `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
-	//   1. ACCUSE-NOTES SCREEN (PIC 0x1A7, the red "accuse-mode" BG):
-	//      `_DrawNotes(_AccuseNoteRect, NULL, 100, _NoteSelected)`
-	//      lists every found clue inside the rect at `29be:1048` =
-	//      `(79, 27, 304, 159)`. `_UpdateSelectionCount(remaining)`
-	//      shows "N clue(s)" at `(209, 11)` (ASM: `_Show_String(0xb,
-	//      0xd1, ...)` at 1df2:0907). `_NoteUnselectedColor = 1` is
-	//      the dim red used for unselected entries; selected ones
-	//      get `0x3c`. Click toggles via `_SearchNoteAreas` +
-	//      `_SwapColors`. Expected count = `6 - DAT_2d5d_3f99`
-	//      (= 6 - chainStage):
-	//          stage 1 → 5 clues, stage 2 → 4, stage 3 → 3.
-	//      When the count matches, `_SolvedCheck` decides:
-	//          fail → KD "not enough evidence" balloon → return.
-	//          pass → `_DoAccuseGallery()` (suspect picker).
+	// `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
+	//   1. Accuse-notes (PIC 0x1A7) — pick `6 - chainStage` clues.
+	//      Pass `_SolvedCheck` → gallery; fail → hint + return.
 	//   2. KD intro balloon (`KDTextIndex[+8]` + `_SayKDDigital(4)`).
-	//   3. `_GetBackground(0x3f)` + `_DrawGallery()` — portraits at
-	//      the 5 fixed slots (`29be:0x116`).
-	//   4. Click loop on portraits → `_WITCH(picked)` → guilty/alibi.
+	//   3. PIC 0x3f + `_DrawGallery` portraits at 5 slots (29be:0x116).
+	//   4. Click portrait → `_WITCH(picked)` → guilty/alibi.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0)
 		return;
 
 	const byte *gd = _mystery.galleryData();
 
-	// ACCUSE-NOTES SCREEN — let the player commit which N clues they
-	// believe solve the case. Mirrors the click-driven selection of
-	// `_DoAccuse @ 1df2:0bdd`'s outer loop. ESC returns to the site
-	// (matches `_DoAccuse @ 1df2:0c11` writing `_NextScreen = 3`);
-	// pointer navigation can also leave for the PDA, gallery, or map.
+	// `_DoAccuse @ 1df2:0c11` outer loop; ESC → NextScreen=3.
 	if (!doAccuseNotes()) {
 		if (_nextScreen == kScreenAccuse) {
 			_nextScreen = _lastScreen != kScreenInvalid
@@ -4388,12 +3765,7 @@ void EEMEngine::doAccuse() {
 		return;
 	}
 
-	// Evidence gate. `_DoAccuse @ 1df2:0c75` runs `_SolvedCheck`
-	// before opening the suspect picker; on failure it renders a
-	// partner balloon over the CURRENT screen (the PDA in the
-	// original) and returns. We render the same hint over the
-	// caller's screen and bail back to `_lastScreen` without ever
-	// touching the gallery BG.
+	// `_DoAccuse @ 1df2:0c75` — `_SolvedCheck` gate.
 	if (!_mystery.solvedCheck()) {
 		const byte *kdIdx = _mystery.kdTextIndex();
 		const int16 hintOff = kdIdx
@@ -4404,11 +3776,8 @@ void EEMEngine::doAccuse() {
 			hint = parseString(_mystery.textAt((uint16)hintOff),
 							   _playerName, _partner);
 		if (hint.empty()) {
-			// Fallback if `KDTextIndex[+6]` isn't set in this mystery.
-			// Spanish text from the Spanish floppy EEM.EXE — there's
-			// only the single "1Necesitamos buscar pistas..." string
-			// in the binary, so use it for both the zero-points and
-			// some-points cases.
+			// Fallback if `KDTextIndex[+6]` missing. Spanish floppy
+			// has only one "Necesitamos buscar pistas" string.
 			if (isSpanish()) {
 				hint = "Necesitamos buscar pistas antes de resolver "
 					   "el misterio. Investiguemos un poco mas!";
@@ -4423,9 +3792,8 @@ void EEMEngine::doAccuse() {
 			}
 		}
 
-		// Compose balloon overlay on the current screen. Mirrors the
-		// `_GetKDTextBalloon` + `_GetBalloon` + `_AddPicBackground`
-		// + `_WordWrap` sequence at 1df2:0c8d-0cd1.
+		// Balloon overlay (`_GetKDTextBalloon` + `_GetBalloon` +
+		// `_AddPicBackground` + `_WordWrap` @ 1df2:0c8d-0cd1).
 		Graphics::ManagedSurface ms(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
@@ -4437,10 +3805,7 @@ void EEMEngine::doAccuse() {
 		const byte firstChar =
 			hint.empty() ? (byte)0 : (byte)hint[0];
 		uint16 bubNum = getKDTextBalloon(firstChar);
-		// Strip the digit prefix used for balloon dispatch — it's
-		// consumed by the original at `_DisplayAlibi @ 1df2:0163`
-		// (`str = pbVar7 + 1`) and shouldn't appear in the rendered
-		// text. `_GetKDTextBalloon` itself doesn't advance past it.
+		// Strip digit prefix (`_DisplayAlibi @ 1df2:0163` `str=pbVar7+1`).
 		if (firstChar >= '0' && firstChar <= '9')
 			hint.deleteChar(0);
 		bubNum = fitBalloonToText(bubNum, hint);
@@ -4469,21 +3834,17 @@ void EEMEngine::doAccuse() {
 								   0, 0, 320, 200);
 		g_system->updateScreen();
 
-		// `_DoAccuse @ 1df2:0cd9` plays `_SayKDDigital(3)` —
-		// partner-specific "not enough evidence" voice line.
+		// `_SayKDDigital(3)` @ 1df2:0cd9.
 		if (_audio && kdIdx)
 			_audio->sayKDDigital(kdIdx, 3, _partner);
 
 		waitForInput(20000);
-		// `_DoAccuse @ 1df2:0ce5` writes `_NextScreen = _LastScreen`
-		// so the player drops back where they came from.
+		// `_DoAccuse @ 1df2:0ce5` — NextScreen = LastScreen.
 		_nextScreen = _lastScreen != kScreenInvalid
 						? (ScreenId)_lastScreen : kScreenSite;
 		return;
 	}
 
-	// Verbatim from 29be:0x116 — same five suspect slot positions as
-	// `_DrawGallery @ 158f:0046`.
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -4496,16 +3857,9 @@ void EEMEngine::doAccuse() {
 
 	int highlighted = 0;
 
-	// Step 1 — KD hint balloon. Mirrors `_DoAccuseGallery @ 1df2:0a31`
-	// (1df2:0a4c-1df2:0afe):
-	//   text  = TextBlock + KDTextIndex[+8]               (1df2:0a4c-0a57)
-	//   bub   = _GetKDTextBalloon(text[0])                (1df2:0a6d)
-	//   GetBalloon(bub)                                   (1df2:0a7c)
-	//   y     = (h < 0x4e) ? (0x50 - h) >> 1 : 1          (1df2:0a8b-0aa5)
-	//   AddPicBackground(pic, 0x21, y)                    (1df2:0aab)
-	//   WordWrap(0x21+tbl[bub].x, y+tbl[bub].y, tbl[bub].w, text, color=0)
-	//     tbl @ 29be:0875, 10-byte entries (1df2:0ad6-0af1)
-	//   _SayKDDigital(4); _Wait();                        (1df2:0b09-0b11)
+	// KD hint balloon @ `_DoAccuseGallery @ 1df2:0a31` (0a4c-0afe).
+	// y = (h < 0x4e) ? (0x50-h)>>1 : 1. Inset table @ 29be:0875.
+	// `_SayKDDigital(4)`.
 	const byte *kdIdx = _mystery.kdTextIndex();
 	if (kdIdx) {
 		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
@@ -4514,20 +3868,10 @@ void EEMEngine::doAccuse() {
 			Common::String hint =
 				parseString(raw ? raw : "", _playerName, _partner);
 			if (!hint.empty()) {
-				// First-char dispatch via getKDTextBalloon (1df2:0105).
-				// Note: we pass the *parsed* first char; the original
-				// reads it BEFORE `_ParseString`, but the player-name /
-				// partner-name substitutions never start with digits, so
-				// the dispatch result is the same either way.
 				const byte firstChar =
 					hint.empty() ? (byte)0 : (byte)hint[0];
 				uint16 bubNum = getKDTextBalloon(firstChar);
-				// Strip the digit prefix used for balloon dispatch.
-				// `_DisplayAlibi @ 1df2:0163` does `str = pbVar7 + 1`
-				// after using `*str` for `bindx`. Same pattern used by
-				// `_DisplayHint`: digit picks the bubble shape AND is
-				// then consumed from the rendered text. Without this
-				// the intro balloon shows e.g. "1Ready to solve?".
+				// Strip digit prefix (`_DisplayAlibi @ 1df2:0163`).
 				if (firstChar >= '0' && firstChar <= '9')
 					hint.deleteChar(0);
 				bubNum = fitBalloonToText(bubNum, hint);
@@ -4536,22 +3880,12 @@ void EEMEngine::doAccuse() {
 					_balloonArchive.size() > (bubNum & 0x7F) &&
 					_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
 
-				// 1df2:0a8b-1df2:0aa5: y = (h < 0x4e) ? (0x50-h)>>1 : 1
 				const int balloonX = 0x21;
 				int balloonY = 1;
 				if (haveBalloon && balloon.surface.h < 0x4e)
 					balloonY = (0x50 - balloon.surface.h) / 2;
 
-				// Render the gallery FIRST so the balloon snapshot
-				// includes the partner sprite. The original
-				// `_DoAccuseGallery @ 1df2:0a31` does this implicitly:
-				// `_NewAnimation` registered the partner slot at
-				// (5, 0x50) before reaching this point, then
-				// `_GetBackground(0x3f)` + `_DrawGallery` paint the
-				// portraits, and `_UpdateAnimations` keeps the partner
-				// visible underneath the balloon overlay. Without this,
-				// the player sees an 8-second partner-less screen
-				// while reading the hint.
+				// Render gallery first so the snapshot includes partner.
 				drawAccuseGallery(num, gd, /*highlighted=*/-1,
 								  slotRects, slotSuspect);
 
@@ -4564,22 +3898,18 @@ void EEMEngine::doAccuse() {
 						ms.simpleBlitFrom(*cur);
 						g_system->unlockScreen();
 					} else if (haveAccuseBg) {
-						// Fallback: lockScreen failed somehow; at least
-						// fill from PIC 0x3f so we don't render against
-						// stale memory. simpleBlitFrom auto-clips, so
-						// no MIN(w, 320)/MIN(h, 200) needed.
+						// Fallback if lockScreen failed.
 						ms.simpleBlitFrom(accuseBg.surface);
 					}
 				}
-				// Masked balloon blit — `_Rect_Move_Mask` (1000:03fc)
-				// skips pixels equal to `pic[0] >> 8`.
+				// `_Rect_Move_Mask` (1000:03fc) — transp = pic[0]>>8.
 				if (haveBalloon) {
 					const byte transp = (byte)(balloon.flags >> 8);
 					ms.transBlitFrom(balloon.surface,
 									 Common::Point(balloonX, balloonY),
 									 transp);
 				}
-				// Inset table @ 29be:0875 — 1df2:0acb pushes color=0.
+				// Inset table @ 29be:0875.
 				uint16 tx = 5;
 				uint16 ty = 4;
 				uint16 tw = 155;
@@ -4599,9 +3929,7 @@ void EEMEngine::doAccuse() {
 		}
 	}
 
-	// Helper to find the next "alive" slot (one whose `_inGallery[phys]`
-	// flag is still set so a portrait was actually drawn). Mirrors the
-	// way the original wraps DI past empty slots.
+	// Wrap past empty slots (matches original DI advance).
 	if (slotRects[highlighted].isEmpty())
 		highlighted = nextLiveSlot(slotRects, highlighted, +1);
 
@@ -4679,10 +4007,7 @@ void EEMEngine::doAccuse() {
 				}
 			}
 		}
-		// 100 ms tick — the original calls `_UpdateAnimations` per
-		// `_CheckFrameRate` (1df2:0b33). The accuse screen has no
-		// animations registered, so the tick is just a redraw cadence.
-		// We still re-render whenever the highlight moves (`dirty`).
+		// 100 ms `_CheckFrameRate` @ 1df2:0b33.
 		const uint32 now = g_system->getMillis();
 		if (dirty || now - lastTick >= 100) {
 			drawAccuseGallery(num, gd, highlighted, slotRects, slotSuspect);
@@ -4695,16 +4020,8 @@ void EEMEngine::doAccuse() {
 	if (picked < 0)
 		return;
 
-	// Real chain evaluation. Mirrors the original two-gate accusation:
-	//   1. `_AccuseEntry @ 1df2:0ff8` checked top-five found points
-	//      before the note picker, so by this point the player has
-	//      enough evidence to attempt an accusation.
-	//   2. `_SolvedCheck @ 1df2:00ec` checked selectedPoints > 99
-	//      before opening this suspect picker, so the clue set is
-	//      valid if we got here.
-	//   3. `_WITCH @ 1df2:089f` checks `GalleryData[picked*0x46+0x02] ==
-	//      0xFFFF`. Innocent suspects store an alibi-text TextBlock
-	//      offset there; the guilty one uses the sentinel.
+	// `_WITCH @ 1df2:089f` — guilty when gd[picked*0x46+0x02] == 0xFFFF;
+	// innocents store an alibi TextBlock offset there.
 	const int points          = _mystery.selectedPoints();
 	const bool pickedGuilty   = _mystery.isGuilty((uint)picked);
 	const bool guessedRight   = pickedGuilty;
@@ -4714,24 +4031,16 @@ void EEMEngine::doAccuse() {
 		   pickedGuilty ? "yes" : "no",
 		   guessedRight ? "correct" : "wrong");
 
-	// Wrong suspect: full alibi flow. Mirrors `_DisplayAlibi @
-	// 1df2:0145`:
-	//   1. Plays MIDI 6 (loser sting) and waits for it to finish while
-	//      the gallery is still on screen (1df2:0184-1df2:0192).
-	//   2. Draws PIC 0x3e + the suspect's speech balloon + their
-	//      portrait at (0x82, py), where the balloon shape comes from
-	//      `AlibiBubbles[bindx]` (table @ 29be:1050) and bindx is the
-	//      digit-prefix on the alibi text (else 2). bindx<8 centres the
-	//      balloon horizontally; bindx>=8 pins it at x=0x21.
-	//   3. Plays the suspect's voice via `_SpoolSound(talk - 1)` where
-	//      `talk = (Partner == 0) ? gd[+0x6] : gd[+0x0]` (1df2:0258),
-	//      then waits for a click.
-	//   4. Overlays the partner's reaction balloon (text @
-	//      `KDTextIndex[+10]`) at (0x21, y) and plays
-	//      `_SayKDDigital(5)`.
-	//   5. Clears `_FirstTry` (1df2:0447) and returns to LastScreen.
+	// `_DisplayAlibi @ 1df2:0145`:
+	//   1. MIDI 6 loser sting (gallery still visible).
+	//   2. PIC 0x3e + suspect balloon + portrait at (0x82, py).
+	//      bindx = digit prefix (else 2). bindx<8 centres balloon,
+	//      bindx>=8 pins at x=0x21.
+	//   3. `_SpoolSound(talk-1)` where talk = partner==0 ? gd[+6] : gd[+0].
+	//   4. Partner reaction balloon @ KDTextIndex[+10], `_SayKDDigital(5)`.
+	//   5. _FirstTry = 0; NextScreen = LastScreen (1df2:043f).
 	if (!guessedRight) {
-		// Balloon-shape table @ 29be:1050 — 16 entries × u16.
+		// Balloon-shape table @ 29be:1050.
 		static const uint16 kAlibiBubbles[16] = {
 			0x002B, 0x002C, 0x002D, 0x002E,
 			0x00AB, 0x00AC, 0x00AD, 0x00AE,
@@ -4746,10 +4055,7 @@ void EEMEngine::doAccuse() {
 			if (raw)
 				alibi = parseString(raw, _playerName, _partner);
 		}
-		// Digit-prefix dispatch — `_DisplayAlibi @ 1df2:0163` reads
-		// `*str` for `bindx` and advances `str = pbVar7 + 1` so the
-		// digit doesn't reach the renderer. Non-digit first chars fall
-		// through to the default bindx=2 (1df2:015e).
+		// `_DisplayAlibi @ 1df2:0163` — bindx = digit prefix, else 2.
 		uint bindx = 2;
 		const byte firstChar = alibi.empty() ? (byte)0 : (byte)alibi[0];
 		if (firstChar >= '0' && firstChar <= '9') {
@@ -4774,9 +4080,7 @@ void EEMEngine::doAccuse() {
 			_balloonArchive.size() > (bubNum & 0x7F) &&
 			_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
 
-		// Position math from 1df2:01a4-1df2:0207. py is the suspect
-		// portrait's Y; defaults to 0x5a, only overridden in the
-		// bindx<8 branch when the balloon is too tall to fit.
+		// Position math @ 1df2:01a4-0207.
 		int balloonX = 0x21;
 		int balloonY = 1;
 		int py = 0x5a;
@@ -4796,14 +4100,7 @@ void EEMEngine::doAccuse() {
 			balloonY = (bh < 0x4f) ? (0x50 - bh) / 2 : 1;
 		}
 
-		// `base` = BG + suspect + partner sprite — the persistent layer
-		// that survives across both balloon phases. The original engine
-		// keeps PIC 0x3e in the master BG buffer (16000), `_AddPicBackground`
-		// commits the suspect there, and the partner animation
-		// registered by `_DoAccuse @ 1df2:0c30` is re-blitted by every
-		// `_Repaint` via `_DrawActiveAnimations`. We don't have a
-		// slot-based animation system, so we manually keep a "base"
-		// surface and re-draw the partner frame for each phase.
+		// `base` = BG + suspect + partner. Survives both balloon phases.
 		Graphics::ManagedSurface base(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		base.clear();
@@ -4815,20 +4112,15 @@ void EEMEngine::doAccuse() {
 							   Common::Point(0x82, py),
 							   (uint32)transp);
 		}
-		// Partner sprite at (5, 0x50). Anim cells: 2 (Jake) / 0x10
-		// (Jenny); script key 0x02 — same indices `_DoAccuse @
-		// 1df2:0c30` uses for its `_NewAnimation` call. Partner is
-		// drawn AFTER the suspect so it doesn't get clipped by the
-		// portrait if their bounding boxes graze.
+		// Partner at (5, 0x50). ANI 2/0x10, script 0x02 (`_DoAccuse
+		// @ 1df2:0c30`). Drawn after suspect.
 		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 		Animation partnerAni;
 		const bool havePartner =
 			_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
 			!partnerAni.empty();
 
-		// Alibi-phase scratch = base + alibi balloon + alibi text +
-		// partner sprite (animation slot drawn on top per
-		// `_DrawActiveAnimations`).
+		// scratch = base + alibi balloon/text + partner.
 		Graphics::ManagedSurface scratch(320, 200,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(base);
@@ -4838,8 +4130,7 @@ void EEMEngine::doAccuse() {
 								  Common::Point(balloonX, balloonY),
 								  (uint32)transp);
 		}
-		// Balloon-text inset table @ 29be:0875 — same dispatch as KD
-		// balloons. WordWrap color is 0 inside a balloon (1df2:0240).
+		// Inset table @ 29be:0875. Color 0 inside balloon (1df2:0240).
 		uint16 tx = 5, ty = 4, tw = 155;
 		getBalloonInsets(bubNum, tx, ty, tw);
 		if (_font.isLoaded() && !alibi.empty()) {
@@ -4854,9 +4145,7 @@ void EEMEngine::doAccuse() {
 								  partnerAni[frameIdx], 5, 0x50);
 		}
 
-		// Step 1 — alibi music. Original blocks until MIDI 6 ends with
-		// the gallery still on screen. We poll `_music->isPlaying`;
-		// click/ESC aborts early.
+		// MIDI 6 — blocks until done (or click/ESC aborts).
 		if (_music && _voiceOn) {
 			_music->playMus(6, /*loop=*/false);
 			const uint32 musStart = g_system->getMillis();
@@ -4875,8 +4164,7 @@ void EEMEngine::doAccuse() {
 						break;
 					}
 				}
-				// Hard cap so we never get stuck if MIDI never reports
-				// finish (some sound configurations).
+				// Hard cap if MIDI never reports finish.
 				if (g_system->getMillis() - musStart > 10000)
 					break;
 				g_system->updateScreen();
@@ -4885,9 +4173,8 @@ void EEMEngine::doAccuse() {
 			_music->stop();
 		}
 
-		// Step 2 — flip the alibi scene to screen + play suspect voice.
-		// `talk = (Partner==0) ? gd[+0x6] : gd[+0x0]` (1df2:0252-0258);
-		// indices are 1-based so subtract 1 before SpoolSound.
+		// Suspect voice. talk = partner==0 ? gd[+0x6] : gd[+0x0] (1df2:0252).
+		// 1-based, so SpoolSound(talk-1).
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, 320, 200);
 		g_system->updateScreen();
@@ -4903,16 +4190,8 @@ void EEMEngine::doAccuse() {
 		}
 		waitForInput(60000);
 
-		// Step 3 — partner reaction balloon. Mirrors 1df2:026e-1df2:02b6.
-		// Rebuild scratch from `base` (BG + suspect + partner sprite)
-		// so the alibi balloon + text don't bleed through. The
-		// original's `_Repaint` re-renders the master BG (which still
-		// has the alibi balloon committed), but the engine ALSO calls
-		// `_GetBackground(0x3e)` again before `_AddPicBackground` for
-		// each new balloon — flushing the master back to a clean state.
-		// We achieve the same end result by restoring `base` here.
-		// `_SayKDDigital(5)` auto-cancels the still-playing alibi voice
-		// (spoolSound calls stopSpool internally) so no explicit stop.
+		// Partner reaction @ 1df2:026e-02b6. Rebuild from `base` so alibi
+		// balloon clears. `_SayKDDigital(5)` auto-cancels alibi voice.
 		const byte *reactIdx = _mystery.kdTextIndex();
 		if (reactIdx) {
 			const int16 reactOff = (int16)READ_LE_UINT16(reactIdx + 10);
@@ -4937,8 +4216,6 @@ void EEMEngine::doAccuse() {
 				if (haveR && rBalloon.surface.h < 0x4e)
 					rY = (0x50 - rBalloon.surface.h) / 2;
 
-				// Reset to a clean BG + suspect, then layer the new
-				// balloon and the (refreshed) partner frame on top.
 				scratch.simpleBlitFrom(base);
 				if (haveR) {
 					const byte transp = (byte)(rBalloon.flags >> 8);
@@ -4971,35 +4248,22 @@ void EEMEngine::doAccuse() {
 		waitForInput(60000);
 
 		_mystery._firstTry = false;
-		// `_DisplayAlibi @ 1df2:043f` writes `_NextScreen =
-		// _LastScreen`. The original returns the player to the caller
-		// (PDA / site / map) for another try.
+		// `_DisplayAlibi @ 1df2:043f` — NextScreen = LastScreen.
 		_nextScreen = _lastScreen != kScreenInvalid
 						? (ScreenId)_lastScreen : kScreenSite;
 		return;
 	}
 
-	// Right suspect — full win flow. Mirrors `_DisplayCorrect @
-	// 1df2:073c`: mark mystery solved, advance chain stage if the
-	// tier is complete, swap MIDI to the win cue, run SCRAPBK.ANI,
-	// show the per-mystery ending, save the profile, return to the
-	// action menu (`_NextScreen = 0xc` at 1df2:0895).
+	// Win — `_DisplayCorrect @ 1df2:073c`: mark solved, advance chain,
+	// MIDI 5, SCRAPBK.ANI, ending, save, NextScreen=0xc (1df2:0895).
 	{
 		const uint mn = _mystery.number();
 		if (mn < sizeof(_mysteriesSolved)) {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 		}
 
-		// Mirrors the chain-advancement loop at `_DisplayCorrect @
-		// 1df2:0824-0850`. Skip mystery 0 (the practice case) per the
-		// `if (_MysteryNumber != 0)` guard at 1df2:080d, then check
-		// every mystery in the current tier:
-		//   stage 1 → check  1..0x18 (24 mysteries, "A chain")
-		//   stage 2 → check 0x19..0x30 (24 mysteries, "B chain")
-		//   stage 3 → check 0x31..0x36 (6 mysteries, "C chain")
-		// If every solve flag in that range is non-zero, bump the
-		// stage. Original increments unconditionally (3→4 is harmless
-		// since no further range covers it); we cap at 3 for clarity.
+		// Chain advance @ 1df2:0824-0850. Skip mystery 0 (practice).
+		// stage 1: 1..0x18, stage 2: 0x19..0x30, stage 3: 0x31..0x36.
 		if (mn != 0) {
 			uint lo = 0, hi = 0;
 			switch (_chainStage) {
@@ -5013,12 +4277,7 @@ void EEMEngine::doAccuse() {
 				if (i >= sizeof(_mysteriesSolved) || _mysteriesSolved[i] == 0)
 					allSolved = false;
 			}
-			// `_DisplayCorrect @ 1df2:0852` increments unconditionally
-			// when every case in the current tier is solved — including
-			// past stage 3 (so a stage-4 endgame state exists in the
-			// `.PLR` save format). `_ActionScreen @ 1c33:19d1` gates
-			// the menu on `if (3 < _chainStage)` to grey
-			// Choose-A-Mystery and Practice once everything's solved.
+			// 1df2:0852 increments past 3 (stage-4 endgame); cap at 4.
 			if (allSolved && _chainStage < 4) {
 				_chainStage++;
 				debugC(1, kDebugMystery,
@@ -5027,25 +4286,17 @@ void EEMEngine::doAccuse() {
 			}
 		}
 
-		// `_DisplayCorrect @ 1df2:073c` order:
-		//   1df2:0773  _AllBlack();
-		//   1df2:0776  _BuildBackground(5, 0x42, 0x14);  // conclusion BG
-		//   1df2:0780  _FadeIn();
-		//   1df2:0789  _MIDIPlay(5);                      // win music
-		//   1df2:07ac  _DisplayClue(MysteryIndex[+0x10]); // chain recap
-		// `_BuildBackground @ 172b:13e2` loads PIC 0x3D (the standard
-		// frame) and overlays SITES.DBD entry 5 at (0x42, 0x14), then
-		// sets palette via `_GetPalette(sitenum + 1)` = palette 6.
-		// Without this BG the chain-recap balloons render on top of
-		// the accuse-gallery BG (PIC 0x3F + suspect portraits), which
-		// is visually jarring — the conclusion is supposed to play
-		// against the dedicated "office / desk" scene.
+		// `_DisplayCorrect @ 1df2:073c`:
+		//   _AllBlack; _BuildBackground(5, 0x42, 0x14); _FadeIn;
+		//   _MIDIPlay(5); _DisplayClue(MysteryIndex[+0x10]).
+		// `_BuildBackground @ 172b:13e2` = PIC 0x3D + SITES entry 5 at
+		// (0x42, 0x14), palette = sitenum+1 = 6.
 		Graphics::Surface *blk = g_system->lockScreen();
 		if (blk) {
 			memset(blk->getPixels(), 0, 320 * 200);
 			g_system->unlockScreen();
 		}
-		setSitePalette(6); // sitenum + 1 per `_GetPalette` call
+		setSitePalette(6); // sitenum + 1 (`_GetPalette`).
 		Picture frame, scene;
 		if (_picsArchive.loadEntry(0x3d, frame)) {
 			g_system->copyRectToScreen(frame.surface.getPixels(),
@@ -5063,15 +4314,7 @@ void EEMEngine::doAccuse() {
 										   sw, sh);
 		}
 
-		// Partner sprite at (5, 0x50). The original `_DoAccuse @
-		// 1df2:0c30` registered the partner anim BEFORE entering the
-		// gallery; that slot stays active across `_DisplayCorrect`'s
-		// `_BuildBackground` (which calls `_Repaint` →
-		// `_DrawActiveAnimations` and re-blits the partner over the
-		// fresh BG). We don't have a slot system, so manually stamp
-		// the resting frame here. `displayClue` snapshots the screen
-		// on entry, so the partner ends up baked into its BG and is
-		// preserved across every clue iteration.
+		// Stamp partner at (5, 0x50); displayClue snapshots screen.
 		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 		Animation partnerAni;
 		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
@@ -5091,62 +4334,40 @@ void EEMEngine::doAccuse() {
 		if (_music && _voiceOn)
 			_music->playMus(5, /*loop=*/false);
 
-		// Chain-by-chain RECAP. Partner enumerates every required
-		// clue ("Look at this — the suspect was here at 8pm", "... and
-		// remember the broken vase from the kitchen", "... so it had
-		// to be X!") and arrives at the conclusion. Without rendering
-		// it the player goes straight from suspect-pick to the
-		// scrapbook anim and misses the deduction entirely.
+		// Chain recap — partner enumerates required clues.
 		const byte *solved = _mystery.solvedClueBlock();
 		if (solved)
 			displayClue(solved);
 		if (_music && _voiceOn)
 			_music->stop();
 
-		// `_DifferenceAnimation("scrapbk.ani")` (1df2:0848) — the
-		// physical scrapbook flip animation that introduces the
-		// per-mystery ending pages.
+		// `_DifferenceAnimation("scrapbk.ani")` @ 1df2:0848.
 		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
 
-		// `_ShowOneScrap @ 1f78:0773` is `_DisplayEnding(num, 1)` —
-		// the multi-page per-mystery ending narrative. This shares the
-		// same red edge cursor + edge-only mouse navigation used by the
-		// scrapbook browser.
+		// `_ShowOneScrap @ 1f78:0773` = `_DisplayEnding(num, 1)`.
 		doShowEnding(mn);
 
-		// Mirrors `_SavePlayerRecord` at 1df2:0857 — once the
-		// `_mysteriesSolved` table is updated, the original
-		// immediately persists the player record so the win sticks
-		// even if the player quits before reaching the menu.
-		//
-		// Order matters: `_mystery.clear()` BEFORE `saveProfile` so the
-		// save records `hasMystery=false`. Otherwise the next load of
-		// this profile sees the just-won mystery still loaded and the
-		// screen driver routes to its map (forcing the player to
-		// replay the win flow). Mirrors `_DisplayCorrect @ 1df2:0851`
-		// (`_DeleteSavedGame` removes the in-progress save before
-		// `_SavePlayerRecord` writes the post-win profile).
+		// `_SavePlayerRecord` @ 1df2:0857. Order: clear() before save
+		// so hasMystery=false (matches `_DeleteSavedGame` @ 1df2:0851).
 		_mystery.clear();
 		const Common::Error err = saveProfile(_playerName);
 		if (err.getCode() != Common::kNoError)
 			warning("saveProfile after solve failed: %s",
 					err.getDesc().c_str());
 
-		// `_DisplayCorrect @ 1df2:0895` writes `_NextScreen = 0xc` —
-		// the winner returns to the post-mystery `_ActionScreen`.
+		// `_DisplayCorrect @ 1df2:0895` — NextScreen = 0xc.
 		_nextScreen = kScreenAction;
 	}
 }
 
 void EEMEngine::doAccuseFloppy() {
-	// Floppy accuse flow — mirrors:
-	//   `_KDHelp_Floppy / FUN_1d40_11fd @ 1d40:11fd` (score gate +
-	//        partner "ready / not ready" balloon)
-	//   `FUN_1d40_0e07 @ 1d40:0e07`   (clue-selection screen — skipped
-	//        in this MVP; selectedPoints() already takes the top-5)
-	//   `FUN_1d40_0c79 @ 1d40:0c79`   (gallery picker)
-	//   `_DisplayCorrect_Floppy @ 1d40:0894`
-	//   `_DisplayAlibi_Floppy   @ 1d40:00df`
+	// Floppy accuse:
+	//   `_KDHelp_Floppy / FUN_1d40_11fd @ 1d40:11fd` — score gate.
+	//   `FUN_1d40_0e07` — clue selection (skipped; selectedPoints()
+	//     already takes top-5).
+	//   `FUN_1d40_0c79 @ 1d40:0c79` — gallery picker.
+	//   `_DisplayCorrect_Floppy @ 1d40:0894`.
+	//   `_DisplayAlibi_Floppy @ 1d40:00df`.
 	const byte *kdIdx     = _mystery.kdTextIndex();
 	const byte *bufBase   = _mystery.blobAt(0);
 	const uint32 mysSize  = _mystery.dataSize();
@@ -5171,9 +4392,8 @@ void EEMEngine::doAccuseFloppy() {
 		if (lineLen == 0)
 			return;
 		Common::String raw(p, lineLen);
-		// Digit prefix → balloon variant (per `_GetKDTextBalloon_Floppy
-		// @ 1d40:009f` and the `_KDBalloonByChar_Floppy` table at
-		// `2608:0c14 + char`).
+		// Digit → balloon variant (`_GetKDTextBalloon_Floppy @ 1d40:009f`
+		// + table @ 2608:0c14).
 		static const uint8 kDigitToBalloon[10] = {
 			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
 		};
@@ -5233,19 +4453,18 @@ void EEMEngine::doAccuseFloppy() {
 		}
 	};
 
-	// `FUN_1d40_11fd` score gate. Picks one of three KD strings and
-	// returns 1 only when score >= 100.
+	// `FUN_1d40_11fd` — score gate, ready when score >= 100.
 	const int score = _mystery.selectedPoints();
 	uint kdSlot;
 	bool readyToSolve;
 	if (score < 50) {
-		kdSlot = 0;        // KDTextIndex[0] — "we've barely started"
+		kdSlot = 0;        // "we've barely started"
 		readyToSolve = false;
 	} else if (score < 100) {
-		kdSlot = 1;        // KDTextIndex[1] — "getting closer"
+		kdSlot = 1;        // "getting closer"
 		readyToSolve = false;
 	} else {
-		kdSlot = 2;        // KDTextIndex[2] — "ready to solve"
+		kdSlot = 2;        // "ready to solve"
 		readyToSolve = true;
 	}
 	showFloppyKDHint(kdSlot);
@@ -5255,14 +4474,8 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// Clue selection screen — `FUN_1d40_0e07 @ 1d40:0e07`. The user
-	// must pick exactly `6 - chainStage` clues from the found list,
-	// rendered on the red accuse-mode BG (PIC 0x1A7). Verified by
-	// the asm at 1d40:0e34 reading `local_c = 6 - DAT_28da_3052`,
-	// matching the CD's `_DoAccuse @ 1df2:0bdd` expected count.
-	// `doAccuseNotes()` already handles this UI for both variants
-	// (note text reading is variant-aware via `noteTextOff`); it
-	// returns true on commit, false on ESC / pointer navigation.
+	// Clue selection — `FUN_1d40_0e07 @ 1d40:0e07`. Pick `6 - chainStage`
+	// clues (1d40:0e34: `local_c = 6 - DAT_28da_3052`).
 	if (!doAccuseNotes()) {
 		if (_nextScreen == kScreenAccuse) {
 			_nextScreen = _lastScreen != kScreenInvalid
@@ -5271,19 +4484,8 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// Second score gate — based on the CLUES THE USER ACTUALLY
-	// PICKED. `_DoAccuse_Floppy @ 1d40:0e07` (after the commit
-	// branch at 1d40:0f3a) reads `local_a = FUN_1d40_0c48()` (=
-	// sum of byte +6 across selected note entries) and:
-	//   if (local_a < 100) → KDTextIndex[+6] hint (slot 3),
-	//                        bail back to last screen.
-	//   else              → call FUN_1d40_0c79 (gallery picker).
-	// Note this is distinct from the entry gate on
-	// `_GetSelectedPoints_Floppy @ 1d40:0c23` (which uses the
-	// auto-top-5 of FOUND clues — what `Mystery::selectedPoints`
-	// returns on floppy). A player can be "ready to solve" by score
-	// yet still pick the wrong subset; that's the case this gate
-	// catches.
+	// 2nd score gate on USER-PICKED clues (1d40:0f3a, FUN_1d40_0c48 =
+	// sum of byte +6 across selected entries). <100 → slot 3 hint.
 	int userSelectedScore = 0;
 	{
 		const byte *ni2 = _mystery.noteIndex();
@@ -5304,13 +4506,10 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// `FUN_1d40_0c79` — gallery picker. Show "Which suspect?" KD
-	// hint at slot 4 (KDTextIndex[+8]/2 = entry index 4), then
-	// render the gallery and wait for click.
+	// `FUN_1d40_0c79` — gallery picker. KDTextIndex slot 4 prompt.
 	showFloppyKDHint(4);
 
-	// Build slot list. Floppy gallery section: byte0 = numSuspects,
-	// then variable-stride entries.
+	// Floppy gallery: byte0 = numSuspects, then variable-stride entries.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0) {
 		_nextScreen = _lastScreen != kScreenInvalid
@@ -5318,8 +4517,7 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// Verbatim from `_DrawGallery_Floppy @ 154e:0050` — slot positions
-	// at `2608:016c` (5 × {u16 x, u16 y}).
+	// `_DrawGallery_Floppy @ 154e:0050` slots @ 2608:016c.
 	struct GallerySlot { int x, y; };
 	static const GallerySlot kFloppySlots[5] = {
 		{ 0x53, 0x0e }, { 0x9b, 0x0e }, { 0xe3, 0x0e },
@@ -5338,7 +4536,6 @@ void EEMEngine::doAccuseFloppy() {
 		if (haveAccuseBg)
 			scratch.simpleBlitFrom(accuseBg.surface);
 
-		// Partner sprite (anim 2 / 0x10) — same as CD doAccuse.
 		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 		Animation partnerAni;
 		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
@@ -5370,8 +4567,7 @@ void EEMEngine::doAccuseFloppy() {
 			if (!_picsArchive.getPicture(picId, portrait))
 				continue;
 			const GallerySlot &s = kFloppySlots[phys];
-			// `_DrawGallery_Floppy @ 154e:00ed` bottom-aligns to baseline
-			// 0x48 (= 72), same as CD: `placeY = s.y + (0x48 - h)`.
+			// Bottom-align to baseline 0x48 (`154e:00ed`).
 			const int placeX = s.x;
 			const int placeY = s.y + (0x48 - portrait.surface.h);
 			const byte transp = (byte)(portrait.flags >> 8);
@@ -5450,33 +4646,16 @@ void EEMEngine::doAccuseFloppy() {
 	const bool guilty = _mystery.isGuilty((uint)picked);
 
 	if (guilty) {
-		// Win path. Mirrors `_DisplayCorrect_Floppy @ 1d40:0894`:
-		//   1d40:08a0  _BuildBackground_Floppy(5, 0x42, 0x14);
-		//                 → PIC 0x3d frame + SITES entry 5 at (0x42,
-		//                   0x14), palette = sitenum + 1 = 6.
-		//   1d40:08b1  _FadeIn();
-		//   1d40:08c0  _MIDIPlayFile("travel-2.xmi");
-		//   1d40:08d0  walk solved chain via _DisplayHotspotClue_Floppy
-		//                 + _WaitForClick per record. Mid-recap: when
-		//                 only 3 records remain, play
-		//                 _PlayTitleANM_Floppy(0) = SCRAPBK.ANI.
-		//   1d40:0939  ((u16 *)0x3054)[mysteryNum] =
-		//                  _firstTry ? 2 : 1;
-		//   1d40:0941  tier-promotion check (advance _chainStage when
-		//                 every mystery in the current tier is solved).
-		//   1d40:0982  _SavePlayerRecord  (= saveProfile)
-		//   1d40:0985  _DeleteMysteryFile (= mystery cleanup)
-		//   1d40:0991  MakeSolvedSound = 1; ShowOneScrap(mystery)
-		//   1d40:09b0  _NextScreen = 0xc.
+		// `_DisplayCorrect_Floppy @ 1d40:0894`:
+		//   _BuildBackground_Floppy(5, 0x42, 0x14); _FadeIn;
+		//   _MIDIPlayFile("travel-2.xmi");
+		//   Walk solved chain, SCRAPBK.ANI when 3 records left.
+		//   Mark solved, tier-promote, SavePlayerRecord,
+		//   ShowOneScrap, NextScreen = 0xc.
 		const uint mn = _mystery.number();
 
-		// Conclusion BG composition. `_BuildBackground_Floppy @
-		// 16e2:12fd` blits PIC 0x3d (the desk frame) onto a cleared
-		// page, then overlays SITES.DBD entry 5 (the conclusion
-		// artwork) at (param_2, param_3) = (0x42, 0x14). Palette
-		// becomes `sitenum + 1` = 6. Same composition the CD `doAccuse`
-		// win flow performs (see ui.cpp:4145-4166), just driven from
-		// the floppy data archives.
+		// `_BuildBackground_Floppy @ 16e2:12fd` — PIC 0x3d + SITES entry 5
+		// at (0x42, 0x14), palette 6.
 		{
 			Graphics::Surface *blk = g_system->lockScreen();
 			if (blk) {
@@ -5501,17 +4680,8 @@ void EEMEngine::doAccuseFloppy() {
 						scene.surface.pitch, sx, sy, sw, sh);
 			}
 
-			// Partner sprite at (5, 0x50). The original keeps its
-			// `_NewAnimation` slot active across `_DisplayCorrect`'s
-			// `_BuildBackground_Floppy`, so `_UpdateAnimations` keeps
-			// re-stamping the partner over the fresh BG. We don't have
-			// a slot system, so manually bake the resting frame into
-			// the BG before the recap kicks off — `displayFloppyDialog
-			// Records` snapshots the screen on entry and the partner
-			// stays visible across every chain record. Without this
-			// the win sequence ends up partner-less. Anim 2 (Jake) /
-			// 0x10 (Jenny), script key 0x02 — same as the gallery
-			// picker and the CD accuse path (ui.cpp:4177).
+			// Stamp partner at (5, 0x50); displayFloppyDialogRecords
+			// snapshots screen. ANI 2/0x10, script 0x02.
 			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 			Animation partnerAni;
 			if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
@@ -5529,22 +4699,12 @@ void EEMEngine::doAccuseFloppy() {
 			g_system->updateScreen();
 		}
 
-		// Win music — TRAVEL-2.XMI (`2608:0c84`, played via
-		// `_MIDIPlayFile` at `_DisplayCorrect_Floppy @ 1d40:08c0`).
-		// The CD path uses internal MUS index 5; the floppy plays the
-		// XMI directly by filename. `_voiceOn` (= `DAT_28da_3050`) is
-		// the gate the original checks first.
+		// TRAVEL-2.XMI @ 2608:0c84 (`_MIDIPlayFile` @ 1d40:08c0).
 		if (_music && _voiceOn)
 			_music->playFile(Common::Path("travel-2.xmi"), false);
 
-		// Walk the solved-clue chain. Header[+0x12] points at a
-		// `count` byte followed by `count` dialog records (same layout
-		// as hotspot dialogs). When only three records remain, the
-		// original clears animation slots and calls
-		// `_PlayTitleANM_Floppy(0)`. The title helper's file table maps
-		// index 0 to `SCRAPBK.ANI` and index 1 to `TITLE.ANM`, so this
-		// is the same scrapbook flip transition used by the CD win flow,
-		// just inserted before the last three floppy recap records.
+		// Walk solved chain (header[+0x12]: count byte + dialog records).
+		// SCRAPBK.ANI fires when 3 records remain (`_PlayTitleANM_Floppy(0)`).
 		const byte *chain = _mystery.solvedClueBlock();
 		if (chain) {
 			const uint count = chain[0];
@@ -5571,23 +4731,11 @@ void EEMEngine::doAccuseFloppy() {
 		if (_music && _voiceOn)
 			_music->stop();
 
-		// Mark mystery solved with first-try bonus tracking.
-		// `_DisplayCorrect_Floppy @ 1d40:0939`:
-		//   ((u16 *)0x3054)[mysteryNum] = 1;
-		//   if (DAT_28da_35df != 0)
-		//       ((u16 *)0x3054)[iVar5] = 2;
-		// `DAT_28da_35df` is `_firstTry`; it starts at 1 on
-		// `_ReadMystery_Floppy` and is cleared to 0 by
-		// `_DisplayAlibi_Floppy` on a wrong accusation. So 2 = won on
-		// first try, 1 = won after at least one alibi.
+		// 1d40:0939 — solved[mn] = _firstTry ? 2 : 1.
 		if (mn < sizeof(_mysteriesSolved))
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 
-		// Tier-promotion check. `_DisplayCorrect_Floppy @
-		// 1d40:0941..0978` walks the current tier's mystery range and
-		// advances `DAT_28da_3052` (= `_chainStage`) when every entry
-		// is non-zero. Skip mystery 0 (practice case) per the
-		// `if (DAT_2608_149c != 0)` guard at 1d40:093f.
+		// Tier promotion @ 1d40:0941..0978. Skip mystery 0 (practice).
 		if (mn != 0) {
 			uint lo = 0, hi = 0;
 			switch (_chainStage) {
@@ -5610,10 +4758,8 @@ void EEMEngine::doAccuseFloppy() {
 			}
 		}
 
-		// `_DisplayCorrect_Floppy` sets `MakeSolvedSound` before the
-		// newly solved ending is shown. `FUN_1d40_05b7` reads byte 0 of
-		// `E<num>.BIN` and maps values 0..2 through the table at
-		// `2608:0c5e` to VOC slots 0x15, 0x16, 0x17.
+		// `MakeSolvedSound`. `FUN_1d40_05b7` maps E<num>.BIN byte 0 (0..2)
+		// via table @ 2608:0c5e to VOC slots 0x15/0x16/0x17.
 		if (_audio && _voiceOn) {
 			Common::File ending;
 			const Common::String fname =
@@ -5631,19 +4777,10 @@ void EEMEngine::doAccuseFloppy() {
 			}
 		}
 
-		// `_DisplayCorrect_Floppy @ 1d40:0991` calls
-		// `FUN_1ee2_06ac(mystery)`, whose body is just
-		// `FUN_1d40_05b7(mystery, 1)` plus font cleanup. That is the
-		// floppy ending/scrapbook viewer, and it receives the
-		// first-try badge state from the solved table entry above.
+		// 1d40:0991 → FUN_1ee2_06ac (= FUN_1d40_05b7 + font cleanup).
 		doShowEnding(mn);
 
-		// Persist progress before clearing the in-progress mystery.
-		// `_DisplayCorrect_Floppy @ 1d40:0982` calls
-		// `_SavePlayerRecord` then `FUN_22dc_0dbd` (which deletes the
-		// per-mystery save file via DOS int 21h). Order matters here
-		// for the same reason as the CD path — clear → save so the
-		// profile records `hasMystery=false`.
+		// 1d40:0982 _SavePlayerRecord. clear() before save.
 		_mystery._solvedPuzzle = true;
 		_mystery.clear();
 		(void)saveProfile(_playerName);
@@ -5651,25 +4788,12 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// Innocent — `_DisplayAlibi_Floppy @ 1d40:00df`:
-	//   1d40:00fb  _GetBackground(0x3e);
-	//   1d40:0103  _GetPicture(suspect.picID);
-	//   1d40:010c  _AddPicBackground(susp_pic, 0x82, 0x5a);
-	//   1d40:0125  _MIDIPlayFile("fanfare2.xmi");  // alibi sting
-	//   1d40:0145  alibi-balloon table at `2608:0c0a + first_char`
-	//                  (separate from the KD digit→balloon table at
-	//                  `2608:0c14` — yes, 0xc0a, NOT 0xc14, verified
-	//                  via the `*(char *)(*local_a + 0xc0a)` access).
-	//                  Default = `DAT_2608_0c3c` = 0x2c.
-	//   1d40:01a3  balloon centred:
-	//                  bx = (0x140 - balloon.w) / 2;
-	//                  by = (0x5a  - balloon.h) / 2;
-	//   1d40:01d5  WordWrap alibi text inside balloon, _WaitForClick.
-	//   1d40:01ee  KD reaction at KDTextIndex[+10] = slot 5
-	//                  (NOT slot 8 — slot 8 is the gallery prompt).
-	//                  Balloon at (0x21, (0x50 - h) / 2).
-	//   1d40:0247  _NextScreen = _LastScreen;
-	//   1d40:024b  _firstTry = 0;
+	// `_DisplayAlibi_Floppy @ 1d40:00df`:
+	//   BG 0x3e + suspect pic at (0x82, 0x5a); MIDI fanfare2.xmi.
+	//   Balloon table @ 2608:0c0a (default 0x2c). Centered:
+	//     bx = (0x140-w)/2, by = (0x5a-h)/2.
+	//   KD reaction @ KDTextIndex[+10] = slot 5; NextScreen = LastScreen;
+	//   _firstTry = 0.
 	const byte *susp = _mystery.floppySuspectEntry((uint)picked);
 	uint16 picId = 0;
 	uint16 alibiOff = 0xFFFF;
@@ -5678,8 +4802,7 @@ void EEMEngine::doAccuseFloppy() {
 		alibiOff = _mystery.alibiTextOffset((uint)picked);
 	}
 
-	// Alibi-screen MIDI sting (FANFARE2.XMI). `_MIDIPlayFile` is
-	// gated on `_voiceOn` (= `DAT_28da_3050`) in the original.
+	// FANFARE2.XMI alibi sting (`_MIDIPlayFile` gated on _voiceOn).
 	if (_music && _voiceOn)
 		_music->playFile(Common::Path("fanfare2.xmi"), false);
 
@@ -5699,18 +4822,12 @@ void EEMEngine::doAccuseFloppy() {
 							_playerName, _partner);
 	}
 
-	// Alibi balloon dispatch. The original reads the alibi-balloon
-	// table at `2608:0c0a + first_char` — a SEPARATE table from the
-	// KD-hint table at `2608:0c14`. For digits '0'..'9' the values at
-	// `2608:0c3a..0c43` are { 0x2a, 0x2b, 0x2c, 0x2d, 0xaa, 0xab,
-	// 0xac, 0xad, 0x09, 0x0a }; default (non-digit char) is
-	// `DAT_2608_0c3c` = 0x2c. The high-bit values (0xAA..0xAD) are
-	// `_GetBalloon`'s "mirrored" flag — the low 7 bits are the
-	// balloon idx, top bit flips horizontally.
+	// Alibi balloon table @ 2608:0c0a (NOT 0c14). Digits @ 2608:0c3a..0c43.
+	// Default 0x2c. High bit = mirror flag.
 	static const uint8 kFloppyAlibiBalloonByDigit[10] = {
 		0x2a, 0x2b, 0x2c, 0x2d, 0xaa, 0xab, 0xac, 0xad, 0x09, 0x0a
 	};
-	uint balloonRaw = 0x2c; // default `DAT_2608_0c3c`
+	uint balloonRaw = 0x2c; // DAT_2608_0c3c
 	if (!alibi.empty() && alibi[0] >= '0' && alibi[0] <= '9') {
 		balloonRaw = kFloppyAlibiBalloonByDigit[(int)(alibi[0] - '0')];
 		alibi.deleteChar(0);
@@ -5734,9 +4851,7 @@ void EEMEngine::doAccuseFloppy() {
 	Picture balloon;
 	const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
 		_balloonArchive.loadEntry(balloonIdx, balloon);
-	// Centred per `_DisplayAlibi_Floppy @ 1d40:01a0`:
-	//   uVar2 = (0x140 - balloon.w) / 2;  // x
-	//   uVar3 = (0x5a  - balloon.h) / 2;  // y (anchor in top-half)
+	// Centered @ 1d40:01a0: x=(0x140-w)/2, y=(0x5a-h)/2.
 	int balloonX = 0x21;
 	int balloonY = 1;
 	if (haveBalloon) {
@@ -5745,10 +4860,7 @@ void EEMEngine::doAccuseFloppy() {
 		if (balloonX < 0) balloonX = 0;
 		if (balloonY < 0) balloonY = 0;
 		const byte transp = (byte)(balloon.flags >> 8);
-		// `_GetBalloon`'s mirror flag (high bit of the table value)
-		// flips the balloon horizontally — the original applies it
-		// inside the blit primitive; ScummVM's `transBlitFrom` exposes
-		// the same via its `flipped` argument.
+		// Mirror flag (high bit) flips balloon horizontally.
 		scene.transBlitFrom(balloon.surface,
 							Common::Point(balloonX, balloonY),
 							transp, flipBalloon);
@@ -5759,19 +4871,13 @@ void EEMEngine::doAccuseFloppy() {
 		_font.drawWordWrapped(&scene, balloonX + tx, balloonY + ty,
 							  MAX<int>(8, (int)tw), alibi, 0);
 	}
-	// The floppy original keeps the accuse partner animation slot alive
-	// across `_DisplayAlibi_Floppy` and restores the screen with active
-	// animations between alibi phases. Stamp the same resting frame here
-	// before the KD reaction helper snapshots the current screen.
+	// Stamp partner resting frame before KD reaction snapshots screen.
 	blitAccusePartner(scene, _aniArchive, _partner, g_system->getMillis());
 	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 
-	// `_DisplayAlibi_Floppy` does NOT play any per-suspect VOC — the
-	// alibi table at `2608:0c5e` (3 bytes: 0x15, 0x16, 0x17) is
-	// referenced by the post-win scrapbook (`FUN_1d40_05b7`), not the
-	// alibi screen. The MIDI sting we kicked off above carries the
-	// audio.
+	// No per-suspect VOC — alibi table @ 2608:0c5e is for post-win
+	// scrapbook (`FUN_1d40_05b7`), not alibi.
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -5791,12 +4897,7 @@ void EEMEngine::doAccuseFloppy() {
 		g_system->delayMillis(10);
 	}
 
-	// Partner reaction — `KDTextIndex[+10]` is byte offset 10 = u16
-	// stride entry 5 (NOT 8). Verified at
-	// `_DisplayAlibi_Floppy @ 1d40:01ee`:
-	//   `iVar4 = MysteryOff + *(KDTextIndex + 10);`
-	// Our `showFloppyKDHint(slot)` reads `kdIdx + slot * 2`, so slot
-	// 5 is the right argument here.
+	// `_DisplayAlibi_Floppy @ 1d40:01ee` — KDTextIndex[+10] = slot 5.
 	showFloppyKDHint(5);
 	if (_music && _voiceOn)
 		_music->stop();
@@ -5810,20 +4911,8 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 								   int highlighted,
 								   Common::Array<Common::Rect> &slotRects,
 								   Common::Array<int> &slotSuspect) {
-	// Accuse-gallery redraw — formerly the `drawGallery` lambda inside
-	// `doAccuse`. Mirrors `_DoAccuseGallery @ 1df2:0a31` portrait grid:
-	// PIC 0x3f backdrop, suspect portraits at the 5 fixed slots
-	// (`kGallerySlots` in this file's anon namespace), and a 1-px
-	// outline (palette index 0xFE) around the highlighted slot.
-	//
-	// Partner sprite at (5, 0x50): the original `_DoAccuse @ 1df2:0bdd`
-	// registers `_NewAnimation(5, 0x50, partnerCells, script=2, prior=1)`
-	// (1df2:0c30) BEFORE calling `_DoAccuseGallery`, then `_DrawGallery`
-	// calls `_DrawActiveAnimations` (158f:00a3) which re-renders the
-	// slot every frame. Without an explicit blit here, our port's
-	// accuse screen comes out partner-less. Anim CELLS are 2 (Jake) /
-	// 0x10 (Jenny); SCRIPT key is 0x02 for both partners (matches the
-	// `CONCAT22(2, ...)` arg verified at 1df2:0c2e).
+	// `_DoAccuseGallery @ 1df2:0a31` portrait grid. PIC 0x3f + 5 slots.
+	// Partner (5, 0x50) ANI 2/0x10, script 0x02 (`_DoAccuse @ 1df2:0c30`).
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -5833,10 +4922,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 	if (haveAccuseBg)
 		scratch.simpleBlitFrom(accuseBg.surface);
 
-	// Partner sprite, drawn BEFORE portraits so the suspect grid
-	// covers it where they overlap (the gallery slots start at
-	// y=14 / y=90, partner is at y=0x50=80 — no overlap, so order
-	// is purely defensive).
+	// Partner drawn first; defensive (no slot overlap).
 	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
 	Animation partnerAni;
 	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
@@ -5856,8 +4942,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		const uint8 phys = _mystery._newOrder[i];
 		if (phys >= 5)
 			continue;
-		// `_DrawGallery @ 158f:00b9` skips suspects whose
-		// `_InGallery[phys]` flag is 0 — that's the original gate.
+		// `_DrawGallery @ 158f:00b9` skip on `_InGallery[phys]==0`.
 		if (_mystery._inGallery[phys] == 0)
 			continue;
 		const GallerySlot &s = kGallerySlots[phys];
@@ -5894,11 +4979,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		slotSuspect[i] = (int)i;
 	}
 
-	// Highlight indicator. The original moves the mouse cursor to the
-	// centre of the highlighted suspect via `_PutMouseInRect` (1df2:0b8e);
-	// we draw a 1-px outline in palette index 0xFE instead, which sits
-	// inside the marching-ants cycle range 0xF9..0xFE and is visible
-	// under any palette without warping the player's cursor.
+	// Highlight outline (original uses `_PutMouseInRect` @ 1df2:0b8e).
 	if (highlighted >= 0 && highlighted < (int)slotRects.size() &&
 		!slotRects[highlighted].isEmpty()) {
 		Common::Rect r = slotRects[highlighted];


Commit: 37e6dc1ba6887c4b784d40246036e1d73cde2107
    https://github.com/scummvm/scummvm/commit/37e6dc1ba6887c4b784d40246036e1d73cde2107
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:10+02:00

Commit Message:
EEM: enforced code standards, mostly removed one-lined statements

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/graphics.cpp
    engines/eem/music.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index ebe7383137a..cedbf6d1319 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -359,7 +359,7 @@ void EEMEngine::doInitClues() {
 				while (g_system->getEventManager()->pollEvent(ev)) {
 					if (ev.type == Common::EVENT_KEYDOWN &&
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						interruptAudio(/*stopMusicToo=*/false);
+						interruptAudio(/* stopMusicToo= */ false);
 						skip = true;
 						break;
 					}
@@ -471,7 +471,7 @@ void EEMEngine::doInitClues() {
 					while (g_system->getEventManager()->pollEvent(ev)) {
 						if (ev.type == Common::EVENT_KEYDOWN &&
 							ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-							interruptAudio(/*stopMusicToo=*/false);
+							interruptAudio(/* stopMusicToo= */ false);
 							skip = true;
 							break;
 						}
@@ -756,13 +756,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				// _WordWrap(bubX + table[bub].x, bubY + table[bub].y,
 				//           table[bub].w, ...).
 				// Fallback (5, 4, 155) = entry-23 inset.
-				uint16 bx = 5;
-				uint16 by = 4;
-				uint16 bw_ = 155;
-				getBalloonInsets(balloonId, bx, by, bw_);
-				textX = bubX + bx;
-				textY = bubY + by;
-				textW = bw_;
+				uint16 insetX = 5;
+				uint16 insetY = 4;
+				uint16 insetW = 155;
+				getBalloonInsets(balloonId, insetX, insetY, insetW);
+				textX = bubX + insetX;
+				textY = bubY + insetY;
+				textW = insetW;
 				copyH = bh;
 			} else {
 				// No balloon: clear band.
@@ -825,7 +825,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						advance = true;
 						skipAll = true;
 						// Cut voice + spool only (keep MIDI).
-						interruptAudio(/*stopMusicToo=*/false);
+						interruptAudio(/* stopMusicToo= */ false);
 						break;
 					}
 					if (ev.type == Common::EVENT_LBUTTONDOWN) {
@@ -856,7 +856,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 
 	// _StopTheVoice @ 1ff1:0283 effect (voice only, keep MIDI). Diverges
 	// from _DisplayClue @ 2404:05e6 which lets voice bleed past dismissal.
-	interruptAudio(/*stopMusicToo=*/false);
+	interruptAudio(/* stopMusicToo= */ false);
 }
 
 void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
@@ -934,7 +934,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					continue;
 				if (ev.type == Common::EVENT_KEYDOWN &&
 					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					interruptAudio(/*stopMusicToo=*/false);
+					interruptAudio(/* stopMusicToo= */ false);
 					return true;  // skip
 				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN ||
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index b809bf86c41..0afc5f5dc9f 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -285,7 +285,7 @@ Common::Error EEMEngine::run() {
 	}
 
 	if (resumed)
-		goto screen_loop;
+		goto screenLoop;
 
 	// _DoOpeningAnims @ 2520:082a:
 	//   EA Kids logo (PIC) -> HighScore logo (PIC) -> Storm logo
@@ -325,13 +325,13 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro)
 			showFloppyStormLogo();
 		if (!shouldQuit() && !_skipIntro && _music)
-			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+			_music->playFile(Common::Path("THEME.XMI"), /* loop= */ true);
 		if (!shouldQuit() && !_skipIntro)
 			playAnm(Common::Path("CHAT.ANM"), 120,
-					/*holdLastFrame=*/false);
+					/* holdLastFrame= */ false);
 		if (!shouldQuit() && !_skipIntro)
 			playAnm(Common::Path("MOVIE.ANM"), 120,
-					/*holdLastFrame=*/false);
+					/* holdLastFrame= */ false);
 	} else {
 		showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
@@ -344,7 +344,7 @@ Common::Error EEMEngine::run() {
 			if (_audio)
 				_audio->playVoc(Common::Path("THUNDER.VOC"));
 			playAnm(Common::Path("BOLT.ANM"), 120,
-					/*holdLastFrame=*/false, /*fadeIn=*/true);
+					/* holdLastFrame= */ false, /* fadeIn= */ true);
 			waitForInput(1800);
 			fadeCurrentPaletteToBlack();
 			if (_audio)
@@ -354,14 +354,14 @@ Common::Error EEMEngine::run() {
 		if (!shouldQuit() && !_skipIntro && _audio)
 			_audio->initMysterySounds(60);
 		if (!shouldQuit() && !_skipIntro && _music)
-			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+			_music->playFile(Common::Path("THEME.XMI"), /* loop= */ true);
 		for (int i = 1; i <= 20 && !shouldQuit() && !_skipIntro; i++) {
 			const bool fadeIn = (i == 1 || i == 5);
 			if (i == 5)
 				fadeCurrentPaletteToBlack();
 			Common::String name = Common::String::format("ANIM%02d.A", i);
 			playAnm(Common::Path(name), 120,
-					/*holdLastFrame=*/false, fadeIn);
+					/* holdLastFrame= */ false, fadeIn);
 			// _SpoolSound(uVar3 - 1) @ 2520:08c2 — per-anim VO, skipped
 			// when uVar3 == 0x14 @ 2520:08a8.
 			if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
@@ -374,17 +374,17 @@ Common::Error EEMEngine::run() {
 			_audio->cleanMysterySounds();
 		// _MIDIPlayFile("theme.xmi") @ 2520:0918.
 		if (!shouldQuit() && !_skipIntro && _music)
-			_music->playFile(Common::Path("THEME.XMI"), /*loop=*/true);
+			_music->playFile(Common::Path("THEME.XMI"), /* loop= */ true);
 		if (!shouldQuit() && !_skipIntro)
 			playAnm(Common::Path("TITLE.ANM"), 120,
-					/*holdLastFrame=*/true, /*fadeIn=*/true);
+					/* holdLastFrame= */ true, /* fadeIn= */ true);
 	}
 	skippedIntro = _skipIntro;
 	_skipIntro = false;
 
 	if (isFloppy() && !shouldQuit() && !skippedIntro) {
 		_nextScreen = kScreenTitle;
-		goto screen_loop;
+		goto screenLoop;
 	}
 
 	// Title(B) -> screen 8 (profile) -> 9 (partner) -> C (action) ->
@@ -413,7 +413,7 @@ Common::Error EEMEngine::run() {
 	// `_mystery.load()` fresh and discard site / clue progress).
 	if (!shouldQuit() && !resumed)
 		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
-screen_loop:
+screenLoop:
 	while (!shouldQuit() && _nextScreen != kScreenInvalid) {
 		const ScreenId current = (ScreenId)_nextScreen;
 		debugC(1, kDebugGeneral, "screenDriver: id=%d", (int)current);
@@ -427,7 +427,7 @@ screen_loop:
 			if (isFloppy()) {
 				CursorMan.showMouse(false);
 				playAnm(Common::Path("TITLE.ANM"), 120,
-						/*holdLastFrame=*/true, /*fadeIn=*/true);
+						/* holdLastFrame= */ true, /* fadeIn= */ true);
 				_skipIntro = false;
 				CursorMan.showMouse(true);
 			}
@@ -963,7 +963,7 @@ void EEMEngine::startTravelMusic() {
 	if (!_music || !_mystery.isLoaded() || !_voiceOn)
 		return;
 	const uint num = _mystery._siteNumber % 5;
-	_music->playMus(num, /*loop=*/false);
+	_music->playMus(num, /* loop= */ false);
 }
 
 void EEMEngine::waitForMusicDone(uint32 maxMs) {
@@ -1180,7 +1180,7 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 	_playerName = name;
 	debugC(1, kDebugGeneral, "saveProfile(%s) -> slot %d",
 		   name.c_str(), slot);
-	return saveGameState(slot, name, /*isAutosave=*/false);
+	return saveGameState(slot, name, /* isAutosave= */ false);
 }
 
 bool EEMEngine::loadProfile(const Common::String &name) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 2d3973a4d97..5935445df7a 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -154,7 +154,7 @@ public:
 	DBDArchive &getPics()    { return _picsArchive; }
 	DBDArchive &getAni()     { return _aniArchive; }
 	DBDArchive &getSites()   { return _sitesArchive; }
-	DBDArchive &getBalloons(){ return _balloonArchive; }
+	DBDArchive &getBalloons() { return _balloonArchive; }
 	DBDArchive &getButtons() { return _buttonArchive; }
 	Mystery    &getMystery() { return _mystery; }
 	const EEMFont &getFont() const { return _font; }
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index c11505fa1dd..8251aee33f7 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -198,12 +198,16 @@ void EEMEngine::doHelp() {
 		const char *str2 = nullptr;
 		const char *str3 = nullptr;
 		const char *p = str1;
-		while ((uint)((const byte *)p - hd) < hsz && *p != 0) p++;
-		if ((uint)((const byte *)p - hd) >= hsz) return;
+		while ((uint)((const byte *)p - hd) < hsz && *p != 0)
+			p++;
+		if ((uint)((const byte *)p - hd) >= hsz)
+			return;
 		str2 = p + 1;
 		p = str2;
-		while ((uint)((const byte *)p - hd) < hsz && *p != 0) p++;
-		if ((uint)((const byte *)p - hd) >= hsz) return;
+		while ((uint)((const byte *)p - hd) < hsz && *p != 0)
+			p++;
+		if ((uint)((const byte *)p - hd) >= hsz)
+			return;
 		str3 = p + 1;
 
 		const char *chosen = nullptr;
diff --git a/engines/eem/music.h b/engines/eem/music.h
index 59dac57e30d..11001a4280a 100644
--- a/engines/eem/music.h
+++ b/engines/eem/music.h
@@ -60,8 +60,8 @@ public:
 	/// floppy: TRAVEL-N.XMI / FANFARE2.XMI.
 	void playMus(uint num, bool loop = false);
 
-	// Miles drivers handle source-channel routing themselves; bypass
-	// Audio::MidiPlayer::sendToChannel. Same workaround as Toltecs / SAGA.
+	// WORKAROUND: Miles drivers handle source-channel routing themselves;
+	// bypass Audio::MidiPlayer::sendToChannel. Same as Toltecs / SAGA.
 	void send(uint32 b) override;
 
 private:
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index f8c888d1843..21a8881c7cc 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1455,11 +1455,17 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 			if (kKid1Rect.contains(mx, my)) {
-				if (_partner != 0) { _partner = 0; dirty = true; }
+				if (_partner != 0) {
+					_partner = 0;
+					dirty = true;
+				}
 				continue;
 			}
 			if (kKid2Rect.contains(mx, my)) {
-				if (_partner != 1) { _partner = 1; dirty = true; }
+				if (_partner != 1) {
+					_partner = 1;
+					dirty = true;
+				}
 				continue;
 			}
 
@@ -1532,7 +1538,7 @@ void EEMEngine::doSetup() {
 					// Restore BG between cards (1560:02e5 `_vga_fvidvid(0)`).
 					draw();
 					const Common::KeyCode k =
-						showFullscreenPic(kHelp1Pics[i], /*transparent=*/true);
+						showFullscreenPic(kHelp1Pics[i], /* transparent= */ true);
 					if (k == Common::KEYCODE_ESCAPE)
 						break;
 				}
@@ -1544,7 +1550,7 @@ void EEMEngine::doSetup() {
 			// Credits [11] @ 1f78:025a — PIC 0x208 fullscreen.
 			if (kCreditsBtn.contains(mx, my)) {
 				CursorMan.showMouse(false);
-				showFullscreenPic(0x208, /*transparent=*/false);
+				showFullscreenPic(0x208, /* transparent= */ false);
 				CursorMan.showMouse(true);
 				// PIC 0x208 has its own baked palette; restore site 0.
 				setSitePalette(0);
@@ -1620,14 +1626,26 @@ void EEMEngine::doActionScreen() {
 	//   stage 3: grey Practice; SB3 needs tier-3 solve
 	//   stage 4: grey Choose + Practice
 	bool anySolved1 = false;
-	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
-		if (_mysteriesSolved[i]) { anySolved1 = true; break; }
+	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++) {
+		if (_mysteriesSolved[i]) {
+			anySolved1 = true;
+			break;
+		}
+	}
 	bool anySolved2 = false;
-	for (uint i = 0x19; i <= 0x30 && i < sizeof(_mysteriesSolved); i++)
-		if (_mysteriesSolved[i]) { anySolved2 = true; break; }
+	for (uint i = 0x19; i <= 0x30 && i < sizeof(_mysteriesSolved); i++) {
+		if (_mysteriesSolved[i]) {
+			anySolved2 = true;
+			break;
+		}
+	}
 	bool anySolved3 = false;
-	for (uint i = 0x31; i <= 0x36 && i < sizeof(_mysteriesSolved); i++)
-		if (_mysteriesSolved[i]) { anySolved3 = true; break; }
+	for (uint i = 0x31; i <= 0x36 && i < sizeof(_mysteriesSolved); i++) {
+		if (_mysteriesSolved[i]) {
+			anySolved3 = true;
+			break;
+		}
+	}
 
 	const bool chooseOn   = _chainStage < 4;
 	const bool practiceOn = _chainStage <= 1;
@@ -1643,7 +1661,10 @@ void EEMEngine::doActionScreen() {
 	// Seed selection on first enabled entry.
 	uint pick = 0;
 	for (uint i = 0; i < kNumPicks; i++) {
-		if (kPickEnabled[i]) { pick = i; break; }
+		if (kPickEnabled[i]) {
+			pick = i;
+			break;
+		}
 	}
 
 	const char *kSeparator = "----------------------------------";
@@ -1838,9 +1859,18 @@ void EEMEngine::doCaseSelection() {
 	uint stageLo = 1, stageHi = 0x18;
 	uint book = 1;
 	switch (_chainStage) {
-	case 2: stageLo = 0x19; stageHi = 0x30; book = 2; break;
-	case 3: stageLo = 0x31; stageHi = 0x36; book = 3; break;
-	default: break;
+	case 2:
+		stageLo = 0x19;
+		stageHi = 0x30;
+		book = 2;
+		break;
+	case 3:
+		stageLo = 0x31;
+		stageHi = 0x36;
+		book = 3;
+		break;
+	default:
+		break;
 	}
 	if (stageHi > kMaxMystery)
 		stageHi = kMaxMystery;
@@ -1924,7 +1954,10 @@ void EEMEngine::doCaseSelection() {
 					return;
 				}
 				if (kChooserUpArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
-					if (topRow > 0) { topRow--; dirty = true; }
+					if (topRow > 0) {
+						topRow--;
+						dirty = true;
+					}
 					continue;
 				}
 				if (kChooserDnArrowRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -3204,20 +3237,21 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 									: READ_LE_UINT16(entry + 0x6);
 		const uint16 crime = floppy ? (uint16)entry[0xa]
 									: READ_LE_UINT16(entry + 0xc);
-		const bool   done_ = (i < Mystery::kVisitedSiteCap)
-							  && _mystery._visitedSite[i];
+		const bool isDone = (i < Mystery::kVisitedSiteCap)
+							 && _mystery._visitedSite[i];
 
 		const Picture *m = nullptr;
 		bool useVisitedColors = false;
-		if (done_ && haveDone)
+		if (isDone && haveDone) {
 			m = &done;
-		else if (done_ && haveNormal) {
+		} else if (isDone && haveNormal) {
 			m = &normal;
 			useVisitedColors = true;
-		} else if (crime != 0 && haveCrime)
+		} else if (crime != 0 && haveCrime) {
 			m = &crimeM;
-		else if (haveNormal)
+		} else if (haveNormal) {
 			m = &normal;
+		}
 
 		if (m) {
 			blitBigMapMarker(scratch, *m, (int)mx, (int)my,
@@ -3886,7 +3920,7 @@ void EEMEngine::doAccuse() {
 					balloonY = (0x50 - balloon.surface.h) / 2;
 
 				// Render gallery first so the snapshot includes partner.
-				drawAccuseGallery(num, gd, /*highlighted=*/-1,
+				drawAccuseGallery(num, gd, /* highlighted= */ -1,
 								  slotRects, slotSuspect);
 
 				Graphics::ManagedSurface ms(320, 200,
@@ -4147,7 +4181,7 @@ void EEMEngine::doAccuse() {
 
 		// MIDI 6 — blocks until done (or click/ESC aborts).
 		if (_music && _voiceOn) {
-			_music->playMus(6, /*loop=*/false);
+			_music->playMus(6, /* loop= */ false);
 			const uint32 musStart = g_system->getMillis();
 			bool aborted = false;
 			while (_music->isPlaying() && !shouldQuit() && !aborted) {
@@ -4265,12 +4299,23 @@ void EEMEngine::doAccuse() {
 		// Chain advance @ 1df2:0824-0850. Skip mystery 0 (practice).
 		// stage 1: 1..0x18, stage 2: 0x19..0x30, stage 3: 0x31..0x36.
 		if (mn != 0) {
-			uint lo = 0, hi = 0;
+			uint lo = 0;
+			uint hi = 0;
 			switch (_chainStage) {
-			case 1: lo = 1;    hi = 0x18; break;
-			case 2: lo = 0x19; hi = 0x30; break;
-			case 3: lo = 0x31; hi = 0x36; break;
-			default: break;
+			case 1:
+				lo = 1;
+				hi = 0x18;
+				break;
+			case 2:
+				lo = 0x19;
+				hi = 0x30;
+				break;
+			case 3:
+				lo = 0x31;
+				hi = 0x36;
+				break;
+			default:
+				break;
 			}
 			bool allSolved = (hi >= lo);
 			for (uint i = lo; i <= hi && allSolved; i++) {
@@ -4332,7 +4377,7 @@ void EEMEngine::doAccuse() {
 		g_system->updateScreen();
 
 		if (_music && _voiceOn)
-			_music->playMus(5, /*loop=*/false);
+			_music->playMus(5, /* loop= */ false);
 
 		// Chain recap — partner enumerates required clues.
 		const byte *solved = _mystery.solvedClueBlock();
@@ -4718,7 +4763,7 @@ void EEMEngine::doAccuseFloppy() {
 				if (tail) {
 					displayFloppyDialogRecords(records, beforeScrapbook, 1);
 					playAnm(Common::Path("SCRAPBK.ANI"), 120,
-							/*holdLastFrame=*/false, /*fadeIn=*/true);
+							/* holdLastFrame= */ false, /* fadeIn= */ true);
 					displayFloppyDialogRecords(tail, 3, 1);
 				} else {
 					warning("doAccuseFloppy: malformed solved chain");
@@ -4737,12 +4782,23 @@ void EEMEngine::doAccuseFloppy() {
 
 		// Tier promotion @ 1d40:0941..0978. Skip mystery 0 (practice).
 		if (mn != 0) {
-			uint lo = 0, hi = 0;
+			uint lo = 0;
+			uint hi = 0;
 			switch (_chainStage) {
-			case 1: lo = 1;    hi = 0x18; break;
-			case 2: lo = 0x19; hi = 0x30; break;
-			case 3: lo = 0x31; hi = 0x36; break;
-			default: break;
+			case 1:
+				lo = 1;
+				hi = 0x18;
+				break;
+			case 2:
+				lo = 0x19;
+				hi = 0x30;
+				break;
+			case 3:
+				lo = 0x31;
+				hi = 0x36;
+				break;
+			default:
+				break;
 			}
 			bool allSolved = (hi >= lo);
 			for (uint i = lo; i <= hi && allSolved; i++) {
@@ -4857,8 +4913,10 @@ void EEMEngine::doAccuseFloppy() {
 	if (haveBalloon) {
 		balloonX = (320 - balloon.surface.w) / 2;
 		balloonY = (0x5a - balloon.surface.h) / 2;
-		if (balloonX < 0) balloonX = 0;
-		if (balloonY < 0) balloonY = 0;
+		if (balloonX < 0)
+			balloonX = 0;
+		if (balloonY < 0)
+			balloonY = 0;
 		const byte transp = (byte)(balloon.flags >> 8);
 		// Mirror flag (high bit) flips balloon horizontally.
 		scene.transBlitFrom(balloon.surface,


Commit: c3c78f3e10f03d724f584c6027e75f66eed3e735
    https://github.com/scummvm/scummvm/commit/c3c78f3e10f03d724f584c6027e75f66eed3e735
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:10+02:00

Commit Message:
EEM: removed lambdas in favor of explicit functions

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index cedbf6d1319..39aca26fb17 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -859,6 +859,40 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	interruptAudio(/* stopMusicToo= */ false);
 }
 
+bool EEMEngine::floppyDialogWaitForClick() {
+	// Drain pending events so a stale keystroke doesn't auto-advance.
+	Common::Event drain;
+	while (g_system->getEventManager()->pollEvent(drain)) {}
+	setInteractiveMouseCursor(false);
+	const uint32 minVisibleMs = 250;
+	const uint32 startedAt = g_system->getMillis();
+	while (!shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return true;  // skip
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				setInteractiveMouseCursor(false);
+				continue;
+			}
+			if (g_system->getMillis() - startedAt < minVisibleMs)
+				continue;
+			if (ev.type == Common::EVENT_KEYDOWN &&
+				ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+				interruptAudio(/* stopMusicToo= */ false);
+				return true;  // skip
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN ||
+				ev.type == Common::EVENT_KEYDOWN)
+				return false;  // advance one page
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+	return true;
+}
+
 void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 											uint lastIndicator) {
 	// Render `count` consecutive floppy dialog records starting at
@@ -913,40 +947,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		}
 	}
 
-	auto waitForClick = [&]() -> bool {
-		// Drain pending events so a stale keystroke doesn't auto-advance.
-		Common::Event drain;
-		while (g_system->getEventManager()->pollEvent(drain)) {}
-		setInteractiveMouseCursor(false);
-		const uint32 minVisibleMs = 250;
-		const uint32 startedAt = g_system->getMillis();
-		while (!shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-					return true;  // skip
-				if (ev.type == Common::EVENT_MOUSEMOVE) {
-					setInteractiveMouseCursor(false);
-					continue;
-				}
-				if (g_system->getMillis() - startedAt < minVisibleMs)
-					continue;
-				if (ev.type == Common::EVENT_KEYDOWN &&
-					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-					interruptAudio(/* stopMusicToo= */ false);
-					return true;  // skip
-				}
-				if (ev.type == Common::EVENT_LBUTTONDOWN ||
-					ev.type == Common::EVENT_KEYDOWN)
-					return false;  // advance one page
-			}
-			g_system->updateScreen();
-			g_system->delayMillis(10);
-		}
-		return true;
-	};
-
 	for (uint i = 0; i < count && !shouldQuit(); i++) {
 		const uint16 picID    = READ_LE_UINT16(rec + 0);
 		const uint16 picX     = READ_LE_UINT16(rec + 2);
@@ -1158,7 +1158,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			g_system->updateScreen();
 
 			if (waitNeeded) {
-				if (waitForClick()) {
+				if (floppyDialogWaitForClick()) {
 					skipAll = true;
 					break;
 				}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 5935445df7a..6d83cdc4da9 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -25,6 +25,7 @@
 #define EEM_EEM_H
 
 #include "common/array.h"
+#include "common/keyboard.h"
 #include "common/language.h"
 #include "common/platform.h"
 #include "common/random.h"
@@ -268,6 +269,52 @@ private:
 
 	/// Re-render helpers for the corresponding `doX()` modal screens.
 	void drawNotebookFrame(int &page);
+
+	/// Resolve a single NoteIndex entry to displayable notebook text.
+	/// Handles the CD (4-byte) vs floppy (7-byte) entry strides.
+	Common::String notebookNoteText(uint clueId, const byte *ni,
+									uint16 niCount, bool floppyNb,
+									const byte *bufBase,
+									uint32 mysSz) const;
+
+	/// Shared state for `doAccuseNotes` helpers (text lookup, pagination,
+	/// render). Pointer fields refer to locals owned by `doAccuseNotes`.
+	struct AccuseNotesCtx {
+		const byte *ni;
+		uint16 niCount;
+		bool floppyNote;
+		const byte *bufBaseNotes;
+		const Common::Array<uint> *found;
+		int rectX, rectY, rectW, rectH;
+		uint expected;
+		bool haveBg;
+		const Picture *accuseBg;
+		Common::Array<Common::Rect> *slotRects;
+		Common::Array<uint> *slotClues;
+		int *pageBreaks;
+		int pageBreaksCap;
+		int *numPages;
+		int *page;
+	};
+
+	/// One NoteIndex entry as displayable accuse-screen text.
+	Common::String accuseNoteText(uint clueId, const AccuseNotesCtx &ctx) const;
+
+	/// Recompute `pageBreaks`/`numPages` after `_noteSelected` or font changes.
+	void accuseRebuildPagination(const AccuseNotesCtx &ctx);
+
+	/// Render one frame of the accuse-notes screen.
+	void accuseDrawScreen(const AccuseNotesCtx &ctx);
+
+	/// Floppy accuse helpers: KD-balloon hint (`_DisplayHint_Floppy @
+	/// 1503:00ca`) and the suspect portrait grid
+	/// (`_DrawGallery_Floppy @ 154e:0050`).
+	void floppyKDHint(uint kdSlot, const byte *kdIdx,
+					  const byte *bufBase, uint32 mysSize);
+	void accuseDrawGallery(int highlighted,
+						   Common::Array<Common::Rect> &rects,
+						   Common::Array<int> &suspects, uint8 num,
+						   bool haveAccuseBg, const Picture &accuseBg);
 	void drawGalleryFrame(const byte *gd, uint8 numSuspects,
 						  Common::Array<Common::Rect> &slotRects,
 						  Common::Array<int> &slotSuspect);
@@ -366,6 +413,12 @@ private:
 	/// pick via SwapColors on Kid1/Kid2 rects.
 	void doSetup();
 
+	/// `doSetup` helpers: redraw BG + label highlights, render a help/
+	/// credits card with blocking input wait, and the shared exit fallback.
+	void setupDrawScreen();
+	Common::KeyCode setupShowFullscreenPic(uint16 picId, bool transparent);
+	void setupLeave();
+
 	/// `_DoInitClues @ 1a35:0411` (minus live ANI sequence playback).
 	void doInitClues();
 
@@ -383,6 +436,11 @@ private:
 	void displayFloppyDialogRecords(const byte *rec, uint count,
 									 uint lastIndicator = 0);
 
+	/// Wait for a click/keypress to advance a floppy dialog page.
+	/// Returns true if the user requested to skip the rest (ESC/QUIT),
+	/// false to advance one page.
+	bool floppyDialogWaitForClick();
+
 public:
 	/// `_StartTravelMusic @ 20a2:0595`. Picks `MUS%05d.XMI` from
 	/// `_mystery._siteNumber % 5`, one-shot.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 167f9d0d1cd..47bd881a88d 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1423,6 +1423,11 @@ void SiteScreen::renderBackground(uint siteNum) {
 	}
 }
 
+void bumpHotspotEdgeColor(byte &color) {
+	const byte next = (byte)(color + 1);
+	color = (next > 0xFE) ? (byte)0xF9 : next;
+}
+
 void SiteScreen::renderHotspots(uint siteNum) {
 	// `_DrawSearchButtons`. Port adds optional "hide hint" setting.
 	if (ConfMan.getBool("hide_highlight_boxes"))
@@ -1484,33 +1489,29 @@ void SiteScreen::renderHotspots(uint siteNum) {
 			// the colour per pixel through palette indices 0xF9..0xFE,
 			// which `_ColorCycle(0xF9, 0xFE)` rotates every tick.
 			byte color = (byte)(0xF9 + ((i + (tickMs / 80)) & 0x07) % 6);
-			auto bumpColor = [&]() {
-				const byte next = (byte)(color + 1);
-				color = (next > 0xFE) ? (byte)0xF9 : next;
-			};
 			// Top edge
 			for (int x = rect.left; x < rect.right; x++) {
 				if (x >= 0 && x < screen->w && rect.top >= 0 && rect.top < screen->h)
 					*(byte *)screen->getBasePtr(x, rect.top) = color;
-				bumpColor();
+				bumpHotspotEdgeColor(color);
 			}
 			// Right edge
 			for (int y = rect.top; y < rect.bottom; y++) {
 				if (rect.right - 1 >= 0 && rect.right - 1 < screen->w && y >= 0 && y < screen->h)
 					*(byte *)screen->getBasePtr(rect.right - 1, y) = color;
-				bumpColor();
+				bumpHotspotEdgeColor(color);
 			}
 			// Bottom edge
 			for (int x = rect.right - 1; x >= rect.left; x--) {
 				if (x >= 0 && x < screen->w && rect.bottom - 1 >= 0 && rect.bottom - 1 < screen->h)
 					*(byte *)screen->getBasePtr(x, rect.bottom - 1) = color;
-				bumpColor();
+				bumpHotspotEdgeColor(color);
 			}
 			// Left edge
 			for (int y = rect.bottom - 1; y >= rect.top; y--) {
 				if (rect.left >= 0 && rect.left < screen->w && y >= 0 && y < screen->h)
 					*(byte *)screen->getBasePtr(rect.left, y) = color;
-				bumpColor();
+				bumpHotspotEdgeColor(color);
 			}
 		}
 	}
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 21a8881c7cc..6e5c9124516 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -86,6 +86,31 @@ void blitBigMapMarker(Graphics::ManagedSurface &dstSurface, const Picture &marke
 	}
 }
 
+// Setup-screen highlight rects (referenced by both `doSetup` and the
+// helper `EEMEngine::setupDrawScreen`). `_SetupHighlights @ 29be:1320`.
+constexpr Common::Rect kSetupKid1Rect    (Common::Point( 99,  44),  49,  8);
+constexpr Common::Rect kSetupKid2Rect    (Common::Point( 99,  54),  49,  8);
+constexpr Common::Rect kSetupSoundOnRect (Common::Point(106,  86),  19,  8);
+constexpr Common::Rect kSetupSoundOffRect(Common::Point(106,  96),  19,  8);
+
+// `_SwapColors @ 172b:1d2a` — replace pixels in r where value==from
+// with to. 0xFE = BG text-key; 0x15 = active palette index, 0x00 =
+// inactive (set by `_SetupSettings @ 1f78:000d`).
+void swapColors(Graphics::ManagedSurface &dst,
+					   const Common::Rect &r, byte from, byte to) {
+	const int x1 = MAX<int>(0, r.left);
+	const int y1 = MAX<int>(0, r.top);
+	const int x2 = MIN<int>(320, r.right);
+	const int y2 = MIN<int>(200, r.bottom);
+	for (int y = y1; y < y2; y++) {
+		byte *row = (byte *)dst.getBasePtr(0, y);
+		for (int x = x1; x < x2; x++) {
+			if (row[x] == from)
+				row[x] = to;
+		}
+	}
+}
+
 void blitAccusePartner(Graphics::ManagedSurface &dstSurface,
 					   DBDArchive &aniArchive, uint8 partner,
 					   uint32 tickMs) {
@@ -1318,114 +1343,15 @@ void EEMEngine::doSetup() {
 	const Common::Rect kHelpBtn      (145, 163, 174, 187); // [9]
 	const Common::Rect kQuitBtn      (212, 153, 266, 184); // [10]
 	const Common::Rect kCreditsBtn   ( 81,  25, 238,  37); // [11]
-	// Highlight / fallback-click rects.
-	const Common::Rect kKid1Rect     ( 99,  44, 148,  52);
-	const Common::Rect kKid2Rect     ( 99,  54, 148,  62);
-	const Common::Rect kSoundOnRect  (106,  86, 125,  94);
-	const Common::Rect kSoundOffRect (106,  96, 125, 104);
-
-	// `_SwapColors @ 172b:1d2a` — replace pixels in r where value==from
-	// with to. 0xFE = BG text-key; 0x15 = active palette index, 0x00 =
-	// inactive (set by `_SetupSettings @ 1f78:000d`).
-	auto swapColors = [](Graphics::ManagedSurface &dst,
-						 const Common::Rect &r, byte from, byte to) {
-		const int x1 = MAX<int>(0, r.left);
-		const int y1 = MAX<int>(0, r.top);
-		const int x2 = MIN<int>(320, r.right);
-		const int y2 = MIN<int>(200, r.bottom);
-		for (int y = y1; y < y2; y++) {
-			byte *row = (byte *)dst.getBasePtr(0, y);
-			for (int x = x1; x < x2; x++) {
-				if (row[x] == from)
-					row[x] = to;
-			}
-		}
-	};
+	// Highlight / fallback-click rects (file-scope copies in `kSetup*Rect`).
+	const Common::Rect &kKid1Rect     = kSetupKid1Rect;
+	const Common::Rect &kKid2Rect     = kSetupKid2Rect;
+	const Common::Rect &kSoundOnRect  = kSetupSoundOnRect;
+	const Common::Rect &kSoundOffRect = kSetupSoundOffRect;
 
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		Picture bg;
-		if (_picsArchive.getPicture(0x40, bg))
-			scratch.simpleBlitFrom(bg.surface);
-
-		const byte kKey    = 0xFE;
-		const byte kBright = 0x15;
-		const byte kDim    = 0x00;
-		swapColors(scratch, kKid1Rect, kKey,
-				   _partner == 0 ? kBright : kDim);
-		swapColors(scratch, kKid2Rect, kKey,
-				   _partner == 1 ? kBright : kDim);
-		swapColors(scratch, kSoundOnRect,  kKey,
-				   _voiceOn ? kBright : kDim);
-		swapColors(scratch, kSoundOffRect, kKey,
-				   _voiceOn ? kDim : kBright);
+	setupDrawScreen();
 
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-	draw();
-
-	// Render picId and block until input. transparent=true: overlay via
-	// `_Rect_Move_Mask` (`_InterfaceHelp @ 1560:0205`). false: raw fullscreen
-	// blit via `_vga_fbuffvid` (credits @ 1f78:0281).
-	auto showFullscreenPic = [&](uint16 picId,
-								  bool transparent) -> Common::KeyCode {
-		Picture pic;
-		if (!_picsArchive.getPicture(picId, pic)) {
-			warning("doSetup: PIC %u missing", (uint)picId);
-			return Common::KEYCODE_INVALID;
-		}
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		if (transparent) {
-			Graphics::Surface *cur = g_system->lockScreen();
-			if (cur) {
-				scratch.simpleBlitFrom(*cur);
-				g_system->unlockScreen();
-			}
-			const byte transp = (byte)(pic.flags >> 8);
-			// Explicit destPos — no-destPos overload stretches to dst.
-			scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
-								  (uint32)transp);
-		} else {
-			scratch.clear();
-			scratch.simpleBlitFrom(pic.surface);
-		}
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-		while (!shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-					return Common::KEYCODE_ESCAPE;
-				if (ev.type == Common::EVENT_KEYDOWN)
-					return ev.kbd.keycode;
-				if (ev.type == Common::EVENT_LBUTTONDOWN)
-					return Common::KEYCODE_INVALID;
-			}
-			g_system->delayMillis(15);
-		}
-		return Common::KEYCODE_ESCAPE;
-	};
-
-	auto leaveSetup = [&]() {
-		// `_DoSetup` entry: _NextScreen = _LastScreen. Fall back to it
-		// unless a handler already overrode _nextScreen.
-		if (_nextScreen == kScreenSetup) {
-			_nextScreen = (ScreenId)_lastScreen;
-			if (_nextScreen == kScreenSetup ||
-				_nextScreen == kScreenInvalid)
-				_nextScreen = kScreenMap;
-		}
-		saveProfile(_playerName);
-	};
-
-	_nextScreen = kScreenSetup;  // sentinel — leaveSetup picks target
+	_nextScreen = kScreenSetup;  // sentinel — setupLeave picks target
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
@@ -1438,7 +1364,7 @@ void EEMEngine::doSetup() {
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE ||
 					ev.kbd.keycode == Common::KEYCODE_RETURN) {
-					leaveSetup();
+					setupLeave();
 					return;
 				}
 			}
@@ -1511,7 +1437,7 @@ void EEMEngine::doSetup() {
 
 			// Done [8] — MOV SI,1; JMP exit (NextScreen stays = LastScreen).
 			if (kDoneBtn.contains(mx, my)) {
-				leaveSetup();
+				setupLeave();
 				return;
 			}
 
@@ -1536,9 +1462,9 @@ void EEMEngine::doSetup() {
 				CursorMan.showMouse(false);
 				for (uint i = 0; i < ARRAYSIZE(kHelp1Pics); i++) {
 					// Restore BG between cards (1560:02e5 `_vga_fvidvid(0)`).
-					draw();
+					setupDrawScreen();
 					const Common::KeyCode k =
-						showFullscreenPic(kHelp1Pics[i], /* transparent= */ true);
+						setupShowFullscreenPic(kHelp1Pics[i], /* transparent= */ true);
 					if (k == Common::KEYCODE_ESCAPE)
 						break;
 				}
@@ -1550,7 +1476,7 @@ void EEMEngine::doSetup() {
 			// Credits [11] @ 1f78:025a — PIC 0x208 fullscreen.
 			if (kCreditsBtn.contains(mx, my)) {
 				CursorMan.showMouse(false);
-				showFullscreenPic(0x208, /* transparent= */ false);
+				setupShowFullscreenPic(0x208, /* transparent= */ false);
 				CursorMan.showMouse(true);
 				// PIC 0x208 has its own baked palette; restore site 0.
 				setSitePalette(0);
@@ -1586,12 +1512,93 @@ void EEMEngine::doSetup() {
 			}
 		}
 		if (dirty)
-			draw();
+			setupDrawScreen();
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 }
 
+void EEMEngine::setupDrawScreen() {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	Picture bg;
+	if (_picsArchive.getPicture(0x40, bg))
+		scratch.simpleBlitFrom(bg.surface);
+
+	const byte kKey    = 0xFE;
+	const byte kBright = 0x15;
+	const byte kDim    = 0x00;
+	swapColors(scratch, kSetupKid1Rect, kKey,
+			   _partner == 0 ? kBright : kDim);
+	swapColors(scratch, kSetupKid2Rect, kKey,
+			   _partner == 1 ? kBright : kDim);
+	swapColors(scratch, kSetupSoundOnRect,  kKey,
+			   _voiceOn ? kBright : kDim);
+	swapColors(scratch, kSetupSoundOffRect, kKey,
+			   _voiceOn ? kDim : kBright);
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
+// Render picId and block until input. transparent=true: overlay via
+// `_Rect_Move_Mask` (`_InterfaceHelp @ 1560:0205`). false: raw fullscreen
+// blit via `_vga_fbuffvid` (credits @ 1f78:0281).
+Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent) {
+	Picture pic;
+	if (!_picsArchive.getPicture(picId, pic)) {
+		warning("doSetup: PIC %u missing", (uint)picId);
+		return Common::KEYCODE_INVALID;
+	}
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	if (transparent) {
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			scratch.simpleBlitFrom(*cur);
+			g_system->unlockScreen();
+		}
+		const byte transp = (byte)(pic.flags >> 8);
+		// Explicit destPos — no-destPos overload stretches to dst.
+		scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
+							  (uint32)transp);
+	} else {
+		scratch.clear();
+		scratch.simpleBlitFrom(pic.surface);
+	}
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+	while (!shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return Common::KEYCODE_ESCAPE;
+			if (ev.type == Common::EVENT_KEYDOWN)
+				return ev.kbd.keycode;
+			if (ev.type == Common::EVENT_LBUTTONDOWN)
+				return Common::KEYCODE_INVALID;
+		}
+		g_system->delayMillis(15);
+	}
+	return Common::KEYCODE_ESCAPE;
+}
+
+void EEMEngine::setupLeave() {
+	// `_DoSetup` entry: _NextScreen = _LastScreen. Fall back to it
+	// unless a handler already overrode _nextScreen.
+	if (_nextScreen == kScreenSetup) {
+		_nextScreen = (ScreenId)_lastScreen;
+		if (_nextScreen == kScreenSetup ||
+			_nextScreen == kScreenInvalid)
+			_nextScreen = kScreenMap;
+	}
+	saveProfile(_playerName);
+}
+
 void EEMEngine::doActionScreen() {
 	// `_ActionScreen @ 1c33:195b` — BG PIC 0x104 + PIC 9 @ (10, 0x87),
 	// `_DoChoose` with ActionNames @ 29be:0d6a. 5 picks alternating with
@@ -2232,6 +2239,28 @@ void EEMEngine::doNotebook() {
 	setInteractiveMouseCursor(false);
 }
 
+Common::String EEMEngine::notebookNoteText(uint clueId, const byte *ni,
+										   uint16 niCount, bool floppyNb,
+										   const byte *bufBase,
+										   uint32 mysSz) const {
+	if (!ni || clueId >= niCount)
+		return Common::String();
+	if (floppyNb && bufBase) {
+		const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
+		if (textOff == 0 || textOff >= mysSz)
+			return Common::String();
+		const char *p = (const char *)(bufBase + textOff);
+		uint32 len = 0;
+		while (textOff + len < mysSz && p[len] != 0)
+			len++;
+		return parseString(Common::String(p, len),
+						   _playerName, _partner);
+	}
+	const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+	return parseString(_mystery.textAt(textOff),
+					   _playerName, _partner);
+}
+
 void EEMEngine::drawNotebookFrame(int &page) {
 	// `_DrawNotes @ 161e:01d0` per-page layout + partner sprite at (5,80)
 	// from `_DoNotebook @ 161e:0500`.
@@ -2282,30 +2311,13 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	const bool floppyNb = isFloppy();
 	const byte *bufBase = _mystery.blobAt(0);
 	const uint32 mysSz  = _mystery.dataSize();
-	auto noteText = [&](uint clueId) -> Common::String {
-		if (!ni || clueId >= niCount)
-			return Common::String();
-		if (floppyNb && bufBase) {
-			const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
-			if (textOff == 0 || textOff >= mysSz)
-				return Common::String();
-			const char *p = (const char *)(bufBase + textOff);
-			uint32 len = 0;
-			while (textOff + len < mysSz && p[len] != 0)
-				len++;
-			return parseString(Common::String(p, len),
-							   _playerName, _partner);
-		}
-		const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-		return parseString(_mystery.textAt(textOff),
-						   _playerName, _partner);
-	};
 	{
 		const int lineH = _font.getFontHeight();
 		int y = kRectY;
 		while (clueCursor < (int)found.size()) {
 			const uint clueId = found[clueCursor];
-			Common::String txt = noteText(clueId);
+			Common::String txt = notebookNoteText(clueId, ni, niCount,
+												  floppyNb, bufBase, mysSz);
 			// Measure height by wrapping the text without drawing.
 			Common::Array<Common::String> wrapped;
 			_font.wordWrapText(txt, kRectW, wrapped);
@@ -2336,7 +2348,8 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	int y = kRectY;
 	for (int i = startClue; i < endClue; i++) {
 		const uint clueId = found[i];
-		Common::String txt = noteText(clueId);
+		Common::String txt = notebookNoteText(clueId, ni, niCount,
+											  floppyNb, bufBase, mysSz);
 		if (txt.empty())
 			txt = Common::String::format(
 				isSpanish() ? "nota %u" : "clue %u", clueId);
@@ -3378,6 +3391,132 @@ uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
 	return kDigitBalloons[firstChar - '0'];
 }
 
+Common::String EEMEngine::accuseNoteText(uint clueId,
+										 const AccuseNotesCtx &ctx) const {
+	if (ctx.floppyNote) {
+		const uint16 textOff = READ_LE_UINT16(ctx.ni + clueId * 7);
+		if (textOff == 0 || textOff >= _mystery.dataSize() ||
+			!ctx.bufBaseNotes)
+			return Common::String();
+		return parseString(
+			(const char *)(ctx.bufBaseNotes + textOff),
+			_playerName, _partner);
+	}
+	const uint16 textOff = READ_LE_UINT16(ctx.ni + clueId * 4);
+	return parseString(_mystery.textAt(textOff),
+					   _playerName, _partner);
+}
+
+void EEMEngine::accuseRebuildPagination(const AccuseNotesCtx &ctx) {
+	*ctx.numPages = 1;
+	ctx.pageBreaks[0] = 0;
+	const int lineH = _font.getFontHeight();
+	int y = ctx.rectY;
+	const Common::Array<uint> &found = *ctx.found;
+	for (uint i = 0; i < found.size(); i++) {
+		const uint clueId = found[i];
+		Common::String txt;
+		if (clueId < ctx.niCount)
+			txt = accuseNoteText(clueId, ctx);
+		Common::Array<Common::String> wrapped;
+		_font.wordWrapText(txt, ctx.rectW, wrapped);
+		const int h = (int)wrapped.size() * lineH;
+		if (y + h + 7 > ctx.rectY + ctx.rectH) {
+			if (*ctx.numPages < ctx.pageBreaksCap) {
+				ctx.pageBreaks[(*ctx.numPages)++] = (int)i;
+				y = ctx.rectY;
+			}
+		}
+		y += h + 7;
+	}
+	if (*ctx.page >= *ctx.numPages)
+		*ctx.page = *ctx.numPages - 1;
+	if (*ctx.page < 0)
+		*ctx.page = 0;
+}
+
+void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (ctx.haveBg)
+		scratch.simpleBlitFrom(ctx.accuseBg->surface);
+
+	// Partner at (5, 0x50). `_DoAccuse @ 1df2:0c2c`: ANI 2/0x10,
+	// script 2, prior 1.
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+		!partnerAni.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = partnerFrameAtTick(0x02,
+												  (uint)partnerAni.size(), now);
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  partnerAni[frameIdx], 5, 0x50);
+	}
+
+	// Selected=0x3c, unselected=1 (`_NoteUnselectedColor` @ 1df2:0c25).
+	Common::Array<Common::Rect> &slotRects = *ctx.slotRects;
+	Common::Array<uint> &slotClues = *ctx.slotClues;
+	const Common::Array<uint> &found = *ctx.found;
+	slotRects.clear();
+	slotClues.clear();
+	const int lineH = _font.getFontHeight();
+	const int startIdx = ctx.pageBreaks[*ctx.page];
+	const int endIdx   = (*ctx.page + 1 < *ctx.numPages)
+		? ctx.pageBreaks[*ctx.page + 1]
+		: (int)found.size();
+	int y = ctx.rectY;
+	uint selectedCount = 0;
+	for (uint i = 0; i < found.size(); i++) {
+		if (_mystery._noteSelected[found[i]])
+			selectedCount++;
+	}
+	for (int i = startIdx; i < endIdx; i++) {
+		const uint clueId = found[i];
+		Common::String txt;
+		if (clueId < ctx.niCount)
+			txt = accuseNoteText(clueId, ctx);
+		if (txt.empty())
+			txt = Common::String::format(
+				isSpanish() ? "nota %u" : "clue %u", clueId);
+		Common::Array<Common::String> wrapped;
+		_font.wordWrapText(txt, ctx.rectW, wrapped);
+		const int h = (int)wrapped.size() * lineH;
+		const byte color = _mystery._noteSelected[clueId] ? 0x3c : 0x01;
+		for (uint li = 0; li < wrapped.size(); li++) {
+			_font.drawString(&scratch, wrapped[li], ctx.rectX,
+							 y + (int)li * lineH, ctx.rectW, color);
+		}
+		slotRects.push_back(Common::Rect(ctx.rectX, y,
+										  ctx.rectX + ctx.rectW, y + h));
+		slotClues.push_back(clueId);
+		y += h + 7;
+	}
+
+	// `_UpdateSelectionCount(remaining)` @ (0xd1, 0xb).
+	const uint remaining = (selectedCount < ctx.expected)
+		? ctx.expected - selectedCount
+		: 0;
+	// Spanish floppy uses "nota/notas".
+	const char *clueWord = isSpanish()
+		? (remaining == 1 ? "nota" : "notas")
+		: (remaining == 1 ? "clue" : "clues");
+	const Common::String counter =
+		Common::String::format("%u %s", remaining, clueWord);
+	_font.drawString(&scratch, counter, 209, 11, 100, 0x0F);
+
+	if (*ctx.numPages > 1) {
+		_font.drawString(&scratch,
+			Common::String::format("p%d/%d", *ctx.page + 1, *ctx.numPages),
+			ctx.rectX, 11, 60, 0x0F);
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 bool EEMEngine::doAccuseNotes() {
 	// `_DoAccuse @ 1df2:0bdd` head. BG PIC 0x1A7. `_AccuseNoteRect @
 	// 29be:1048` = (79, 27, 304, 159). Counter @ (209, 11) shows
@@ -3434,129 +3573,29 @@ bool EEMEngine::doAccuseNotes() {
 	// Notebook always uses +0 (`FUN_15e0_01e8`).
 	const bool floppyNote = isFloppy();
 	const byte *bufBaseNotes = _mystery.blobAt(0);
-	auto noteText = [&](uint clueId) -> Common::String {
-		if (floppyNote) {
-			const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
-			if (textOff == 0 || textOff >= _mystery.dataSize() ||
-				!bufBaseNotes)
-				return Common::String();
-			return parseString(
-				(const char *)(bufBaseNotes + textOff),
-				_playerName, _partner);
-		}
-		const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-		return parseString(_mystery.textAt(textOff),
-						   _playerName, _partner);
-	};
-
-	auto rebuildPagination = [&]() {
-		numPages = 1;
-		pageBreaks[0] = 0;
-		const int lineH = _font.getFontHeight();
-		int y = rectY;
-		for (uint i = 0; i < found.size(); i++) {
-			const uint clueId = found[i];
-			Common::String txt;
-			if (clueId < niCount)
-				txt = noteText(clueId);
-			Common::Array<Common::String> wrapped;
-			_font.wordWrapText(txt, rectW, wrapped);
-			const int h = (int)wrapped.size() * lineH;
-			if (y + h + 7 > rectY + rectH) {
-				if (numPages < (int)ARRAYSIZE(pageBreaks)) {
-					pageBreaks[numPages++] = (int)i;
-					y = rectY;
-				}
-			}
-			y += h + 7;
-		}
-		if (page >= numPages)
-			page = numPages - 1;
-		if (page < 0)
-			page = 0;
-	};
-
-	auto draw = [&]() {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveBg)
-			scratch.simpleBlitFrom(accuseBg.surface);
-
-		// Partner at (5, 0x50). `_DoAccuse @ 1df2:0c2c`: ANI 2/0x10,
-		// script 2, prior 1.
-		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-		Animation partnerAni;
-		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-			!partnerAni.empty()) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = partnerFrameAtTick(0x02,
-													  (uint)partnerAni.size(), now);
-			blitAnimFrameAnchored(scratch.surfacePtr(),
-								  partnerAni[frameIdx], 5, 0x50);
-		}
-
-		// Selected=0x3c, unselected=1 (`_NoteUnselectedColor` @ 1df2:0c25).
-		slotRects.clear();
-		slotClues.clear();
-		const int lineH = _font.getFontHeight();
-		const int startIdx = pageBreaks[page];
-		const int endIdx   = (page + 1 < numPages)
-			? pageBreaks[page + 1]
-			: (int)found.size();
-		int y = rectY;
-		uint selectedCount = 0;
-		for (uint i = 0; i < found.size(); i++) {
-			if (_mystery._noteSelected[found[i]])
-				selectedCount++;
-		}
-		for (int i = startIdx; i < endIdx; i++) {
-			const uint clueId = found[i];
-			Common::String txt;
-			if (clueId < niCount)
-				txt = noteText(clueId);
-			if (txt.empty())
-				txt = Common::String::format(
-					isSpanish() ? "nota %u" : "clue %u", clueId);
-			Common::Array<Common::String> wrapped;
-			_font.wordWrapText(txt, rectW, wrapped);
-			const int h = (int)wrapped.size() * lineH;
-			const byte color = _mystery._noteSelected[clueId] ? 0x3c : 0x01;
-			for (uint li = 0; li < wrapped.size(); li++) {
-				_font.drawString(&scratch, wrapped[li], rectX,
-								 y + (int)li * lineH, rectW, color);
-			}
-			slotRects.push_back(Common::Rect(rectX, y,
-											  rectX + rectW, y + h));
-			slotClues.push_back(clueId);
-			y += h + 7;
-		}
-
-		// `_UpdateSelectionCount(remaining)` @ (0xd1, 0xb).
-		const uint remaining = (selectedCount < expected)
-			? expected - selectedCount
-			: 0;
-		// Spanish floppy uses "nota/notas".
-		const char *clueWord = isSpanish()
-			? (remaining == 1 ? "nota" : "notas")
-			: (remaining == 1 ? "clue" : "clues");
-		const Common::String counter =
-			Common::String::format("%u %s", remaining, clueWord);
-		_font.drawString(&scratch, counter, 209, 11, 100, 0x0F);
-
-		if (numPages > 1) {
-			_font.drawString(&scratch,
-				Common::String::format("p%d/%d", page + 1, numPages),
-				rectX, 11, 60, 0x0F);
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
 
-	rebuildPagination();
-	draw();
+	AccuseNotesCtx ctx;
+	ctx.ni            = ni;
+	ctx.niCount       = niCount;
+	ctx.floppyNote    = floppyNote;
+	ctx.bufBaseNotes  = bufBaseNotes;
+	ctx.found         = &found;
+	ctx.rectX         = rectX;
+	ctx.rectY         = rectY;
+	ctx.rectW         = rectW;
+	ctx.rectH         = rectH;
+	ctx.expected      = expected;
+	ctx.haveBg        = haveBg;
+	ctx.accuseBg      = &accuseBg;
+	ctx.slotRects     = &slotRects;
+	ctx.slotClues     = &slotClues;
+	ctx.pageBreaks    = pageBreaks;
+	ctx.pageBreaksCap = (int)ARRAYSIZE(pageBreaks);
+	ctx.numPages      = &numPages;
+	ctx.page          = &page;
+
+	accuseRebuildPagination(ctx);
+	accuseDrawScreen(ctx);
 	Common::Point mouse = g_system->getEventManager()->getMousePos();
 	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
 							  kPdaNotebookRect.contains(mouse.x, mouse.y) ||
@@ -3674,13 +3713,13 @@ bool EEMEngine::doAccuseNotes() {
 			}
 		}
 		if (dirty)
-			draw();
+			accuseDrawScreen(ctx);
 		// 100 ms `_CheckFrameRate` cadence @ 1df2:0bfa.
 		static uint32 sLastTick = 0;
 		const uint32 now = g_system->getMillis();
 		if (now - sLastTick >= 100) {
 			sLastTick = now;
-			draw();
+			accuseDrawScreen(ctx);
 		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
@@ -4405,6 +4444,148 @@ void EEMEngine::doAccuse() {
 	}
 }
 
+// Render one KDTextIndex string as a centred KD balloon over the current
+// screen, mirroring `_DisplayHint_Floppy @ 1503:00ca` (= `FUN_1d40_11fd`'s
+// body). `kdSlot` is the index into KDTextIndex (0..N) — entries are u16
+// absolute text offsets.
+void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
+							 const byte *bufBase, uint32 mysSize) {
+	if ((uint)(kdSlot * 2) + 2 > (uint)(mysSize - (kdIdx - bufBase)))
+		return;
+	const uint16 textOff = READ_LE_UINT16(kdIdx + kdSlot * 2);
+	if (textOff == 0 || textOff >= mysSize)
+		return;
+	const char *p = (const char *)(bufBase + textOff);
+	uint32 lineLen = 0;
+	while (textOff + lineLen < mysSize && p[lineLen] != 0)
+		lineLen++;
+	if (lineLen == 0)
+		return;
+	Common::String raw(p, lineLen);
+	// Digit → balloon variant (`_GetKDTextBalloon_Floppy @ 1d40:009f`
+	// + table @ 2608:0c14).
+	static const uint8 kDigitToBalloon[10] = {
+		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
+	};
+	uint balloonIdx = 0x17;
+	const char *txt = raw.c_str();
+	if (*txt >= '0' && *txt <= '9') {
+		balloonIdx = kDigitToBalloon[(int)(*txt - '0')];
+		txt++;
+	}
+	Common::String text =
+		parseString(Common::String(txt), _playerName, _partner);
+	balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
+	Graphics::ManagedSurface ms(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	Graphics::Surface *cur = g_system->lockScreen();
+	if (cur) {
+		ms.simpleBlitFrom(*cur);
+		g_system->unlockScreen();
+	}
+	Picture balloon;
+	const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
+		_balloonArchive.loadEntry(balloonIdx, balloon);
+	uint16 balloonY = 1;
+	if (haveBalloon) {
+		const uint h = (uint)balloon.surface.h;
+		if (h < 0x4e)
+			balloonY = (uint16)((0x50 - h) >> 1);
+		const byte transp = (byte)(balloon.flags >> 8);
+		ms.transBlitFrom(balloon.surface,
+						 Common::Point(0x21, balloonY), transp);
+	}
+	uint16 bx = 5;
+	uint16 by = 4;
+	uint16 bw = 142;
+	getBalloonInsets(balloonIdx, bx, by, bw);
+	_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
+						  MAX<int>(8, (int)bw), text, 0);
+	g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
+	g_system->updateScreen();
+	// Wait for click.
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool advance = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				ev.type == Common::EVENT_LBUTTONDOWN ||
+				ev.type == Common::EVENT_KEYDOWN) {
+				advance = true;
+				break;
+			}
+		}
+		if (advance)
+			break;
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+}
+
+void EEMEngine::accuseDrawGallery(int highlighted,
+								  Common::Array<Common::Rect> &rects,
+								  Common::Array<int> &suspects, uint8 num,
+								  bool haveAccuseBg,
+								  const Picture &accuseBg) {
+	Graphics::ManagedSurface scratch(320, 200,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	if (haveAccuseBg)
+		scratch.simpleBlitFrom(accuseBg.surface);
+
+	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
+	Animation partnerAni;
+	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
+		!partnerAni.empty()) {
+		const uint32 now = g_system->getMillis();
+		const uint frameIdx = partnerFrameAtTick(0x02,
+												  (uint)partnerAni.size(), now);
+		blitAnimFrameAnchored(scratch.surfacePtr(),
+							  partnerAni[frameIdx], 5, 0x50);
+	}
+
+	rects.resize(num);
+	suspects.resize(num);
+	for (uint i = 0; i < num; i++) {
+		rects[i] = Common::Rect();
+		suspects[i] = -1;
+		const uint8 phys = _mystery._newOrder[i];
+		if (phys >= 5)
+			continue;
+		if (_mystery._inGallery[phys] == 0)
+			continue;
+		const byte *e = _mystery.floppySuspectEntry(i);
+		if (!e)
+			continue;
+		const uint16 picId = READ_LE_UINT16(e + 0);
+		if (picId == 0)
+			continue;
+		Picture portrait;
+		if (!_picsArchive.getPicture(picId, portrait))
+			continue;
+		const GallerySlot &s = kFloppyGallerySlots[phys];
+		// Bottom-align to baseline 0x48 (`154e:00ed`).
+		const int placeX = s.x;
+		const int placeY = s.y + (0x48 - portrait.surface.h);
+		const byte transp = (byte)(portrait.flags >> 8);
+		scratch.transBlitFrom(portrait.surface,
+							  Common::Point(placeX, placeY),
+							  (uint32)transp);
+		rects[i] = Common::Rect(placeX, placeY,
+								 placeX + portrait.surface.w,
+								 placeY + portrait.surface.h);
+		suspects[i] = (int)i;
+		if (highlighted == (int)i) {
+			scratch.frameRect(rects[i], 0xFE);
+		}
+	}
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, 320, 200);
+	g_system->updateScreen();
+}
+
 void EEMEngine::doAccuseFloppy() {
 	// Floppy accuse:
 	//   `_KDHelp_Floppy / FUN_1d40_11fd @ 1d40:11fd` — score gate.
@@ -4419,85 +4600,6 @@ void EEMEngine::doAccuseFloppy() {
 	if (!kdIdx || !bufBase)
 		return;
 
-	// Helper: render one KDTextIndex string as a centred KD balloon
-	// over the current screen, mirroring `_DisplayHint_Floppy @
-	// 1503:00ca` (= `FUN_1d40_11fd`'s body). `kdSlot` is the index
-	// into KDTextIndex (0..N) — entries are u16 absolute text
-	// offsets.
-	auto showFloppyKDHint = [&](uint kdSlot) {
-		if ((uint)(kdSlot * 2) + 2 > (uint)(mysSize - (kdIdx - bufBase)))
-			return;
-		const uint16 textOff = READ_LE_UINT16(kdIdx + kdSlot * 2);
-		if (textOff == 0 || textOff >= mysSize)
-			return;
-		const char *p = (const char *)(bufBase + textOff);
-		uint32 lineLen = 0;
-		while (textOff + lineLen < mysSize && p[lineLen] != 0)
-			lineLen++;
-		if (lineLen == 0)
-			return;
-		Common::String raw(p, lineLen);
-		// Digit → balloon variant (`_GetKDTextBalloon_Floppy @ 1d40:009f`
-		// + table @ 2608:0c14).
-		static const uint8 kDigitToBalloon[10] = {
-			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
-		};
-		uint balloonIdx = 0x17;
-		const char *txt = raw.c_str();
-		if (*txt >= '0' && *txt <= '9') {
-			balloonIdx = kDigitToBalloon[(int)(*txt - '0')];
-			txt++;
-		}
-		Common::String text =
-			parseString(Common::String(txt), _playerName, _partner);
-		balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
-		Graphics::ManagedSurface ms(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		Graphics::Surface *cur = g_system->lockScreen();
-		if (cur) {
-			ms.simpleBlitFrom(*cur);
-			g_system->unlockScreen();
-		}
-		Picture balloon;
-		const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
-			_balloonArchive.loadEntry(balloonIdx, balloon);
-		uint16 balloonY = 1;
-		if (haveBalloon) {
-			const uint h = (uint)balloon.surface.h;
-			if (h < 0x4e)
-				balloonY = (uint16)((0x50 - h) >> 1);
-			const byte transp = (byte)(balloon.flags >> 8);
-			ms.transBlitFrom(balloon.surface,
-							 Common::Point(0x21, balloonY), transp);
-		}
-		uint16 bx = 5;
-		uint16 by = 4;
-		uint16 bw = 142;
-		getBalloonInsets(balloonIdx, bx, by, bw);
-		_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
-							  MAX<int>(8, (int)bw), text, 0);
-		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
-		g_system->updateScreen();
-		// Wait for click.
-		while (!shouldQuit()) {
-			Common::Event ev;
-			bool advance = false;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
-					ev.type == Common::EVENT_LBUTTONDOWN ||
-					ev.type == Common::EVENT_KEYDOWN) {
-					advance = true;
-					break;
-				}
-			}
-			if (advance)
-				break;
-			g_system->updateScreen();
-			g_system->delayMillis(10);
-		}
-	};
-
 	// `FUN_1d40_11fd` — score gate, ready when score >= 100.
 	const int score = _mystery.selectedPoints();
 	uint kdSlot;
@@ -4512,7 +4614,7 @@ void EEMEngine::doAccuseFloppy() {
 		kdSlot = 2;        // "ready to solve"
 		readyToSolve = true;
 	}
-	showFloppyKDHint(kdSlot);
+	floppyKDHint(kdSlot, kdIdx, bufBase, mysSize);
 	if (!readyToSolve) {
 		_nextScreen = _lastScreen != kScreenInvalid
 			? (ScreenId)_lastScreen : kScreenSite;
@@ -4545,14 +4647,14 @@ void EEMEngine::doAccuseFloppy() {
 		}
 	}
 	if (userSelectedScore < 100) {
-		showFloppyKDHint(3);
+		floppyKDHint(3, kdIdx, bufBase, mysSize);
 		_nextScreen = _lastScreen != kScreenInvalid
 			? (ScreenId)_lastScreen : kScreenSite;
 		return;
 	}
 
 	// `FUN_1d40_0c79` — gallery picker. KDTextIndex slot 4 prompt.
-	showFloppyKDHint(4);
+	floppyKDHint(4, kdIdx, bufBase, mysSize);
 
 	// Floppy gallery: byte0 = numSuspects, then variable-stride entries.
 	const uint8 num = _mystery.numSuspects();
@@ -4562,82 +4664,15 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// `_DrawGallery_Floppy @ 154e:0050` slots @ 2608:016c.
-	struct GallerySlot { int x, y; };
-	static const GallerySlot kFloppySlots[5] = {
-		{ 0x53, 0x0e }, { 0x9b, 0x0e }, { 0xe3, 0x0e },
-		{ 0x77, 0x5a }, { 0xbf, 0x5a },
-	};
-
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
-	auto drawGallery = [&](int highlighted,
-						   Common::Array<Common::Rect> &rects,
-						   Common::Array<int> &suspects) {
-		Graphics::ManagedSurface scratch(320, 200,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.clear();
-		if (haveAccuseBg)
-			scratch.simpleBlitFrom(accuseBg.surface);
-
-		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-		Animation partnerAni;
-		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-			!partnerAni.empty()) {
-			const uint32 now = g_system->getMillis();
-			const uint frameIdx = partnerFrameAtTick(0x02,
-													  (uint)partnerAni.size(), now);
-			blitAnimFrameAnchored(scratch.surfacePtr(),
-								  partnerAni[frameIdx], 5, 0x50);
-		}
-
-		rects.resize(num);
-		suspects.resize(num);
-		for (uint i = 0; i < num; i++) {
-			rects[i] = Common::Rect();
-			suspects[i] = -1;
-			const uint8 phys = _mystery._newOrder[i];
-			if (phys >= 5)
-				continue;
-			if (_mystery._inGallery[phys] == 0)
-				continue;
-			const byte *e = _mystery.floppySuspectEntry(i);
-			if (!e)
-				continue;
-			const uint16 picId = READ_LE_UINT16(e + 0);
-			if (picId == 0)
-				continue;
-			Picture portrait;
-			if (!_picsArchive.getPicture(picId, portrait))
-				continue;
-			const GallerySlot &s = kFloppySlots[phys];
-			// Bottom-align to baseline 0x48 (`154e:00ed`).
-			const int placeX = s.x;
-			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const byte transp = (byte)(portrait.flags >> 8);
-			scratch.transBlitFrom(portrait.surface,
-								  Common::Point(placeX, placeY),
-								  (uint32)transp);
-			rects[i] = Common::Rect(placeX, placeY,
-									 placeX + portrait.surface.w,
-									 placeY + portrait.surface.h);
-			suspects[i] = (int)i;
-			if (highlighted == (int)i) {
-				scratch.frameRect(rects[i], 0xFE);
-			}
-		}
-
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
-		g_system->updateScreen();
-	};
-
 	Common::Array<Common::Rect> slotRects;
 	Common::Array<int> slotSuspect;
 	int highlighted = 0;
 	int picked = -1;
-	drawGallery(highlighted, slotRects, slotSuspect);
+	accuseDrawGallery(highlighted, slotRects, slotSuspect, num,
+					  haveAccuseBg, accuseBg);
 
 	uint32 lastTick = g_system->getMillis();
 	while (picked < 0 && !shouldQuit()) {
@@ -4649,17 +4684,20 @@ void EEMEngine::doAccuseFloppy() {
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					openMainMenuDialog();
-					drawGallery(highlighted, slotRects, slotSuspect);
+					accuseDrawGallery(highlighted, slotRects, slotSuspect,
+									  num, haveAccuseBg, accuseBg);
 					continue;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_TAB ||
 					ev.kbd.keycode == Common::KEYCODE_RIGHT) {
 					highlighted = (highlighted + 1) % MAX<int>(1, (int)num);
-					drawGallery(highlighted, slotRects, slotSuspect);
+					accuseDrawGallery(highlighted, slotRects, slotSuspect,
+									  num, haveAccuseBg, accuseBg);
 				} else if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
 					highlighted = (highlighted + (int)num - 1) %
 								  MAX<int>(1, (int)num);
-					drawGallery(highlighted, slotRects, slotSuspect);
+					accuseDrawGallery(highlighted, slotRects, slotSuspect,
+									  num, haveAccuseBg, accuseBg);
 				} else if ((ev.kbd.keycode == Common::KEYCODE_RETURN ||
 							ev.kbd.keycode == Common::KEYCODE_KP_ENTER) &&
 						   highlighted < (int)slotRects.size() &&
@@ -4679,7 +4717,8 @@ void EEMEngine::doAccuseFloppy() {
 		}
 		const uint32 now = g_system->getMillis();
 		if (now - lastTick >= 100) {
-			drawGallery(highlighted, slotRects, slotSuspect);
+			accuseDrawGallery(highlighted, slotRects, slotSuspect, num,
+							  haveAccuseBg, accuseBg);
 			lastTick = now;
 		}
 		g_system->updateScreen();
@@ -4956,7 +4995,7 @@ void EEMEngine::doAccuseFloppy() {
 	}
 
 	// `_DisplayAlibi_Floppy @ 1d40:01ee` — KDTextIndex[+10] = slot 5.
-	showFloppyKDHint(5);
+	floppyKDHint(5, kdIdx, bufBase, mysSize);
 	if (_music && _voiceOn)
 		_music->stop();
 


Commit: b8ebea90adecad7133d828add76452026b3249ea
    https://github.com/scummvm/scummvm/commit/b8ebea90adecad7133d828add76452026b3249ea
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:11+02:00

Commit Message:
EEM: correct implementation of OpenColorCycle

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 0afc5f5dc9f..89c89a88b48 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -804,76 +804,128 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 	}
 }
 
+// _OpenColorCycle @ 2520:04f7. Rotate `fpal[start..end]` by one slot:
+//   saved = fpal[end]
+//   for u = end..start+1: fpal[u] = fpal[u-1]
+//   fpal[start] = saved
+// If `show`, upload `end - start` entries (fpal[start..end-1]) — note that
+// fpal[end] is rotated in memory but intentionally not uploaded each tick.
+static void openColorCycle(byte *fpal, uint8 start, uint8 end, bool show) {
+	if (end <= start)
+		return;
+	const byte savedR = fpal[end * 3 + 0];
+	const byte savedG = fpal[end * 3 + 1];
+	const byte savedB = fpal[end * 3 + 2];
+	for (uint u = end; u > start; u--) {
+		fpal[u * 3 + 0] = fpal[(u - 1) * 3 + 0];
+		fpal[u * 3 + 1] = fpal[(u - 1) * 3 + 1];
+		fpal[u * 3 + 2] = fpal[(u - 1) * 3 + 2];
+	}
+	fpal[start * 3 + 0] = savedR;
+	fpal[start * 3 + 1] = savedG;
+	fpal[start * 3 + 2] = savedB;
+	if (show) {
+		g_system->getPaletteManager()->setPalette(fpal + start * 3, start,
+												   end - start);
+	}
+}
+
 void EEMEngine::showEAKidsLogo() {
 	// _ShowEAKids @ 2520:05f0:
-	//   1. GetPicture(0x54) + MemoryCopy to VGA + GetPalette(0x25).
-	//   2. FRAME_RATE = 25; for j in 0..1, for u in 0..0x36 (= 55):
-	//        OpenColorCycle(0x01, 0x6e)   // bg / outer ring shimmer
-	//        OpenColorCycle(0x81, 0xee)   // inner gradient shimmer
-	//        every 8 ticks: OpenColorCycle(0x70, 0x80)  // mid band
-	//   3. Tail: 5 more cycles of 0x70..0x80.
-	//   4. Wait 0x23 (=35) more frames.
-	//   5. _OpenFadeOut.
-	// The cycling is what gives the EA Kids logo its shifting glow —
-	// it's NOT a static logo.
+	//   _GetPicture(0x54) + memcpy to 0xa000 (VGA).
+	//   _GetPalette(0x25) loads pal 0x25 into _fpal (NOT uploaded to DAC).
+	//   FRAME_RATE = 0x19 (25 fps); _InitFrameReg.
+	//   for j in 0..1: show = j;
+	//     for u in 0..0x37 (= 55):
+	//       if (show) wait for next 25-fps tick (abort on key/click).
+	//       _OpenColorCycle(0x01, 0x6e, show)   // bg / outer ring shimmer
+	//       _OpenColorCycle(0x81, 0xee, show)   // inner gradient shimmer
+	//       if (--delay == 0) {
+	//         delay = 8;
+	//         _OpenColorCycle(0x70, 0x80, show) // mid band
+	//       }
+	//   if (!abort) {
+	//     for i in 0..5: _OpenColorCycle(0x70, 0x80, 1);
+	//     for i in 0..0x23: wait one frame;
+	//   }
+	//   _OpenFadeOut().
+	//
+	// Pass 1 (j=0, show=0) pre-rolls _fpal 55 frames in memory only — no
+	// DAC upload, no frame sync. Pass 2 (j=1, show=1) uploads each shift
+	// at 25 fps. Without the pre-roll, the logo first appears at the
+	// unrotated palette-0x25 phase instead of the intended "55-shifts-in"
+	// phase.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicEAKidsLogo, pic)) {
 		warning("EA Kids logo (%u) load failed", kPicEAKidsLogo);
 		return;
 	}
 	blitAt(pic, 0, 0);
-	setSitePalette(kPalEAKids);
-	g_system->updateScreen();
 
-	// 25 fps -> 40 ms / tick.
-	const uint kFrameMs = 40;
-	int delayCount = 8;
+	// _GetPalette(0x25) — load into our shadow buffer; do not upload.
+	// The logo bitmap is on screen but invisible until the first
+	// _OpenColorCycle(..., show=1) upload in pass 2 lights it up.
+	byte fpal[kPalSize];
+	if (!getSitePalette(kPalEAKids, fpal)) {
+		warning("EA Kids palette (%u) load failed", kPalEAKids);
+		return;
+	}
+
+	const uint kFrameMs = 40;  // FRAME_RATE = 0x19 (25 fps).
 	bool aborted = false;
-	for (uint outer = 0; outer < 2 && !aborted && !shouldQuit(); outer++) {
-		for (uint i = 0; i < 0x37 && !aborted && !shouldQuit(); i++) {
-			cyclePaletteRangeReverse(0x01, 0x6e);
-			cyclePaletteRangeReverse(0x81, 0xee);
-			delayCount--;
-			if (delayCount == 0) {
-				delayCount = 8;
-				cyclePaletteRangeReverse(0x70, 0x80);
-			}
-			g_system->updateScreen();
 
-			const uint32 frameEnd = g_system->getMillis() + kFrameMs;
-			while (g_system->getMillis() < frameEnd && !aborted) {
-				Common::Event ev;
-				while (g_system->getEventManager()->pollEvent(ev)) {
-					if (ev.type == Common::EVENT_QUIT ||
-						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
-						_skipIntro = true;
-						return;
-					}
-					if (ev.type == Common::EVENT_KEYDOWN ||
-						ev.type == Common::EVENT_LBUTTONDOWN) {
-						if (ev.type == Common::EVENT_KEYDOWN &&
-							ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+	for (uint j = 0; j < 2 && !aborted && !shouldQuit(); j++) {
+		const bool show = (j != 0);
+		int delayCount = 8;
+
+		for (uint u = 0; u < 0x37 && !aborted && !shouldQuit(); u++) {
+			if (show) {
+				const uint32 frameEnd = g_system->getMillis() + kFrameMs;
+				while (g_system->getMillis() < frameEnd && !aborted) {
+					Common::Event ev;
+					while (g_system->getEventManager()->pollEvent(ev)) {
+						if (ev.type == Common::EVENT_QUIT ||
+							ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
 							_skipIntro = true;
+							return;
+						}
+						if (ev.type == Common::EVENT_KEYDOWN ||
+							ev.type == Common::EVENT_LBUTTONDOWN) {
+							if (ev.type == Common::EVENT_KEYDOWN &&
+								ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+								_skipIntro = true;
+							}
+							aborted = true;
+							break;
 						}
-						aborted = true;
-						break;
 					}
+					g_system->delayMillis(5);
 				}
-				g_system->delayMillis(5);
 			}
+
+			openColorCycle(fpal, 0x01, 0x6e, show);
+			openColorCycle(fpal, 0x81, 0xee, show);
+			delayCount--;
+			if (delayCount == 0) {
+				delayCount = 8;
+				openColorCycle(fpal, 0x70, 0x80, show);
+			}
+			if (show)
+				g_system->updateScreen();
 		}
 	}
 
-	if (aborted)
+	if (aborted) {
+		fadeCurrentPaletteToBlack();
 		return;
+	}
 
 	for (uint i = 0; i < 5 && !shouldQuit(); i++)
-		cyclePaletteRangeReverse(0x70, 0x80);
+		openColorCycle(fpal, 0x70, 0x80, true);
 	g_system->updateScreen();
 	waitForInput(0x23 * kFrameMs);
 
-	// `_OpenFadeOut @ 2520:0093` — 16 linear steps from current palette
-	// to black.
+	// _OpenFadeOut @ 2520:0093 — 16 linear steps from current palette to black.
 	fadeCurrentPaletteToBlack();
 }
 


Commit: ad29adf75013f363dc72fb775af4b03c68b3c3de
    https://github.com/scummvm/scummvm/commit/ad29adf75013f363dc72fb775af4b03c68b3c3de
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:11+02:00

Commit Message:
EEM: fixed bug when picking up the phone earlier than expected

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 39aca26fb17..c435ab85c66 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -407,6 +407,28 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
+	// Setup voice — _DoInitClues @ 1a35:061d runs this BEFORE _PlayInSequence
+	// (i.e. the phone rings, the player hears it ring out, and only then
+	// does Jake/Jenny reach for the receiver):
+	//   CD:     caseType 2 -> PHONE.VOC then _WaitForVoiceDone
+	//   Floppy (_DoInitClues_Floppy @ 19bb:042f):
+	//     caseType 2 -> slot 0xc (PHONESL.VOC)
+	//     caseType 3 -> slot 3   (NEWSCAN.VOC, news-anchor variant)
+	// While the voice plays the screen continues showing the game anim's
+	// last frame (saved into briefingBase above).
+	if (_audio) {
+		if (caseType == 2) {
+			if (floppy)
+				_audio->playFloppyVoiceSlot(0x0c, _partner);
+			else
+				_audio->playVoc(Common::Path("PHONE.VOC"));
+			_audio->waitForVoiceDone();
+		} else if (caseType == 3 && floppy) {
+			_audio->playFloppyVoiceSlot(0x03, _partner);
+			_audio->waitForVoiceDone();
+		}
+	}
+
 	// _PlayInSequence @ 172b:2d03. Anim selection per partner + caseType:
 	//   Jake:  caseType 1 -> 0x38 @ (0xcd, 0x6d)
 	//          caseType 2 -> 0x37 @ (0xcd, 0x6c)
@@ -487,24 +509,6 @@ void EEMEngine::doInitClues() {
 		}
 	}
 
-	// Setup voice (caseType 2/3 only):
-	//   CD:     caseType 2 -> PHONE.VOC
-	//   Floppy (_DoInitClues_Floppy @ 19bb:042f):
-	//     caseType 2 -> slot 0xc (PHONESL.VOC)
-	//     caseType 3 -> slot 3   (NEWSCAN.VOC, news-anchor variant)
-	if (_audio) {
-		if (caseType == 2) {
-			if (floppy)
-				_audio->playFloppyVoiceSlot(0x0c, _partner);
-			else
-				_audio->playVoc(Common::Path("PHONE.VOC"));
-			_audio->waitForVoiceDone();
-		} else if (caseType == 3 && floppy) {
-			_audio->playFloppyVoiceSlot(0x03, _partner);
-			_audio->waitForVoiceDone();
-		}
-	}
-
 	// Briefing dialogue. CD: clue block @ ib+4 (after caseType,startSite).
 	// Floppy: dialog records dispatched via FUN_22dc_05c8 @ 22dc:05c8
 	// (record size = 11 + textCount bytes).


Commit: b69b12c24432f19666c632e1c0312a5cc0e768a2
    https://github.com/scummvm/scummvm/commit/b69b12c24432f19666c632e1c0312a5cc0e768a2
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:11+02:00

Commit Message:
EEM: fixed bug in Nancy animation

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index c435ab85c66..b68b3076ff4 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -321,17 +321,27 @@ void EEMEngine::doInitClues() {
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
 
-	// Cycle game animation once (10 fps = _CheckFrameRate cadence).
-	// _DoInitClues @ 1a35:0507/0541 hard-codes the SCRIPT index to Jake's
-	// IDs (0x17/0x18/0x19) regardless of partner, so look up scripts by
-	// those IDs unconditionally.
+	// Cycle game animation once at _CheckFrameRate cadence (~7 fps, 140 ms
+	// per _UpdateAnimations call — `LastFrame + 0xe` cs in _InitFrameCounter
+	// @ 1a35:01ae). Original loop @ 1a35:0507:
+	//   uVar9 = 1;
+	//   while (uVar9 != gameNum) {
+	//     if (_CheckFrameRate || skipped) { _UpdateAnimations(); uVar9++; }
+	//   }
+	// So `gameNum - 1` _UpdateAnimations calls; each call advances every
+	// registered slot by one script tick. _DoInitClues @ 1a35:0507/0541
+	// hard-codes the SCRIPT index to Jake's IDs (0x17/0x18/0x19) regardless
+	// of partner, so we look up scripts by those IDs unconditionally.
 	if (haveGame || haveBook || haveNancy) {
-		const uint frameCount = haveGame ? game.size() : 8;
+		const uint kCheckFrameRateMs = 140;
+		const uint baseFrames = haveGame ? game.size() : 8;
+		// `gameNum - 1` ticks: scriptIdx 0..gameNum-2.
+		const uint frameCount = (baseFrames > 0) ? baseFrames - 1 : 0;
 		bool skip = false;
 		for (uint frame = 0; frame < frameCount && !shouldQuit() && !skip; frame++) {
 			if (haveBriefingBg)
 				blitAt(bg, 0, 0);
-			const uint32 t = frame * 100;
+			const uint32 t = frame * kCheckFrameRateMs;
 			Graphics::Surface *scr = g_system->lockScreen();
 			if (!scr) {
 				skip = true;
@@ -353,7 +363,7 @@ void EEMEngine::doInitClues() {
 			g_system->updateScreen();
 
 			// ESC interrupts voice/spool so audio doesn't bleed into the MAP.
-			const uint32 wakeup = g_system->getMillis() + 100;
+			const uint32 wakeup = g_system->getMillis() + kCheckFrameRateMs;
 			while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
 				Common::Event ev;
 				while (g_system->getEventManager()->pollEvent(ev)) {


Commit: 24a3e5a40cd63cb210c684a5454c93567da757e1
    https://github.com/scummvm/scummvm/commit/24a3e5a40cd63cb210c684a5454c93567da757e1
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:12+02:00

Commit Message:
EEM: fixed global-constructors from kHappyZones

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index b68b3076ff4..0f8a56ff977 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -45,12 +45,13 @@ const uint kAniBoy  = 8;                // Jake
 const uint kAniGirl = 9;                // Jenny
 
 // _DoHappiness @ 172b:27b5 — cursor X picks one of 4 rects @ 29be:030f.
-// Past rect 3 = level 4.
-const Common::Rect kHappyZones[4] = {
-	Common::Rect(  0, 0,  70, 200), // far left — girl very happy, boy neutral
-	Common::Rect( 70, 0, 126, 200), // girl's column
-	Common::Rect(126, 0, 182, 200), // middle
-	Common::Rect(182, 0, 235, 200), // boy's column
+// Past rect 3 = level 4. Constexpr (Point, w, h) form to avoid a global
+// constructor (-Wglobal-constructors).
+constexpr Common::Rect kHappyZones[4] = {
+	Common::Rect(Common::Point(  0, 0),  70, 200), // far left — girl very happy, boy neutral
+	Common::Rect(Common::Point( 70, 0),  56, 200), // girl's column
+	Common::Rect(Common::Point(126, 0),  56, 200), // middle
+	Common::Rect(Common::Point(182, 0),  53, 200), // boy's column
 };
 
 // _NewAnimation positions @ 1a35:07b9 / 07d5.


Commit: 12776d655aa8e45ed88d02e26c45d7f73d60a5eb
    https://github.com/scummvm/scummvm/commit/12776d655aa8e45ed88d02e26c45d7f73d60a5eb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:12+02:00

Commit Message:
EEM: removed the 'create new' workaround

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6e5c9124516..048e8df5efb 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -403,7 +403,7 @@ bool animateProfilePickerReveal(EEMEngine *vm, const Picture *bg,
 
 struct ProfilePickerEntry {
 	Common::String label;
-	int slot;       ///< -1 means "create new"
+	int slot;
 };
 
 struct ProfilePickerView {
@@ -706,8 +706,11 @@ void EEMEngine::doProfilePicker() {
 		return;
 	}
 
-	// Existing profiles + "[New Player]". DOS bottom click area @ 29be:0d08
-	// returns 0xfffe → `_NewPlayer`; rect remains active too.
+	// Existing profiles only — the original `screen8_handler @ 1c33:1012`
+	// passes the bare *.PLR list to `_DoChoose` with NO synthesized "new
+	// player" entry. _DoChoose returns 0xfffe / 0xffff for the bottom click
+	// rect @ 29be:0d08 / ESC, which routes to `_NewPlayer` (handled below
+	// via kChooserNewPlayerRect / KEYCODE_ESCAPE).
 	Common::Array<ProfilePickerEntry> entries;
 	for (const SaveStateDescriptor &s : saves) {
 		ProfilePickerEntry e;
@@ -715,10 +718,6 @@ void EEMEngine::doProfilePicker() {
 		e.slot  = s.getSaveSlot();
 		entries.push_back(e);
 	}
-	ProfilePickerEntry newEntry;
-	newEntry.label = "[New Player]";
-	newEntry.slot  = -1;
-	entries.push_back(newEntry);
 
 	int sel = 0;
 	int start = 0;
@@ -871,16 +870,12 @@ void EEMEngine::doProfilePicker() {
 		return;
 	}
 
+	// `_LoadPlayerRecord @ 1c33:1281`.
 	const ProfilePickerEntry &e = entries[sel];
-	if (e.slot < 0) {
+	if (!loadProfile(e.label)) {
+		warning("doProfilePicker: failed to load profile '%s' at slot %d",
+				e.label.c_str(), e.slot);
 		doNewPlayer();
-	} else {
-		// `_LoadPlayerRecord @ 1c33:1281`.
-		if (!loadProfile(e.label)) {
-			warning("doProfilePicker: failed to load profile '%s' at slot %d",
-					e.label.c_str(), e.slot);
-			doNewPlayer();
-		}
 	}
 }
 


Commit: 613dac165b5ddaf8a5ddc528c0caf6abcea644c8
    https://github.com/scummvm/scummvm/commit/613dac165b5ddaf8a5ddc528c0caf6abcea644c8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:12+02:00

Commit Message:
EEM: improved animation in the PDA

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 048e8df5efb..503874824e6 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2113,6 +2113,7 @@ void EEMEngine::doNotebook() {
 											   mouse.x, mouse.y));
 
 	uint32 lastDraw = g_system->getMillis();
+	uint32 gizmoLastTick = lastDraw;
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -2228,6 +2229,12 @@ void EEMEngine::doNotebook() {
 									  rectListContains(_notebookSlotRects,
 													   mouse.x, mouse.y));
 		}
+		// _GizmoColorCycle @ 1c33:0002 — `_DoNotebook` rotates 0x6f..0x73 each
+		// _CheckFrameRate tick (the PDA gizmo / LED indicator shimmer).
+		if (now - gizmoLastTick >= kChooserCycleMillis) {
+			gizmoLastTick = now;
+			cycleChooserPalette();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -2417,6 +2424,7 @@ void EEMEngine::doGallery() {
 							  gallerySlotAt(slotRects, slotSuspect,
 											mouse.x, mouse.y));
 	uint32 lastDraw = g_system->getMillis();
+	uint32 gizmoLastTick = lastDraw;
 
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -2524,6 +2532,11 @@ void EEMEngine::doGallery() {
 									  gallerySlotAt(slotRects, slotSuspect,
 													mouse.x, mouse.y));
 		}
+		// _GizmoColorCycle @ 1c33:0002 — `_DoGallery` rotates 0x6f..0x73 each tick.
+		if (now - gizmoLastTick >= kChooserCycleMillis) {
+			gizmoLastTick = now;
+			cycleChooserPalette();
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -2699,6 +2712,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		bool advance = false;
 		bool prev = false;
 		bool redraw = false;
+		uint32 gizmoLastTick = g_system->getMillis();
 		while (!back && !advance && !prev && !redraw && !shouldQuit()) {
 			Common::Event e2;
 			while (g_system->getEventManager()->pollEvent(e2)) {
@@ -2786,6 +2800,13 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 					break;
 				}
 			}
+			// _GizmoColorCycle @ 1c33:0002 — `MoreInfo` (158f:0480) rotates
+			// 0x6f..0x73 each tick.
+			const uint32 now = g_system->getMillis();
+			if (now - gizmoLastTick >= kChooserCycleMillis) {
+				gizmoLastTick = now;
+				cycleChooserPalette();
+			}
 			g_system->updateScreen();
 			g_system->delayMillis(20);
 		}


Commit: 23baebb9a800604e43f16a02f7fe5bf5935aa3c3
    https://github.com/scummvm/scummvm/commit/23baebb9a800604e43f16a02f7fe5bf5935aa3c3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:13+02:00

Commit Message:
EEM: removed incorrect attribution in the headers

Changed paths:
    engines/eem/eem.h


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 6d83cdc4da9..8ba5a135353 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -17,7 +17,6 @@
  * You should have received a copy of the GNU General Public License
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  *
- * Based on the original engine code by EA Kids / Storm Software (1994).
  *
  */
 


Commit: d901f38d663f85784e2f5664fe659272bbe547e2
    https://github.com/scummvm/scummvm/commit/d901f38d663f85784e2f5664fe659272bbe547e2
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:13+02:00

Commit Message:
EEM: reduced code duplication using transBlitFrom

Changed paths:
    engines/eem/clues.cpp
    engines/eem/graphics.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 0f8a56ff977..fcb47cdb06d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -114,23 +114,17 @@ uint happinessLevel(int x) {
 // _Rect_Move_Mask's mask byte (the on-disk u16 at file offset 0 maps to
 // Picture::flags).
 void blitMaskedToScreen(const Picture &p, int x, int y) {
-	const byte transp = (byte)(p.flags >> 8);
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return;
-	for (int row = 0; row < p.surface.h; row++) {
-		const int dstY = y + row;
-		if (dstY < 0 || dstY >= screen->h)
-			continue;
-		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-		byte *dst = (byte *)screen->getBasePtr(0, dstY);
-		for (int col = 0; col < p.surface.w; col++) {
-			const int dstX = x + col;
-			if (dstX < 0 || dstX >= screen->w)
-				continue;
-			if (src[col] != transp)
-				dst[dstX] = src[col];
-		}
+	const Common::Rect dst = Common::Rect(x, y, x + p.surface.w,
+										  y + p.surface.h)
+		.findIntersectingRect(Common::Rect(screen->w, screen->h));
+	if (!dst.isEmpty()) {
+		const Common::Rect src(dst.left - x, dst.top - y,
+							   dst.right - x, dst.bottom - y);
+		screen->copyRectToSurfaceWithKey(p.surface, dst.left, dst.top,
+										 src, (uint32)(byte)(p.flags >> 8));
 	}
 	g_system->unlockScreen();
 }
@@ -1093,19 +1087,9 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 				if (picID != 0 && picID != 0xFFFF) {
 					Picture pic;
 					if (_picsArchive.getPicture(picID, pic)) {
-						const byte transpC = (byte)(pic.flags >> 8);
-						const int pw = MIN<int>(pic.surface.w, 320 - picX);
-						const int ph = MIN<int>(pic.surface.h, 200 - picY);
-						for (int row = 0; row < ph; row++) {
-							const byte *src = (const byte *)
-								pic.surface.getBasePtr(0, row);
-							byte *dst = (byte *)
-								scratch.getBasePtr(picX, picY + row);
-							for (int col = 0; col < pw; col++) {
-								if (src[col] != transpC)
-									dst[col] = src[col];
-							}
-						}
+						scratch.transBlitFrom(pic.surface,
+											  Common::Point(picX, picY),
+											  (uint32)(byte)(pic.flags >> 8));
 					}
 				}
 				if (haveBalloon) {
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 8251aee33f7..544e66c7f50 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -274,18 +274,9 @@ void EEMEngine::doHelp() {
 			const uint h = (uint)balloon.surface.h;
 			if (h < 0x4e)
 				balloonY = (uint16)((0x50 - h) >> 1);
-			const byte transp = (byte)(balloon.flags >> 8);
-			for (int row = 0; row < balloon.surface.h && balloonY + row < 200;
-				 row++) {
-				const byte *src =
-					(const byte *)balloon.surface.getBasePtr(0, row);
-				byte *dst = (byte *)ms.getBasePtr(0x21, balloonY + row);
-				for (int col = 0; col < balloon.surface.w && 0x21 + col < 320;
-					 col++) {
-					if (src[col] != transp)
-						dst[col] = src[col];
-				}
-			}
+			ms.transBlitFrom(balloon.surface,
+							 Common::Point(0x21, balloonY),
+							 (uint32)(byte)(balloon.flags >> 8));
 		}
 		uint16 bx = 5;
 		uint16 by = 4;
@@ -669,19 +660,8 @@ void EEMEngine::drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
 		return;
 	const int x = ballX + (int)dx;
 	const int y = ballY + (int)dy;
-	const byte transp = (byte)(pic.flags >> 8);
-	const int pw = MIN<int>(pic.surface.w, 320 - x);
-	const int ph = MIN<int>(pic.surface.h, 200 - y);
-	if (x < 0 || y < 0 || pw <= 0 || ph <= 0)
-		return;
-	for (int row = 0; row < ph; row++) {
-		const byte *src = (const byte *)pic.surface.getBasePtr(0, row);
-		byte *out = (byte *)dst.getBasePtr(x, y + row);
-		for (int col = 0; col < pw; col++) {
-			if (src[col] != transp)
-				out[col] = src[col];
-		}
-	}
+	dst.transBlitFrom(pic.surface, Common::Point(x, y),
+					  (uint32)(byte)(pic.flags >> 8));
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 47bd881a88d..b0b5a2e1226 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -45,6 +45,24 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 	dst.transBlitFrom(p.surface, Common::Point(x, y), (uint32)transp);
 }
 
+// Masked top-left blit onto a locked screen surface. Clips both src and
+// dst against the screen, then delegates to copyRectToSurfaceWithKey
+// (Graphics::Surface's transparent-key blit).
+static void keyBlitToScreen(Graphics::Surface *screen, const Picture &p,
+							int x, int y) {
+	if (!screen || p.surface.empty())
+		return;
+	const Common::Rect dst = Common::Rect(x, y, x + p.surface.w,
+										  y + p.surface.h)
+		.findIntersectingRect(Common::Rect(screen->w, screen->h));
+	if (dst.isEmpty())
+		return;
+	const Common::Rect src(dst.left - x, dst.top - y,
+						   dst.right - x, dst.bottom - y);
+	screen->copyRectToSurfaceWithKey(p.surface, dst.left, dst.top,
+									 src, (uint32)(byte)(p.flags >> 8));
+}
+
 // Top-left masked blit. `_AddDrop @ 172b:1a77` calls
 // `_Rect_Move_Mask(..., x, y, ...)` with the raw (x, y) and IGNORES
 // per-frame anchor offsets — so this is the correct path for static
@@ -53,50 +71,18 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 // (miscflags = X, rowoff = Y) apply correctly.
 void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 					   int x, int y) {
-	if (!screen)
-		return;
-	const byte transp = (byte)(p.flags >> 8);
-	for (int row = 0; row < p.surface.h; row++) {
-		const int dstY = y + row;
-		if (dstY < 0 || dstY >= screen->h)
-			continue;
-		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-		byte *dst = (byte *)screen->getBasePtr(0, dstY);
-		for (int col = 0; col < p.surface.w; col++) {
-			const int dstX = x + col;
-			if (dstX < 0 || dstX >= screen->w)
-				continue;
-			if (src[col] != transp)
-				dst[dstX] = src[col];
-		}
-	}
+	keyBlitToScreen(screen, p, x, y);
 }
 
+// `_UpdateAnimations @ 172b:09c1`: blit at
+//   (anchor_x - puVar5[4], anchor_y - puVar5[3])
+// where puVar5[3]/[4] are per-frame rowoff/miscflags (signed int16) from
+// the 16-byte PicData header. Transparency = flags >> 8.
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY) {
-	// `_UpdateAnimations @ 172b:09c1`: blit at
-	//   (anchor_x - puVar5[4], anchor_y - puVar5[3])
-	// where puVar5[3]/[4] are per-frame rowoff/miscflags (signed int16)
-	// from the 16-byte PicData header. Transparency = flags >> 8.
-	if (!screen)
-		return;
-	const int blitX = anchorX - (int)(int16)p.miscflags;
-	const int blitY = anchorY - (int)(int16)p.rowoff;
-	const byte transp = (byte)(p.flags >> 8);
-	for (int row = 0; row < p.surface.h; row++) {
-		const int dstY = blitY + row;
-		if (dstY < 0 || dstY >= screen->h)
-			continue;
-		const byte *src = (const byte *)p.surface.getBasePtr(0, row);
-		byte *dst = (byte *)screen->getBasePtr(0, dstY);
-		for (int col = 0; col < p.surface.w; col++) {
-			const int dstX = blitX + col;
-			if (dstX < 0 || dstX >= screen->w)
-				continue;
-			if (src[col] != transp)
-				dst[dstX] = src[col];
-		}
-	}
+	keyBlitToScreen(screen, p,
+					anchorX - (int)(int16)p.miscflags,
+					anchorY - (int)(int16)p.rowoff);
 }
 
 // `_ColorCycle @ 172b:2015` — rotate `_fpal[start..end]` by one slot:
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 503874824e6..c1398a0fc48 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -275,23 +275,9 @@ void blitMaskedPicSlice(Graphics::ManagedSurface &dst, const Picture &pic,
 						int dstX, int dstY) {
 	if (pic.surface.empty() || w <= 0 || h <= 0)
 		return;
-
-	const byte transp = (byte)(pic.flags >> 8);
-	for (int row = 0; row < h; row++) {
-		const int sy = srcY + row;
-		const int dy = dstY + row;
-		if (sy < 0 || sy >= pic.surface.h || dy < 0 || dy >= dst.h)
-			continue;
-		for (int col = 0; col < w; col++) {
-			const int sx = srcX + col;
-			const int dx = dstX + col;
-			if (sx < 0 || sx >= pic.surface.w || dx < 0 || dx >= dst.w)
-				continue;
-			const byte c = *(const byte *)pic.surface.getBasePtr(sx, sy);
-			if (c != transp)
-				*(byte *)dst.getBasePtr(dx, dy) = c;
-		}
-	}
+	const Common::Rect srcRect(srcX, srcY, srcX + w, srcY + h);
+	dst.transBlitFrom(pic.surface, srcRect, Common::Point(dstX, dstY),
+					  (uint32)(byte)(pic.flags >> 8));
 }
 
 void blitMaskedPic(Graphics::ManagedSurface &dst, const Picture &pic,
@@ -2898,24 +2884,13 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 
 			const int placeX = s.x;
 			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const byte transp = (byte)(portrait.flags >> 8);
 			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
 			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
 			if (w <= 0 || h <= 0)
 				continue;
-			for (int row = 0; row < h; row++) {
-				const int dstY = placeY + row;
-				if (dstY < 0)
-					continue;
-				const byte *src =
-					(const byte *)portrait.surface.getBasePtr(0, row);
-				byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-				for (int col = 0; col < w; col++) {
-					const int dstX = placeX + col;
-					if (src[col] != transp)
-						dst[dstX] = src[col];
-				}
-			}
+			scratch.transBlitFrom(portrait.surface,
+								  Common::Point(placeX, placeY),
+								  (uint32)(byte)(portrait.flags >> 8));
 			slotRects[i] = Common::Rect(placeX, placeY,
 										 placeX + w, placeY + h);
 			slotSuspect[i] = (int)i;
@@ -3360,21 +3335,16 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 		}
 		const int sx = (int)mx - scrollX + kMapWinX;
 		const int sy = (int)my - scrollY + kMapWinY;
-		const byte transp = (byte)(button.flags >> 8);
-
-		// Crop blit against the viewport.
+		// Crop the button blit against the viewport.
 		const int x0 = MAX<int>(sx, kMapWinX);
 		const int y0 = MAX<int>(sy, kMapWinY);
 		const int x1 = MIN<int>(sx + button.surface.w, kMapWinX + kMapWinW);
 		const int y1 = MIN<int>(sy + button.surface.h, kMapWinY + kMapWinH);
-		for (int row = y0; row < y1; row++) {
-			const byte *src = (const byte *)button.surface.getBasePtr(0, row - sy);
-			byte *dst = (byte *)scratch.getBasePtr(0, row);
-			for (int col = x0; col < x1; col++) {
-				const byte px = src[col - sx];
-				if (px != transp)
-					dst[col] = px;
-			}
+		if (x1 > x0 && y1 > y0) {
+			const Common::Rect srcRect(x0 - sx, y0 - sy, x1 - sx, y1 - sy);
+			scratch.transBlitFrom(button.surface, srcRect,
+								  Common::Point(x0, y0),
+								  (uint32)(byte)(button.flags >> 8));
 		}
 	}
 
@@ -5069,24 +5039,13 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 
 		const int placeX = s.x;
 		const int placeY = s.y + (0x48 - portrait.surface.h);
-		const byte transp = (byte)(portrait.flags >> 8);
 		const int w = MIN<int>(portrait.surface.w, 320 - placeX);
 		const int h = MIN<int>(portrait.surface.h, 200 - placeY);
 		if (w <= 0 || h <= 0)
 			continue;
-		for (int row = 0; row < h; row++) {
-			const int dstY = placeY + row;
-			if (dstY < 0)
-				continue;
-			const byte *src =
-				(const byte *)portrait.surface.getBasePtr(0, row);
-			byte *dst = (byte *)scratch.getBasePtr(0, dstY);
-			for (int col = 0; col < w; col++) {
-				const int dstX = placeX + col;
-				if (src[col] != transp)
-					dst[dstX] = src[col];
-			}
-		}
+		scratch.transBlitFrom(portrait.surface,
+							  Common::Point(placeX, placeY),
+							  (uint32)(byte)(portrait.flags >> 8));
 		slotRects[i] = Common::Rect(placeX, placeY,
 									 placeX + w, placeY + h);
 		slotSuspect[i] = (int)i;


Commit: e34c624ee4211dcce40b9693f53caf32364d7e5a
    https://github.com/scummvm/scummvm/commit/e34c624ee4211dcce40b9693f53caf32364d7e5a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:13+02:00

Commit Message:
EEM: reduced code duplication in the PDA rendering

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index c1398a0fc48..0416fea7120 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -111,19 +111,50 @@ void swapColors(Graphics::ManagedSurface &dst,
 	}
 }
 
-void blitAccusePartner(Graphics::ManagedSurface &dstSurface,
-					   DBDArchive &aniArchive, uint8 partner,
-					   uint32 tickMs) {
-	const uint partnerAnim = (partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	if (aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-		!partnerAni.empty()) {
-		const uint frameIdx = partnerFrameAtTick(0x02,
-												 (uint)partnerAni.size(),
-												 tickMs);
-		blitAnimFrameAnchored(dstSurface.surfacePtr(),
-							  partnerAni[frameIdx], 5, 0x50);
-	}
+// PDA-frame partner sprite specs. `(5, 0x50)` is the partner-at-desk anchor
+// shared by _DoNotebook (script 0x01, anim 1/0xb) and _DoGallery / _DoAccuse
+// / _MoreInfo (script 0x02, anim 2/0x10).
+struct PdaPartnerSpec {
+	uint16 scriptId;
+	uint16 animJake;
+	uint16 animJenny;
+	int    anchorX;
+	int    anchorY;
+};
+constexpr PdaPartnerSpec kPdaGalleryPartner { 0x02, 0x02, 0x10, 5, 0x50 };
+constexpr PdaPartnerSpec kPdaNotebookPartner{ 0x01, 0x01, 0x0b, 5, 0x50 };
+
+// Lookup current frame for spec; returns nullptr if anim load fails. `outAni`
+// owns the loaded cells; the returned pointer is valid until `outAni` goes
+// out of scope.
+const Picture *partnerFrameFor(DBDArchive &aniArchive, uint8 partner,
+							   const PdaPartnerSpec &spec, uint32 tickMs,
+							   Animation &outAni) {
+	const uint animId = (partner == 0) ? spec.animJake : spec.animJenny;
+	if (!aniArchive.loadAnimation(animId, outAni) || outAni.empty())
+		return nullptr;
+	const uint frameIdx = partnerFrameAtTick(spec.scriptId,
+											  (uint)outAni.size(), tickMs);
+	return &outAni[frameIdx];
+}
+
+void blitPdaPartner(Graphics::ManagedSurface &dst, DBDArchive &aniArchive,
+					uint8 partner, const PdaPartnerSpec &spec,
+					uint32 tickMs) {
+	Animation ani;
+	if (const Picture *fr = partnerFrameFor(aniArchive, partner, spec,
+											tickMs, ani))
+		blitAnimFrameAnchored(dst.surfacePtr(), *fr, spec.anchorX,
+							  spec.anchorY);
+}
+
+void blitPdaPartner(Graphics::Surface *screen, DBDArchive &aniArchive,
+					uint8 partner, const PdaPartnerSpec &spec,
+					uint32 tickMs) {
+	Animation ani;
+	if (const Picture *fr = partnerFrameFor(aniArchive, partner, spec,
+											tickMs, ani))
+		blitAnimFrameAnchored(screen, *fr, spec.anchorX, spec.anchorY);
 }
 
 constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
@@ -2263,15 +2294,8 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		scratch.simpleBlitFrom(frame.surface);
 
 	// Partner ANI 1/0xb (cells); script 0x01 (`_NewAnimation @ 161e:054c`).
-	const uint partnerAnim = (_partner == 0) ? 1 : 0xb;
-	Animation partnerAni;
-	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) && !partnerAni.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x01,
-												  (uint)partnerAni.size(), now);
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  partnerAni[frameIdx], 5, 80);
-	}
+	blitPdaPartner(scratch, _aniArchive, _partner, kPdaNotebookPartner,
+				   g_system->getMillis());
 
 	// `_DrawNotes` walks `_NoteIndex` for current page; word-wraps each
 	// found clue in `_NotebookRect`. Selected = color 0x3c.
@@ -2574,18 +2598,8 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		if (haveBg)
 			ms.simpleBlitFrom(galBg.surface);
 		// Partner sprite at (5, 0x50). Re-blitted per page.
-		{
-			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-			Animation partnerAni;
-			if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-				!partnerAni.empty()) {
-				const uint32 now = g_system->getMillis();
-				const uint frameIdx = partnerFrameAtTick(0x02,
-					(uint)partnerAni.size(), now);
-				blitAnimFrameAnchored(ms.surfacePtr(),
-									  partnerAni[frameIdx], 5, 0x50);
-			}
-		}
+		blitPdaPartner(ms, _aniArchive, _partner, kPdaGalleryPartner,
+					   g_system->getMillis());
 		Picture detail;
 		if (_picsArchive.getPicture(detailPic, detail)) {
 			const byte transp = (byte)(detail.flags >> 8);
@@ -2818,10 +2832,6 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	// positions live in `kGallerySlots` in this file's anon namespace.
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	const bool havePartner = _aniArchive.loadAnimation(partnerAnim, partnerAni)
-							  && !partnerAni.empty();
 
 	Graphics::ManagedSurface scratch(320, 200,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -2830,24 +2840,13 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	if (haveBg)
 		scratch.simpleBlitFrom(galBg.surface);
 
-	// Partner sprite frame @ (5, 0x50). The original `_DoGallery @
-	// 158f:065b` registers `_NewAnimation(..., CONCAT22(2, ...), ...)`
-	// — script key 0x02 regardless of partner. Jake's 0x02 script
-	// (26 frames, brief wave + long hold + second wave) is what
-	// drives BOTH partners' cells. Earlier our port used 0x10 for
-	// Jenny, which is a 9-frame short blip — so Jenny was missing
-	// 17 frames of the wave-and-pause cadence that Jake has.
-	if (havePartner) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x02,
-												  (uint)partnerAni.size(), now);
-		// Anchor-aware blit, consistent with site-loop / BigMap
-		// rendering paths. Anim 0x02 has rowoff = miscflags = 0
-		// per the audit but the anchored blitter is still the
-		// right semantic for an `_NewAnimation`-rendered sprite.
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  partnerAni[frameIdx], 5, 0x50);
-	}
+	// Partner sprite frame @ (5, 0x50). `_DoGallery @ 158f:065b` registers
+	// _NewAnimation(..., CONCAT22(2, ...), ...) — script key 0x02 regardless
+	// of partner. Jake's 26-frame 0x02 script (brief wave + long hold +
+	// second wave) drives BOTH partners' cells (Jenny's own 0x10 anim is
+	// only 9 frames so it wouldn't cover the full cadence).
+	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
+				   g_system->getMillis());
 
 	// Portraits — `_DrawGallery @ 158f:0046` (CD) /
 	// `_DrawGallery_Floppy @ 154e:0045` (floppy) walks suspects 0..N-1
@@ -3430,16 +3429,8 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 
 	// Partner at (5, 0x50). `_DoAccuse @ 1df2:0c2c`: ANI 2/0x10,
 	// script 2, prior 1.
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-		!partnerAni.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x02,
-												  (uint)partnerAni.size(), now);
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  partnerAni[frameIdx], 5, 0x50);
-	}
+	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
+				   g_system->getMillis());
 
 	// Selected=0x3c, unselected=1 (`_NoteUnselectedColor` @ 1df2:0c25).
 	Common::Array<Common::Rect> &slotRects = *ctx.slotRects;
@@ -4173,11 +4164,6 @@ void EEMEngine::doAccuse() {
 		}
 		// Partner at (5, 0x50). ANI 2/0x10, script 0x02 (`_DoAccuse
 		// @ 1df2:0c30`). Drawn after suspect.
-		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-		Animation partnerAni;
-		const bool havePartner =
-			_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-			!partnerAni.empty();
 
 		// scratch = base + alibi balloon/text + partner.
 		Graphics::ManagedSurface scratch(320, 200,
@@ -4197,12 +4183,8 @@ void EEMEngine::doAccuse() {
 								  balloonY + ty, tw, alibi,
 								  haveBalloon ? 0 : 0xF);
 		}
-		if (havePartner) {
-			const uint frameIdx = partnerFrameAtTick(0x02,
-				(uint)partnerAni.size(), g_system->getMillis());
-			blitAnimFrameAnchored(scratch.surfacePtr(),
-								  partnerAni[frameIdx], 5, 0x50);
-		}
+		blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
+					   g_system->getMillis());
 
 		// MIDI 6 — blocks until done (or click/ESC aborts).
 		if (_music && _voiceOn) {
@@ -4289,14 +4271,8 @@ void EEMEngine::doAccuse() {
 										  rY + rty, rtw, react,
 										  haveR ? 0 : 0xF);
 				}
-				if (havePartner) {
-					const uint frameIdx = partnerFrameAtTick(0x02,
-						(uint)partnerAni.size(),
-						g_system->getMillis());
-					blitAnimFrameAnchored(scratch.surfacePtr(),
-										  partnerAni[frameIdx],
-										  5, 0x50);
-				}
+				blitPdaPartner(scratch, _aniArchive, _partner,
+							   kPdaGalleryPartner, g_system->getMillis());
 				g_system->copyRectToScreen(scratch.getPixels(),
 					scratch.pitch, 0, 0, 320, 200);
 				g_system->updateScreen();
@@ -4385,19 +4361,10 @@ void EEMEngine::doAccuse() {
 		}
 
 		// Stamp partner at (5, 0x50); displayClue snapshots screen.
-		const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-		Animation partnerAni;
-		if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-			!partnerAni.empty()) {
-			Graphics::Surface *screen = g_system->lockScreen();
-			if (screen) {
-				const uint frameIdx = partnerFrameAtTick(0x02,
-					(uint)partnerAni.size(),
-					g_system->getMillis());
-				blitAnimFrameAnchored(screen, partnerAni[frameIdx],
-									  5, 0x50);
-				g_system->unlockScreen();
-			}
+		if (Graphics::Surface *screen = g_system->lockScreen()) {
+			blitPdaPartner(screen, _aniArchive, _partner,
+						   kPdaGalleryPartner, g_system->getMillis());
+			g_system->unlockScreen();
 		}
 		g_system->updateScreen();
 
@@ -4520,16 +4487,8 @@ void EEMEngine::accuseDrawGallery(int highlighted,
 	if (haveAccuseBg)
 		scratch.simpleBlitFrom(accuseBg.surface);
 
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-		!partnerAni.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x02,
-												  (uint)partnerAni.size(), now);
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  partnerAni[frameIdx], 5, 0x50);
-	}
+	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
+				   g_system->getMillis());
 
 	rects.resize(num);
 	suspects.resize(num);
@@ -4752,19 +4711,10 @@ void EEMEngine::doAccuseFloppy() {
 
 			// Stamp partner at (5, 0x50); displayFloppyDialogRecords
 			// snapshots screen. ANI 2/0x10, script 0x02.
-			const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-			Animation partnerAni;
-			if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-				!partnerAni.empty()) {
-				Graphics::Surface *screen = g_system->lockScreen();
-				if (screen) {
-					const uint frameIdx = partnerFrameAtTick(0x02,
-						(uint)partnerAni.size(),
-						g_system->getMillis());
-					blitAnimFrameAnchored(screen, partnerAni[frameIdx],
-										  5, 0x50);
-					g_system->unlockScreen();
-				}
+			if (Graphics::Surface *screen = g_system->lockScreen()) {
+				blitPdaPartner(screen, _aniArchive, _partner,
+							   kPdaGalleryPartner, g_system->getMillis());
+				g_system->unlockScreen();
 			}
 			g_system->updateScreen();
 		}
@@ -4955,7 +4905,8 @@ void EEMEngine::doAccuseFloppy() {
 							  MAX<int>(8, (int)tw), alibi, 0);
 	}
 	// Stamp partner resting frame before KD reaction snapshots screen.
-	blitAccusePartner(scene, _aniArchive, _partner, g_system->getMillis());
+	blitPdaPartner(scene, _aniArchive, _partner, kPdaGalleryPartner,
+				   g_system->getMillis());
 	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
 	g_system->updateScreen();
 
@@ -5006,16 +4957,8 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		scratch.simpleBlitFrom(accuseBg.surface);
 
 	// Partner drawn first; defensive (no slot overlap).
-	const uint partnerAnim = (_partner == 0) ? 2 : 0x10;
-	Animation partnerAni;
-	if (_aniArchive.loadAnimation(partnerAnim, partnerAni) &&
-		!partnerAni.empty()) {
-		const uint32 now = g_system->getMillis();
-		const uint frameIdx = partnerFrameAtTick(0x02,
-												  (uint)partnerAni.size(), now);
-		blitAnimFrameAnchored(scratch.surfacePtr(),
-							  partnerAni[frameIdx], 5, 0x50);
-	}
+	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
+				   g_system->getMillis());
 
 	for (uint i = 0; i < numSuspects && i < Mystery::kGalleryCap; i++) {
 		slotRects[i] = Common::Rect();


Commit: 40c2459237d7641db42ef98a4222bd5b4b8b9192
    https://github.com/scummvm/scummvm/commit/40c2459237d7641db42ef98a4222bd5b4b8b9192
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:14+02:00

Commit Message:
EEM: some comment fixes and clarifications

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index fcb47cdb06d..03233dafb3f 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -541,7 +541,13 @@ void EEMEngine::doInitClues() {
 //   0x83 _Partner == 0 ? "he"       : "she"
 //   0x84 _Partner == 0 ? "him"      : "her"
 //   0x85 _Partner == 0 ? "his"      : "her"
-//   0x86..0x88 read a separate gender flag @ 0x7985 (TODO).
+//   0x86..0x88 mirror 0x83..0x85 but branch on a separate gender flag
+//     (DAT_29be_7985, handlers @ 1b66:0ad2/0b41/0bb0). That flag has no
+//     writers anywhere in EEMCD.EXE — only the three handlers read it —
+//     and no shipping mystery text (CD or floppy) contains the 0x86/0x87
+//     /0x88 bytes inside parseString-formatted strings. The opcodes are
+//     dead in the original engine and dead in our data, so we drop them
+//     silently (which matches the always-0 flag path in DOS: he/him/his).
 //   0x89 KD hint placeholder (caller handles).
 Common::String EEMEngine::parseString(const Common::String &raw,
 									  const Common::String &playerName,
@@ -572,8 +578,12 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 		case 0x86:
 		case 0x87:
 		case 0x88:
+			// Stubbed suspect-gender pronouns; the DOS flag they branch
+			// on is never written and no mystery msg uses these bytes
+			// (see jumptable comment above).
+			break;
 		case 0x89:
-			// Eaten silently — see comment above.
+			// KD hint placeholder (caller handles before this point).
 			break;
 		case 0:
 			return out;
@@ -602,7 +612,7 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 	// `_DoWordWrap @ 1b66:04a7`, which advances past spaces at the
 	// start of every output line via `for (; str[last] == ' '; last++)`.
 	// ~60% of mystery-text strings carry 1-2 leading spaces in the data
-	// (verified across all CD M*.BIN files); the original WordWrap
+	// of the original WordWrap
 	// discards them, so we do the same before the text reaches
 	// `Font::wordWrapText` (which only trims at wrap-induced line
 	// boundaries, not at start-of-input or after an embedded '\n').
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 89c89a88b48..088f8a8d5d4 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1182,7 +1182,7 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 
 SaveStateList EEMEngine::listProfiles() const {
 	// _findfirst("*.PLR") in screen8_handler @ 1c33:1012.
-	// Filter out slot 0 (ScummVM autosave) to match the original which
+	// Filter out slot 0 (autosave) to match the original which
 	// has no autosave concept.
 	SaveStateList saves = getMetaEngine()->listSaves(_targetName.c_str());
 	for (uint i = 0; i < saves.size(); ) {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 0416fea7120..2d36b72f204 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1993,7 +1993,7 @@ void EEMEngine::doCaseSelection() {
 					const uint idx = topRow + (uint)row;
 					if (idx >= listLen || solvedFlags[idx])
 						continue;
-					// Second click on selected row = OK (ScummVM ergonomic).
+					// Second click on selected row = OK (QoL fix).
 					if (idx == selRow) {
 						confirmed = true;
 						break;
@@ -2854,8 +2854,7 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	// Layout differs by variant:
 	//   * CD: fixed 0x46-byte stride, slot positions at `kGallerySlots`.
 	//   * Floppy: variable-stride entries (5 + entry[4] bytes per
-	//     suspect), slot positions at `kFloppyGallerySlots` (verified
-	//     at `2608:0x16c`).
+	//     suspect), slot positions at `kFloppyGallerySlots` (`2608:0x16c`).
 	const bool floppy = isFloppy();
 	const GallerySlot * const slots =
 		floppy ? kFloppyGallerySlots : kGallerySlots;
@@ -3228,7 +3227,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 			continue;
 		// CD entries are 14 bytes: X at +4, Y at +6, crime at +12.
 		// Floppy entries are 11 bytes: X at +6, Y at +8, recolor at +10.
-		// Floppy layout verified at `FUN_1fed_07ed` (BigMap iteration):
+		// Floppy layout: `FUN_1fed_07ed` (BigMap iteration):
 		//   `*(int *)(pcVar2 + i*0xb + 7)` (= entry+6, X u16)
 		//   `*(int *)(pcVar2 + i*0xb + 9)` (= entry+8, Y u16)
 		//   `pcVar2[i*0xb + 0xb]` (= entry+10, recolor flag — non-zero


Commit: 0a11cfeeded82cb19bf471d0a78ec6525cb4e4fb
    https://github.com/scummvm/scummvm/commit/0a11cfeeded82cb19bf471d0a78ec6525cb4e4fb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:14+02:00

Commit Message:
EEM: use named constants here and there

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/graphics.cpp
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 03233dafb3f..ee88f8a8a43 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -130,8 +130,8 @@ void blitMaskedToScreen(const Picture &p, int x, int y) {
 }
 
 void blitRawToScreen(const Picture &p, int x, int y) {
-	const int w = MIN<int>(p.surface.w, 320 - x);
-	const int h = MIN<int>(p.surface.h, 200 - y);
+	const int w = MIN<int>(p.surface.w, kScreenWidth - x);
+	const int h = MIN<int>(p.surface.h, kScreenHeight - y);
 	if (x < 0 || y < 0 || w <= 0 || h <= 0)
 		return;
 
@@ -219,18 +219,18 @@ void EEMEngine::doChoosePartner() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				_partner = (ev.mouse.x >= 160) ? 0 : 1;
+				_partner = (ev.mouse.x >= 160) ? kPartnerJake : kPartnerJenny;
 				debugC(1, kDebugGeneral, "Partner picked: %s",
-					   _partner == 0 ? "Jake" : "Jennifer");
+					   _partner == kPartnerJake ? "Jake" : "Jennifer");
 				done = true;
 				break;
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
-					_partner = 1; done = true; break;
+					_partner = kPartnerJenny; done = true; break;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					_partner = 0; done = true; break;
+					_partner = kPartnerJake; done = true; break;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_RETURN ||
 					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
@@ -255,7 +255,7 @@ void EEMEngine::doChoosePartner() {
 			_audio->playFloppyVoiceSlot(0x14, _partner);
 		} else {
 			_audio->playVoc(Common::Path(
-				(_partner == 0) ? "JAKE.VOC" : "JEN.VOC"));
+				(_partner == kPartnerJake) ? "JAKE.VOC" : "JEN.VOC"));
 		}
 		_audio->waitForVoiceDone();
 	}
@@ -306,8 +306,8 @@ void EEMEngine::doInitClues() {
 	if (haveBriefingBg)
 		blitAt(bg, 0, 0);
 
-	const uint gameAni = _partner == 0 ? 0x17 : 0x3b;
-	const uint bookAni = _partner == 0 ? 0x18 : 0x3c;
+	const uint gameAni = _partner == kPartnerJake ? 0x17 : 0x3b;
+	const uint bookAni = _partner == kPartnerJake ? 0x18 : 0x3c;
 	Animation game, book, nancy;
 	const bool haveGame  = _aniArchive.loadAnimation(gameAni, game) && !game.empty();
 	const bool haveBook  = _aniArchive.loadAnimation(bookAni, book) && !book.empty();
@@ -384,7 +384,7 @@ void EEMEngine::doInitClues() {
 	// clearing registered animations. Width is in mode-X cols (0x28 = 160px).
 	// Intentionally drops the right-side game anim; _PlayInSequence redraws
 	// that character over a clean BG next.
-	Graphics::ManagedSurface briefingBase(320, 200,
+	Graphics::ManagedSurface briefingBase(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	briefingBase.clear();
 	if (haveBriefingBg) {
@@ -442,7 +442,7 @@ void EEMEngine::doInitClues() {
 	//          caseType 3 -> 0x3d @ (0xcd, 0x6c)
 	uint16 seqAni = 0xFFFF;
 	uint16 seqY   = 0x6c;
-	if (_partner == 0) {
+	if (_partner == kPartnerJake) {
 		switch (caseType) {
 		case 1:
 			seqAni = 0x38;
@@ -482,7 +482,7 @@ void EEMEngine::doInitClues() {
 				const Picture &fr = seq[frame];
 				g_system->copyRectToScreen(briefingBase.getPixels(),
 										   briefingBase.pitch, 0, 0,
-										   320, 200);
+										   kScreenWidth, kScreenHeight);
 				// _PlayInSequence @ 172b:2d35-2d50:
 				//   dstX = sx - cell[+0x8]   ; signed X anchor (miscflags)
 				//   dstY = sy - cell[+0x6]   ; signed Y anchor (rowoff)
@@ -667,7 +667,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		return;
 
 	// Snapshot BG so per-entry character pics don't stack.
-	Graphics::ManagedSurface bg(320, 200,
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	bg.clear();
 	{
@@ -692,7 +692,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	// bubText1 != -1; otherwise falls back to partner 0 fields entirely.
 	// Partner 0 always uses field 0.
 	for (uint i = 0; i < number && !shouldQuit(); i++) {
-		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
 		const byte *c = clueBlock + 4 + i * 62;
 
 		// _DisplayClue @ 2404:0635-064b: _DoKDAnim(num) runs before the
@@ -702,10 +702,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			playKdAnim((uint16)kdAnimNum);
 			// _UpdateAnimations @ 172b:09c1 reactivates the wait anim.
 			g_system->copyRectToScreen(bg.getPixels(), bg.pitch,
-									   0, 0, 320, 200);
+									   0, 0, kScreenWidth, kScreenHeight);
 		}
 
-		const bool useP1 = (_partner == 1) &&
+		const bool useP1 = (_partner == kPartnerJenny) &&
 			(READ_LE_UINT16(c + 10) != 0xFFFF);
 		const uint partner = useP1 ? 1 : 0;
 		const uint16 textOff = READ_LE_UINT16(c + 8 + partner * 2);
@@ -726,7 +726,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		if (charPicId != 0 && charPicId != 0xFFFF) {
 			Picture charPic;
 			if (_picsArchive.getPicture(charPicId, charPic) &&
-				charX < 320 && charY < 200) {
+				charX < kScreenWidth && charY < kScreenHeight) {
 				blitMaskedToScreen(charPic, charX, charY);
 			}
 		}
@@ -746,20 +746,20 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			Graphics::Surface *screen = g_system->lockScreen();
 			if (!screen)
 				break;
-			Graphics::ManagedSurface scratch(320, 200,
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.simpleBlitFrom(*screen);
 			g_system->unlockScreen();
 
 			int textX = bubX;
 			int textY = bubY;
-			int textW = MIN<int>(320 - bubX, 200);
+			int textW = MIN<int>(kScreenWidth - bubX, 200);
 			int copyY = bubY;
 			int copyH = _font.getFontHeight() * 4 + 8;
 
 			if (haveBalloon) {
-				const int bw = MIN<int>(balloon.surface.w, 320 - bubX);
-				const int bh = MIN<int>(balloon.surface.h, 200 - bubY);
+				const int bw = MIN<int>(balloon.surface.w, kScreenWidth - bubX);
+				const int bh = MIN<int>(balloon.surface.h, kScreenHeight - bubY);
 				// _AddPicBackground: transparent colour = pic->miscflags >> 8.
 				const byte transp = (byte)(balloon.flags >> 8);
 				// _GetBalloon @ 172b:1d7d mirrors horizontally when
@@ -785,8 +785,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				copyH = bh;
 			} else {
 				// No balloon: clear band.
-				const Common::Rect band(0, bubY, 320,
-					MIN<int>(bubY + copyH, 200));
+				const Common::Rect band(0, bubY, kScreenWidth,
+					MIN<int>(bubY + copyH, kScreenHeight));
 				scratch.fillRect(band, 0);
 				copyY = bubY;
 			}
@@ -797,8 +797,8 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				MAX<int>(8, textW), text, 0);
 
 			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
-				scratch.pitch, 0, copyY, 320,
-				MIN<int>(copyH, 200 - copyY));
+				scratch.pitch, 0, copyY, kScreenWidth,
+				MIN<int>(copyH, kScreenHeight - copyY));
 			g_system->updateScreen();
 		}
 
@@ -814,7 +814,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
 			if (voiceJenny != 0 && voiceJenny != 0xFFFF) {
 				const uint16 voiceJake = READ_LE_UINT16(c + 0x1a);
-				const uint16 voice = (_partner == 0) ? voiceJake : voiceJenny;
+				const uint16 voice = (_partner == kPartnerJake) ? voiceJake : voiceJenny;
 				if (voice != 0 && voice != 0xFFFF)
 					_audio->spoolSound((uint)(voice - 1));
 			}
@@ -937,7 +937,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		return;
 
 	// Snapshot BG for between-bubble restores.
-	Graphics::ManagedSurface bg(320, 200,
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
 		Graphics::Surface *screen = g_system->lockScreen();
@@ -1010,7 +1010,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			const uint32 noteAbs = notesBase + (uint32)idx * 7;
 			if (noteAbs + 6 > dsz)
 				continue;
-			const uint16 textOff = (_partner == 0)
+			const uint16 textOff = (_partner == kPartnerJake)
 				? READ_LE_UINT16(notes + idx * 7 + 2)
 				: READ_LE_UINT16(notes + idx * 7 + 4);
 			if (textOff >= dsz)
@@ -1074,7 +1074,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			const uint32 noteAbs = notesBase + (uint32)idx * 7;
 			if (noteAbs + 6 > dsz)
 				break;
-			const uint16 textOff = (_partner == 0)
+			const uint16 textOff = (_partner == kPartnerJake)
 				? READ_LE_UINT16(notes + idx * 7 + 2)
 				: READ_LE_UINT16(notes + idx * 7 + 4);
 			if (textOff >= dsz)
@@ -1088,7 +1088,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 				parseString(raw, _playerName, _partner);
 
 			// Render this text page.
-			Graphics::ManagedSurface scratch(320, 200,
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.simpleBlitFrom(*bg.surfacePtr());
 
@@ -1163,7 +1163,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			}
 
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
+									   0, 0, kScreenWidth, kScreenHeight);
 			g_system->updateScreen();
 
 			if (waitNeeded) {
@@ -1230,7 +1230,7 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		return;
 	// Snapshot clean site BG and restore between main + continuation calls
 	// so each displayFloppyDialogRecords sees a bubble-free background.
-	Graphics::ManagedSurface siteBG(320, 200,
+	Graphics::ManagedSurface siteBG(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
 		Graphics::Surface *screen = g_system->lockScreen();
@@ -1263,7 +1263,7 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		return;
 	// Wipe main bubble so the continuation chain snapshots a clean BG.
 	g_system->copyRectToScreen(siteBG.getPixels(), siteBG.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 	// _DisplayDialogContinuations_Floppy @ 1652:006c: lastIndicator=0
 	// means no indicator on the final continuation.
@@ -1272,7 +1272,7 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 
 bool EEMEngine::areYouSure() {
 	Graphics::Surface *screen = g_system->lockScreen();
-	Graphics::ManagedSurface saved(320, 200,
+	Graphics::ManagedSurface saved(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	if (screen) {
 		saved.simpleBlitFrom(*screen);
@@ -1299,8 +1299,8 @@ bool EEMEngine::areYouSure() {
 	int noY = 0;
 
 	if (haveOriginalDialog) {
-		const int x = (320 - dialogPic.surface.w) / 2;
-		const int y = (200 - dialogPic.surface.h) / 2;
+		const int x = (kScreenWidth - dialogPic.surface.w) / 2;
+		const int y = (kScreenHeight - dialogPic.surface.h) / 2;
 		yesX = x + 0x0c;
 		yesY = y + 0x23;
 		noX = x + 0x60;
@@ -1315,7 +1315,7 @@ bool EEMEngine::areYouSure() {
 		noRect = Common::Rect(dlg.left + 100, dlg.top + 34,
 							  dlg.left + 160, dlg.top + 54);
 
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(saved);
 		scratch.fillRect(dlg, 0);
@@ -1323,14 +1323,14 @@ bool EEMEngine::areYouSure() {
 		_font.drawString(&scratch,
 			isSpanish() ? "Estas seguro que quieres salir?"
 						: "Are you sure you want to quit?",
-			dlg.left + 8, dlg.top + 8, 320, 0xF);
+			dlg.left + 8, dlg.top + 8, kScreenWidth, 0xF);
 		_font.drawString(&scratch,
 			isSpanish() ? "S - Si" : "Y - Yes",
-			dlg.left + 16, dlg.top + 36, 320, 0xF);
+			dlg.left + 16, dlg.top + 36, kScreenWidth, 0xF);
 		_font.drawString(&scratch, "N - No", dlg.left + 100,
-						 dlg.top + 36, 320, 0xF);
+						 dlg.top + 36, kScreenWidth, 0xF);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 	} else {
 		return true;
 	}
@@ -1384,7 +1384,7 @@ bool EEMEngine::areYouSure() {
 		g_system->delayMillis(15);
 	}
 
-	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, 320, 200);
+	g_system->copyRectToScreen(saved.getPixels(), saved.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 	return result;
 }
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 088f8a8d5d4..5118851df06 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -222,7 +222,7 @@ void EEMEngine::applyStartupTestOverrides() {
 
 Common::Error EEMEngine::run() {
 	// _SetMode13X @ 1000:0358 — VGA mode 13h.
-	initGraphics(320, 200);
+	initGraphics(kScreenWidth, kScreenHeight);
 
 	if (!openArchives())
 		return Common::Error(Common::kReadingFailed, "EEM archive open failed");
@@ -760,8 +760,8 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 
 void EEMEngine::blitAt(const Picture &pic, int x, int y) {
 	// Clip against the 320x200 frame buffer.
-	const int w = MIN<int>(pic.surface.w, 320 - x);
-	const int h = MIN<int>(pic.surface.h, 200 - y);
+	const int w = MIN<int>(pic.surface.w, kScreenWidth - x);
+	const int h = MIN<int>(pic.surface.h, kScreenHeight - y);
 	if (w <= 0 || h <= 0)
 		return;
 	g_system->copyRectToScreen(pic.surface.getPixels(), pic.surface.pitch,
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8ba5a135353..8c06aa64cb9 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -104,6 +104,17 @@ enum Variant {
 	kVariantFloppy = 1,
 };
 
+/// `_Partner @ 29be:7918`. Selected at the partner-pick screen
+/// (_DoChoosePartner @ 1a35:0756) and persisted in the player profile.
+enum Partner {
+	kPartnerJake  = 0,
+	kPartnerJenny = 1,
+};
+
+/// VGA mode 13h dimensions (initGraphics(320, 200) in `EEMEngine::run`).
+constexpr int kScreenWidth  = 320;
+constexpr int kScreenHeight = 200;
+
 class EEMEngine : public Engine {
 public:
 	EEMEngine(OSystem *syst, const ADGameDescription *gameDesc);
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 544e66c7f50..b80eaa85730 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -256,7 +256,7 @@ void EEMEngine::doHelp() {
 		Common::String text = parseString(Common::String(txt),
 										   _playerName, _partner);
 		balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
-		Graphics::ManagedSurface ms(320, 200,
+		Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
 		{
@@ -284,7 +284,7 @@ void EEMEngine::doHelp() {
 		getBalloonInsets(balloonIdx, bx, by, bw);
 		_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
 							  MAX<int>(8, (int)bw), text, 0);
-		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
+		g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
 		while (!shouldQuit()) {
@@ -398,7 +398,7 @@ void EEMEngine::doHelp() {
 	//
 	// BG is the caller's CURRENT screen (site / PDA / gallery), not a cleared
 	// scratch.
-	Graphics::ManagedSurface ms(320, 200,
+	Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	ms.clear();
 	{
@@ -445,7 +445,7 @@ void EEMEngine::doHelp() {
 						  haveBalloon ? 0 : 0xF);
 
 	g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
 	// _DisplayHint @ 1560:0009 plays _SayKDDigital(soundnum) — a
@@ -476,7 +476,7 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		   num, kHelpPics[num][0], kHelpPics[num][1]);
 
 	// Snapshot caller's screen once: each PIC overlays the same clean BG.
-	Graphics::ManagedSurface bg(320, 200,
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
 		Graphics::Surface *cur = g_system->lockScreen();
@@ -501,14 +501,14 @@ void EEMEngine::doInterfaceHelp(uint num) {
 
 		// transBlitFrom transp = pic.flags >> 8 matches _Rect_Move_Mask param_10
 		// @ 1000:03fc. Explicit (0,0) destPos: no-arg overload stretches to fill.
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
 		const byte transp = (byte)(pic.flags >> 8);
 		scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
 							  (uint32)transp);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
 		bool escape = false;
@@ -548,8 +548,8 @@ void EEMEngine::doInterfaceHelp(uint num) {
 }
 
 void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
-	if (bg && bg->w == 320 && bg->h == 200) {
-		_partnerEraseBg.create(320, 200,
+	if (bg && bg->w == kScreenWidth && bg->h == kScreenHeight) {
+		_partnerEraseBg.create(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		_partnerEraseBg.simpleBlitFrom(*bg);
 	} else {
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index b0b5a2e1226..d523b1e3f5b 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -987,7 +987,7 @@ bool SiteScreen::enterSiteAnim() {
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
 		return false;
-	Graphics::ManagedSurface bg(320, 200,
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	bg.simpleBlitFrom(*screen);
 	g_system->unlockScreen();
@@ -995,10 +995,10 @@ bool SiteScreen::enterSiteAnim() {
 	// Phase 1 — skateboard scroll.
 	Animation skate;
 	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
-		// `iVar4 = 199 - sprite_h`, `uVar5 = 320 - sprite_w` (frame 0).
+		// `iVar4 = 199 - sprite_h`, `uVar5 = kScreenWidth - sprite_w` (frame 0).
 		const int spriteH = skate[0].surface.h;
 		const int spriteW = skate[0].surface.w;
-		int x = (320 - spriteW) & ~3;            // 4-px aligned (mode-X)
+		int x = (kScreenWidth - spriteW) & ~3;            // 4-px aligned (mode-X)
 		const int y = 199 - spriteH;
 		const byte transp = (byte)(skate[0].flags >> 8);
 		uint frameIdx = 0;
@@ -1007,12 +1007,12 @@ bool SiteScreen::enterSiteAnim() {
 		const int kFrameTicks = 0xc;    // original switches frame at 12 px
 
 		while (x + spriteW > 0 && !_vm->shouldQuit()) {
-			Graphics::ManagedSurface scratch(320, 200,
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.simpleBlitFrom(bg);
 			blitFrame(scratch, skate[frameIdx], x, y, transp);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
+									   0, 0, kScreenWidth, kScreenHeight);
 			g_system->updateScreen();
 
 			Common::Event ev;
@@ -1048,12 +1048,12 @@ bool SiteScreen::enterSiteAnim() {
 			const int destX = -(int)(int16)fr.miscflags;
 			const int destY = kKDY - (int)(int16)fr.rowoff;
 
-			Graphics::ManagedSurface scratch(320, 200,
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.simpleBlitFrom(bg);
 			blitFrame(scratch, fr, destX, destY, transp);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
+									   0, 0, kScreenWidth, kScreenHeight);
 			g_system->updateScreen();
 
 			Common::Event ev;
@@ -1267,7 +1267,7 @@ void SiteScreen::applyColorCycles() {
 }
 
 void SiteScreen::captureBgSnapshot() {
-	_bgSnapshot.create(320, 200, Graphics::PixelFormat::createFormatCLUT8());
+	_bgSnapshot.create(kScreenWidth, kScreenHeight, Graphics::PixelFormat::createFormatCLUT8());
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen) {
 		_snapshotSite = -1;
@@ -1278,10 +1278,10 @@ void SiteScreen::captureBgSnapshot() {
 }
 
 void SiteScreen::restoreBgSnapshot() {
-	if (_bgSnapshot.w != 320 || _bgSnapshot.h != 200)
+	if (_bgSnapshot.w != kScreenWidth || _bgSnapshot.h != kScreenHeight)
 		return;
 	g_system->copyRectToScreen(_bgSnapshot.getPixels(), _bgSnapshot.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 }
 
 void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
@@ -1401,8 +1401,8 @@ void SiteScreen::renderBackground(uint siteNum) {
 		// `_Rect_Move(0, 0, h, ..., 0x42, 0x14, 48000, h, w)`.
 		const int x = 0x42;
 		const int y = 0x14;
-		const int w = MIN<int>(scene.surface.w, 320 - x);
-		const int h = MIN<int>(scene.surface.h, 200 - y);
+		const int w = MIN<int>(scene.surface.w, kScreenWidth - x);
+		const int h = MIN<int>(scene.surface.h, kScreenHeight - y);
 		if (w > 0 && h > 0)
 			g_system->copyRectToScreen(scene.surface.getPixels(),
 									   scene.surface.pitch, x, y, w, h);
@@ -1635,7 +1635,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
 
-	const uint partner = (_partner == 0) ? 0 : 1;
+	const uint partner = (_partner == kPartnerJake) ? 0 : 1;
 	const uint16 animId = kKdAnimTable[num][partner];
 	const int    px     = (int)kKdAnimTable[num][2 + partner];
 	const int    py     = (int)kKdAnimTable[num][4 + partner];
@@ -1657,9 +1657,9 @@ void EEMEngine::playKdAnim(uint16 num) {
 
 	// Erase-source: caller-stashed partner-less BG (via `setPartnerEraseBg`)
 	// or fall back to current screen (works for full-screen contexts).
-	Graphics::ManagedSurface bg(320, 200,
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
-	if (_partnerEraseBg.w == 320 && _partnerEraseBg.h == 200) {
+	if (_partnerEraseBg.w == kScreenWidth && _partnerEraseBg.h == kScreenHeight) {
 		bg.simpleBlitFrom(_partnerEraseBg);
 	} else {
 		Graphics::Surface *screen = g_system->lockScreen();
@@ -1676,7 +1676,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 		const Picture &fr = anim[frameIdx];
 		const byte transp = (byte)(fr.flags >> 8);
 
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
 		// Anchor-aware: kdAnim cells (0x03/0x04/0x0c/0x0d ...) have
@@ -1687,7 +1687,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 		(void)transp;  // anchored blitter recomputes from p.flags
 		blitAnimFrameAnchored(scratch.surfacePtr(), fr, px, py);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
 		// 100 ms per frame (~10 fps). Pump updateScreen inside the wait
@@ -1706,7 +1706,7 @@ void EEMEngine::playKdAnim(uint16 num) {
 		}
 	}
 
-	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, 320, 200);
+	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 } // End of namespace EEM
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2d36b72f204..507d27d0506 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -100,8 +100,8 @@ void swapColors(Graphics::ManagedSurface &dst,
 					   const Common::Rect &r, byte from, byte to) {
 	const int x1 = MAX<int>(0, r.left);
 	const int y1 = MAX<int>(0, r.top);
-	const int x2 = MIN<int>(320, r.right);
-	const int y2 = MIN<int>(200, r.bottom);
+	const int x2 = MIN<int>(kScreenWidth, r.right);
+	const int y2 = MIN<int>(kScreenHeight, r.bottom);
 	for (int y = y1; y < y2; y++) {
 		byte *row = (byte *)dst.getBasePtr(0, y);
 		for (int x = x1; x < x2; x++) {
@@ -157,8 +157,8 @@ void blitPdaPartner(Graphics::Surface *screen, DBDArchive &aniArchive,
 		blitAnimFrameAnchored(screen, *fr, spec.anchorX, spec.anchorY);
 }
 
-constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, 200);
-constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, 200);
+constexpr Common::Rect kEndingPrevPageRect(Common::Point(0, 0), 28, kScreenHeight);
+constexpr Common::Rect kEndingNextPageRect(Common::Point(292, 0), 28, kScreenHeight);
 constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
 constexpr uint16 kFirstTryBadgePic = 0x205;
 constexpr Common::Point kFirstTryBadgePos(0x1e, 9);
@@ -292,7 +292,7 @@ int nextLiveSlot(const Common::Array<Common::Rect> &slotRects,
 
 void copyToScreen(Graphics::ManagedSurface &scratch) {
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -357,13 +357,13 @@ void drawCaseBookTitle(Graphics::ManagedSurface &scratch, const EEMEngine *vm,
 		: Common::String::format(spanish ? "Lib. %u" : "Book %u", book);
 	const int titleW = vm->getFont().getStringWidth(title);
 	const int titleX = (0xba - titleW) / 2 + 0x3c;
-	vm->getFont().drawString(&scratch, title, titleX, 12, 320, 0xF);
+	vm->getFont().drawString(&scratch, title, titleX, 12, kScreenWidth, 0xF);
 }
 
 void drawNameEntryFrame(EEMEngine *vm, const Picture *bg, bool haveBG,
 						const Picture *peek, const Common::String &name,
 						const char *prompt) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (haveBG)
@@ -383,7 +383,7 @@ bool animateNameEntryPeek(EEMEngine *vm, const Picture *bg, bool haveBG,
 	for (int w = 1; w <= peek->surface.w; w++) {
 		if (pumpQuitEvents(vm))
 			return true;
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		if (haveBG)
@@ -404,7 +404,7 @@ bool animateProfilePickerReveal(EEMEngine *vm, const Picture *bg,
 	for (int h = 1; h <= reveal->surface.h; h++) {
 		if (pumpQuitEvents(vm))
 			return true;
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		if (haveBG)
@@ -451,7 +451,7 @@ void clampProfileScroll(int &selected, int &start, int count) {
 }
 
 void drawProfilePickerFrame(const ProfilePickerView &v) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (v.haveBG)
@@ -584,7 +584,7 @@ bool animateCaseSelectionReveal(EEMEngine *vm, const Picture *caseBg,
 	for (int i = 1; i <= steps; i++) {
 		if (pumpQuitEvents(vm))
 			return true;
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.clear();
 		if (haveCaseBg && caseBg)
@@ -610,7 +610,7 @@ bool animateCaseSelectionReveal(EEMEngine *vm, const Picture *caseBg,
 // Original colours 0x13 sel / 0x1B greyed / 0x5C default approximated
 // here as 0xF / 0x8 / 0x7 from site palette 0.
 void drawCaseSubmenu(const CaseSubmenuView &v) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	drawCaseBase(scratch, v.vm, v.caseBg, v.haveCaseBg,
 				 v.revealPic, v.haveRevealPic,
@@ -668,7 +668,7 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 }
 
 void drawActionMenuFrame(const ActionMenuView &v) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (v.haveBg && v.bg)
@@ -946,7 +946,7 @@ void EEMEngine::doNewPlayer() {
 					_playerName = name;
 					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
 					_mystery.clear();
-					_partner = 0;
+					_partner = kPartnerJake;
 					// `_NewPlayer @ 1c33:0fa3`: DAT_2d5d_3f99 = 1 (Junior).
 					_chainStage = 1;
 					saveProfile(name);
@@ -1109,7 +1109,7 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 			uint16 y1 = 0;
 			uint16 x2 = 0;
 			const char *raw = nullptr;
-			Graphics::ManagedSurface scratch(320, 200,
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 				Graphics::PixelFormat::createFormatCLUT8());
 			scratch.clear();
 
@@ -1170,13 +1170,13 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 			// TINY.FNT + color 0 (asm 1df2:04cf — not 0xF as Ghidra shows).
 			const EEMFont &renderFont = haveTinyFont ? tinyFont : _font;
 			if (renderFont.isLoaded() && x2 > x1) {
-				const int textW = MIN<int>((int)x2 - (int)x1, 320 - (int)x1);
+				const int textW = MIN<int>((int)x2 - (int)x1, kScreenWidth - (int)x1);
 				renderFont.drawWordWrapped(&scratch, (int)x1, (int)y1,
 										   textW, text, 0);
 			}
 
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-									   0, 0, 320, 200);
+									   0, 0, kScreenWidth, kScreenHeight);
 			g_system->updateScreen();
 			dirty = false;
 		}
@@ -1388,20 +1388,20 @@ void EEMEngine::doSetup() {
 			// Partner toggle [0]. Direct Jake/Jenny label clicks are a
 			// ScummVM-only fallback.
 			if (kPartnerBtn.contains(mx, my)) {
-				_partner = _partner == 0 ? 1 : 0;
+				_partner = _partner == kPartnerJake ? kPartnerJenny : kPartnerJake;
 				dirty = true;
 				continue;
 			}
 			if (kKid1Rect.contains(mx, my)) {
-				if (_partner != 0) {
-					_partner = 0;
+				if (_partner != kPartnerJake) {
+					_partner = kPartnerJake;
 					dirty = true;
 				}
 				continue;
 			}
 			if (kKid2Rect.contains(mx, my)) {
-				if (_partner != 1) {
-					_partner = 1;
+				if (_partner != kPartnerJenny) {
+					_partner = kPartnerJenny;
 					dirty = true;
 				}
 				continue;
@@ -1531,7 +1531,7 @@ void EEMEngine::doSetup() {
 }
 
 void EEMEngine::setupDrawScreen() {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	Picture bg;
@@ -1542,16 +1542,16 @@ void EEMEngine::setupDrawScreen() {
 	const byte kBright = 0x15;
 	const byte kDim    = 0x00;
 	swapColors(scratch, kSetupKid1Rect, kKey,
-			   _partner == 0 ? kBright : kDim);
+			   _partner == kPartnerJake ? kBright : kDim);
 	swapColors(scratch, kSetupKid2Rect, kKey,
-			   _partner == 1 ? kBright : kDim);
+			   _partner == kPartnerJenny ? kBright : kDim);
 	swapColors(scratch, kSetupSoundOnRect,  kKey,
 			   _voiceOn ? kBright : kDim);
 	swapColors(scratch, kSetupSoundOffRect, kKey,
 			   _voiceOn ? kDim : kBright);
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -1564,7 +1564,7 @@ Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent
 		warning("doSetup: PIC %u missing", (uint)picId);
 		return Common::KEYCODE_INVALID;
 	}
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	if (transparent) {
 		Graphics::Surface *cur = g_system->lockScreen();
@@ -1581,7 +1581,7 @@ Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent
 		scratch.simpleBlitFrom(pic.surface);
 	}
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 	while (!shouldQuit()) {
 		Common::Event ev;
@@ -1866,7 +1866,7 @@ void EEMEngine::doCaseSelection() {
 		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
 
 	// `_CaseSelection @ 1c33:0a87` greeter ANI 0x15 (Jake) / 0x16 (Jenny).
-	const uint kKdAniId = (_partner == 0) ? 0x15 : 0x16;
+	const uint kKdAniId = (_partner == kPartnerJake) ? 0x15 : 0x16;
 	Animation kdAnim;
 	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
 							 && !kdAnim.empty();
@@ -2285,7 +2285,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	// from `_DoNotebook @ 161e:0500`.
 	const Common::Rect kNotebookRect(78, 12, 288, 152);
 
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
@@ -2384,10 +2384,10 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	// Page indicator only (original has no points display).
 	_font.drawString(&scratch, Common::String::format("p%d/%d",
 							   page + 1, (int)pageStarts.size()),
-					 270, 4, 320, 0x5C);
+					 270, 4, kScreenWidth, 0x5C);
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
 	// Publish slot info to `doNotebook`'s click handler.
@@ -2592,7 +2592,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 	bool isFirstShow = true;
 
 	while (!back && !shouldQuit()) {
-		Graphics::ManagedSurface ms(320, 200,
+		Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
 		if (haveBg)
@@ -2688,7 +2688,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 				rx, ry + rh + 2, MAX<int>(8, rw), 0x3C);
 		}
 		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-			0, 0, 320, 200);
+			0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
 		// Drain the LBUTTONDOWN that opened MoreInfo (first page only).
@@ -2833,7 +2833,7 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
@@ -2882,8 +2882,8 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 
 			const int placeX = s.x;
 			const int placeY = s.y + (0x48 - portrait.surface.h);
-			const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-			const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+			const int w = MIN<int>(portrait.surface.w, kScreenWidth - placeX);
+			const int h = MIN<int>(portrait.surface.h, kScreenHeight - placeY);
 			if (w <= 0 || h <= 0)
 				continue;
 			scratch.transBlitFrom(portrait.surface,
@@ -2898,7 +2898,7 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 			const int phH = 0x48;
 			const int phX = s.x;
 			const int phY = s.y;
-			if (phX + phW <= 320 && phY + phH <= 200) {
+			if (phX + phW <= kScreenWidth && phY + phH <= kScreenHeight) {
 				scratch.fillRect(Common::Rect(phX, phY,
 					phX + phW, phY + phH), 0x20);
 				scratch.frameRect(Common::Rect(phX, phY,
@@ -2913,7 +2913,7 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -3032,7 +3032,7 @@ void EEMEngine::doBigMap() {
 		bool returnToOverview = false;
 
 		// `SmallMapButtons[4]` @ 20fe:156c — return to overview.
-		const Common::Rect kBigMapReturnRect(252, 43, 320, 200);
+		const Common::Rect kBigMapReturnRect(252, 43, kScreenWidth, kScreenHeight);
 		const Common::Rect kArrowYUp(237, 2, 247, 11);
 		const Common::Rect kArrowYDown(237, 163, 247, 172);
 		const Common::Rect kArrowXLeft(2, 175, 12, 185);
@@ -3201,7 +3201,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	// PIC 0x42 + per-site Done/Crime/Site marker (`_DrawBigMapButtons @
 	// 20fe:0877`) + partner idle at (0xfd, 0x50). Idle ANI: Jake=0x14,
 	// Jenny=0x12. elapsedMs anchors unfold→wait timeline.
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
@@ -3266,7 +3266,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 
 	// Partner idle at (0xfd, 0x50). `_DoBigMap @ 20fe:0a47` always passes
 	// script 0x14 (count-up) to `_NewAnimation` regardless of partner.
-	const uint kMapAniId = (_partner == 0) ? 0x14 : 0x12;
+	const uint kMapAniId = (_partner == kPartnerJake) ? 0x14 : 0x12;
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
 		const uint frameIdx = bigMapPartnerFrameAtTick((uint)mapAnim.size(),
@@ -3277,7 +3277,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -3293,7 +3293,7 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	const int kMapWinX = 2;
 	const int kMapWinY = 2;
 
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 
@@ -3347,7 +3347,7 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	}
 
 	// Always script 0x13 (`_NewAnimation @ _DoBigMap 20fe:0a47`).
-	const uint kDetailAniId = (_partner == 0) ? 0x13 : 0x11;
+	const uint kDetailAniId = (_partner == kPartnerJake) ? 0x13 : 0x11;
 	Animation detailAnim;
 	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
 		!detailAnim.empty()) {
@@ -3358,7 +3358,7 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -3420,7 +3420,7 @@ void EEMEngine::accuseRebuildPagination(const AccuseNotesCtx &ctx) {
 }
 
 void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (ctx.haveBg)
@@ -3489,7 +3489,7 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -3747,7 +3747,7 @@ void EEMEngine::doAccuse() {
 								_playerName, _partner);
 	}
 	if (!entryText.empty()) {
-		Graphics::ManagedSurface ms(320, 200,
+		Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
 		Graphics::Surface *cur = g_system->lockScreen();
@@ -3781,7 +3781,7 @@ void EEMEngine::doAccuse() {
 								  tw, entryText, haveBalloon ? 0 : 0xF);
 		}
 		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 		if (_audio)
 			_audio->sayKDDigital(entryKdIdx, entryKDSpeak, _partner);
@@ -3843,7 +3843,7 @@ void EEMEngine::doAccuse() {
 
 		// Balloon overlay (`_GetKDTextBalloon` + `_GetBalloon` +
 		// `_AddPicBackground` + `_WordWrap` @ 1df2:0c8d-0cd1).
-		Graphics::ManagedSurface ms(320, 200,
+		Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
 		Graphics::Surface *cur = g_system->lockScreen();
@@ -3880,7 +3880,7 @@ void EEMEngine::doAccuse() {
 								  haveBalloon ? 0 : 0xF);
 		}
 		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
 		// `_SayKDDigital(3)` @ 1df2:0cd9.
@@ -3938,7 +3938,7 @@ void EEMEngine::doAccuse() {
 				drawAccuseGallery(num, gd, /* highlighted= */ -1,
 								  slotRects, slotSuspect);
 
-				Graphics::ManagedSurface ms(320, 200,
+				Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 					Graphics::PixelFormat::createFormatCLUT8());
 				ms.clear();
 				{
@@ -3969,7 +3969,7 @@ void EEMEngine::doAccuse() {
 										  haveBalloon ? 0 : 0xF);
 				}
 				g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
-					0, 0, 320, 200);
+					0, 0, kScreenWidth, kScreenHeight);
 				g_system->updateScreen();
 				if (_audio)
 					_audio->sayKDDigital(kdIdx, 4, _partner);
@@ -4136,7 +4136,7 @@ void EEMEngine::doAccuse() {
 		if (bindx < 8) {
 			const int bw = haveBalloon ? balloon.surface.w : 0;
 			const int bh = haveBalloon ? balloon.surface.h : 0;
-			balloonX = (320 - bw) / 2;
+			balloonX = (kScreenWidth - bw) / 2;
 			if (bh < 0x5a) {
 				balloonY = (0x5a - bh) / 2;
 			} else {
@@ -4150,7 +4150,7 @@ void EEMEngine::doAccuse() {
 		}
 
 		// `base` = BG + suspect + partner. Survives both balloon phases.
-		Graphics::ManagedSurface base(320, 200,
+		Graphics::ManagedSurface base(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		base.clear();
 		if (haveAlibiBg)
@@ -4165,7 +4165,7 @@ void EEMEngine::doAccuse() {
 		// @ 1df2:0c30`). Drawn after suspect.
 
 		// scratch = base + alibi balloon/text + partner.
-		Graphics::ManagedSurface scratch(320, 200,
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(base);
 		if (haveBalloon) {
@@ -4216,7 +4216,7 @@ void EEMEngine::doAccuse() {
 		// Suspect voice. talk = partner==0 ? gd[+0x6] : gd[+0x0] (1df2:0252).
 		// 1-based, so SpoolSound(talk-1).
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, 320, 200);
+								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 		if (_audio && gd) {
 			const uint16 alibiVoice =
@@ -4224,7 +4224,7 @@ void EEMEngine::doAccuse() {
 			const uint16 jakeVoice =
 				READ_LE_UINT16(gd + (uint)picked * 0x46 + 0x06);
 			const uint16 talk =
-				(_partner == 0) ? jakeVoice : alibiVoice;
+				(_partner == kPartnerJake) ? jakeVoice : alibiVoice;
 			if (talk != 0)
 				_audio->spoolSound((uint)(talk - 1));
 		}
@@ -4273,7 +4273,7 @@ void EEMEngine::doAccuse() {
 				blitPdaPartner(scratch, _aniArchive, _partner,
 							   kPdaGalleryPartner, g_system->getMillis());
 				g_system->copyRectToScreen(scratch.getPixels(),
-					scratch.pitch, 0, 0, 320, 200);
+					scratch.pitch, 0, 0, kScreenWidth, kScreenHeight);
 				g_system->updateScreen();
 				if (_audio)
 					_audio->sayKDDigital(reactIdx, 5, _partner);
@@ -4338,7 +4338,7 @@ void EEMEngine::doAccuse() {
 		// (0x42, 0x14), palette = sitenum+1 = 6.
 		Graphics::Surface *blk = g_system->lockScreen();
 		if (blk) {
-			memset(blk->getPixels(), 0, 320 * 200);
+			memset(blk->getPixels(), 0, kScreenWidth * kScreenHeight);
 			g_system->unlockScreen();
 		}
 		setSitePalette(6); // sitenum + 1 (`_GetPalette`).
@@ -4351,8 +4351,8 @@ void EEMEngine::doAccuse() {
 		if (5 < _sitesArchive.size() &&
 			_sitesArchive.loadEntry(5, scene)) {
 			const int sx = 0x42, sy = 0x14;
-			const int sw = MIN<int>(scene.surface.w, 320 - sx);
-			const int sh = MIN<int>(scene.surface.h, 200 - sy);
+			const int sw = MIN<int>(scene.surface.w, kScreenWidth - sx);
+			const int sh = MIN<int>(scene.surface.h, kScreenHeight - sy);
 			if (sw > 0 && sh > 0)
 				g_system->copyRectToScreen(scene.surface.getPixels(),
 										   scene.surface.pitch, sx, sy,
@@ -4428,7 +4428,7 @@ void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 	Common::String text =
 		parseString(Common::String(txt), _playerName, _partner);
 	balloonIdx = fitBalloonToText((uint16)balloonIdx, text) & 0x7F;
-	Graphics::ManagedSurface ms(320, 200,
+	Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	Graphics::Surface *cur = g_system->lockScreen();
 	if (cur) {
@@ -4453,7 +4453,7 @@ void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 	getBalloonInsets(balloonIdx, bx, by, bw);
 	_font.drawWordWrapped(&ms, 0x21 + bx, balloonY + by,
 						  MAX<int>(8, (int)bw), text, 0);
-	g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, 320, 200);
+	g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 	// Wait for click.
 	while (!shouldQuit()) {
@@ -4480,7 +4480,7 @@ void EEMEngine::accuseDrawGallery(int highlighted,
 								  Common::Array<int> &suspects, uint8 num,
 								  bool haveAccuseBg,
 								  const Picture &accuseBg) {
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (haveAccuseBg)
@@ -4526,7 +4526,7 @@ void EEMEngine::accuseDrawGallery(int highlighted,
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 
@@ -4687,7 +4687,7 @@ void EEMEngine::doAccuseFloppy() {
 		{
 			Graphics::Surface *blk = g_system->lockScreen();
 			if (blk) {
-				memset(blk->getPixels(), 0, 320 * 200);
+				memset(blk->getPixels(), 0, kScreenWidth * kScreenHeight);
 				g_system->unlockScreen();
 			}
 			setSitePalette(6);
@@ -4701,8 +4701,8 @@ void EEMEngine::doAccuseFloppy() {
 			if (5 < _sitesArchive.size() &&
 				_sitesArchive.loadEntry(5, scene)) {
 				const int sx = 0x42, sy = 0x14;
-				const int sw = MIN<int>(scene.surface.w, 320 - sx);
-				const int sh = MIN<int>(scene.surface.h, 200 - sy);
+				const int sw = MIN<int>(scene.surface.w, kScreenWidth - sx);
+				const int sh = MIN<int>(scene.surface.h, kScreenHeight - sy);
 				if (sw > 0 && sh > 0)
 					g_system->copyRectToScreen(scene.surface.getPixels(),
 						scene.surface.pitch, sx, sy, sw, sh);
@@ -4867,7 +4867,7 @@ void EEMEngine::doAccuseFloppy() {
 	const bool flipBalloon = (balloonRaw & 0x80) != 0;
 
 	// Compose alibi screen.
-	Graphics::ManagedSurface scene(320, 200,
+	Graphics::ManagedSurface scene(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scene.clear();
 	if (haveAlibiBg)
@@ -4885,7 +4885,7 @@ void EEMEngine::doAccuseFloppy() {
 	int balloonX = 0x21;
 	int balloonY = 1;
 	if (haveBalloon) {
-		balloonX = (320 - balloon.surface.w) / 2;
+		balloonX = (kScreenWidth - balloon.surface.w) / 2;
 		balloonY = (0x5a - balloon.surface.h) / 2;
 		if (balloonX < 0)
 			balloonX = 0;
@@ -4906,7 +4906,7 @@ void EEMEngine::doAccuseFloppy() {
 	// Stamp partner resting frame before KD reaction snapshots screen.
 	blitPdaPartner(scene, _aniArchive, _partner, kPdaGalleryPartner,
 				   g_system->getMillis());
-	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, 320, 200);
+	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
 	// No per-suspect VOC — alibi table @ 2608:0c5e is for post-win
@@ -4949,7 +4949,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
-	Graphics::ManagedSurface scratch(320, 200,
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
 	if (haveAccuseBg)
@@ -4981,8 +4981,8 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 
 		const int placeX = s.x;
 		const int placeY = s.y + (0x48 - portrait.surface.h);
-		const int w = MIN<int>(portrait.surface.w, 320 - placeX);
-		const int h = MIN<int>(portrait.surface.h, 200 - placeY);
+		const int w = MIN<int>(portrait.surface.w, kScreenWidth - placeX);
+		const int h = MIN<int>(portrait.surface.h, kScreenHeight - placeY);
 		if (w <= 0 || h <= 0)
 			continue;
 		scratch.transBlitFrom(portrait.surface,
@@ -5002,7 +5002,7 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-							   0, 0, 320, 200);
+							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 }
 


Commit: 9cd879dd5cd34cd252e7551b92553f9ce05290df
    https://github.com/scummvm/scummvm/commit/9cd879dd5cd34cd252e7551b92553f9ce05290df
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:14+02:00

Commit Message:
EEM: removed useless usage of static

Changed paths:
    engines/eem/audio.cpp
    engines/eem/eem.cpp
    engines/eem/graphics.cpp
    engines/eem/metaengine.cpp
    engines/eem/music.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 3954fbb02f9..10df7170c3c 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -186,7 +186,7 @@ void AudioPlayer::playPcmBuffer(byte *pcm, uint32 size, uint sampleRate,
 // Filename FAR-ptr arrays at 2608:0f0e (Jake) and 2608:0f76 (Jenny), each
 // 26 * FAR-ptr to a NUL-terminated `*.voc` name. Slots align across partners,
 // e.g. 12 = PHONESL.VOC, 20 = partner intro, 25 = THUNDER.VOC.
-static const char *const kFloppyJakeVoiceTable[26] = {
+const char *const kFloppyJakeVoiceTable[26] = {
 	"DING.VOC",       "M-0083SL.VOC", "M-0085SL.VOC", "NEWSCAN.VOC",
 	"M-0089SL.VOC",   "M-0091SL.VOC", "M-0092SL.VOC", "NEWSSHRT.VOC",
 	"M-0096SL.VOC",   "M-0102SL.VOC", "M-0104SL.VOC", "M-0107SL.VOC",
@@ -195,7 +195,7 @@ static const char *const kFloppyJakeVoiceTable[26] = {
 	"M-0113SL.VOC",   "B-0006SL.VOC", "B-0003SL.VOC", "B-0004SL.VOC",
 	"M-0163SL.VOC",   "THUNDER.VOC",
 };
-static const char *const kFloppyJennyVoiceTable[26] = {
+const char *const kFloppyJennyVoiceTable[26] = {
 	"DING.VOC",       "F-0194SL.VOC", "F-0191SL.VOC", "NEWSCAN.VOC",
 	"F-0187SL.VOC",   "F-0184SL.VOC", "F-0181SL.VOC", "NEWSSHRT.VOC",
 	"F-0177SL.VOC",   "F-0170SL.VOC", "F-0168SL.VOC", "F-0166SL.VOC",
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5118851df06..9dfe48519f7 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -96,7 +96,7 @@ const byte kCursorInteractivePalette[] = {
 	0xFF, 0xFF, 0xFF
 };
 
-static void fadeCurrentPaletteToBlack(uint delayMs = 8) {
+void fadeCurrentPaletteToBlack(uint delayMs = 8) {
 	byte start[kPalSize];
 	byte stepPal[kPalSize];
 	g_system->getPaletteManager()->grabPalette(start, 0, 256);
@@ -111,7 +111,7 @@ static void fadeCurrentPaletteToBlack(uint delayMs = 8) {
 	}
 }
 
-static void fadePaletteFromBlack(const byte *target, uint delayMs = 8) {
+void fadePaletteFromBlack(const byte *target, uint delayMs = 8) {
 	byte stepPal[kPalSize];
 
 	for (uint step = 1; step <= 16; step++) {
@@ -124,7 +124,7 @@ static void fadePaletteFromBlack(const byte *target, uint delayMs = 8) {
 	}
 }
 
-static void setInteractiveCursorPalette(const Picture &cursor, byte transparent) {
+void setInteractiveCursorPalette(const Picture &cursor, byte transparent) {
 	byte palette[kPalSize];
 	bool used[256];
 	memset(used, 0, sizeof(used));
@@ -170,7 +170,7 @@ static void setInteractiveCursorPalette(const Picture &cursor, byte transparent)
 	CursorMan.replaceCursorPalette(palette, 0, 256);
 }
 
-static void installMouseCursor(DBDArchive &pics, bool interactive) {
+void installMouseCursor(DBDArchive &pics, bool interactive) {
 	Picture cursor;
 	if (pics.getPicture(kPicMousePointer, cursor) && !cursor.surface.empty()) {
 		const byte transparent = (byte)(cursor.flags >> 8);
@@ -810,7 +810,7 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 //   fpal[start] = saved
 // If `show`, upload `end - start` entries (fpal[start..end-1]) — note that
 // fpal[end] is rotated in memory but intentionally not uploaded each tick.
-static void openColorCycle(byte *fpal, uint8 start, uint8 end, bool show) {
+void openColorCycle(byte *fpal, uint8 start, uint8 end, bool show) {
 	if (end <= start)
 		return;
 	const byte savedR = fpal[end * 3 + 0];
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index b80eaa85730..8f8441b0c41 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -127,7 +127,7 @@ uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 // FUN_22dc_096c @ 22dc:096c: walks per-site dialog records at site_data[+6]
 // to skip hotspotIdx hotspots, returns _cluesFound flag for hotspot's first
 // text index.
-static bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
+bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
 								   uint hotspotIdx) {
 	const byte *site = mystery.siteData(siteIdx);
 	if (!site)
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index 04e778c35bc..ee16e3e10d3 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -31,7 +31,7 @@
 
 namespace EEM {
 
-static const ADExtraGuiOptionsMap optionsList[] = {
+const ADExtraGuiOptionsMap optionsList[] = {
 	{
 		GAMEOPTION_HIDE_HIGHLIGHT_BOXES,
 		{
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
index c1fd4bf26b0..31c51f225c5 100644
--- a/engines/eem/music.cpp
+++ b/engines/eem/music.cpp
@@ -32,12 +32,8 @@
 
 namespace EEM {
 
-namespace {
-
 const int kMidiDriverFlags = MDT_MIDI | MDT_ADLIB | MDT_PREFER_MT32;
 
-} // End of anonymous namespace
-
 MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 	// _InitMIDI @ 20a2:013a — `_AIL_register_driver` against
 	// ADLIB.ADV / SBFM.ADV / MT32MPU.ADV. We honour the launcher's
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index d523b1e3f5b..0f9fc01f99a 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -48,7 +48,7 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 // Masked top-left blit onto a locked screen surface. Clips both src and
 // dst against the screen, then delegates to copyRectToSurfaceWithKey
 // (Graphics::Surface's transparent-key blit).
-static void keyBlitToScreen(Graphics::Surface *screen, const Picture &p,
+void keyBlitToScreen(Graphics::Surface *screen, const Picture &p,
 							int x, int y) {
 	if (!screen || p.surface.empty())
 		return;
@@ -278,15 +278,15 @@ struct AnimScriptLong {
 // 0x17 (game) / 0x18 (book) / 0x19 (nancy) regardless of partner; the
 // per-partner ANI.DBD cells come from a separate entry (e.g. 0x3b for
 // Jenny's briefing).
-static const uint8 kScript17[] = {
+const uint8 kScript17[] = {
 	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	20,21,22,23,24,25,26,27,28,29
 };
-static const uint8 kScript18[] = {
+const uint8 kScript18[] = {
 	0,1,2,3,4,5,6,7,8,8,8,8,8,8,8,8,
 	8,8,8,8,8,8,8,9,10,11,12,13,14,15
 };
-static const uint8 kScript19[] = {
+const uint8 kScript19[] = {
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
 	1,2,3,4,5,6,7,8,9,10,11,12
 };
@@ -295,7 +295,7 @@ static const uint8 kScript19[] = {
 // frames are the original's frame-hold mechanism (one entry per tick).
 
 // 0x1a (29be:19a4) — count-up 0..7, idle hold, repeat 1..7, idle, mirror 7..0, idle.
-static const uint8 kScript1a[] = {
+const uint8 kScript1a[] = {
 	0,1,2,3,4,5,6,7,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
 	1,2,3,4,5,6,7,
@@ -304,7 +304,7 @@ static const uint8 kScript1a[] = {
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
 // 0x1e (29be:1a40) — slow walk-stutter with idle tail (76 entries).
-static const uint8 kScript1e[] = {
+const uint8 kScript1e[] = {
 	0,1,2,3,3,3,3,4,4,3,4,4,4,4,4,3,
 	5,5,5,5,5,5,5,5,4,4,4,4,4,4,4,4,
 	5,5,5,5,5,5,6,5,6,5,7,7,7,7,7,7,
@@ -313,7 +313,7 @@ static const uint8 kScript1e[] = {
 };
 // 0x1f (29be:1ada) — 0..5, idle, 0..5, idle, 6..8 alternation,
 // idle (50 entries).
-static const uint8 kScript1f[] = {
+const uint8 kScript1f[] = {
 	0,1,2,3,4,5,
 	0,0,0,0,
 	1,2,3,4,5,
@@ -324,13 +324,13 @@ static const uint8 kScript1f[] = {
 	0,0,0,0,0
 };
 // 0x20 (29be:1b40) — count-up 0..33 (34 frames).
-static const uint8 kScript20[] = {
+const uint8 kScript20[] = {
 	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	20,21,22,23,24,25,26,27,28,29,30,31,32,33
 };
 // 0x22 (29be:1ba4) — long held-frame walker 0..22 with idle tail
 // (115 entries; most frames held 4-7 ticks each).
-static const uint8 kScript22[] = {
+const uint8 kScript22[] = {
 	0,
 	1,1,1,1,1,
 	2,2,2,2,2,
@@ -358,7 +358,7 @@ static const uint8 kScript22[] = {
 };
 // 0x23 (29be:1c8c) — 29 entries: 0, 6 holds of 1, count-up 2..4,
 // down-up gesture, 5 idle frames.
-static const uint8 kScript23[] = {
+const uint8 kScript23[] = {
 	0,1,1,1,1,1,1,
 	2,3,4,3,2,
 	5,5,5,
@@ -367,7 +367,7 @@ static const uint8 kScript23[] = {
 };
 // 0x24 (29be:1cc8) — bell-curve hold (58 entries): 0,0, 1,1, 2,2,
 // 3 held for 26 ticks, mirror back, idle.
-static const uint8 kScript24[] = {
+const uint8 kScript24[] = {
 	0,0,1,1,2,2,
 	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
 	2,2,1,1,
@@ -375,7 +375,7 @@ static const uint8 kScript24[] = {
 };
 // 0x28 (29be:1d7e) — gentle hold 0..3 with long hold on 3, mirror
 // back, idle (45 entries).
-static const uint8 kScript28[] = {
+const uint8 kScript28[] = {
 	0,1,1,2,2,
 	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
 	2,2,1,1,
@@ -383,7 +383,7 @@ static const uint8 kScript28[] = {
 };
 // 0x29 (29be:1dda) — paired-step count-up 0..21 plus idle
 // (58 entries).
-static const uint8 kScript29[] = {
+const uint8 kScript29[] = {
 	0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,
 	11,11,12,12,13,13,14,14,15,15,16,16,17,17,18,18,19,19,
 	20,20,21,21,
@@ -391,14 +391,14 @@ static const uint8 kScript29[] = {
 };
 // 0x2b (29be:1e5c) — count-up 0..11 with each frame held 4 ticks
 // (48 entries).
-static const uint8 kScript2b[] = {
+const uint8 kScript2b[] = {
 	0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,
 	4,4,4,4,5,5,5,5,6,6,6,6,7,7,7,7,
 	8,8,8,8,9,9,9,9,10,10,10,10,11,11,11,11
 };
 // 0x2c (29be:1ebe) — alternation walk 0..19 with idle tail
 // (54 entries).
-static const uint8 kScript2c[] = {
+const uint8 kScript2c[] = {
 	0,1,2,3,4,5,
 	0,
 	6,7,8,9,10,10,10,10,10,10,
@@ -410,7 +410,7 @@ static const uint8 kScript2c[] = {
 };
 // 0x2d (29be:1f2c) — count-up 0..11 with each frame held 8 ticks
 // (96 entries).
-static const uint8 kScript2d[] = {
+const uint8 kScript2d[] = {
 	0,0,0,0,0,0,0,0,
 	1,1,1,1,1,1,1,1,
 	2,2,2,2,2,2,2,2,
@@ -426,7 +426,7 @@ static const uint8 kScript2d[] = {
 };
 // 0x30 (29be:1fee) — 0,0, count-up 1..19, idle, mirror down, extra
 // idle (86 entries).
-static const uint8 kScript30[] = {
+const uint8 kScript30[] = {
 	0,0,
 	1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	0,0,0,0,0,0,0,0,0,0,
@@ -435,7 +435,7 @@ static const uint8 kScript30[] = {
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
 // 0x31 (29be:209c) — paired-step idle alternations (57 entries).
-static const uint8 kScript31[] = {
+const uint8 kScript31[] = {
 	0,0,0,1,1,1,
 	0,0,0,1,1,1,
 	2,2,2,3,3,3,
@@ -450,7 +450,7 @@ static const uint8 kScript31[] = {
 };
 // 0x36 (29be:2110) — 0..8 forward, 1..8 forward, frame 1 held 20
 // ticks, 8..0 mirror, idle tail (60 entries).
-static const uint8 kScript36[] = {
+const uint8 kScript36[] = {
 	0,1,2,3,4,5,6,7,8,
 	1,2,3,4,5,6,7,8,
 	1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
@@ -484,19 +484,19 @@ const AnimScriptLong kAnimScriptsLong[] = {
 // never calls the switchers; floppy calls them from `_DoSiteLoop_Floppy`
 // (via `_Switch2Patient` / `_Switch2Impatient`). We intentionally enable
 // the same switch for both builds.
-static const uint8 kPatientSequence[]   = { 0,0,0,0,0,0,0,0,0,2 };
-static const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
+const uint8 kPatientSequence[]   = { 0,0,0,0,0,0,0,0,0,2 };
+const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 
 // Test-shortened impatience delay. The original stores an hour-rounded
 // wall-clock value via DOS gettime; this keeps the same reset/switch
 // behavior but makes the feature observable during normal testing.
-static const uint32 kImpatienceDelayMs = 60 * 1000;
+const uint32 kImpatienceDelayMs = 60 * 1000;
 
 struct AnimScriptRef {
 	const uint8 *frames;
 	uint16 len;
 };
-static AnimScriptRef findAnimScript(uint16 seqnum) {
+AnimScriptRef findAnimScript(uint16 seqnum) {
 	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++) {
 		if (kAnimScripts[i].seqnum == seqnum) {
 			AnimScriptRef r;
@@ -525,9 +525,9 @@ static AnimScriptRef findAnimScript(uint16 seqnum) {
 // (Borland C `struct time` memory order is min, hour, hund, sec.)
 // `+ 0xe` is 14 centiseconds → ~140 ms per frame, matching
 // `_CheckFrameRate @ 1a35:0204`.
-static const uint kFramePeriodMs = 140;
+const uint kFramePeriodMs = 140;
 
-static uint frameFromScriptAtTick(const uint8 *frames, uint len,
+uint frameFromScriptAtTick(const uint8 *frames, uint len,
 								  uint numFrames, uint32 tickMs) {
 	if (!frames || len == 0)
 		return numFrames > 0 ? (uint)((tickMs / kFramePeriodMs) % numFrames) : 0;
@@ -639,7 +639,7 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 // `_SmallMapWaitSeq @ 29be:1548`). `partnerFrameAtTick` can't model
 // that swap on its own (it always wraps on the same script), hence
 // this helper.
-static uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
+uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 									   const uint8 *waitSeq, uint waitSeqLen,
 									   uint numFrames, uint32 elapsedMs) {
 	const uint tick = elapsedMs / kFramePeriodMs;


Commit: f7890714707e231e068bca32bc05939ee5baade7
    https://github.com/scummvm/scummvm/commit/f7890714707e231e068bca32bc05939ee5baade7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:15+02:00

Commit Message:
CI: temporary enable CI in github

Changed paths:
    .github/workflows/ci.yml


diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa19879897e..5a7badb96c9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,12 @@
 name: CI
-on: [push, pull_request]
+# Temporarily scoped to dev-eem to fix merge-blocking CI issues without
+# burning runner minutes on every push. Revert to `on: [push, pull_request]`
+# before opening upstream PRs from other branches.
+on:
+  push:
+    branches: [dev-eem]
+  pull_request:
+    branches: [dev-eem]
 #  schedule:
 #    - cron: '0 0-23/4 * * *'
 permissions:


Commit: ba065aa1a9a6a4b0979c3e09aa197a4deea33c5f
    https://github.com/scummvm/scummvm/commit/ba065aa1a9a6a4b0979c3e09aa197a4deea33c5f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:15+02:00

Commit Message:
CI: reverted temporary enable CI in github commit

Changed paths:
    .github/workflows/ci.yml


diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5a7badb96c9..aa19879897e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,5 @@
 name: CI
-# Temporarily scoped to dev-eem to fix merge-blocking CI issues without
-# burning runner minutes on every push. Revert to `on: [push, pull_request]`
-# before opening upstream PRs from other branches.
-on:
-  push:
-    branches: [dev-eem]
-  pull_request:
-    branches: [dev-eem]
+on: [push, pull_request]
 #  schedule:
 #    - cron: '0 0-23/4 * * *'
 permissions:


Commit: 8bcc62ad5d61919786b859a2dadb3cb2ad5a9312
    https://github.com/scummvm/scummvm/commit/8bcc62ad5d61919786b859a2dadb3cb2ad5a9312
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:15+02:00

Commit Message:
EEM: removed duplicated definitions

Changed paths:
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8c06aa64cb9..04234a5a89b 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -28,6 +28,7 @@
 #include "common/language.h"
 #include "common/platform.h"
 #include "common/random.h"
+#include "common/rect.h"
 #include "common/scummsys.h"
 
 #include "common/serializer.h"
@@ -115,6 +116,13 @@ enum Partner {
 constexpr int kScreenWidth  = 320;
 constexpr int kScreenHeight = 200;
 
+/// Shared PDA-frame navigation rects (PIC 0x3f) — reachable from Site,
+/// Notebook, Gallery, Accuse, and MoreInfo. Original `_NoteButtons` table
+/// @ 29be:0147 + the site-screen hit tests in `_DoSiteLoop @ 168d:03f4`.
+constexpr Common::Rect kPdaSiteRect             (Common::Point(35, 111), 21, 25);
+constexpr Common::Rect kPdaPartnerFootMapRect   (Common::Point( 7, 177), 50, 23);
+constexpr Common::Rect kPdaPartnerHeadHintRect  (Common::Point( 5,  80), 39, 30);
+
 class EEMEngine : public Engine {
 public:
 	EEMEngine(OSystem *syst, const ADGameDescription *gameDesc);
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0f9fc01f99a..8358e1d6fe2 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -35,10 +35,6 @@
 
 namespace EEM {
 
-constexpr Common::Rect kSitePdaRect(Common::Point(35, 111), 21, 25);
-constexpr Common::Rect kSitePartnerFootMapRect(Common::Point(7, 177), 50, 23);
-constexpr Common::Rect kSitePartnerHeadHintRect(Common::Point(5, 80), 39, 30);
-
 // Masked blit using `transp` = high byte of `pic.flags` (`_Rect_Move_Mask @ 1000:03fc`).
 void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 			   int x, int y, byte transp) {
@@ -882,14 +878,14 @@ void SiteScreen::run() {
 				// Partner-head click is port-only: `_KDHelp` shortcut
 				// mirroring `_HandleNoteButton[3]` (0x0403) /
 				// `_HandleGalleryButton[3]` (0x061e). Rect = (5,80,44,110).
-				if (kSitePdaRect.contains(event.mouse.x, event.mouse.y)) {
+				if (kPdaSiteRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					_vm->setNextScreen(kScreenNotebook);
 					_vm->stopMusic();
 					return;
 				}
-				if (kSitePartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
+				if (kPdaPartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
 					// CD: _NextScreen=1, floppy=2.
@@ -898,7 +894,7 @@ void SiteScreen::run() {
 					_vm->stopMusic();
 					return;
 				}
-				if (kSitePartnerHeadHintRect.contains(event.mouse.x, event.mouse.y)) {
+				if (kPdaPartnerHeadHintRect.contains(event.mouse.x, event.mouse.y)) {
 					_vm->setHotspotMouseCursor(false);
 					_vm->doHelp();
 					notePartnerActivity();
@@ -1527,9 +1523,9 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 	if (!_vm)
 		return;
-	const bool siteControl = kSitePdaRect.contains(x, y) ||
-							 kSitePartnerFootMapRect.contains(x, y) ||
-							 kSitePartnerHeadHintRect.contains(x, y);
+	const bool siteControl = kPdaSiteRect.contains(x, y) ||
+							 kPdaPartnerFootMapRect.contains(x, y) ||
+							 kPdaPartnerHeadHintRect.contains(x, y);
 	_vm->setHotspotMouseCursor(siteControl || hotspotAtPoint(siteNum, x, y) >= 0);
 }
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 507d27d0506..7ef17cafaa4 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -163,16 +163,16 @@ constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
 constexpr uint16 kFirstTryBadgePic = 0x205;
 constexpr Common::Point kFirstTryBadgePos(0x1e, 9);
 
+// kPdaSiteRect / kPdaPartnerFootMapRect / kPdaPartnerHeadHintRect live in
+// eem.h (shared with site.cpp). The button-row rects below are PDA-screen-
+// only (`_NoteButtons @ 29be:0147`).
 constexpr Common::Rect kPdaHelpRect(Common::Point(93, 174), 22, 16);
 constexpr Common::Rect kPdaNotebookRect(Common::Point(134, 174), 21, 16);
 constexpr Common::Rect kPdaGalleryRect(Common::Point(157, 174), 21, 16);
-constexpr Common::Rect kPdaPartnerHeadHintRect(Common::Point(5, 80), 39, 30);
 constexpr Common::Rect kPdaAccuseRect(Common::Point(180, 174), 21, 16);
 constexpr Common::Rect kPdaPageNextRect(Common::Point(204, 174), 20, 16);
 constexpr Common::Rect kPdaPagePrevRect(Common::Point(226, 174), 21, 16);
 constexpr Common::Rect kPdaHelp2Rect(Common::Point(267, 174), 21, 16);
-constexpr Common::Rect kPdaPartnerFootMapRect(Common::Point(7, 177), 50, 23);
-constexpr Common::Rect kPdaSiteRect(Common::Point(35, 111), 21, 25);
 
 constexpr uint16 kProfilePickerRevealPic = 0x105;
 constexpr int kProfilePickerRevealX = 0x3e;


Commit: 79188e6bd223fc5dd10476f7916af06c93b2cc84
    https://github.com/scummvm/scummvm/commit/79188e6bd223fc5dd10476f7916af06c93b2cc84
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:16+02:00

Commit Message:
EEM: renamed _ASM_Decompress to avoid confusion

Changed paths:
    engines/eem/animation.cpp
    engines/eem/animation.h


diff --git a/engines/eem/animation.cpp b/engines/eem/animation.cpp
index 34364c6bf12..c52ee496293 100644
--- a/engines/eem/animation.cpp
+++ b/engines/eem/animation.cpp
@@ -27,7 +27,7 @@
 
 namespace EEM {
 
-void asmDecompress(const byte *src, uint srcSize, byte *dst, uint dstSize) {
+void decodeAnmFrameRLE(const byte *src, uint srcSize, byte *dst, uint dstSize) {
 	const byte *srcEnd = src + srcSize;
 	byte *dstEnd = dst + dstSize;
 
@@ -162,7 +162,7 @@ const byte *ANMDecoder::nextFrame() {
 		return nullptr;
 	}
 
-	asmDecompress(_packed.data(), packedSize, _buffer.data(), _buffer.size());
+	decodeAnmFrameRLE(_packed.data(), packedSize, _buffer.data(), _buffer.size());
 	_nextFrameIdx++;
 	return _buffer.data();
 }
diff --git a/engines/eem/animation.h b/engines/eem/animation.h
index fa5c7b8f4c2..51c07315f79 100644
--- a/engines/eem/animation.h
+++ b/engines/eem/animation.h
@@ -36,7 +36,7 @@ namespace EEM {
  *   - u16: frame count
  *   - 12 bytes: header (height @ +2, width @ +4, rest unused)
  *   - frames*u16: packed length per frame
- *   - per frame: lengths[i] bytes of RLE delta data (asmDecompress).
+ *   - per frame: lengths[i] bytes of RLE delta data (decodeAnmFrameRLE).
  */
 class ANMDecoder {
 public:
@@ -82,9 +82,11 @@ private:
 	uint16 _nextFrameIdx = 0;
 };
 
-/// _ASM_Decompress @ 1000:0953. dst holds the previous frame; skip opcodes
-/// leave those pixels untouched (difference encoding).
-void asmDecompress(const byte *src, uint srcSize, byte *dst, uint dstSize);
+/// Decode a single ANM frame's RLE-encoded delta payload into @p dst.
+/// `dst` already holds the previous frame; skip opcodes leave those pixels
+/// untouched (difference encoding). Original symbol: `_ASM_Decompress`
+/// @ 1000:0953.
+void decodeAnmFrameRLE(const byte *src, uint srcSize, byte *dst, uint dstSize);
 
 } // End of namespace EEM
 


Commit: 2b9c008c66397e6bba2cacbd3dbac8b3040f1e51
    https://github.com/scummvm/scummvm/commit/2b9c008c66397e6bba2cacbd3dbac8b3040f1e51
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:16+02:00

Commit Message:
EEM: add end of notes in the PDA rendering

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 04234a5a89b..3115e45d7d4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -518,10 +518,6 @@ private:
 	/// ESC during intro: skip remaining opening-anim chain.
 	bool _skipIntro = false;
 
-	/// Cached notebook slot rects + clue IDs for click hit-testing.
-	Common::Array<Common::Rect> _notebookSlotRects;
-	Common::Array<uint>         _notebookSlotClues;
-
 	/// Clean BG (no partner/NPC) used by `playKdAnim` between camera-anim
 	/// cells. See `setPartnerEraseBg`.
 	Graphics::ManagedSurface _partnerEraseBg;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7ef17cafaa4..ca54e079b9e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2125,9 +2125,7 @@ void EEMEngine::doNotebook() {
 
 	drawNotebookFrame(page);
 	Common::Point mouse = g_system->getEventManager()->getMousePos();
-	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
-							  rectListContains(_notebookSlotRects,
-											   mouse.x, mouse.y));
+	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y));
 
 	uint32 lastDraw = g_system->getMillis();
 	uint32 gizmoLastTick = lastDraw;
@@ -2145,9 +2143,6 @@ void EEMEngine::doNotebook() {
 			}
 			if (ev.type == Common::EVENT_MOUSEMOVE) {
 				setInteractiveMouseCursor(notebookButtonAt(ev.mouse.x,
-														   ev.mouse.y) ||
-										  rectListContains(_notebookSlotRects,
-														   ev.mouse.x,
 														   ev.mouse.y));
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
@@ -2220,17 +2215,10 @@ void EEMEngine::doNotebook() {
 					dirty = true;
 					continue;
 				}
-				// ScummVM-only: click on clue slot toggles _NoteSelected
-				// (original toggles only in accuse screen).
-				for (uint i = 0; i < _notebookSlotRects.size(); i++) {
-					if (_notebookSlotRects[i].contains(ev.mouse.x,
-													   ev.mouse.y)) {
-						const uint clueId = _notebookSlotClues[i];
-						_mystery._noteSelected[clueId] ^= 1;
-						dirty = true;
-						break;
-					}
-				}
+				// The notebook screen is read-only in the original:
+				// `_DoNotebook @ 161e:0500` only checks clicks against
+				// `_NoteButtons` (11 rects). Clue toggling lives in the
+				// accuse screen.
 			}
 		}
 		if (exitFlag)
@@ -2242,9 +2230,7 @@ void EEMEngine::doNotebook() {
 			drawNotebookFrame(page);
 			lastDraw = now;
 			mouse = g_system->getEventManager()->getMousePos();
-			setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y) ||
-									  rectListContains(_notebookSlotRects,
-													   mouse.x, mouse.y));
+			setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y));
 		}
 		// _GizmoColorCycle @ 1c33:0002 — `_DoNotebook` rotates 0x6f..0x73 each
 		// _CheckFrameRate tick (the PDA gizmo / LED indicator shimmer).
@@ -2348,10 +2334,10 @@ void EEMEngine::drawNotebookFrame(int &page) {
 			page = 0;
 	}
 
-	// Per-slot rects published to the click handler.
-	Common::Array<Common::Rect> slotRects;
-	Common::Array<uint> slotClues;
-
+	// `_DrawNotes @ 161e:01d0` is read-only on this screen — clue selection
+	// is driven exclusively from the accuse screen. The 0x3C / 0x5C colour
+	// choice still reflects `_NoteSelected[]` so picks made in accuse remain
+	// visible when the player flips back here.
 	const int startClue = (page < (int)pageStarts.size())
 							? pageStarts[page] : 0;
 	const int endClue   = (page + 1 < (int)pageStarts.size())
@@ -2365,7 +2351,6 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		if (txt.empty())
 			txt = Common::String::format(
 				isSpanish() ? "nota %u" : "clue %u", clueId);
-		// `_DrawNotes @ 161e:01d0`: 0x5c unselected, 0x3c selected.
 		Common::Array<Common::String> wrapped;
 		_font.wordWrapText(txt, kRectW, wrapped);
 		const int lineH = _font.getFontHeight();
@@ -2375,24 +2360,22 @@ void EEMEngine::drawNotebookFrame(int &page) {
 			_font.drawString(&scratch, wrapped[li], kRectX,
 							 y + (int)li * lineH, kRectW, color);
 		}
-		slotRects.push_back(Common::Rect(kRectX, y,
-										  kRectX + kRectW, y + h));
-		slotClues.push_back(clueId);
 		y += h + 7;
 	}
 
-	// Page indicator only (original has no points display).
-	_font.drawString(&scratch, Common::String::format("p%d/%d",
-							   page + 1, (int)pageStarts.size()),
-					 270, 4, kScreenWidth, 0x5C);
+	// `_DrawNotes @ 161e:01d0` appends a terminator line at the bottom of
+	// the last page once the clue list is exhausted — string @ 29be:01f4.
+	const bool isLastPage = (page + 1 >= (int)pageStarts.size());
+	if (isLastPage) {
+		const char *kEndMarker = isSpanish()
+			? "-- Fin de las notas --"
+			: "-- End of notes --";
+		_font.drawString(&scratch, kEndMarker, kRectX, y, kRectW, 0x5C);
+	}
 
 	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
-
-	// Publish slot info to `doNotebook`'s click handler.
-	_notebookSlotRects = slotRects;
-	_notebookSlotClues = slotClues;
 }
 
 void EEMEngine::doGallery() {


Commit: c9a5bbc56c706c24039210f01702ba3f6740b650
    https://github.com/scummvm/scummvm/commit/c9a5bbc56c706c24039210f01702ba3f6740b650
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:16+02:00

Commit Message:
EEM: add an option to skip repeated cases

Changed paths:
    engines/eem/detection.cpp
    engines/eem/detection.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/metaengine.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 48cf5629a1d..17ff91e826b 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -31,8 +31,8 @@ const PlainGameDescriptor eemGames[] = {
 	{ nullptr, nullptr }
 };
 
-#define GUI_OPTIONS_EEM_FLOPPY GUIO3(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
-#define GUI_OPTIONS_EEM_CD     GUIO4(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_FIT_DIALOG_BALLOONS, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+#define GUI_OPTIONS_EEM_FLOPPY GUIO4(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_SKIP_REPEATED_CASES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+#define GUI_OPTIONS_EEM_CD     GUIO5(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_FIT_DIALOG_BALLOONS, GAMEOPTION_SKIP_REPEATED_CASES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
 
 const ADGameDescription gameDescriptions[] = {
 	{
diff --git a/engines/eem/detection.h b/engines/eem/detection.h
index 3ad726bb5f0..937fbe6a4e3 100644
--- a/engines/eem/detection.h
+++ b/engines/eem/detection.h
@@ -28,6 +28,7 @@ namespace EEM {
 
 #define GAMEOPTION_HIDE_HIGHLIGHT_BOXES   GUIO_GAMEOPTIONS1
 #define GAMEOPTION_FIT_DIALOG_BALLOONS    GUIO_GAMEOPTIONS2
+#define GAMEOPTION_SKIP_REPEATED_CASES    GUIO_GAMEOPTIONS3
 
 enum EEMDebugChannels {
 	kDebugGeneral = 1 << 0,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 9dfe48519f7..2ad4b74f687 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -197,6 +197,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	  _lastScreen(kScreenInvalid), _nextScreen(kScreenTitle), _partner(0) {
 	ConfMan.registerDefault("hide_highlight_boxes", false);
 	ConfMan.registerDefault("fit_dialog_balloons", false);
+	ConfMan.registerDefault("skip_repeated_cases", false);
 
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
@@ -220,6 +221,69 @@ void EEMEngine::applyStartupTestOverrides() {
 		   "startup test override: populated ScrapBook 1 mystery flags");
 }
 
+bool EEMEngine::areMysteriesSolved(uint lo, uint hi) const {
+	if (hi < lo)
+		return false;
+	for (uint i = lo; i <= hi; i++) {
+		if (i >= sizeof(_mysteriesSolved) || _mysteriesSolved[i] == 0)
+			return false;
+	}
+	return true;
+}
+
+void EEMEngine::advanceChainStageAfterSolve(uint mysteryNum) {
+	if (mysteryNum == 0 || _chainStage >= 4)
+		return;
+
+	uint lo = 0;
+	uint hi = 0;
+	switch (_chainStage) {
+	case 1:
+		lo = 1;
+		hi = 0x18;
+		break;
+	case 2:
+		lo = 0x19;
+		hi = 0x30;
+		break;
+	case 3:
+		lo = 0x31;
+		hi = 0x36;
+		break;
+	default:
+		return;
+	}
+
+	if (!areMysteriesSolved(lo, hi))
+		return;
+
+	const uint oldStage = _chainStage;
+	// Book 2 repeats the Book 1 cases; this option keeps the original solve
+	// state but jumps the profile's active chain straight to Book 3.
+	if (_chainStage == 1 && ConfMan.getBool("skip_repeated_cases"))
+		_chainStage = 3;
+	else
+		_chainStage++;
+
+	debugC(1, kDebugMystery,
+		   "chainStage advanced from %u to %u after solving mystery %u",
+		   oldStage, _chainStage, mysteryNum);
+}
+
+void EEMEngine::applySkipRepeatedCasesOption() {
+	if (!ConfMan.getBool("skip_repeated_cases"))
+		return;
+	if (_mystery.isLoaded())
+		return;
+	if (_chainStage <= 2 && areMysteriesSolved(1, 0x18)) {
+		const uint oldStage = _chainStage;
+		_chainStage = 3;
+		debugC(1, kDebugMystery,
+			   "skip_repeated_cases advanced chainStage from %u to %u",
+			   oldStage, _chainStage);
+	}
+}
+
 Common::Error EEMEngine::run() {
 	// _SetMode13X @ 1000:0358 — VGA mode 13h.
 	initGraphics(kScreenWidth, kScreenHeight);
@@ -268,6 +332,7 @@ Common::Error EEMEngine::run() {
 		const Common::Error err = loadGameState(wantedSave);
 		if (err.getCode() == Common::kNoError) {
 			applyStartupTestOverrides();
+			applySkipRepeatedCasesOption();
 			CursorMan.showMouse(true);
 			if (_mystery.isLoaded()) {
 				debugC(1, kDebugGeneral,
@@ -398,6 +463,8 @@ Common::Error EEMEngine::run() {
 		doProfilePicker();
 	if (!shouldQuit())
 		applyStartupTestOverrides();
+	if (!shouldQuit())
+		applySkipRepeatedCasesOption();
 	if (!shouldQuit())
 		doChoosePartner();
 
@@ -521,6 +588,8 @@ screenLoop:
 			doProfilePicker();
 			if (!shouldQuit())
 				applyStartupTestOverrides();
+			if (!shouldQuit())
+				applySkipRepeatedCasesOption();
 			if (!shouldQuit())
 				doChoosePartner();
 			if (!shouldQuit())
@@ -1172,6 +1241,7 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 		_mystery.clear();
 		resetSiteArrivalState();
 	}
+	applySkipRepeatedCasesOption();
 
 	debugC(1, kDebugGeneral,
 		   "Loaded profile name=%s partner=%u mystery=%d",
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 3115e45d7d4..e2ebb1151d0 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -278,6 +278,9 @@ public:
 
 private:
 	void applyStartupTestOverrides();
+	bool areMysteriesSolved(uint lo, uint hi) const;
+	void advanceChainStageAfterSolve(uint mysteryNum);
+	void applySkipRepeatedCasesOption();
 
 	/// Central dispatch loop matching `_ScreenDriver @ 1a35:0dc1`. Each
 	/// iteration calls the handler that matches `_nextScreen`; handlers
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index ee16e3e10d3..d3efe9c89cc 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -55,6 +55,17 @@ const ADExtraGuiOptionsMap optionsList[] = {
 			0
 		}
 	},
+	{
+		GAMEOPTION_SKIP_REPEATED_CASES,
+		{
+			_s("Skip repeated cases"),
+			_s("Skip all Book 2 cases and jump to Book 3 once Book 1 is complete."),
+			"skip_repeated_cases",
+			false,
+			0,
+			0
+		}
+	},
 
 	AD_EXTRA_GUI_OPTIONS_TERMINATOR
 };
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index ca54e079b9e..3a29e5252cf 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -4281,38 +4281,7 @@ void EEMEngine::doAccuse() {
 
 		// Chain advance @ 1df2:0824-0850. Skip mystery 0 (practice).
 		// stage 1: 1..0x18, stage 2: 0x19..0x30, stage 3: 0x31..0x36.
-		if (mn != 0) {
-			uint lo = 0;
-			uint hi = 0;
-			switch (_chainStage) {
-			case 1:
-				lo = 1;
-				hi = 0x18;
-				break;
-			case 2:
-				lo = 0x19;
-				hi = 0x30;
-				break;
-			case 3:
-				lo = 0x31;
-				hi = 0x36;
-				break;
-			default:
-				break;
-			}
-			bool allSolved = (hi >= lo);
-			for (uint i = lo; i <= hi && allSolved; i++) {
-				if (i >= sizeof(_mysteriesSolved) || _mysteriesSolved[i] == 0)
-					allSolved = false;
-			}
-			// 1df2:0852 increments past 3 (stage-4 endgame); cap at 4.
-			if (allSolved && _chainStage < 4) {
-				_chainStage++;
-				debugC(1, kDebugMystery,
-					   "chainStage advanced to %u after solving mystery %u",
-					   _chainStage, mn);
-			}
-		}
+		advanceChainStageAfterSolve(mn);
 
 		// `_DisplayCorrect @ 1df2:073c`:
 		//   _AllBlack; _BuildBackground(5, 0x42, 0x14); _FadeIn;
@@ -4738,38 +4707,7 @@ void EEMEngine::doAccuseFloppy() {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 
 		// Tier promotion @ 1d40:0941..0978. Skip mystery 0 (practice).
-		if (mn != 0) {
-			uint lo = 0;
-			uint hi = 0;
-			switch (_chainStage) {
-			case 1:
-				lo = 1;
-				hi = 0x18;
-				break;
-			case 2:
-				lo = 0x19;
-				hi = 0x30;
-				break;
-			case 3:
-				lo = 0x31;
-				hi = 0x36;
-				break;
-			default:
-				break;
-			}
-			bool allSolved = (hi >= lo);
-			for (uint i = lo; i <= hi && allSolved; i++) {
-				if (i >= sizeof(_mysteriesSolved) ||
-					_mysteriesSolved[i] == 0)
-					allSolved = false;
-			}
-			if (allSolved && _chainStage < 4) {
-				_chainStage++;
-				debugC(1, kDebugMystery,
-					   "chainStage advanced to %u after solving mystery %u",
-					   _chainStage, mn);
-			}
-		}
+		advanceChainStageAfterSolve(mn);
 
 		// `MakeSolvedSound`. `FUN_1d40_05b7` maps E<num>.BIN byte 0 (0..2)
 		// via table @ 2608:0c5e to VOC slots 0x15/0x16/0x17.


Commit: f2c5be745a959c9ce15f92350b0ee5282c358e2a
    https://github.com/scummvm/scummvm/commit/f2c5be745a959c9ce15f92350b0ee5282c358e2a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:17+02:00

Commit Message:
EEM: make sure screenshots contain all the elements from sites

Changed paths:
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 8358e1d6fe2..7c9112d97a4 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -35,6 +35,27 @@
 
 namespace EEM {
 
+const uint kSiteBackendActionFlushPriority = 100;
+
+class SiteBackendActionObserverRegistration {
+public:
+	SiteBackendActionObserverRegistration(Common::EventObserver *observer)
+		: _dispatcher(g_system->getEventManager()->getEventDispatcher()),
+		  _observer(observer) {
+		if (_dispatcher)
+			_dispatcher->registerObserver(_observer, kSiteBackendActionFlushPriority, false);
+	}
+
+	~SiteBackendActionObserverRegistration() {
+		if (_dispatcher)
+			_dispatcher->unregisterObserver(_observer);
+	}
+
+private:
+	Common::EventDispatcher *_dispatcher;
+	Common::EventObserver *_observer;
+};
+
 // Masked blit using `transp` = high byte of `pic.flags` (`_Rect_Move_Mask @ 1000:03fc`).
 void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 			   int x, int y, byte transp) {
@@ -843,6 +864,18 @@ void SiteScreen::notePartnerActivity() {
 		debugC(1, kDebugSite, "Partner impatience: reset to patient");
 }
 
+bool SiteScreen::notifyEvent(const Common::Event &event) {
+	if (event.type != Common::EVENT_CUSTOM_BACKEND_ACTION_START)
+		return false;
+
+	if (_snapshotSite < 0 || g_system->isOverlayVisible())
+		return false;
+
+	syncCompositedScreen();
+	g_system->updateScreen();
+	return false;
+}
+
 void SiteScreen::run() {
 	if (!_mystery || !_mystery->isLoaded())
 		return;
@@ -851,6 +884,8 @@ void SiteScreen::run() {
 	uint cur = _mystery->_siteNumber;
 	if (cur >= _mystery->numSites())
 		cur = 0;
+	_snapshotSite = -1;
+	SiteBackendActionObserverRegistration backendActionRegistration(this);
 	enter(cur);
 	Common::Point mouse = g_system->getEventManager()->getMousePos();
 	updateHotspotCursor(cur, mouse.x, mouse.y);
@@ -1280,6 +1315,25 @@ void SiteScreen::restoreBgSnapshot() {
 							   0, 0, kScreenWidth, kScreenHeight);
 }
 
+void SiteScreen::syncCompositedScreen() {
+	// OpenGL screenshots read the current backbuffer. After a buffer swap,
+	// that backbuffer can contain an older site frame unless the engine keeps
+	// presenting the full composited screen, even while no animation tick
+	// fired. Copying the current game surface through copyRectToScreen keeps
+	// screenshots in sync without backend-specific changes.
+	Graphics::ManagedSurface snapshot(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return;
+	snapshot.simpleBlitFrom(*screen);
+	g_system->unlockScreen();
+
+	g_system->copyRectToScreen(snapshot.getPixels(), snapshot.pitch,
+							   0, 0, kScreenWidth, kScreenHeight);
+}
+
 void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
 	// table index, then for each (speaker x partner) loads:
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 895d2124a9f..1719e68c47f 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -22,6 +22,7 @@
 #ifndef EEM_SITE_H
 #define EEM_SITE_H
 
+#include "common/events.h"
 #include "common/rect.h"
 #include "common/scummsys.h"
 
@@ -88,7 +89,7 @@ struct Hotspot {
 
 /// Site / scene controller. Mirrors `_DrawSearchButtons @ 2404:0a8f` /
 /// `_SearchButtons @ 2404:0bfb` site loop.
-class SiteScreen {
+class SiteScreen : public Common::EventObserver {
 public:
 	SiteScreen(EEMEngine *vm, Mystery *mystery)
 		: _vm(vm), _mystery(mystery) {}
@@ -100,6 +101,8 @@ public:
 	void run();
 
 private:
+	bool notifyEvent(const Common::Event &event) override;
+
 	void renderBackground(uint siteNum);
 	void renderHotspots(uint siteNum);
 	int  hotspotAtPoint(uint siteNum, int x, int y) const;
@@ -140,6 +143,12 @@ private:
 	/// Restore the snapshot taken at `captureBgSnapshot` time.
 	void restoreBgSnapshot();
 
+	/// Push pixels written through `lockScreen()` back through the backend's
+	/// normal screen-copy path. The SDL/OpenGL screenshot code captures the
+	/// presented backend buffer, so site frames must not leave partner/NPC
+	/// sprites only in the locked software surface.
+	void syncCompositedScreen();
+
 	/// scanColorCycles: scan Loop 1 for ColorCycle entries (animId == -1),
 	/// cache (start, end) palette ranges. Mirrors `_DoSiteLoop @ 168d:03f4` init scan.
 	void scanColorCycles(uint siteNum);


Commit: 24fe380eedd014bab00afd035e6f0289875fb93e
    https://github.com/scummvm/scummvm/commit/24fe380eedd014bab00afd035e6f0289875fb93e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:17+02:00

Commit Message:
EEM: fixed invalid reference to a sound clip in practice mystery

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 3a29e5252cf..98960cd0b83 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3707,6 +3707,7 @@ void EEMEngine::doAccuse() {
 	const int foundPoints = _mystery.foundPoints();
 	uint entryKDSpeak = 0;
 	uint16 entryTextOff = 0xFFFF;
+	uint16 entryVoiceOverride = 0xFFFF;
 	bool canAccuse = false;
 	Common::String entryText;
 	if (foundPoints == 0) {
@@ -3714,6 +3715,11 @@ void EEMEngine::doAccuse() {
 		entryText = "3We're not ready to solve this mystery yet.  "
 					"Let's keep investigating until we have some more solid "
 					"evidence to make our case!";
+		// Practice mystery M0 ships the matching ZeroText takes as the
+		// final two SDB entries, but its KD digital table points kdspeak 9
+		// at earlier hint clips. Use the otherwise unreferenced pair.
+		if (_mystery.number() == 0)
+			entryVoiceOverride = (_partner == kPartnerJake) ? 105 : 104;
 	} else if (foundPoints < 0x32) {
 		entryKDSpeak = 0;
 		entryTextOff = READ_LE_UINT16(entryKdIdx + 0);
@@ -3766,8 +3772,12 @@ void EEMEngine::doAccuse() {
 		g_system->copyRectToScreen(ms.getPixels(), ms.pitch,
 								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
-		if (_audio)
-			_audio->sayKDDigital(entryKdIdx, entryKDSpeak, _partner);
+		if (_audio) {
+			if (entryVoiceOverride != 0xFFFF)
+				_audio->spoolSound(entryVoiceOverride);
+			else
+				_audio->sayKDDigital(entryKdIdx, entryKDSpeak, _partner);
+		}
 		waitForInput(60000);
 	}
 	if (!canAccuse) {
@@ -4330,7 +4340,8 @@ void EEMEngine::doAccuse() {
 			_music->stop();
 
 		// `_DifferenceAnimation("scrapbk.ani")` @ 1df2:0848.
-		playAnm(Common::Path("SCRAPBK.ANI"), 120, true);
+		playAnm(Common::Path("SCRAPBK.ANI"), 120,
+				/* holdLastFrame= */ false);
 
 		// `_ShowOneScrap @ 1f78:0773` = `_DisplayEnding(num, 1)`.
 		doShowEnding(mn);


Commit: 32e5d42d209c4cae47fa86047b2b73bb5c456aed
    https://github.com/scummvm/scummvm/commit/32e5d42d209c4cae47fa86047b2b73bb5c456aed
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:17+02:00

Commit Message:
EEM: implemented restored content from the floppy data into the CD one

Changed paths:
  A devtools/create_eem/README.md
  A devtools/create_eem/create_scrapbk_extra.py
  A devtools/create_eem/files/eem/SCRAPBK_EXTRA.ANI
  A devtools/create_eem/files/eem/version.txt
  A dists/engine-data/eem.dat
    dists/engine-data/README
    dists/engine-data/engine_data.mk
    engines/eem/detection.cpp
    engines/eem/detection.h
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/metaengine.cpp
    engines/eem/ui.cpp


diff --git a/devtools/create_eem/README.md b/devtools/create_eem/README.md
new file mode 100644
index 00000000000..94234b2f217
--- /dev/null
+++ b/devtools/create_eem/README.md
@@ -0,0 +1,19 @@
+EEM engine data
+-------------------------------------------------------------------------------
+
+`SCRAPBK_EXTRA.ANI` stores the three floppy-only dialog records displayed
+after `SCRAPBK.ANI` and before the scrapbook page. The CD executable omits
+those records from its solved recap data. The restored subtitles are kept for
+all books, but voice indices are only attached for Book 1 mysteries because
+matching unused CD clips could not be identified reliably for Books 2 and 3.
+
+Generate the file from extracted English floppy and CD data:
+
+  ./create_scrapbk_extra.py \
+      --floppy-dir ../../eem-full-game/floppy/EAKIDS/EEM \
+      --cd-dir ../../eem-full-game/cd
+
+Then rebuild the distributable data archive from this directory:
+
+  cd files
+  zip -r9 ../../../dists/engine-data/eem.dat .
diff --git a/devtools/create_eem/create_scrapbk_extra.py b/devtools/create_eem/create_scrapbk_extra.py
new file mode 100755
index 00000000000..2b0b73a47f2
--- /dev/null
+++ b/devtools/create_eem/create_scrapbk_extra.py
@@ -0,0 +1,398 @@
+#!/usr/bin/env python3
+#
+# Extract the floppy-only pre-scrapbook dialog records into an EEM engine
+# data file consumed by the CD runtime.
+
+from __future__ import annotations
+
+import argparse
+import struct
+from dataclasses import dataclass
+from pathlib import Path
+
+
+MAGIC = b"EEMSBX02"
+VERSION = 2
+CASE_COUNT = 55
+NO_VOICE = 0xFFFF
+RESTORED_VOICE_FIRST_CASE = 1
+RESTORED_VOICE_LAST_CASE = 24
+
+
+ at dataclass(frozen=True)
+class ScrapbookRecord:
+    pic_id: int
+    pic_x: int
+    pic_y: int
+    balloon: int
+    balloon_x: int
+    balloon_y: int
+    jake_text: bytes
+    jenny_text: bytes
+    voice_jake: int = NO_VOICE
+    voice_jenny: int = NO_VOICE
+    voice_nancy: int = NO_VOICE
+
+
+def read_u16(data: bytes, offset: int) -> int:
+    if offset < 0 or offset + 2 > len(data):
+        return 0
+    return struct.unpack_from("<H", data, offset)[0]
+
+
+def c_string(data: bytes, offset: int) -> bytes:
+    if offset <= 0 or offset >= len(data):
+        return b""
+    end = data.find(b"\0", offset)
+    if end < 0:
+        end = len(data)
+    return data[offset:end]
+
+
+def valid_floppy_record(data: bytes, offset: int) -> bool:
+    if offset < 0 or offset + 11 > len(data):
+        return False
+    return offset + 11 + data[offset + 10] <= len(data)
+
+
+def sorted_case_numbers(data_dir: Path) -> list[int]:
+    out: list[int] = []
+    for path in data_dir.glob("M*.BIN"):
+        stem = path.stem
+        if len(stem) > 1 and stem[1:].isdigit():
+            out.append(int(stem[1:]))
+    return sorted(out)
+
+
+def extract_floppy_tail(path: Path) -> list[ScrapbookRecord]:
+    data = path.read_bytes()
+    if len(data) < 0x14 or read_u16(data, 0) <= 0x100:
+        raise ValueError(f"{path} is not an EEM floppy mystery file")
+
+    notes_off = read_u16(data, 0x08)
+    solved_off = read_u16(data, 0x12)
+    if solved_off <= 0 or solved_off >= len(data):
+        raise ValueError(f"{path} has no valid solved chain")
+
+    count = data[solved_off]
+    pos = solved_off + 1
+    record_offsets: list[int] = []
+    for _ in range(count):
+        if not valid_floppy_record(data, pos):
+            raise ValueError(f"{path} has a malformed solved-chain record at 0x{pos:04x}")
+        record_offsets.append(pos)
+        pos += 11 + data[pos + 10]
+
+    if len(record_offsets) < 3:
+        return []
+
+    records: list[ScrapbookRecord] = []
+    for offset in record_offsets[-3:]:
+        text_count = data[offset + 10]
+        if text_count != 1:
+            raise ValueError(
+                f"{path} tail record at 0x{offset:04x} has {text_count} text entries"
+            )
+
+        note_idx = data[offset + 11] & 0x7F
+        note_entry = notes_off + note_idx * 7
+        if note_entry + 6 > len(data):
+            raise ValueError(f"{path} tail record references invalid note {note_idx}")
+
+        records.append(ScrapbookRecord(
+            pic_id=read_u16(data, offset + 0),
+            pic_x=read_u16(data, offset + 2),
+            pic_y=data[offset + 4],
+            balloon=data[offset + 5],
+            balloon_x=read_u16(data, offset + 6),
+            balloon_y=data[offset + 8],
+            jake_text=c_string(data, read_u16(data, note_entry + 2)),
+            jenny_text=c_string(data, read_u16(data, note_entry + 4)),
+        ))
+
+    return records
+
+
+def sdx_entry_count(cd_dir: Path, case_num: int) -> int:
+    path = cd_dir / f"M{case_num}.SDX"
+    if not path.exists():
+        return 0
+    return path.stat().st_size // 12
+
+
+def valid_cd_clue_block(data: bytes, offset: int) -> bool:
+    if offset in (0, 0xFFFF) or offset + 4 > len(data):
+        return False
+    count = read_u16(data, offset)
+    return 0 < count <= 64 and offset + 4 + count * 62 <= len(data)
+
+
+def add_voice_ref(used: set[int], raw_voice: int, max_entries: int) -> None:
+    if raw_voice in (0, 0xFFFF):
+        return
+    if raw_voice <= max_entries:
+        used.add(raw_voice - 1)
+
+
+def collect_cd_voice_usage(
+    cd_dir: Path,
+    case_num: int,
+) -> tuple[set[int], list[int], list[int]]:
+    path = cd_dir / f"M{case_num}.BIN"
+    if not path.exists():
+        return set(), [], []
+
+    data = path.read_bytes()
+    max_entries = sdx_entry_count(cd_dir, case_num)
+    used: set[int] = set()
+    solved_jenny: list[int] = []
+    solved_jake: list[int] = []
+
+    def parse_clue_block(offset: int, solved: bool = False) -> None:
+        if not valid_cd_clue_block(data, offset):
+            return
+        count = read_u16(data, offset)
+        for entry_idx in range(count):
+            entry = offset + 4 + entry_idx * 62
+            voice_jenny = read_u16(data, entry + 0x18)
+            voice_jake = read_u16(data, entry + 0x1A)
+
+            # Original _DisplayClue gates voice playback on the Jenny/default
+            # slot; a Jake-only value with a zero Jenny slot is not played.
+            if voice_jenny in (0, 0xFFFF):
+                continue
+            add_voice_ref(used, voice_jenny, max_entries)
+            if solved and voice_jenny <= max_entries:
+                solved_jenny.append(voice_jenny - 1)
+
+            add_voice_ref(used, voice_jake, max_entries)
+            if solved and voice_jake not in (0, 0xFFFF) and voice_jake <= max_entries:
+                solved_jake.append(voice_jake - 1)
+
+    parse_clue_block(read_u16(data, 0x00) + 4)
+    parse_clue_block(read_u16(data, 0x10), solved=True)
+
+    site_index_off = read_u16(data, 0x06)
+    num_sites = read_u16(data, 0x14)
+    for site_idx in range(min(num_sites, 128)):
+        site_index = site_index_off + site_idx * 6
+        if site_index + 6 > len(data):
+            break
+        site_data_off = read_u16(data, site_index)
+        parse_clue_block(read_u16(data, site_index + 2))
+
+        if site_data_off + 8 > len(data):
+            continue
+        hotspot_count = read_u16(data, site_data_off + 6)
+        hotspot_table = read_u16(data, site_index + 4)
+        for hot_idx in range(min(hotspot_count, 300)):
+            hotspot = hotspot_table + hot_idx * 14
+            if hotspot + 14 > len(data):
+                break
+            parse_clue_block(read_u16(data, hotspot + 8))
+
+    gallery_off = read_u16(data, 0x0C)
+    num_suspects = read_u16(data, 0x1A)
+    if gallery_off and gallery_off + num_suspects * 0x46 <= len(data):
+        for suspect_idx in range(num_suspects):
+            entry = gallery_off + suspect_idx * 0x46
+            add_voice_ref(used, read_u16(data, entry + 4), max_entries)
+            add_voice_ref(used, read_u16(data, entry + 6), max_entries)
+
+    kd_text_off = read_u16(data, 0x0E)
+    if kd_text_off and kd_text_off + 0x12 < len(data):
+        digital = kd_text_off + 0x12
+        for kdspeak in (0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11):
+            for slot in (kdspeak * 2 + 1, kdspeak * 2 + 2):
+                raw_voice = read_u16(data, digital + slot * 2)
+                add_voice_ref(used, raw_voice, max_entries)
+
+    return used, solved_jenny, solved_jake
+
+
+def contiguous_unused_after(used: set[int], sequence: list[int], max_entries: int) -> list[int]:
+    if not sequence:
+        return []
+
+    out: list[int] = []
+    entry = sequence[-1] + 1
+    while entry < max_entries and entry not in used:
+        out.append(entry)
+        entry += 1
+    return out
+
+
+def sound_duration(cd_dir: Path, case_num: int, entry: int) -> float:
+    sdx_path = cd_dir / f"M{case_num}.SDX"
+    sdb_path = cd_dir / f"M{case_num}.SDB"
+    if not sdx_path.exists() or not sdb_path.exists():
+        return 0.0
+
+    sdx_data = sdx_path.read_bytes()
+    if entry < 0 or entry * 12 + 12 > len(sdx_data):
+        return 0.0
+
+    offset = struct.unpack_from("<I", sdx_data, entry * 12)[0]
+    uncompressed_size = struct.unpack_from("<I", sdx_data, entry * 12 + 8)[0]
+    with sdb_path.open("rb") as sdb:
+        sdb.seek(offset)
+        tc_raw = sdb.read(1)
+    if not tc_raw:
+        return 0.0
+
+    tc = tc_raw[0]
+    sample_rate = 1000000 // (256 - tc) if tc < 0xFF else 44100
+    return uncompressed_size / sample_rate if sample_rate else 0.0
+
+
+def find_nancy_voice(
+    cd_dir: Path,
+    case_num: int,
+    used: set[int],
+    partner_voices: set[int],
+    first_partner_voice: int,
+) -> int:
+    max_entries = sdx_entry_count(cd_dir, case_num)
+
+    for entry in range(max(0, first_partner_voice), max_entries):
+        if entry in used or entry in partner_voices:
+            continue
+        if sound_duration(cd_dir, case_num, entry) < 1.2:
+            continue
+        return entry
+
+    return NO_VOICE
+
+
+def attach_cd_voices(cd_dir: Path, case_num: int,
+                     records: list[ScrapbookRecord]) -> list[ScrapbookRecord]:
+    if len(records) < 3:
+        return records
+    if not RESTORED_VOICE_FIRST_CASE <= case_num <= RESTORED_VOICE_LAST_CASE:
+        return records
+
+    used, solved_jenny, solved_jake = collect_cd_voice_usage(cd_dir, case_num)
+    max_entries = sdx_entry_count(cd_dir, case_num)
+    jenny_candidates = contiguous_unused_after(used, solved_jenny, max_entries)
+    jake_candidates = contiguous_unused_after(used, solved_jake, max_entries)
+
+    voice_jenny = jenny_candidates[:2] if len(jenny_candidates) >= 2 else []
+    voice_jake = jake_candidates[:2] if len(jake_candidates) >= 2 else []
+    partner_voices = set(voice_jenny) | set(voice_jake)
+    voice_nancy = NO_VOICE
+    if len(voice_jenny) >= 2 and len(voice_jake) >= 2:
+        first_partner_voice = min(voice_jenny[0], voice_jake[0])
+        voice_nancy = find_nancy_voice(cd_dir, case_num, used,
+                                       partner_voices, first_partner_voice)
+
+    out: list[ScrapbookRecord] = []
+    for idx, record in enumerate(records):
+        extra_idx = idx - 1
+        out.append(ScrapbookRecord(
+            pic_id=record.pic_id,
+            pic_x=record.pic_x,
+            pic_y=record.pic_y,
+            balloon=record.balloon,
+            balloon_x=record.balloon_x,
+            balloon_y=record.balloon_y,
+            jake_text=record.jake_text,
+            jenny_text=record.jenny_text,
+            voice_jake=voice_jake[extra_idx] if 0 <= extra_idx < len(voice_jake) else NO_VOICE,
+            voice_jenny=voice_jenny[extra_idx] if 0 <= extra_idx < len(voice_jenny) else NO_VOICE,
+            voice_nancy=voice_nancy if idx == 0 else NO_VOICE,
+        ))
+    return out
+
+
+def encode_records(cases: list[list[ScrapbookRecord]]) -> bytes:
+    table_size = CASE_COUNT * 8
+    header = bytearray()
+    header += MAGIC
+    header += struct.pack("<HH", VERSION, CASE_COUNT)
+    table = bytearray(table_size)
+    body = bytearray()
+
+    for case_num, records in enumerate(cases):
+        if not records:
+            continue
+
+        offset = len(header) + table_size + len(body)
+        struct.pack_into("<IHH", table, case_num * 8, offset, len(records), 0)
+        for record in records:
+            if len(record.jake_text) > 0xFFFF or len(record.jenny_text) > 0xFFFF:
+                raise ValueError(f"case {case_num} contains a text string longer than 65535 bytes")
+            body += struct.pack(
+                "<HHBBHBBHHHHH",
+                record.pic_id,
+                record.pic_x,
+                record.pic_y,
+                record.balloon,
+                record.balloon_x,
+                record.balloon_y,
+                0,
+                record.voice_jake,
+                record.voice_jenny,
+                record.voice_nancy,
+                len(record.jake_text),
+                len(record.jenny_text),
+            )
+            body += record.jake_text
+            body += record.jenny_text
+
+    return bytes(header + table + body)
+
+
+def create_scrapbook_extra(floppy_dir: Path, cd_dir: Path, out_path: Path) -> None:
+    cases: list[list[ScrapbookRecord]] = [[] for _ in range(CASE_COUNT)]
+    voiced_cases = 0
+    nancy_cases = 0
+
+    for case_num in sorted_case_numbers(floppy_dir):
+        if case_num == 0 or case_num >= CASE_COUNT:
+            continue
+        records = extract_floppy_tail(floppy_dir / f"M{case_num}.BIN")
+        records = attach_cd_voices(cd_dir, case_num, records)
+        if any(record.voice_jake != NO_VOICE or record.voice_jenny != NO_VOICE
+               for record in records):
+            voiced_cases += 1
+        if any(record.voice_nancy != NO_VOICE for record in records):
+            nancy_cases += 1
+        cases[case_num] = records
+
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+    out_path.write_bytes(encode_records(cases))
+    print(f"Wrote {out_path}")
+    print(f"Cases with text: {sum(1 for records in cases if records)}")
+    print(f"Cases with mapped CD voice clips: {voiced_cases}")
+    print(f"Cases with mapped Nancy clips: {nancy_cases}")
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Create EEM SCRAPBK_EXTRA.ANI from floppy mystery data."
+    )
+    parser.add_argument(
+        "--floppy-dir",
+        type=Path,
+        default=Path("../../eem-full-game/floppy/EAKIDS/EEM"),
+        help="Directory containing English floppy M*.BIN data.",
+    )
+    parser.add_argument(
+        "--cd-dir",
+        type=Path,
+        default=Path("../../eem-full-game/cd"),
+        help="Directory containing CD M*.BIN/M*.SDX data for voice mapping.",
+    )
+    parser.add_argument(
+        "--out",
+        type=Path,
+        default=Path("files/eem/SCRAPBK_EXTRA.ANI"),
+        help="Output file inside devtools/create_eem/files/eem.",
+    )
+    args = parser.parse_args()
+
+    create_scrapbook_extra(args.floppy_dir, args.cd_dir, args.out)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/devtools/create_eem/files/eem/SCRAPBK_EXTRA.ANI b/devtools/create_eem/files/eem/SCRAPBK_EXTRA.ANI
new file mode 100644
index 00000000000..add4d3b906c
Binary files /dev/null and b/devtools/create_eem/files/eem/SCRAPBK_EXTRA.ANI differ
diff --git a/devtools/create_eem/files/eem/version.txt b/devtools/create_eem/files/eem/version.txt
new file mode 100644
index 00000000000..d3827e75a5c
--- /dev/null
+++ b/devtools/create_eem/files/eem/version.txt
@@ -0,0 +1 @@
+1.0
diff --git a/dists/engine-data/README b/dists/engine-data/README
index 1a68494852b..15c2dec6087 100644
--- a/dists/engine-data/README
+++ b/dists/engine-data/README
@@ -27,6 +27,10 @@ Those informations were stored in the original executables.
 darkseed.dat:
 This file contains essential game data used by the Darkseed engine.
 
+eem.dat:
+This file contains floppy-only scrapbook dialog data used by the Eagle Eye
+Mysteries engine.
+
 drascula.dat:
 This file contains essential game data used by the Drascula engine.
 
diff --git a/dists/engine-data/eem.dat b/dists/engine-data/eem.dat
new file mode 100644
index 00000000000..ae70fa2fe91
Binary files /dev/null and b/dists/engine-data/eem.dat differ
diff --git a/dists/engine-data/engine_data.mk b/dists/engine-data/engine_data.mk
index dbe5527a65d..25341375dbd 100644
--- a/dists/engine-data/engine_data.mk
+++ b/dists/engine-data/engine_data.mk
@@ -13,6 +13,9 @@ endif
 ifdef ENABLE_DARKSEED
 DIST_FILES_LIST += dists/engine-data/darkseed.dat
 endif
+ifdef ENABLE_EEM
+DIST_FILES_LIST += dists/engine-data/eem.dat
+endif
 ifdef ENABLE_DRASCULA
 DIST_FILES_LIST += dists/engine-data/drascula.dat
 endif
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 17ff91e826b..fab534952a9 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -32,7 +32,7 @@ const PlainGameDescriptor eemGames[] = {
 };
 
 #define GUI_OPTIONS_EEM_FLOPPY GUIO4(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_SKIP_REPEATED_CASES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
-#define GUI_OPTIONS_EEM_CD     GUIO5(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_FIT_DIALOG_BALLOONS, GAMEOPTION_SKIP_REPEATED_CASES, GUIO_MIDIADLIB, GUIO_MIDIMT32)
+#define GUI_OPTIONS_EEM_CD     GUIO6(GAMEOPTION_HIDE_HIGHLIGHT_BOXES, GAMEOPTION_FIT_DIALOG_BALLOONS, GAMEOPTION_SKIP_REPEATED_CASES, GAMEOPTION_RESTORED_CONTENT, GUIO_MIDIADLIB, GUIO_MIDIMT32)
 
 const ADGameDescription gameDescriptions[] = {
 	{
diff --git a/engines/eem/detection.h b/engines/eem/detection.h
index 937fbe6a4e3..cac7a171f60 100644
--- a/engines/eem/detection.h
+++ b/engines/eem/detection.h
@@ -29,6 +29,7 @@ namespace EEM {
 #define GAMEOPTION_HIDE_HIGHLIGHT_BOXES   GUIO_GAMEOPTIONS1
 #define GAMEOPTION_FIT_DIALOG_BALLOONS    GUIO_GAMEOPTIONS2
 #define GAMEOPTION_SKIP_REPEATED_CASES    GUIO_GAMEOPTIONS3
+#define GAMEOPTION_RESTORED_CONTENT       GUIO_GAMEOPTIONS4
 
 enum EEMDebugChannels {
 	kDebugGeneral = 1 << 0,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 2ad4b74f687..f6a17a87ca9 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -21,6 +21,7 @@
 
 #include "common/debug.h"
 #include "common/debug-channels.h"
+#include "common/engine_data.h"
 #include "common/error.h"
 #include "common/events.h"
 #include "common/file.h"
@@ -198,6 +199,7 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	ConfMan.registerDefault("hide_highlight_boxes", false);
 	ConfMan.registerDefault("fit_dialog_balloons", false);
 	ConfMan.registerDefault("skip_repeated_cases", false);
+	ConfMan.registerDefault("restored_content", false);
 
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
@@ -288,6 +290,17 @@ Common::Error EEMEngine::run() {
 	// _SetMode13X @ 1000:0358 — VGA mode 13h.
 	initGraphics(kScreenWidth, kScreenHeight);
 
+	if (!isFloppy() && ConfMan.getBool("restored_content")) {
+		Common::U32String engineDataError;
+		if (!Common::load_engine_data("eem.dat", "eem", 1, 0,
+									  engineDataError)) {
+			warning("EEM restored content unavailable: %s",
+					Common::String(engineDataError).c_str());
+		} else {
+			_restoredContentDataLoaded = true;
+		}
+	}
+
 	if (!openArchives())
 		return Common::Error(Common::kReadingFailed, "EEM archive open failed");
 
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index e2ebb1151d0..8b44120f0bc 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -332,6 +332,7 @@ private:
 	/// (`_DrawGallery_Floppy @ 154e:0050`).
 	void floppyKDHint(uint kdSlot, const byte *kdIdx,
 					  const byte *bufBase, uint32 mysSize);
+	void displayScrapbookExtra(uint mysteryNum);
 	void accuseDrawGallery(int highlighted,
 						   Common::Array<Common::Rect> &rects,
 						   Common::Array<int> &suspects, uint8 num,
@@ -531,6 +532,8 @@ private:
 	/// Lives on the engine because PDA/gallery destroys+recreates SiteScreen.
 	int _lastSiteArrivalAnim = -1;
 
+	bool _restoredContentDataLoaded = false;
+
 	/// `MIDI.C` family (`_MIDIPlayFile`/`_MIDIPlay`/`_StopMIDI`/
 	/// `_StartTravelMusic` @ 20a2:00e2-05c9). Constructed lazily in `run()`.
 	MusicPlayer *_music = nullptr;
diff --git a/engines/eem/metaengine.cpp b/engines/eem/metaengine.cpp
index d3efe9c89cc..c2f01eafc03 100644
--- a/engines/eem/metaengine.cpp
+++ b/engines/eem/metaengine.cpp
@@ -66,6 +66,17 @@ const ADExtraGuiOptionsMap optionsList[] = {
 			0
 		}
 	},
+	{
+		GAMEOPTION_RESTORED_CONTENT,
+		{
+			_s("Enable restored content"),
+			_s("Restore floppy release extras in the CD version, including pre-scrapbook conversations and first-try badges."),
+			"restored_content",
+			false,
+			0,
+			0
+		}
+	},
 
 	AD_EXTRA_GUI_OPTIONS_TERMINATOR
 };
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 98960cd0b83..803cc20cf40 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "common/debug.h"
+#include "common/config-manager.h"
 #include "common/events.h"
 #include "common/file.h"
 #include "common/path.h"
@@ -204,6 +205,16 @@ constexpr int kActionScreenDecorY = 0x87;
 constexpr byte kChooserCycleStart = 0x6f;
 constexpr byte kChooserCycleEnd = 0x73;
 constexpr uint32 kChooserCycleMillis = 100;
+const char kScrapbookExtraFilename[] = "SCRAPBK_EXTRA.ANI";
+const byte kScrapbookExtraMagic[] = {
+	'E', 'E', 'M', 'S', 'B', 'X', '0', '2'
+};
+constexpr uint16 kScrapbookExtraVersion = 2;
+constexpr uint16 kScrapbookExtraCaseCount = 55;
+constexpr uint16 kScrapbookExtraNoVoice = 0xFFFF;
+constexpr uint16 kScrapbookExtraMaxRecords = 16;
+constexpr uint kRestoredContentFirstMystery = 1;
+constexpr uint kRestoredContentLastMystery = 0x18;
 
 bool notebookButtonAt(int x, int y) {
 	return kPdaHelpRect.contains(x, y) ||
@@ -234,6 +245,26 @@ bool rectListContains(const Common::Array<Common::Rect> &rects, int x, int y) {
 	return false;
 }
 
+bool readScrapbookExtraText(Common::File &file, uint16 size,
+							Common::String &out) {
+	out.clear();
+	if (size == 0)
+		return true;
+
+	Common::Array<char> buf;
+	buf.resize(size);
+	if (file.read(buf.data(), size) != size)
+		return false;
+
+	out = Common::String(buf.data(), size);
+	return true;
+}
+
+bool restoredContentVoiceAppliesTo(uint mysteryNum) {
+	return mysteryNum >= kRestoredContentFirstMystery &&
+		   mysteryNum <= kRestoredContentLastMystery;
+}
+
 bool gallerySlotAt(const Common::Array<Common::Rect> &rects,
 				   const Common::Array<int> &suspects, int x, int y) {
 	for (uint i = 0; i < rects.size() && i < suspects.size(); i++) {
@@ -1087,7 +1118,8 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		return 0;
 
 	const bool showFirstTryBadge =
-		num < sizeof(_mysteriesSolved) && _mysteriesSolved[num] == 2;
+		num < sizeof(_mysteriesSolved) && _mysteriesSolved[num] == 2 &&
+		(floppyEnding || ConfMan.getBool("restored_content"));
 	Picture firstTryBadge;
 	const bool haveFirstTryBadge =
 		showFirstTryBadge &&
@@ -4343,6 +4375,8 @@ void EEMEngine::doAccuse() {
 		playAnm(Common::Path("SCRAPBK.ANI"), 120,
 				/* holdLastFrame= */ false);
 
+		displayScrapbookExtra(mn);
+
 		// `_ShowOneScrap @ 1f78:0773` = `_DisplayEnding(num, 1)`.
 		doShowEnding(mn);
 
@@ -4438,6 +4472,146 @@ void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 	}
 }
 
+void EEMEngine::displayScrapbookExtra(uint mysteryNum) {
+	if (isFloppy() || !ConfMan.getBool("restored_content") ||
+		!_restoredContentDataLoaded ||
+		mysteryNum >= kScrapbookExtraCaseCount || !_font.isLoaded())
+		return;
+
+	Common::File file;
+	if (!file.open(Common::Path(kScrapbookExtraFilename))) {
+		warning("EEM restored content unavailable: %s missing",
+				kScrapbookExtraFilename);
+		return;
+	}
+
+	byte magic[sizeof(kScrapbookExtraMagic)];
+	if (file.read(magic, sizeof(magic)) != sizeof(magic) ||
+		memcmp(magic, kScrapbookExtraMagic, sizeof(magic)) != 0)
+		return;
+
+	const uint16 version = file.readUint16LE();
+	const uint16 caseCount = file.readUint16LE();
+	if (version != kScrapbookExtraVersion ||
+		caseCount > kScrapbookExtraCaseCount ||
+		mysteryNum >= caseCount)
+		return;
+
+	const uint32 tableOffset = (uint32)sizeof(kScrapbookExtraMagic) + 4 +
+		mysteryNum * 8;
+	if (!file.seek(tableOffset))
+		return;
+
+	const uint32 recordsOffset = file.readUint32LE();
+	const uint16 recordCount = file.readUint16LE();
+	(void)file.readUint16LE();
+	if (recordsOffset == 0 || recordCount == 0 ||
+		recordCount > kScrapbookExtraMaxRecords ||
+		!file.seek(recordsOffset))
+		return;
+
+	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	bg.clear();
+	if (Graphics::Surface *screen = g_system->lockScreen()) {
+		bg.simpleBlitFrom(*screen);
+		g_system->unlockScreen();
+	}
+
+	for (uint recordIdx = 0; recordIdx < recordCount && !shouldQuit();
+		 recordIdx++) {
+		const uint16 picId = file.readUint16LE();
+		const uint16 picX = file.readUint16LE();
+		const uint8 picY = file.readByte();
+		const uint8 balloonRaw = file.readByte();
+		const uint16 balloonX = file.readUint16LE();
+		const uint8 balloonY = file.readByte();
+		(void)file.readByte();
+		const uint16 voiceJake = file.readUint16LE();
+		const uint16 voiceJenny = file.readUint16LE();
+		const uint16 voiceNancy = file.readUint16LE();
+		const uint16 jakeTextSize = file.readUint16LE();
+		const uint16 jennyTextSize = file.readUint16LE();
+
+		if (file.eos() || file.err())
+			return;
+
+		Common::String rawJake;
+		Common::String rawJenny;
+		if (!readScrapbookExtraText(file, jakeTextSize, rawJake) ||
+			!readScrapbookExtraText(file, jennyTextSize, rawJenny))
+			return;
+
+		Common::String text = parseString(
+			_partner == kPartnerJake ? rawJake : rawJenny,
+			_playerName, _partner);
+		if (text.empty())
+			continue;
+
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.simpleBlitFrom(*bg.surfacePtr());
+
+		if (picId != 0 && picId != 0xFFFF) {
+			Picture pic;
+			if (_picsArchive.getPicture(picId, pic) &&
+				picX < kScreenWidth && picY < kScreenHeight) {
+				const byte transp = (byte)(pic.flags >> 8);
+				scratch.transBlitFrom(pic.surface,
+									  Common::Point(picX, picY),
+									  (uint32)transp);
+			}
+		}
+
+		const uint16 fittedBalloon = fitBalloonToText(balloonRaw, text);
+		const uint16 balloonId = fittedBalloon & 0x7F;
+		const bool flipBalloon = (fittedBalloon & 0x80) != 0;
+		Picture balloon;
+		const bool haveBalloon = balloonRaw != 0xFF &&
+			_balloonArchive.size() > balloonId &&
+			_balloonArchive.loadEntry(balloonId, balloon);
+
+		uint16 textXInset = 5;
+		uint16 textYInset = 4;
+		uint16 textWidth = 155;
+		if (haveBalloon) {
+			const byte transp = (byte)(balloon.flags >> 8);
+			scratch.transBlitFrom(balloon.surface,
+								  Common::Point(balloonX, balloonY),
+								  (uint32)transp, flipBalloon);
+			getBalloonInsets(balloonId, textXInset, textYInset, textWidth);
+		}
+
+		_font.drawWordWrapped(&scratch, balloonX + textXInset,
+							  balloonY + textYInset,
+							  MAX<int>(8, (int)textWidth), text,
+							  haveBalloon ? 0 : 0xF);
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, kScreenWidth, kScreenHeight);
+		g_system->updateScreen();
+
+		if (_audio && restoredContentVoiceAppliesTo(mysteryNum)) {
+			uint16 voice = kScrapbookExtraNoVoice;
+			if (recordIdx == 0 && voiceNancy != kScrapbookExtraNoVoice) {
+				voice = voiceNancy;
+			} else if (recordIdx == 1) {
+				voice = (_partner == kPartnerJake) ? voiceJake : voiceJenny;
+			} else {
+				voice = (_partner == kPartnerJake) ? voiceJenny : voiceJake;
+			}
+			if (voice != kScrapbookExtraNoVoice)
+				_audio->spoolSound(voice);
+		}
+
+		const bool skipRest = floppyDialogWaitForClick();
+		if (_audio)
+			_audio->stopSpool();
+		if (skipRest)
+			return;
+	}
+}
+
 void EEMEngine::accuseDrawGallery(int highlighted,
 								  Common::Array<Common::Rect> &rects,
 								  Common::Array<int> &suspects, uint8 num,


Commit: 02418babc46f9de3402039152d424abedec7413e
    https://github.com/scummvm/scummvm/commit/02418babc46f9de3402039152d424abedec7413e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:18+02:00

Commit Message:
EEM: fixes and missing code for the floppy release

Changed paths:
    engines/eem/eem.h
    engines/eem/graphics.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8b44120f0bc..3a405a74f95 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -193,6 +193,10 @@ public:
 	/// Locates dialog records in site_data[+6] and dispatches them.
 	void displayFloppyHotspotDialog(uint siteNum, uint hotIdx);
 
+	/// `_HotspotSearched_Floppy @ 22dc:096c`. Walks the per-hotspot dialog
+	/// records and returns whether the hotspot's searched text index was seen.
+	bool floppyHotspotSearched(uint siteNum, uint hotspotIdx) const;
+
 	/// Active player name (= profile-save description).
 	const Common::String &playerName() const { return _playerName; }
 
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 8f8441b0c41..81c553eb35f 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -124,39 +124,50 @@ uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 	return MAX<uint>(1, ((int)insets.indDY - (int)insets.y) / lineH + 1);
 }
 
-// FUN_22dc_096c @ 22dc:096c: walks per-site dialog records at site_data[+6]
-// to skip hotspotIdx hotspots, returns _cluesFound flag for hotspot's first
-// text index.
-bool floppyHotspotSearched(EEM::Mystery &mystery, uint siteIdx,
-								   uint hotspotIdx) {
-	const byte *site = mystery.siteData(siteIdx);
+bool EEMEngine::floppyHotspotSearched(uint siteIdx, uint hotspotIdx) const {
+	// FUN_22dc_096c @ 22dc:096c: walks per-site dialog records at
+	// site_data[+6] to skip hotspotIdx hotspots, then returns _TextSeen for
+	// the selected hotspot's searched text index.
+	const byte *site = _mystery.siteData(siteIdx);
 	if (!site)
 		return false;
 	const uint16 dlgListOff = READ_LE_UINT16(site + 6);
-	const byte *bufBase = mystery.blobAt(0);
-	if (!bufBase || dlgListOff == 0 || dlgListOff >= mystery.dataSize())
+	const byte *bufBase = _mystery.blobAt(0);
+	const uint32 dsz = _mystery.dataSize();
+	if (!bufBase || dlgListOff == 0 || dlgListOff >= dsz)
 		return false;
 	uint32 off = dlgListOff;
 	for (uint h = 0; h < hotspotIdx; h++) {
-		const byte *rec = bufBase + off;
-		off += 11u + (uint)rec[10];
-		if (off >= mystery.dataSize())
+		if (off + 10 >= dsz)
+			return false;
+		const uint32 mainLen = 11u + (uint)bufBase[off + 10];
+		off += mainLen;
+		if (off >= dsz)
 			return false;
 		const uint contCount = (uint)(bufBase[off] & 0x7F);
 		off += 1;
 		for (uint c = 0; c < contCount; c++) {
-			const byte *cr = bufBase + off;
-			off += 11u + (uint)cr[10];
-			if (off >= mystery.dataSize())
+			if (off + 10 >= dsz)
+				return false;
+			off += 11u + (uint)bufBase[off + 10];
+			if (off >= dsz)
 				return false;
 		}
 	}
-	if (off + 11 >= mystery.dataSize())
+	if (off + 10 >= dsz)
+		return false;
+	const uint32 mainLen = 11u + (uint)bufBase[off + 10];
+	const uint32 contFlagsOff = off + mainLen;
+	if (contFlagsOff >= dsz)
+		return false;
+	uint32 searchedRecOff = off;
+	if ((bufBase[contFlagsOff] & 0x7F) != 0)
+		searchedRecOff = contFlagsOff + 1;
+	if (searchedRecOff + 11 >= dsz || bufBase[searchedRecOff + 10] == 0)
 		return false;
-	const byte *mainRec = bufBase + off;
-	const uint8 textIdx = mainRec[11] & 0x7F;
+	const uint8 textIdx = bufBase[searchedRecOff + 11] & 0x7F;
 	return textIdx < EEM::Mystery::kCluesFoundCap &&
-		   mystery._cluesFound[textIdx] != 0;
+		   _mystery._cluesFound[textIdx] != 0;
 }
 
 void EEMEngine::doHelp() {
@@ -215,7 +226,7 @@ void EEMEngine::doHelp() {
 		for (uint i = 0; i < chainCount; i++) {
 			const uint8 siteIdx    = hd[off + i * 2 + 0];
 			const uint8 hotspotIdx = hd[off + i * 2 + 1];
-			if (!floppyHotspotSearched(_mystery, siteIdx, hotspotIdx)) {
+			if (!floppyHotspotSearched(siteIdx, hotspotIdx)) {
 				anyChainUnseen = true;
 				break;
 			}
@@ -225,7 +236,7 @@ void EEMEngine::doHelp() {
 			for (uint i = 0; i < extraCount; i++) {
 				const uint8 siteIdx    = hd[extraStart + i * 2 + 0];
 				const uint8 hotspotIdx = hd[extraStart + i * 2 + 1];
-				if (!floppyHotspotSearched(_mystery, siteIdx, hotspotIdx)) {
+				if (!floppyHotspotSearched(siteIdx, hotspotIdx)) {
 					anyExtraUnseen = true;
 					break;
 				}
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 7c9112d97a4..90a2bc011de 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1352,7 +1352,7 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	int    y;
 	if (_vm->isFloppy()) {
 		// `_DoSiteLoop_Floppy @ 1652:042b`: site_data+8 is a u16 OFFSET
-		// to a 10-byte speakerInfo struct:
+		// to `_SpeakerInfo_Floppy`; the first 10 bytes are the idle pair:
 		//   +0..1 Jake anim, +2..3 Jake X, +4 Jake Y,
 		//   +5..6 Jenny anim, +7..8 Jenny X, +9 Jenny Y.
 		const uint16 spkOff = READ_LE_UINT16(site + 8);
@@ -1410,6 +1410,44 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	g_system->unlockScreen();
 }
 
+bool SiteScreen::renderFloppyHotspotPartnerPose(uint siteNum) {
+	if (!_vm || !_vm->isFloppy() || !_mystery)
+		return false;
+
+	const byte *site = _mystery->siteData(siteNum);
+	if (!site)
+		return false;
+
+	const uint16 spkOff = READ_LE_UINT16(site + 8);
+	// `_OnSiteHotspotClicked_Floppy @ 1652:017a`: after restoring the site
+	// it loads the active pair from `_SpeakerInfo_Floppy + 0x28/0x2d`.
+	const uint poseOff = (_vm->getPartnerIndex() == kPartnerJake)
+		? 0x28 : 0x2d;
+	if ((uint32)spkOff + poseOff + 5 > _mystery->dataSize())
+		return false;
+
+	const byte *pose = _mystery->blobAt((uint32)spkOff + poseOff);
+	if (!pose)
+		return false;
+
+	const uint16 animId = READ_LE_UINT16(pose + 0);
+	const int x = (int)READ_LE_UINT16(pose + 2);
+	const int y = (int)pose[4];
+
+	Animation anim;
+	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
+		return false;
+
+	Graphics::Surface *screen = g_system->lockScreen();
+	if (!screen)
+		return false;
+
+	const uint frameIdx = partnerFrameAtTick(animId, (uint)anim.size(), 0);
+	blitAnimFrameAnchored(screen, anim[frameIdx], x, y);
+	g_system->unlockScreen();
+	return true;
+}
+
 void SiteScreen::renderBackground(uint siteNum) {
 	// `_BuildBackground(sitepic, 0x42, 0x14)` from `_DoSiteLoop @
 	// 168d:03f4` / `_DisplayCorrect`:
@@ -1464,6 +1502,17 @@ void bumpHotspotEdgeColor(byte &color) {
 	color = (next > 0xFE) ? (byte)0xF9 : next;
 }
 
+byte currentWhitePaletteIndex(byte fallback) {
+	byte palette[256 * 3];
+	g_system->getPaletteManager()->grabPalette(palette, 0, 256);
+	for (uint i = 0; i < 256; i++) {
+		const byte *rgb = palette + i * 3;
+		if (rgb[0] >= 0xFC && rgb[1] >= 0xFC && rgb[2] >= 0xFC)
+			return (byte)i;
+	}
+	return fallback;
+}
+
 void SiteScreen::renderHotspots(uint siteNum) {
 	// `_DrawSearchButtons`. Port adds optional "hide hint" setting.
 	if (ConfMan.getBool("hide_highlight_boxes"))
@@ -1502,9 +1551,14 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	//   +0xc..d extra         (CD cursor ID for `_SwitchMouse`; shipped = 0)
 	// Seen key = the +0xa ordinal (so unrelated hotspots on later sites
 	// don't inherit the first site's seen state after travel/reload).
-	// Floppy = 8-byte plain rect only; seen key falls back to row index.
+	// Floppy = 8-byte plain rect only; searched state is derived by
+	// walking the dialog record list, like `_HotspotSearched_Floppy`.
 	const bool floppy = _vm && _vm->isFloppy();
 	const uint stride = floppy ? 8 : 14;
+	// The floppy SITEPALS has at least one searchable site where 0xFF is
+	// yellow. The CD corrected that palette data; for floppy, draw with an
+	// existing white entry from the current palette instead of changing it.
+	const byte searchedColor = floppy ? currentWhitePaletteIndex(0xFF) : 0xFF;
 	for (uint i = 0; i < count; i++) {
 		const byte *r = spots + i * stride;
 		const int16 x1 = (int16)READ_LE_UINT16(r + 0);
@@ -1514,12 +1568,17 @@ void SiteScreen::renderHotspots(uint siteNum) {
 		const Common::Rect rect(MAX<int>(0, x1), MAX<int>(0, y1),
 								MIN<int>(screen->w, x2),
 								MIN<int>(screen->h, y2));
-		const uint seenKey = floppy ? i : READ_LE_UINT16(r + 0xa);
-		const bool seen = seenKey < Mystery::kHotSpotsCap &&
-						   _mystery->_hotSpotsSeen[seenKey];
+		bool seen = false;
+		if (floppy) {
+			seen = _vm->floppyHotspotSearched(siteNum, i);
+		} else {
+			const uint seenKey = READ_LE_UINT16(r + 0xa);
+			seen = seenKey < Mystery::kHotSpotsCap &&
+				   _mystery->_hotSpotsSeen[seenKey];
+		}
 		if (seen) {
-			// `_DrawSolidRect @ 172b:0506` — solid 0xFF (non-cycling).
-			screen->frameRect(rect, 0xFF);
+			// `_DrawSolidRect @ 172b:0506` — solid, non-cycling outline.
+			screen->frameRect(rect, searchedColor);
 		} else {
 			// `_DrawRect @ 172b:03e2` — walk all four edges incrementing
 			// the colour per pixel through palette indices 0xF9..0xFE,
@@ -1587,20 +1646,29 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
 	// Floppy: 8-byte rects only (no clue metadata @ +0xa/+8). Dialog
-	// records live in a separate list @ `site_data[+6]`. Seen key =
-	// array index (only ordinal available).
+	// records live in a separate list @ `site_data[+6]`.
 	if (_vm->isFloppy()) {
 		if (hotIdx < Mystery::kHotSpotsCap)
 			_mystery->_hotSpotsSeen[hotIdx] = 1;
 		_mystery->_searchLocationNumber = (uint16)hotIdx;
+
 		// Snapshot `_cluesFound` before dialog → autosave on new clue.
 		// Floppy side-effect path is `displayFloppyDialogRecords`
 		// (clues.cpp), not `displayClue` → autosave must be duplicated.
 		byte before[Mystery::kCluesFoundCap];
 		memcpy(before, _mystery->_cluesFound, sizeof(before));
+
+		restoreBgSnapshot();
+		const uint32 now = g_system->getMillis();
+		renderAnimatedDrops(siteNum, now);
+		if (!renderFloppyHotspotPartnerPose(siteNum))
+			renderPartner(siteNum, now);
+		g_system->updateScreen();
+
 		_vm->setPartnerEraseBg(&_bgSnapshot);
 		_vm->displayFloppyHotspotDialog(siteNum, hotIdx);
 		_vm->setPartnerEraseBg(nullptr);
+
 		bool foundNewClue = false;
 		for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
 			if (!before[i] && _mystery->_cluesFound[i]) {
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 1719e68c47f..cc1b798e453 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -121,6 +121,10 @@ private:
 	/// Mirrors `_GetAnimation` + `_NewAnimation` tail of `_DoSiteLoop @ 168d:03f4`.
 	void renderPartner(uint siteNum, uint32 tickMs);
 
+	/// Floppy active speaker pose shown before `_HandleHotspotClick_Floppy`.
+	/// Uses `_SpeakerInfo_Floppy + 0x28/0x2d`, replacing the idle partner.
+	bool renderFloppyHotspotPartnerPose(uint siteNum);
+
 	/// renderStaticDrops: `_AddDrop` static decorations (Loop 2).
 	/// siteData[+0x4] count, siteData[+0xc] entries (6 bytes: {picId, x, y}).
 	void renderStaticDrops(uint siteNum);


Commit: 558a00c546844d6a51f146cf6bc3140d11283767
    https://github.com/scummvm/scummvm/commit/558a00c546844d6a51f146cf6bc3140d11283767
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:18+02:00

Commit Message:
EEM: use skip api instead of (void)

Changed paths:
    engines/eem/animation.cpp
    engines/eem/audio.cpp
    engines/eem/resource.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/animation.cpp b/engines/eem/animation.cpp
index c52ee496293..bf534e23d5f 100644
--- a/engines/eem/animation.cpp
+++ b/engines/eem/animation.cpp
@@ -101,12 +101,10 @@ bool ANMDecoder::open(const Common::Path &path) {
 		return false;
 	}
 
-	(void)_file.readUint16LE();        // header[+0]: ignored
+	_file.skip(2);                     // header[+0]: ignored
 	_height = _file.readUint16LE();    // header[+2]
 	_width  = _file.readUint16LE();    // header[+4]
-	(void)_file.readUint16LE();        // header[+6]
-	(void)_file.readUint16LE();        // header[+8]
-	(void)_file.readUint16LE();        // header[+10]
+	_file.skip(6);                     // header[+6..+10]: ignored
 
 	if (_width == 0 || _height == 0) {
 		warning("ANMDecoder: zero dimensions in %s", path.toString().c_str());
diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 10df7170c3c..b20f5553138 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -250,7 +250,7 @@ void AudioPlayer::spoolSound(uint num) {
 	//   byte 0 = Sound Blaster Time Constant
 	//   byte 1 = total AIL playback blocks (internal to AIL DDS; unused here)
 	const byte tc          = sdb.readByte();
-	(void)sdb.readByte(); // AIL block count, unused outside AIL
+	sdb.skip(1); // AIL block count, unused outside AIL
 
 	// SB Time Constant formula: rate = 1000000 / (256 - tc).
 	// e.g. tc=0xD2 -> 22 kHz. tc=0xFF would divide by 1 (1 MHz, nonsense); clamp.
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index deb49700d57..d8722ceb598 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -131,7 +131,7 @@ bool DBDArchive::loadEntry(uint num, Picture &out) {
 	}
 
 	// _GetFromDB @ 172b:105d. Leading u16 = frame count (always 1 for pictures).
-	(void)_dbd.readUint16LE();
+	_dbd.skip(2);
 	return readFrame(_dbd, entry.compressed != 0, out);
 }
 
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 803cc20cf40..420c799701c 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -4504,7 +4504,7 @@ void EEMEngine::displayScrapbookExtra(uint mysteryNum) {
 
 	const uint32 recordsOffset = file.readUint32LE();
 	const uint16 recordCount = file.readUint16LE();
-	(void)file.readUint16LE();
+	file.skip(2);                  // reserved
 	if (recordsOffset == 0 || recordCount == 0 ||
 		recordCount > kScrapbookExtraMaxRecords ||
 		!file.seek(recordsOffset))
@@ -4526,7 +4526,7 @@ void EEMEngine::displayScrapbookExtra(uint mysteryNum) {
 		const uint8 balloonRaw = file.readByte();
 		const uint16 balloonX = file.readUint16LE();
 		const uint8 balloonY = file.readByte();
-		(void)file.readByte();
+		file.skip(1);              // reserved
 		const uint16 voiceJake = file.readUint16LE();
 		const uint16 voiceJenny = file.readUint16LE();
 		const uint16 voiceNancy = file.readUint16LE();


Commit: 209d590655e95cccf581ef3caafced6266617060
    https://github.com/scummvm/scummvm/commit/209d590655e95cccf581ef3caafced6266617060
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:19+02:00

Commit Message:
EEM: moved cleanMysterySound after sdx/sdb declarations

Changed paths:
    engines/eem/audio.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index b20f5553138..1a553b47c00 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -139,13 +139,13 @@ bool AudioPlayer::readSdxIndex(const Common::Path &sdxPath) {
 
 // _InitMysterySounds @ 202f:05cb. Strings "m%u.sdx" @ 29be:144f, "m%u.sdb" @ 29be:145b.
 bool AudioPlayer::initMysterySounds(uint mysteryNum) {
-	cleanMysterySounds();
-
 	const Common::String sdxName = Common::String::format("M%u.SDX", mysteryNum);
 	const Common::String sdbName = Common::String::format("M%u.SDB", mysteryNum);
 	const Common::Path sdxPath(sdxName);
 	const Common::Path sdbPath(sdbName);
 
+	cleanMysterySounds();
+
 	if (!readSdxIndex(sdxPath)) {
 		_sdxIndex.clear();
 		return false;


Commit: a789df41d3064cf78e1bb1d8490676d6ff5ab174
    https://github.com/scummvm/scummvm/commit/a789df41d3064cf78e1bb1d8490676d6ff5ab174
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:19+02:00

Commit Message:
EEM: reduced some technical comments

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index ee88f8a8a43..f4774f6fc9d 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -316,17 +316,12 @@ void EEMEngine::doInitClues() {
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
 
-	// Cycle game animation once at _CheckFrameRate cadence (~7 fps, 140 ms
-	// per _UpdateAnimations call — `LastFrame + 0xe` cs in _InitFrameCounter
-	// @ 1a35:01ae). Original loop @ 1a35:0507:
-	//   uVar9 = 1;
-	//   while (uVar9 != gameNum) {
-	//     if (_CheckFrameRate || skipped) { _UpdateAnimations(); uVar9++; }
-	//   }
-	// So `gameNum - 1` _UpdateAnimations calls; each call advances every
+	// Cycle the game animation once at _CheckFrameRate cadence (~7 fps, 140 ms
+	// per tick; _InitFrameCounter @ 1a35:01ae). The original loop @ 1a35:0507
+	// makes `gameNum - 1` _UpdateAnimations calls, each advancing every
 	// registered slot by one script tick. _DoInitClues @ 1a35:0507/0541
-	// hard-codes the SCRIPT index to Jake's IDs (0x17/0x18/0x19) regardless
-	// of partner, so we look up scripts by those IDs unconditionally.
+	// hard-codes Jake's script IDs (0x17/0x18/0x19) regardless of partner, so
+	// we look up scripts by those IDs unconditionally.
 	if (haveGame || haveBook || haveNancy) {
 		const uint kCheckFrameRateMs = 140;
 		const uint baseFrames = haveGame ? game.size() : 8;
@@ -802,14 +797,9 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			g_system->updateScreen();
 		}
 
-		// _DisplayClue @ 2404:0833-085a — per-clue voice gate:
-		//   if (clue[+0x18] != 0 && voiceOn && voiceAvail) {
-		//       iVar6 = clue[+0x18];                  // Jenny default
-		//       if (Partner == 0) iVar6 = clue[+0x1a]; // Jake override
-		//       _SpoolSound(iVar6 - 1);
-		//   }
-		// Gate is on the Jenny slot regardless of partner; entries with
-		// +0x18 == 0 but +0x1a set are text-only.
+		// _DisplayClue @ 2404:0833-085a — per-clue voice gate. The gate is on
+		// the Jenny slot regardless of partner; entries with +0x18 == 0 but
+		// +0x1a set are text-only.
 		if (_audio) {
 			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
 			if (voiceJenny != 0 && voiceJenny != 0xFFFF) {


Commit: c63d19aea9e9f83d26c965f851b7b7b0a8c40e90
    https://github.com/scummvm/scummvm/commit/c63d19aea9e9f83d26c965f851b7b7b0a8c40e90
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:19+02:00

Commit Message:
EEM: remove dead code related with fallback pointer

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index f6a17a87ca9..e531f32c460 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -66,37 +66,6 @@ const byte kSaveBodyVer = 1;
 // option or changing save format. Set false before release.
 const bool kDebugPopulateScrapbook1AtStartup = false;
 
-// Fallback 11x16 cursor used if PIC pointer load fails.
-//   0 = transparent, 1 = black outline, 2 = white fill
-const byte kCursorBitmap[11 * 16] = {
-	1,1,0,0,0,0,0,0,0,0,0,
-	1,2,1,0,0,0,0,0,0,0,0,
-	1,2,2,1,0,0,0,0,0,0,0,
-	1,2,2,2,1,0,0,0,0,0,0,
-	1,2,2,2,2,1,0,0,0,0,0,
-	1,2,2,2,2,2,1,0,0,0,0,
-	1,2,2,2,2,2,2,1,0,0,0,
-	1,2,2,2,2,2,2,2,1,0,0,
-	1,2,2,2,2,2,2,2,2,1,0,
-	1,2,2,2,2,2,2,2,2,2,1,
-	1,2,2,2,2,2,1,0,0,0,0,
-	1,2,1,0,1,2,2,1,0,0,0,
-	1,1,0,0,1,2,2,1,0,0,0,
-	0,0,0,0,0,1,2,2,1,0,0,
-	0,0,0,0,0,1,2,2,1,0,0,
-	0,0,0,0,0,0,1,2,2,1,0
-};
-const byte kCursorPalette[] = {
-	0x00, 0x00, 0x00,
-	0x00, 0x00, 0x00,
-	0xFF, 0xFF, 0xFF
-};
-const byte kCursorInteractivePalette[] = {
-	0x00, 0x00, 0x00,
-	0xFF, 0x00, 0x00,
-	0xFF, 0xFF, 0xFF
-};
-
 void fadeCurrentPaletteToBlack(uint delayMs = 8) {
 	byte start[kPalSize];
 	byte stepPal[kPalSize];
@@ -173,23 +142,15 @@ void setInteractiveCursorPalette(const Picture &cursor, byte transparent) {
 
 void installMouseCursor(DBDArchive &pics, bool interactive) {
 	Picture cursor;
-	if (pics.getPicture(kPicMousePointer, cursor) && !cursor.surface.empty()) {
-		const byte transparent = (byte)(cursor.flags >> 8);
-		CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0,
-								transparent);
-		if (interactive)
-			setInteractiveCursorPalette(cursor, transparent);
-		else
-			CursorMan.replaceCursorPalette(nullptr, 0, 0);
-		return;
-	}
+	if (!pics.getPicture(kPicMousePointer, cursor) || cursor.surface.empty())
+		error("EEM: mouse cursor PIC 0x%x missing", kPicMousePointer);
 
-	warning("EEM: mouse cursor PIC 0x%x missing; using fallback cursor",
-			kPicMousePointer);
-	CursorMan.replaceCursor(kCursorBitmap, 11, 16, 0, 0, 0);
-	CursorMan.replaceCursorPalette(interactive ? kCursorInteractivePalette
-											   : kCursorPalette,
-								   0, 3);
+	const byte transparent = (byte)(cursor.flags >> 8);
+	CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0, transparent);
+	if (interactive)
+		setInteractiveCursorPalette(cursor, transparent);
+	else
+		CursorMan.replaceCursorPalette(nullptr, 0, 0);
 }
 
 EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)


Commit: 79bf202d8cb02e2f41f86b80aa0a71cc23b7ce12
    https://github.com/scummvm/scummvm/commit/79bf202d8cb02e2f41f86b80aa0a71cc23b7ce12
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:20+02:00

Commit Message:
EEM: boy -> jake, girl -> jenny

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index f4774f6fc9d..3f70172723b 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -41,24 +41,24 @@ namespace EEM {
 
 // _DoChoosePartner @ 1a35:0756.
 const uint kPicChooseBackground = 0x8c; // _GetBackground(0x8c)
-const uint kAniBoy  = 8;                // Jake
-const uint kAniGirl = 9;                // Jenny
+const uint kAniJake  = 8;
+const uint kAniJenny = 9;
 
 // _DoHappiness @ 172b:27b5 — cursor X picks one of 4 rects @ 29be:030f.
 // Past rect 3 = level 4. Constexpr (Point, w, h) form to avoid a global
 // constructor (-Wglobal-constructors).
 constexpr Common::Rect kHappyZones[4] = {
-	Common::Rect(Common::Point(  0, 0),  70, 200), // far left — girl very happy, boy neutral
-	Common::Rect(Common::Point( 70, 0),  56, 200), // girl's column
+	Common::Rect(Common::Point(  0, 0),  70, 200), // far left — Jenny very happy, Jake neutral
+	Common::Rect(Common::Point( 70, 0),  56, 200), // Jenny's column
 	Common::Rect(Common::Point(126, 0),  56, 200), // middle
-	Common::Rect(Common::Point(182, 0),  53, 200), // boy's column
+	Common::Rect(Common::Point(182, 0),  53, 200), // Jake's column
 };
 
 // _NewAnimation positions @ 1a35:07b9 / 07d5.
-const int kBoyX  = 0xe2; // 226
-const int kBoyY  = 0x62; // 98
-const int kGirlX = 0x42; // 66
-const int kGirlY = 0x60; // 96
+const int kJakeX  = 0xe2; // 226
+const int kJakeY  = 0x62; // 98
+const int kJennyX = 0x42; // 66
+const int kJennyY = 0x60; // 96
 
 uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
 	if (!clueBlock)
@@ -84,16 +84,16 @@ uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
 }
 
 // _DoHappiness @ 172b:27b5 — per-zone sequence scripts.
-// Boy seqs @ 29be:0337 (5 × 0x14 bytes), girl seqs @ 29be:039b. 9 frames each;
+// Jake seqs @ 29be:0337 (5 × 0x14 bytes), Jenny seqs @ 29be:039b. 9 frames each;
 // the anim cells contain 10 cells = pairs of (neutral, smile) at 5 intensities.
-const uint8 kBoySeqs[5][9] = {
+const uint8 kJakeSeqs[5][9] = {
 	{ 0,0,0,0,0,0,0,1,0 }, // level 0
 	{ 2,2,2,2,2,2,2,3,2 }, // level 1
 	{ 4,4,4,4,4,4,4,5,4 }, // level 2
 	{ 6,6,6,6,6,6,7,6,6 }, // level 3
 	{ 8,8,8,8,8,8,8,8,9 }, // level 4 (cursor past zone 3)
 };
-const uint8 kGirlSeqs[5][9] = {
+const uint8 kJennySeqs[5][9] = {
 	{ 8,9,8,8,8,8,8,8,8 },
 	{ 6,6,6,7,6,6,6,6,6 },
 	{ 4,4,5,4,4,4,4,4,4 },
@@ -139,7 +139,7 @@ void blitRawToScreen(const Picture &p, int x, int y) {
 							   x, y, w, h);
 }
 
-// _DoChoosePartner @ 1a35:0756. The original places boy + girl animations
+// _DoChoosePartner @ 1a35:0756. The original places Jake + Jenny animations
 // on a backdrop and polls four click rectangles (two per character); we
 // approximate with a single split at x=160 (left=Jenny, right=Jake).
 void EEMEngine::doChoosePartner() {
@@ -149,14 +149,14 @@ void EEMEngine::doChoosePartner() {
 		return;
 	}
 
-	Animation boyAnim;
-	if (!_aniArchive.loadAnimation(kAniBoy, boyAnim) || boyAnim.empty()) {
-		warning("Boy animation (%u) load failed", kAniBoy);
+	Animation jakeAnim;
+	if (!_aniArchive.loadAnimation(kAniJake, jakeAnim) || jakeAnim.empty()) {
+		warning("Jake animation (%u) load failed", kAniJake);
 		return;
 	}
-	Animation girlAnim;
-	if (!_aniArchive.loadAnimation(kAniGirl, girlAnim) || girlAnim.empty()) {
-		warning("Girl animation (%u) load failed", kAniGirl);
+	Animation jennyAnim;
+	if (!_aniArchive.loadAnimation(kAniJenny, jennyAnim) || jennyAnim.empty()) {
+		warning("Jenny animation (%u) load failed", kAniJenny);
 		return;
 	}
 
@@ -168,16 +168,16 @@ void EEMEngine::doChoosePartner() {
 	uint seqIdx = 0;
 
 	blitAt(background, 0, 0);
-	blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
-		   kGirlX, kGirlY);
-	blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
-		   kBoyX, kBoyY);
+	blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+		   kJennyX, kJennyY);
+	blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+		   kJakeX, kJakeY);
 	g_system->updateScreen();
 
-	debugC(1, kDebugGeneral, "ChoosePartner: %u boy frames at (%d,%d), "
-		   "%u girl frames at (%d,%d)",
-		   (uint)boyAnim.size(), kBoyX, kBoyY,
-		   (uint)girlAnim.size(), kGirlX, kGirlY);
+	debugC(1, kDebugGeneral, "ChoosePartner: %u Jake frames at (%d,%d), "
+		   "%u Jenny frames at (%d,%d)",
+		   (uint)jakeAnim.size(), kJakeX, kJakeY,
+		   (uint)jennyAnim.size(), kJennyX, kJennyY);
 
 	uint32 lastTick = g_system->getMillis();
 	while (!shouldQuit()) {
@@ -189,10 +189,10 @@ void EEMEngine::doChoosePartner() {
 			lastTick = g_system->getMillis();
 			seqIdx = (seqIdx + 1) % 9;
 			blitAt(background, 0, 0);
-			blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
-				   kGirlX, kGirlY);
-			blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
-				   kBoyX, kBoyY);
+			blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+				   kJennyX, kJennyY);
+			blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+				   kJakeX, kJakeY);
 			g_system->updateScreen();
 		}
 
@@ -211,10 +211,10 @@ void EEMEngine::doChoosePartner() {
 					level = newLevel;
 					seqIdx = 0; // restart cycle so the gesture pops
 					blitAt(background, 0, 0);
-					blitAt(girlAnim[kGirlSeqs[level][seqIdx % 9] % girlAnim.size()],
-						   kGirlX, kGirlY);
-					blitAt(boyAnim [kBoySeqs [level][seqIdx % 9] % boyAnim.size()],
-						   kBoyX, kBoyY);
+					blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+						   kJennyX, kJennyY);
+					blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+						   kJakeX, kJakeY);
 					g_system->updateScreen();
 				}
 			}


Commit: 8f54582d7d8cfdab891a8630ee6e5c0a6c8535d3
    https://github.com/scummvm/scummvm/commit/8f54582d7d8cfdab891a8630ee6e5c0a6c8535d3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:20+02:00

Commit Message:
EEM: EEM2 proof-of-concept (intro)

Changed paths:
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index fab534952a9..62d6c3901bd 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -28,6 +28,7 @@ namespace EEM {
 
 const PlainGameDescriptor eemGames[] = {
 	{ "eem", "Eagle Eye Mysteries" },
+	{ "eem2", "Eagle Eye Mysteries in London" },
 	{ nullptr, nullptr }
 };
 
@@ -66,6 +67,20 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_NO_FLAGS,
 		GUI_OPTIONS_EEM_FLOPPY
 	},
+	{
+		// Eagle Eye Mysteries in London (EEM2CD.EXE) — proof of concept.
+		// The sequel reuses this engine's resource formats (DBD/DBX
+		// archives, SITEPALS palettes, FONT.FNT), so only the opening
+		// screens load for now; flagged unstable until properly supported.
+		"eem2",
+		"CD",
+		AD_ENTRY2s("EEM2CD.EXE", "211a376b23a1b6259d0c36cf46d26ed4", 172560,
+				   "PICS.DBD",   "da0b13a117bc3a207aec907c05769cd8", 2972988),
+		Common::EN_ANY,
+		Common::kPlatformDOS,
+		ADGF_UNSTABLE,
+		GUIO2(GUIO_MIDIADLIB, GUIO_MIDIMT32)
+	},
 
 	AD_TABLE_END_MARKER
 };
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index e531f32c460..56e839889e2 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -165,6 +165,11 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
 				 ? kVariantFloppy : kVariantCD;
+	// EEM2 ("...in London") ships as a separate detection entry (gameId
+	// "eem2"); it reuses this engine but only the opening screens work.
+	if (gameDesc && gameDesc->gameId &&
+		Common::String(gameDesc->gameId) == "eem2")
+		_variant = kVariantLondonCD;
 	_language = gameDesc ? gameDesc->language : Common::EN_ANY;
 }
 
@@ -296,6 +301,17 @@ Common::Error EEMEngine::run() {
 
 	debugC(1, kDebugGeneral, "EEM engine starting");
 
+	// EEM2 ("Eagle Eye Mysteries in London") proof of concept. The archives,
+	// SITEPALS. palettes, font and mouse cursor above all load from EEM2's
+	// data using the same code paths as EEM1, which already proves the
+	// shared resource formats. Everything below here (saves, mystery/site
+	// state machine) is EEM1-specific, so the PoC just renders EEM2's
+	// opening logo screens and exits.
+	if (isLondon()) {
+		runLondonScreensPoc();
+		return Common::kNoError;
+	}
+
 	// Resume from save: mystery in progress → MAP (handler 0 @ 1a35:0e1d);
 	// otherwise → ACTION.
 	const int wantedSave = ConfMan.hasKey("save_slot")
@@ -630,8 +646,11 @@ bool EEMEngine::openArchives() {
 
 bool EEMEngine::loadSitePalettes() {
 	Common::File f;
-	if (!f.open(Common::Path("SITEPALS"))) {
-		warning("SITEPALS missing");
+	// EEM1 ships "SITEPALS" (40 palettes); EEM2/London ships "SITEPALS."
+	// with a trailing dot (63 palettes). _ReadPalettes @ 17ee:0cdb.
+	const char *palFile = isLondon() ? "SITEPALS." : "SITEPALS";
+	if (!f.open(Common::Path(palFile))) {
+		warning("%s missing", palFile);
 		return false;
 	}
 	_sitePals.resize(f.size());
@@ -645,7 +664,9 @@ bool EEMEngine::loadSitePalettes() {
 }
 
 bool EEMEngine::getSitePalette(uint num, byte *out) const {
-	if (num >= kNumSitePals || _sitePals.size() < (num + 1) * kPalSize)
+	// EEM1 SITEPALS has kNumSitePals (40) entries; EEM2/London SITEPALS.
+	// has 63. Validate against the actual loaded buffer so both work.
+	if (_sitePals.size() < (num + 1) * kPalSize)
 		return false;
 	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects
 	// 8-bit (0..255), so left-shift by 2 like the original VGA hardware.
@@ -999,6 +1020,65 @@ void EEMEngine::showHighScoreLogo() {
 	fadeCurrentPaletteToBlack();
 }
 
+void EEMEngine::showLondonLogo(uint picId, uint palId, uint holdMs) {
+	// Parameterised twin of `showHighScoreLogo` for EEM2's opening logos
+	// (`_ShowEAKids` @ 2721:05e3 and `_ShowHScoreLogo` @ 2721:084d both do
+	// _GetPicture(pic) -> blit -> _GetPalette(pal) -> fade in -> hold ->
+	// fade out).
+	Picture pic;
+	if (!_picsArchive.getPicture(picId, pic) || pic.surface.empty()) {
+		warning("London logo PIC 0x%x load failed", picId);
+		return;
+	}
+	blitAt(pic, 0, 0);
+
+	byte target[kPalSize];
+	if (!getSitePalette(palId, target)) {
+		warning("London palette 0x%x load failed", palId);
+		return;
+	}
+	byte black[kPalSize] = {};
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
+	g_system->updateScreen();
+	fadePaletteFromBlack(target);
+
+	waitForInput(holdMs);
+	fadeCurrentPaletteToBlack();
+}
+
+void EEMEngine::runLondonScreensPoc() {
+	// EEM2 opening logos — still-image portion of `_DoOpeningAnims`
+	// @ 2721:08e6. Picture/palette IDs come from RE of EEM2CD.EXE:
+	//   _ShowEAKids     @ 2721:05e3 — _GetPicture(0x54),  _GetPalette(0x3c)
+	//   _ShowStormLogo  @ 2721:0729 — BOLT.ANM + THUNDER.VOC
+	//   _ShowHScoreLogo @ 2721:084d — _GetPicture(0x356), _GetPalette(0x3d)
+	CursorMan.showMouse(false);
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: rendering opening screens");
+
+	// EA Kids logo.
+	if (!shouldQuit())
+		showLondonLogo(0x54, 0x3c, 2500);
+
+	// Storm Software lightning-bolt animation with thunder (same BOLT.ANM
+	// container as EEM1 CD; the palette lives in the ANM file header).
+	if (!shouldQuit()) {
+		if (_audio)
+			_audio->playVoc(Common::Path("THUNDER.VOC"));
+		playAnm(Common::Path("BOLT.ANM"), 120, /* holdLastFrame= */ false,
+				/* fadeIn= */ true);
+		waitForInput(1500);
+		fadeCurrentPaletteToBlack();
+		if (_audio)
+			_audio->stopVoice();
+	}
+
+	// High Score Productions logo.
+	if (!shouldQuit())
+		showLondonLogo(0x356, 0x3d, 2500);
+
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: opening screens done");
+}
+
 void EEMEngine::showFloppyStormLogo() {
 	// Floppy storm-logo splash — `FUN_23d2_0605 @ 23d2:0605`:
 	//   GetPicture(0x20b); BlitToVGA;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 3a405a74f95..accf569bb2d 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -101,8 +101,9 @@ enum ScreenId {
 /// ANIM01..20.A), and per-variant SFX (DING.VOC / NEWSCAN.VOC ship only
 /// with floppy).
 enum Variant {
-	kVariantCD     = 0,
-	kVariantFloppy = 1,
+	kVariantCD       = 0,
+	kVariantFloppy   = 1,
+	kVariantLondonCD = 2, ///< Eagle Eye Mysteries in London (EEM2CD.EXE) — PoC.
 };
 
 /// `_Partner @ 29be:7918`. Selected at the partner-pick screen
@@ -134,6 +135,10 @@ public:
 	Common::Platform getPlatform() const;
 	Variant getVariant() const { return _variant; }
 	bool isFloppy() const { return _variant == kVariantFloppy; }
+	/// EEM2 ("Eagle Eye Mysteries in London"). Proof-of-concept variant:
+	/// same DBD/palette/font formats, but a 63-entry "SITEPALS." file and
+	/// different opening picture/palette IDs.
+	bool isLondon() const { return _variant == kVariantLondonCD; }
 
 	bool hasFeature(EngineFeature f) const override;
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
@@ -403,6 +408,15 @@ private:
 	void showHighScoreLogo();
 	void showFloppyStormLogo();
 
+	// --- EEM2 ("...in London") proof of concept ---
+	/// Reproduce the opening-logo portion of EEM2's `_DoOpeningAnims`
+	/// (@ 2721:08e6): EA Kids (PIC 0x54, pal 0x3c) -> Storm (BOLT.ANM) ->
+	/// High Score (PIC 0x356, pal 0x3d), then stop.
+	void runLondonScreensPoc();
+	/// Blit a full-screen still PIC and fade it in / hold / out using the
+	/// given SITEPALS. palette index.
+	void showLondonLogo(uint picId, uint palId, uint holdMs);
+
 	/// `screen8_handler @ 1c33:1012`. Profile selector — walks
 	/// `listProfiles()`, falls through to `doNewPlayer()` if "New" or
 	/// no profiles exist (1c33:1170: `if (saves == 0) _NewPlayer();`).


Commit: ff0025ab9680d7d6323d66fdd7a1d79f57cfbad2
    https://github.com/scummvm/scummvm/commit/ff0025ab9680d7d6323d66fdd7a1d79f57cfbad2
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:20+02:00

Commit Message:
EEM: Added a more complete intro and character selection for EEM2

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


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 56e839889e2..32f03c7d982 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1047,36 +1047,93 @@ void EEMEngine::showLondonLogo(uint picId, uint palId, uint holdMs) {
 }
 
 void EEMEngine::runLondonScreensPoc() {
-	// EEM2 opening logos — still-image portion of `_DoOpeningAnims`
-	// @ 2721:08e6. Picture/palette IDs come from RE of EEM2CD.EXE:
-	//   _ShowEAKids     @ 2721:05e3 — _GetPicture(0x54),  _GetPalette(0x3c)
-	//   _ShowStormLogo  @ 2721:0729 — BOLT.ANM + THUNDER.VOC
-	//   _ShowHScoreLogo @ 2721:084d — _GetPicture(0x356), _GetPalette(0x3d)
+	// Full opening sequence — EEM2 `_DoOpeningAnims` @ 2721:08e6:
+	//   _ShowEAKids   @ 2721:05e3 — PIC 0x54,  palette 0x3c
+	//   FUN_2721_07be @ 2721:07be — PIC 0x20c, palette 0x3e (publisher logo)
+	//   _ShowStormLogo@ 2721:0729 — anim 0 ("bolt.anm") + "thunder.voc"
+	//   _MIDIPlay(0x65)=MUS00101.XMI; anim 1 ("movie.anm")
+	//   _MIDIPlay(0x66)=MUS00102.XMI (loop); anim 2 ("wave.anm"), looped
+	//   _MIDIPlay(0x67)=MUS00103.XMI; fade out
+	// Anim names come from the table @ DS:1b54 -> {bolt,movie,wave}.anm;
+	// _MIDIPlay(n) maps to MUS%05d.XMI (n).
+	const uint32 kHoldForever = 0xFFFFFFFFu;
 	CursorMan.showMouse(false);
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: rendering opening screens");
+	_skipIntro = false;
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: opening sequence");
 
-	// EA Kids logo.
-	if (!shouldQuit())
-		showLondonLogo(0x54, 0x3c, 2500);
+	// Two still logos.
+	if (!shouldQuit() && !_skipIntro)
+		showLondonLogo(0x54, 0x3c, 2500);   // EA Kids
+	if (!shouldQuit() && !_skipIntro)
+		showLondonLogo(0x20c, 0x3e, 2500);  // publisher logo (FUN_2721_07be)
 
-	// Storm Software lightning-bolt animation with thunder (same BOLT.ANM
-	// container as EEM1 CD; the palette lives in the ANM file header).
-	if (!shouldQuit()) {
+	// Storm Software — bolt.anm with the thunder roar.
+	if (!shouldQuit() && !_skipIntro) {
 		if (_audio)
 			_audio->playVoc(Common::Path("THUNDER.VOC"));
 		playAnm(Common::Path("BOLT.ANM"), 120, /* holdLastFrame= */ false,
 				/* fadeIn= */ true);
-		waitForInput(1500);
-		fadeCurrentPaletteToBlack();
 		if (_audio)
 			_audio->stopVoice();
+		fadeCurrentPaletteToBlack();
 	}
 
-	// High Score Productions logo.
+	// Intro movie with its theme (MUS00101.XMI).
+	if (!shouldQuit() && !_skipIntro && _music)
+		_music->playFile(Common::Path("MUS00101.XMI"), /* loop= */ false);
+	if (!shouldQuit() && !_skipIntro)
+		playAnm(Common::Path("MOVIE.ANM"), 120, /* holdLastFrame= */ false,
+				/* fadeIn= */ true);
+
+	// Animated title (wave.anm) over the looping theme (MUS00102.XMI);
+	// a click / key advances to character creation. The original loops
+	// wave.anm; the PoC plays it once, holds the last frame and waits.
+	if (!shouldQuit() && !_skipIntro && _music)
+		_music->playFile(Common::Path("MUS00102.XMI"), /* loop= */ true);
+	if (!shouldQuit() && !_skipIntro)
+		playAnm(Common::Path("WAVE.ANM"), 120, /* holdLastFrame= */ true,
+				/* fadeIn= */ true);
+	if (!shouldQuit() && !_skipIntro)
+		waitForInput(kHoldForever);
+	if (_music)
+		_music->stop();
+	_skipIntro = false;
+
+	// Character-selection screen.
 	if (!shouldQuit())
-		showLondonLogo(0x356, 0x3d, 2500);
+		showLondonCharSelect();
 
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: opening screens done");
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: done");
+}
+
+void EEMEngine::showLondonCharSelect() {
+	// `_NewPlayer` @ 1cd3:0f27 opens the character-creation screen with
+	// `_GetPalette(0)` + `_GetBackground(0xc)` (PICS picture 0xc), then
+	// reads a name and toggles Jake/Jenny (left/right arrow -> DAT_4c4c,
+	// Enter confirms). This PoC renders the screen; wiring up the
+	// interactive name + partner entry is the next step.
+	const uint32 kHoldForever = 0xFFFFFFFFu;
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: character-selection screen");
+
+	Picture bg;
+	if (!_picsArchive.getPicture(0xc, bg) || bg.surface.empty()) {
+		warning("London char-select background PIC 0xc load failed");
+		return;
+	}
+	blitAt(bg, 0, 0);
+
+	byte target[kPalSize];
+	byte black[kPalSize] = {};
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
+	g_system->updateScreen();
+	if (getSitePalette(0, target))
+		fadePaletteFromBlack(target);
+	else
+		warning("London char-select: palette 0 unavailable");
+
+	CursorMan.showMouse(true);
+	setInteractiveMouseCursor(true);
+	waitForInput(kHoldForever);
 }
 
 void EEMEngine::showFloppyStormLogo() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index accf569bb2d..8fcb1abeec3 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -409,13 +409,16 @@ private:
 	void showFloppyStormLogo();
 
 	// --- EEM2 ("...in London") proof of concept ---
-	/// Reproduce the opening-logo portion of EEM2's `_DoOpeningAnims`
-	/// (@ 2721:08e6): EA Kids (PIC 0x54, pal 0x3c) -> Storm (BOLT.ANM) ->
-	/// High Score (PIC 0x356, pal 0x3d), then stop.
+	/// Full opening sequence + character-selection screen — EEM2's
+	/// `_DoOpeningAnims` @ 2721:08e6 then `_NewPlayer` @ 1cd3:0f27.
 	void runLondonScreensPoc();
 	/// Blit a full-screen still PIC and fade it in / hold / out using the
 	/// given SITEPALS. palette index.
 	void showLondonLogo(uint picId, uint palId, uint holdMs);
+	/// Render EEM2's character-creation screen (`_NewPlayer`: palette 0 +
+	/// background PIC 0xc, where the player types a name and picks
+	/// Jake/Jenny). PoC display; interactive entry is still TODO.
+	void showLondonCharSelect();
 
 	/// `screen8_handler @ 1c33:1012`. Profile selector — walks
 	/// `listProfiles()`, falls through to `doNewPlayer()` if "New" or


Commit: 3fb2a3ea4488b5f83767141883896bf51569311a
    https://github.com/scummvm/scummvm/commit/3fb2a3ea4488b5f83767141883896bf51569311a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:21+02:00

Commit Message:
EEM: load case menu is working in EEM2

Changed paths:
    engines/eem/eem.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 32f03c7d982..92527a49871 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1099,41 +1099,134 @@ void EEMEngine::runLondonScreensPoc() {
 		_music->stop();
 	_skipIntro = false;
 
-	// Character-selection screen.
+	// Character creation (name + Jake/Jenny), then the case-selection menu.
 	if (!shouldQuit())
 		showLondonCharSelect();
+	// Reuse EEM1's `doCaseSelection()` verbatim — EEM2 shares its assets
+	// (background PIC 0x41, partner greeter ANI 0x15/0x16, BOOK*.NME, the
+	// generic chooser). The mystery load at the end is guarded out for
+	// London since EEM2's case data isn't ported yet.
+	if (!shouldQuit())
+		doCaseSelection();
 
 	debugC(1, kDebugGeneral, "EEM2 (London) PoC: done");
 }
 
 void EEMEngine::showLondonCharSelect() {
-	// `_NewPlayer` @ 1cd3:0f27 opens the character-creation screen with
-	// `_GetPalette(0)` + `_GetBackground(0xc)` (PICS picture 0xc), then
-	// reads a name and toggles Jake/Jenny (left/right arrow -> DAT_4c4c,
-	// Enter confirms). This PoC renders the screen; wiring up the
-	// interactive name + partner entry is the next step.
-	const uint32 kHoldForever = 0xFFFFFFFFu;
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: character-selection screen");
+	// `_NewPlayer` @ 1cd3:0f27 — the character-creation screen: palette 0 +
+	// background PIC 0xc, type a name, then pick Jake/Jenny with the
+	// left/right arrows (stored at DAT_4c4c) and confirm with Enter. The
+	// original's name-field / Jake-Jenny box rects are runtime-initialised
+	// (BSS, beyond the EXE image), so the overlay positions here are
+	// approximate over EEM2's PIC 0xc artwork — tune once visible. Text
+	// uses the shared engine font.
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: character creation");
 
 	Picture bg;
-	if (!_picsArchive.getPicture(0xc, bg) || bg.surface.empty()) {
-		warning("London char-select background PIC 0xc load failed");
-		return;
-	}
-	blitAt(bg, 0, 0);
+	const bool haveBg = _picsArchive.getPicture(0xc, bg) && !bg.surface.empty();
 
-	byte target[kPalSize];
+	byte pal[kPalSize];
 	byte black[kPalSize] = {};
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
-	if (getSitePalette(0, target))
-		fadePaletteFromBlack(target);
-	else
-		warning("London char-select: palette 0 unavailable");
+	const bool havePal = getSitePalette(0, pal);
 
-	CursorMan.showMouse(true);
-	setInteractiveMouseCursor(true);
-	waitForInput(kHoldForever);
+	CursorMan.showMouse(false);
+
+	Common::String name;
+	const uint kMaxName = 12;
+	uint8 partner = kPartnerJake;
+	bool nameDone = false, blink = true, fadedIn = false;
+	uint32 blinkMs = g_system->getMillis();
+	bool done = false, needRedraw = true;
+
+	while (!done && !shouldQuit()) {
+		if (needRedraw) {
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+				Graphics::PixelFormat::createFormatCLUT8());
+			scratch.clear();
+			if (haveBg)
+				scratch.simpleBlitFrom(bg.surface);
+			if (getFont().isLoaded()) {
+				getFont().drawString(&scratch,
+					isSpanish() ? "Escribe tu nombre:" : "Type your name:",
+					80, 36, 240, 0xF);
+				Common::String shown = name;
+				if (!nameDone && blink)
+					shown += "_";
+				getFont().drawString(&scratch, shown, 80, 52, 240, 0xF);
+				getFont().drawString(&scratch, "Jake", 104, 116, 80,
+					partner == kPartnerJake ? 0xF : 0x8);
+				getFont().drawString(&scratch, "Jenny", 184, 116, 80,
+					partner == kPartnerJenny ? 0xF : 0x8);
+				getFont().drawString(&scratch,
+					nameDone ? (isSpanish() ? "Flechas eligen - Enter juega"
+											: "Arrows choose - Enter to play")
+							 : (isSpanish() ? "Enter para continuar"
+											: "Enter to continue"),
+					80, 150, 240, 0x7);
+			}
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, kScreenWidth, kScreenHeight);
+			if (!fadedIn) {
+				if (havePal)
+					fadePaletteFromBlack(pal);
+				fadedIn = true;
+			}
+			g_system->updateScreen();
+			needRedraw = false;
+		}
+
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				return;
+			if (ev.type != Common::EVENT_KEYDOWN)
+				continue;
+			const Common::KeyCode k = ev.kbd.keycode;
+			if (!nameDone) {
+				if ((k == Common::KEYCODE_RETURN ||
+					 k == Common::KEYCODE_KP_ENTER) && !name.empty())
+					nameDone = true;
+				else if (k == Common::KEYCODE_BACKSPACE && !name.empty())
+					name.deleteLastChar();
+				else if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
+						 name.size() < kMaxName)
+					name += (char)ev.kbd.ascii;
+			} else {
+				if (k == Common::KEYCODE_LEFT)
+					partner = kPartnerJake;
+				else if (k == Common::KEYCODE_RIGHT)
+					partner = kPartnerJenny;
+				else if (k == Common::KEYCODE_RETURN ||
+						 k == Common::KEYCODE_KP_ENTER)
+					done = true;
+			}
+			needRedraw = true;
+		}
+
+		const uint32 now = g_system->getMillis();
+		if (!nameDone && now - blinkMs >= 400) {
+			blinkMs = now;
+			blink = !blink;
+			needRedraw = true;
+		}
+		g_system->delayMillis(15);
+	}
+	if (shouldQuit())
+		return;
+
+	// Commit the profile so the reused case-selection menu has valid state
+	// (player name shown in clue text, partner greeter ANI 0x15/0x16,
+	// Junior chain stage -> BOOK1.NME, nothing solved yet).
+	_playerName = name.empty() ? Common::String("Detective") : name;
+	_partner = partner;
+	_chainStage = 1;
+	for (uint i = 0; i < sizeof(_mysteriesSolved); i++)
+		_mysteriesSolved[i] = 0;
+	debugC(1, kDebugGeneral, "London PoC: player='%s' partner=%s",
+		   _playerName.c_str(), partner == kPartnerJake ? "Jake" : "Jenny");
 }
 
 void EEMEngine::showFloppyStormLogo() {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 420c799701c..6be8928886a 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2117,6 +2117,14 @@ void EEMEngine::doCaseSelection() {
 		return;
 
 	const uint mn = stageLo + selRow;
+	if (isLondon()) {
+		// EEM2 PoC: the menu (PIC 0x41, ANI 0x15/0x16, BOOK*.NME) is shared
+		// with EEM1, but EEM2's mystery data (M*.BIN/E*.BIN) isn't ported,
+		// so stop here instead of parsing it with the EEM1 loader.
+		debugC(1, kDebugMystery,
+			   "London PoC: selected mystery %u (load not implemented)", mn);
+		return;
+	}
 	if (!_mystery.load(mn, &_rng)) {
 		warning("doCaseSelection: failed to load mystery %u", mn);
 		_mystery.clear();


Commit: a477a58ccf0f2a5bc7e70172d8ef6c77710e2e09
    https://github.com/scummvm/scummvm/commit/a477a58ccf0f2a5bc7e70172d8ef6c77710e2e09
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:21+02:00

Commit Message:
EEM: add more code in the partner selection screen

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 92527a49871..4355db818d8 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1113,15 +1113,23 @@ void EEMEngine::runLondonScreensPoc() {
 }
 
 void EEMEngine::showLondonCharSelect() {
-	// `_NewPlayer` @ 1cd3:0f27 — the character-creation screen: palette 0 +
-	// background PIC 0xc, type a name, then pick Jake/Jenny with the
-	// left/right arrows (stored at DAT_4c4c) and confirm with Enter. The
-	// original's name-field / Jake-Jenny box rects are runtime-initialised
-	// (BSS, beyond the EXE image), so the overlay positions here are
-	// approximate over EEM2's PIC 0xc artwork — tune once visible. Text
-	// uses the shared engine font.
+	// `_NewPlayer` @ 1cd3:0f27 — character creation over background PIC 0xc
+	// (palette 0). Two text fields then a Jake/Jenny pick (left/right arrow
+	// 0x4b/0x4d -> DAT_4c4c, Enter confirms). The field/box rects are
+	// constants in EEM2's data segment (read at 2bca:0e3a); they map to the
+	// `pr` player record's FirstName[12] / LastName[20]:
+	//   first name : (54,75)-(151,85)    last name : (167,75)-(266,85)
+	//   Jake box   : (110,116)-(120,122) Jenny box : (190,116)-(200,122)
 	debugC(1, kDebugGeneral, "EEM2 (London) PoC: character creation");
 
+	const Common::Rect kFirstRect(54, 75, 151, 85);
+	const Common::Rect kLastRect(167, 75, 266, 85);
+	const Common::Rect kJakeBox(110, 116, 120, 122);
+	const Common::Rect kJennyBox(190, 116, 200, 122);
+	const uint kMaxFirst = 12, kMaxLast = 20;
+	const uint8 kInkColor = 0x0F;     // typed-name ink
+	const uint8 kHiBox    = 0x0F;     // selected-partner box highlight
+
 	Picture bg;
 	const bool haveBg = _picsArchive.getPicture(0xc, bg) && !bg.surface.empty();
 
@@ -1133,12 +1141,12 @@ void EEMEngine::showLondonCharSelect() {
 
 	CursorMan.showMouse(false);
 
-	Common::String name;
-	const uint kMaxName = 12;
+	enum { kFieldFirst = 0, kFieldLast = 1, kFieldPartner = 2 };
+	int field = kFieldFirst;
+	Common::String first, last;
 	uint8 partner = kPartnerJake;
-	bool nameDone = false, blink = true, fadedIn = false;
+	bool blink = true, fadedIn = false, done = false, needRedraw = true;
 	uint32 blinkMs = g_system->getMillis();
-	bool done = false, needRedraw = true;
 
 	while (!done && !shouldQuit()) {
 		if (needRedraw) {
@@ -1148,24 +1156,22 @@ void EEMEngine::showLondonCharSelect() {
 			if (haveBg)
 				scratch.simpleBlitFrom(bg.surface);
 			if (getFont().isLoaded()) {
-				getFont().drawString(&scratch,
-					isSpanish() ? "Escribe tu nombre:" : "Type your name:",
-					80, 36, 240, 0xF);
-				Common::String shown = name;
-				if (!nameDone && blink)
-					shown += "_";
-				getFont().drawString(&scratch, shown, 80, 52, 240, 0xF);
-				getFont().drawString(&scratch, "Jake", 104, 116, 80,
-					partner == kPartnerJake ? 0xF : 0x8);
-				getFont().drawString(&scratch, "Jenny", 184, 116, 80,
-					partner == kPartnerJenny ? 0xF : 0x8);
-				getFont().drawString(&scratch,
-					nameDone ? (isSpanish() ? "Flechas eligen - Enter juega"
-											: "Arrows choose - Enter to play")
-							 : (isSpanish() ? "Enter para continuar"
-											: "Enter to continue"),
-					80, 150, 240, 0x7);
+				Common::String f = first;
+				if (field == kFieldFirst && blink)
+					f += "_";
+				Common::String l = last;
+				if (field == kFieldLast && blink)
+					l += "_";
+				getFont().drawString(&scratch, f, kFirstRect.left + 2,
+									 kFirstRect.top + 1, kFirstRect.width(),
+									 kInkColor);
+				getFont().drawString(&scratch, l, kLastRect.left + 2,
+									 kLastRect.top + 1, kLastRect.width(),
+									 kInkColor);
 			}
+			// Highlight the selected partner's indicator box.
+			scratch.fillRect(partner == kPartnerJake ? kJakeBox : kJennyBox,
+							 kHiBox);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, kScreenWidth, kScreenHeight);
 			if (!fadedIn) {
@@ -1185,29 +1191,34 @@ void EEMEngine::showLondonCharSelect() {
 			if (ev.type != Common::EVENT_KEYDOWN)
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
-			if (!nameDone) {
-				if ((k == Common::KEYCODE_RETURN ||
-					 k == Common::KEYCODE_KP_ENTER) && !name.empty())
-					nameDone = true;
-				else if (k == Common::KEYCODE_BACKSPACE && !name.empty())
-					name.deleteLastChar();
+			const bool enter = (k == Common::KEYCODE_RETURN ||
+								k == Common::KEYCODE_KP_ENTER);
+			if (field == kFieldFirst || field == kFieldLast) {
+				Common::String &buf = (field == kFieldFirst) ? first : last;
+				const uint cap = (field == kFieldFirst) ? kMaxFirst : kMaxLast;
+				if ((enter || k == Common::KEYCODE_TAB) &&
+					(field == kFieldLast || !first.empty()))
+					field++;  // advance to last name, then to partner pick
+				else if (k == Common::KEYCODE_BACKSPACE && !buf.empty())
+					buf.deleteLastChar();
 				else if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
-						 name.size() < kMaxName)
-					name += (char)ev.kbd.ascii;
-			} else {
+						 buf.size() < cap)
+					buf += (char)ev.kbd.ascii;
+			} else {  // partner pick
 				if (k == Common::KEYCODE_LEFT)
 					partner = kPartnerJake;
 				else if (k == Common::KEYCODE_RIGHT)
 					partner = kPartnerJenny;
-				else if (k == Common::KEYCODE_RETURN ||
-						 k == Common::KEYCODE_KP_ENTER)
+				else if (k == Common::KEYCODE_BACKSPACE)
+					field = kFieldLast;  // back to editing
+				else if (enter)
 					done = true;
 			}
 			needRedraw = true;
 		}
 
 		const uint32 now = g_system->getMillis();
-		if (!nameDone && now - blinkMs >= 400) {
+		if (field != kFieldPartner && now - blinkMs >= 400) {
 			blinkMs = now;
 			blink = !blink;
 			needRedraw = true;
@@ -1218,9 +1229,11 @@ void EEMEngine::showLondonCharSelect() {
 		return;
 
 	// Commit the profile so the reused case-selection menu has valid state
-	// (player name shown in clue text, partner greeter ANI 0x15/0x16,
-	// Junior chain stage -> BOOK1.NME, nothing solved yet).
-	_playerName = name.empty() ? Common::String("Detective") : name;
+	// (player name in clue text, partner greeter ANI 0x15/0x16, Junior
+	// chain stage -> BOOK1.NME, nothing solved yet).
+	if (first.empty())
+		first = "Detective";
+	_playerName = last.empty() ? first : (first + " " + last);
 	_partner = partner;
 	_chainStage = 1;
 	for (uint i = 0; i < sizeof(_mysteriesSolved); i++)


Commit: e69c835219a58d26adcac729f3b7d773f93e0d42
    https://github.com/scummvm/scummvm/commit/e69c835219a58d26adcac729f3b7d773f93e0d42
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:21+02:00

Commit Message:
EEM: corrected palette

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 3f70172723b..1c84992d616 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -300,7 +300,12 @@ void EEMEngine::doInitClues() {
 		_mystery._lastSite = 0;
 	}
 
-	setSitePalette(0x22);
+	// Case-briefing palette. EEM1 `_DoInitClues` uses SITEPALS index 0x22;
+	// EEM2 `_DoInitClues` @ 1abf:03b3 does `_GetBackground(0x52)` then
+	// `_GetPalette(0x39)` (its 63-entry SITEPALS. shifts the UI palettes).
+	// The briefing partner animation is an ANI sprite drawn under the screen
+	// palette, so the same index fixes both the background and the anim.
+	setSitePalette(isLondon() ? 0x39 : 0x22);
 	Picture bg;
 	const bool haveBriefingBg = _picsArchive.getPicture(0x52, bg);
 	if (haveBriefingBg)
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 4355db818d8..66d5b11f08e 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -301,17 +301,6 @@ Common::Error EEMEngine::run() {
 
 	debugC(1, kDebugGeneral, "EEM engine starting");
 
-	// EEM2 ("Eagle Eye Mysteries in London") proof of concept. The archives,
-	// SITEPALS. palettes, font and mouse cursor above all load from EEM2's
-	// data using the same code paths as EEM1, which already proves the
-	// shared resource formats. Everything below here (saves, mystery/site
-	// state machine) is EEM1-specific, so the PoC just renders EEM2's
-	// opening logo screens and exits.
-	if (isLondon()) {
-		runLondonScreensPoc();
-		return Common::kNoError;
-	}
-
 	// Resume from save: mystery in progress → MAP (handler 0 @ 1a35:0e1d);
 	// otherwise → ACTION.
 	const int wantedSave = ConfMan.hasKey("save_slot")
@@ -342,6 +331,31 @@ Common::Error EEMEngine::run() {
 	if (resumed)
 		goto screenLoop;
 
+	// EEM2 ("Eagle Eye Mysteries in London") proof of concept: opening
+	// sequence + character creation, then start the training case (mystery
+	// 0 — what a new detective plays first) and reuse the engine's gameplay
+	// screen loop. EEM2's mystery data format matches EEM1-CD, so the shared
+	// Mystery parser + InitClues (case intro) / BigMap / Site handlers apply.
+	if (isLondon()) {
+		runLondonScreensPoc();
+		if (!shouldQuit()) {
+			CursorMan.showMouse(true);
+			if (_mystery.load(0, &_rng)) {
+				resetSiteArrivalState();
+				if (_audio)
+					_audio->initMysterySounds(0);
+				debugC(1, kDebugMystery,
+					   "London: training mystery 0 loaded — %u sites, %u suspects",
+					   _mystery.numSites(), _mystery.numSuspects());
+				_nextScreen = kScreenInitClues;
+			} else {
+				warning("London: training mystery 0 (M0.BIN) failed to load");
+				_nextScreen = kScreenInvalid;
+			}
+		}
+		goto screenLoop;
+	}
+
 	// _DoOpeningAnims @ 2520:082a:
 	//   EA Kids logo (PIC) -> HighScore logo (PIC) -> Storm logo
 	//   (BOLT.ANM) -> [music starts] -> 20 character-intro anims
@@ -1099,17 +1113,14 @@ void EEMEngine::runLondonScreensPoc() {
 		_music->stop();
 	_skipIntro = false;
 
-	// Character creation (name + Jake/Jenny), then the case-selection menu.
+	// Character creation (name + Jake/Jenny). The caller then loads the
+	// training case (mystery 0) and enters the shared gameplay screen loop
+	// (case intro -> map -> site); the case-selection menu is reachable
+	// later via the action screen.
 	if (!shouldQuit())
 		showLondonCharSelect();
-	// Reuse EEM1's `doCaseSelection()` verbatim — EEM2 shares its assets
-	// (background PIC 0x41, partner greeter ANI 0x15/0x16, BOOK*.NME, the
-	// generic chooser). The mystery load at the end is guarded out for
-	// London since EEM2's case data isn't ported yet.
-	if (!shouldQuit())
-		doCaseSelection();
 
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: done");
+	debugC(1, kDebugGeneral, "EEM2 (London) PoC: intro + character creation done");
 }
 
 void EEMEngine::showLondonCharSelect() {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6be8928886a..0d6cb294edb 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2958,7 +2958,9 @@ void EEMEngine::doBigMap() {
 
 	while (!shouldQuit()) {
 		setInteractiveMouseCursor(false);
-		setSitePalette(0x24); // `_GetPalette(0x24)` @ `_DoBigMap`.
+		// `_GetPalette(0x24)` @ EEM1 `_DoBigMap`; EEM2 `_DoBigMap`
+		// @ 2237:0a04 uses `_GetPalette(0x3b)` (shifted UI palettes).
+		setSitePalette(isLondon() ? 0x3b : 0x24);
 
 		// Stage 1: Overview. mapStartTick anchors partner timeline;
 		// `_NewAnimation` seeds frame to 0xffff so unfold plays once then


Commit: 29bef8261a7ee1b937bc269b7661f8f10100518f
    https://github.com/scummvm/scummvm/commit/29bef8261a7ee1b937bc269b7661f8f10100518f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:22+02:00

Commit Message:
EEM: play correctly initial animation in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 1c84992d616..fc35a14407a 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -29,6 +29,7 @@
 
 #include "graphics/cursorman.h"
 #include "graphics/managed_surface.h"
+#include "graphics/paletteman.h"
 
 #include "eem/audio.h"
 #include "eem/detection.h"
@@ -261,56 +262,87 @@ void EEMEngine::doChoosePartner() {
 	}
 }
 
-// _DoInitClues @ 1a35:0411. Sequence:
-//   1. BG 0x52, palette 0x22
-//   2. anims: game @ (0xcd, 0x6c), book @ (0, 99), nancy @ (0x68, 0x8b) on caseType=1
-//   3. cycle game anim once (click skips)
-//   4. _PlayInSequence per partner+caseType
-//   5. _DisplayClue(InitBlock + 2, 1) — briefing dialogue
-//   6. _OnSites[startSite] = 1
-// Anim IDs: gameAni = 0x17 (Jake) / 0x3b (Jenny);
-// bookAni = 0x18 / 0x3c; nancyAni = 0x19 (caseType 1 only).
-void EEMEngine::doInitClues() {
-	if (!_mystery.isLoaded())
-		return;
-
-	const byte *ib = _mystery.initBlock();
-	if (!ib)
-		return;
+// EEM2 case-intro animation — `_DoInitClues` @ 1abf:03b3. Ghidra-confirmed
+// flow (decompiled prologue):
+//   _AllBlack(); _GetBackground(0x52); _GetPalette(0x39);
+//   anim = _GetAnimation(DAT_4bd4 ? 0x71 : 0x18);   // Jake 0x18 / Jenny 0x71
+//   _NewAnimation(0xd2, 0x3f, anim, ...);            // registered at (210,63)
+//   _UpdateAnimations(); _FadeIn();                  // draw frame 0, then fade
+//   while (i != _max()) { _CheckFrameRate(); _UpdateAnimations(); kbhit->skip }
+//   if (caseType == 1) { _LoadSoundName("phone1.voc"); _PlayVoice; _Wait... }
+// So, unlike EEM1's game/book/nancy cycle + `_PlayInSequence`, EEM2 registers
+// ONE partner animation drawn by `_UpdateAnimations` — hence we anchor frames
+// with `blitAnimFrameAnchored` ((0xd2 - miscflags, 0x3f - rowoff), per
+// `_UpdateAnimations @ 172b:09c1`), the same helper the map/site screens use.
+void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
+										bool haveBriefingBg) {
+	const uint introAni = (_partner == kPartnerJake) ? 0x18 : 0x71;
+	const int kAnchorX = 0xd2, kAnchorY = 0x3f;  // _NewAnimation(0xd2, 0x3f)
+	Animation anim;
+	const bool haveAnim =
+		_aniArchive.loadAnimation(introAni, anim) && !anim.empty();
+
+	// _AllBlack(): blank the palette so the _FadeIn after frame 0 reveals the
+	// briefing (palette 0x39 — EEM2's 63-entry SITEPALS. shifts EEM1's UI set).
+	byte pal[kPalSize];
+	const bool havePal = getSitePalette(0x39, pal);
+	byte black[kPalSize] = {};
+	g_system->getPaletteManager()->setPalette(black, 0, 256);
+	g_system->updateScreen();
 
-	// CD InitBlock: u16 caseType; u16 startSite; <clue block>.
-	// Floppy InitBlock (FUN_19bb_042f): u8 caseType; u8 nSubjects;
-	// subjects[]; u8 nDialog; dialog_records[]. No startSite.
-	const bool floppy = isFloppy();
-	const uint16 caseType = floppy ? (uint16)ib[0] : READ_LE_UINT16(ib);
+	bool skip = false;
+	const uint frames = haveAnim ? (uint)anim.size() : 1;
+	for (uint frame = 0; frame < frames && !shouldQuit() && !skip; frame++) {
+		if (haveBriefingBg)
+			blitAt(bg, 0, 0);
+		if (haveAnim) {
+			Graphics::Surface *scr = g_system->lockScreen();
+			if (scr) {
+				blitAnimFrameAnchored(scr, anim[frame], kAnchorX, kAnchorY);
+				g_system->unlockScreen();
+			}
+		}
+		if (frame == 0 && havePal)
+			fadePaletteFromBlack(pal);  // _FadeIn @ 1abf:03b3
+		else
+			g_system->updateScreen();
 
-	if (!floppy) {
-		const uint16 startSite = READ_LE_UINT16(ib + 2);
-		if (startSite < Mystery::kVisitedSiteCap)
-			_mystery._onSites[startSite] = 1;
-		_mystery._siteNumber = startSite;
-		_mystery._lastSite = startSite;
-	} else {
-		// Floppy _DoMapScreen @ 1fed:1060 (FUN_1fed_07ed) walks every
-		// site unconditionally — mirror that by marking all visible.
-		const uint sites = _mystery.numSites();
-		for (uint s = 0; s < sites && s < Mystery::kVisitedSiteCap; s++)
-			_mystery._onSites[s] = 1;
-		_mystery._siteNumber = 0;
-		_mystery._lastSite = 0;
+		const uint32 wakeup = g_system->getMillis() + 140;
+		while (g_system->getMillis() < wakeup && !shouldQuit() && !skip) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_KEYDOWN &&
+					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
+					interruptAudio(/* stopMusicToo= */ false);
+					skip = true;
+					break;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN ||
+					ev.type == Common::EVENT_KEYDOWN) {
+					skip = true;
+					break;
+				}
+			}
+			g_system->delayMillis(10);
+		}
 	}
 
-	// Case-briefing palette. EEM1 `_DoInitClues` uses SITEPALS index 0x22;
-	// EEM2 `_DoInitClues` @ 1abf:03b3 does `_GetBackground(0x52)` then
-	// `_GetPalette(0x39)` (its 63-entry SITEPALS. shifts the UI palettes).
-	// The briefing partner animation is an ANI sprite drawn under the screen
-	// palette, so the same index fixes both the background and the anim.
-	setSitePalette(isLondon() ? 0x39 : 0x22);
-	Picture bg;
-	const bool haveBriefingBg = _picsArchive.getPicture(0x52, bg);
-	if (haveBriefingBg)
-		blitAt(bg, 0, 0);
+	// caseType 1 rings phone1.voc (EEM1's PHONE.VOC fires on caseType 2).
+	if (_audio && caseType == 1) {
+		_audio->playVoc(Common::Path("phone1.voc"));
+		_audio->waitForVoiceDone();
+	}
+}
 
+// EEM1 CD/floppy case-intro animation — `_DoInitClues @ 1a35:0411`:
+//   anims registered: game @ (0xcd,0x6c) [0x17 Jake / 0x3b Jenny],
+//   book @ (0,99) [0x18 / 0x3c], nancy @ (0x68,0x8b) [0x19, caseType 1 only];
+//   cycle the game anim once (click skips), then `_PlayInSequence @ 172b:2d03`
+//   plays the partner entrance per partner+caseType. A phone/news voice rings
+//   first on CD caseType 2 / floppy caseType 2-3. (London: see
+//   playLondonInitCluesAnim.)
+void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
+										  const Picture &bg, bool haveBriefingBg) {
 	const uint gameAni = _partner == kPartnerJake ? 0x17 : 0x3b;
 	const uint bookAni = _partner == kPartnerJake ? 0x18 : 0x3c;
 	Animation game, book, nancy;
@@ -513,6 +545,62 @@ void EEMEngine::doInitClues() {
 			}
 		}
 	}
+}
+
+// _DoInitClues @ 1a35:0411 (EEM1) / 1abf:03b3 (EEM2). Sequence:
+//   1. mark the start site / visited sites from the InitBlock
+//   2. BG PIC 0x52 + briefing palette (EEM1 0x22 / EEM2 0x39)
+//   3. partner entrance animation (CD/floppy or London variant)
+//   4. _DisplayClue(InitBlock + 2) — briefing dialogue
+void EEMEngine::doInitClues() {
+	if (!_mystery.isLoaded())
+		return;
+
+	const byte *ib = _mystery.initBlock();
+	if (!ib)
+		return;
+
+	// CD InitBlock: u16 caseType; u16 startSite; <clue block>.
+	// Floppy InitBlock (FUN_19bb_042f): u8 caseType; u8 nSubjects;
+	// subjects[]; u8 nDialog; dialog_records[]. No startSite.
+	const bool floppy = isFloppy();
+	const uint16 caseType = floppy ? (uint16)ib[0] : READ_LE_UINT16(ib);
+
+	if (!floppy) {
+		const uint16 startSite = READ_LE_UINT16(ib + 2);
+		if (startSite < Mystery::kVisitedSiteCap)
+			_mystery._onSites[startSite] = 1;
+		_mystery._siteNumber = startSite;
+		_mystery._lastSite = startSite;
+	} else {
+		// Floppy _DoMapScreen @ 1fed:1060 (FUN_1fed_07ed) walks every
+		// site unconditionally — mirror that by marking all visible.
+		const uint sites = _mystery.numSites();
+		for (uint s = 0; s < sites && s < Mystery::kVisitedSiteCap; s++)
+			_mystery._onSites[s] = 1;
+		_mystery._siteNumber = 0;
+		_mystery._lastSite = 0;
+	}
+
+	// Case-briefing palette. EEM1 `_DoInitClues` uses SITEPALS index 0x22;
+	// EEM2 `_DoInitClues` @ 1abf:03b3 does `_GetBackground(0x52)` then
+	// `_GetPalette(0x39)` (its 63-entry SITEPALS. shifts the UI palettes).
+	// The briefing partner animation is an ANI sprite drawn under the screen
+	// palette, so the same index fixes both the background and the anim.
+	setSitePalette(isLondon() ? 0x39 : 0x22);
+	Picture bg;
+	const bool haveBriefingBg = _picsArchive.getPicture(0x52, bg);
+	if (haveBriefingBg)
+		blitAt(bg, 0, 0);
+
+	// Case-intro partner animation. EEM2 (`_DoInitClues` @ 1abf:03b3) plays a
+	// single partner anim (Jake 0x18 / Jenny 0x71); EEM1 CD/floppy runs a
+	// game/book/nancy cycle + `_PlayInSequence` entrance. Both then feed the
+	// shared briefing dialogue below.
+	if (isLondon())
+		playLondonInitCluesAnim(caseType, bg, haveBriefingBg);
+	else
+		playCdFloppyInitCluesAnim(caseType, floppy, bg, haveBriefingBg);
 
 	// Briefing dialogue. CD: clue block @ ib+4 (after caseType,startSite).
 	// Floppy: dialog records dispatched via FUN_22dc_05c8 @ 22dc:05c8
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 62d6c3901bd..714b2d81821 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -68,10 +68,10 @@ const ADGameDescription gameDescriptions[] = {
 		GUI_OPTIONS_EEM_FLOPPY
 	},
 	{
-		// Eagle Eye Mysteries in London (EEM2CD.EXE) — proof of concept.
-		// The sequel reuses this engine's resource formats (DBD/DBX
-		// archives, SITEPALS palettes, FONT.FNT), so only the opening
-		// screens load for now; flagged unstable until properly supported.
+		// Eagle Eye Mysteries in London (EEM2CD.EXE), the sequel.
+		// It reuses this engine's resource formats (DBD/DBX archives,
+		// SITEPALS palettes, FONT.FNT); reimplementation is in progress,
+		// so it stays flagged unstable until fully supported.
 		"eem2",
 		"CD",
 		AD_ENTRY2s("EEM2CD.EXE", "211a376b23a1b6259d0c36cf46d26ed4", 172560,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 66d5b11f08e..9b78267ec4c 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -48,7 +48,6 @@
 
 namespace EEM {
 
-const uint kPalSize = 768;
 const uint kNumSitePals = 40;  // SITEPALS: 40 * 768 = 30720
 
 // 1-based picture/palette IDs.
@@ -66,7 +65,7 @@ const byte kSaveBodyVer = 1;
 // option or changing save format. Set false before release.
 const bool kDebugPopulateScrapbook1AtStartup = false;
 
-void fadeCurrentPaletteToBlack(uint delayMs = 8) {
+void fadeCurrentPaletteToBlack(uint delayMs) {
 	byte start[kPalSize];
 	byte stepPal[kPalSize];
 	g_system->getPaletteManager()->grabPalette(start, 0, 256);
@@ -81,7 +80,7 @@ void fadeCurrentPaletteToBlack(uint delayMs = 8) {
 	}
 }
 
-void fadePaletteFromBlack(const byte *target, uint delayMs = 8) {
+void fadePaletteFromBlack(const byte *target, uint delayMs) {
 	byte stepPal[kPalSize];
 
 	for (uint step = 1; step <= 16; step++) {
@@ -331,13 +330,13 @@ Common::Error EEMEngine::run() {
 	if (resumed)
 		goto screenLoop;
 
-	// EEM2 ("Eagle Eye Mysteries in London") proof of concept: opening
-	// sequence + character creation, then start the training case (mystery
-	// 0 — what a new detective plays first) and reuse the engine's gameplay
-	// screen loop. EEM2's mystery data format matches EEM1-CD, so the shared
-	// Mystery parser + InitClues (case intro) / BigMap / Site handlers apply.
+	// EEM2 ("Eagle Eye Mysteries in London"): opening sequence + character
+	// creation, then start the training case (mystery 0 — what a new
+	// detective plays first) and reuse the engine's gameplay screen loop.
+	// EEM2's mystery data format matches EEM1-CD, so the shared Mystery
+	// parser + InitClues (case intro) / BigMap / Site handlers apply.
 	if (isLondon()) {
-		runLondonScreensPoc();
+		runLondonStartup();
 		if (!shouldQuit()) {
 			CursorMan.showMouse(true);
 			if (_mystery.load(0, &_rng)) {
@@ -1060,7 +1059,7 @@ void EEMEngine::showLondonLogo(uint picId, uint palId, uint holdMs) {
 	fadeCurrentPaletteToBlack();
 }
 
-void EEMEngine::runLondonScreensPoc() {
+void EEMEngine::runLondonStartup() {
 	// Full opening sequence — EEM2 `_DoOpeningAnims` @ 2721:08e6:
 	//   _ShowEAKids   @ 2721:05e3 — PIC 0x54,  palette 0x3c
 	//   FUN_2721_07be @ 2721:07be — PIC 0x20c, palette 0x3e (publisher logo)
@@ -1073,7 +1072,7 @@ void EEMEngine::runLondonScreensPoc() {
 	const uint32 kHoldForever = 0xFFFFFFFFu;
 	CursorMan.showMouse(false);
 	_skipIntro = false;
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: opening sequence");
+	debugC(1, kDebugGeneral, "EEM2 (London): opening sequence");
 
 	// Two still logos.
 	if (!shouldQuit() && !_skipIntro)
@@ -1101,7 +1100,7 @@ void EEMEngine::runLondonScreensPoc() {
 
 	// Animated title (wave.anm) over the looping theme (MUS00102.XMI);
 	// a click / key advances to character creation. The original loops
-	// wave.anm; the PoC plays it once, holds the last frame and waits.
+	// wave.anm; we play it once, hold the last frame and wait.
 	if (!shouldQuit() && !_skipIntro && _music)
 		_music->playFile(Common::Path("MUS00102.XMI"), /* loop= */ true);
 	if (!shouldQuit() && !_skipIntro)
@@ -1120,7 +1119,7 @@ void EEMEngine::runLondonScreensPoc() {
 	if (!shouldQuit())
 		showLondonCharSelect();
 
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: intro + character creation done");
+	debugC(1, kDebugGeneral, "EEM2 (London): intro + character creation done");
 }
 
 void EEMEngine::showLondonCharSelect() {
@@ -1131,7 +1130,7 @@ void EEMEngine::showLondonCharSelect() {
 	// `pr` player record's FirstName[12] / LastName[20]:
 	//   first name : (54,75)-(151,85)    last name : (167,75)-(266,85)
 	//   Jake box   : (110,116)-(120,122) Jenny box : (190,116)-(200,122)
-	debugC(1, kDebugGeneral, "EEM2 (London) PoC: character creation");
+	debugC(1, kDebugGeneral, "EEM2 (London): character creation");
 
 	const Common::Rect kFirstRect(54, 75, 151, 85);
 	const Common::Rect kLastRect(167, 75, 266, 85);
@@ -1249,7 +1248,7 @@ void EEMEngine::showLondonCharSelect() {
 	_chainStage = 1;
 	for (uint i = 0; i < sizeof(_mysteriesSolved); i++)
 		_mysteriesSolved[i] = 0;
-	debugC(1, kDebugGeneral, "London PoC: player='%s' partner=%s",
+	debugC(1, kDebugGeneral, "London: player='%s' partner=%s",
 		   _playerName.c_str(), partner == kPartnerJake ? "Jake" : "Jenny");
 }
 
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8fcb1abeec3..773e6a9532c 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -47,6 +47,14 @@ namespace EEM {
 class AudioPlayer;
 class MusicPlayer;
 
+/// VGA palette size in bytes (256 colours × RGB). Defined in eem.cpp.
+const uint kPalSize = 768;
+
+/// Palette fade helpers (defined in eem.cpp): ramp the VGA palette to / from
+/// black over 16 steps, matching `_FadeToBlack` / `_OpenFadeIn`.
+void fadeCurrentPaletteToBlack(uint delayMs = 8);
+void fadePaletteFromBlack(const byte *target, uint delayMs = 8);
+
 /// _ScreenDriver dispatch table @ 1a35:0e5e (fallback @ 1a35:0e54).
 /// 14 entries total: each is a screen ID + near fn ptr at +0x1c; driver
 /// tail-calls via `JMP word ptr CS:[BX + 0x1c]`. Handler bodies update
@@ -103,7 +111,7 @@ enum ScreenId {
 enum Variant {
 	kVariantCD       = 0,
 	kVariantFloppy   = 1,
-	kVariantLondonCD = 2, ///< Eagle Eye Mysteries in London (EEM2CD.EXE) — PoC.
+	kVariantLondonCD = 2, ///< Eagle Eye Mysteries in London (EEM2CD.EXE).
 };
 
 /// `_Partner @ 29be:7918`. Selected at the partner-pick screen
@@ -135,9 +143,9 @@ public:
 	Common::Platform getPlatform() const;
 	Variant getVariant() const { return _variant; }
 	bool isFloppy() const { return _variant == kVariantFloppy; }
-	/// EEM2 ("Eagle Eye Mysteries in London"). Proof-of-concept variant:
-	/// same DBD/palette/font formats, but a 63-entry "SITEPALS." file and
-	/// different opening picture/palette IDs.
+	/// EEM2 ("Eagle Eye Mysteries in London") — reimplementation in progress.
+	/// Shares the EEM1 DBD/palette/font formats, but ships a 63-entry
+	/// "SITEPALS." file and uses different picture/palette IDs per screen.
 	bool isLondon() const { return _variant == kVariantLondonCD; }
 
 	bool hasFeature(EngineFeature f) const override;
@@ -408,17 +416,24 @@ private:
 	void showHighScoreLogo();
 	void showFloppyStormLogo();
 
-	// --- EEM2 ("...in London") proof of concept ---
-	/// Full opening sequence + character-selection screen — EEM2's
-	/// `_DoOpeningAnims` @ 2721:08e6 then `_NewPlayer` @ 1cd3:0f27.
-	void runLondonScreensPoc();
+	// --- EEM2 ("Eagle Eye Mysteries in London") — reimplementation in progress ---
+	/// Opening sequence + character creation — EEM2's `_DoOpeningAnims`
+	/// @ 2721:08e6 then `_NewPlayer` @ 1cd3:0f27.
+	void runLondonStartup();
 	/// Blit a full-screen still PIC and fade it in / hold / out using the
 	/// given SITEPALS. palette index.
 	void showLondonLogo(uint picId, uint palId, uint holdMs);
-	/// Render EEM2's character-creation screen (`_NewPlayer`: palette 0 +
-	/// background PIC 0xc, where the player types a name and picks
-	/// Jake/Jenny). PoC display; interactive entry is still TODO.
+	/// EEM2 character creation (`_NewPlayer`: palette 0 + background PIC 0xc):
+	/// first/last name entry + Jake/Jenny selection.
 	void showLondonCharSelect();
+	/// EEM2 case-intro animation (`_DoInitClues` @ 1abf:03b3): single partner
+	/// anim (Jake 0x18 / Jenny 0x71) faded in, then phone1.voc on caseType 1.
+	void playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
+								 bool haveBriefingBg);
+	/// EEM1 CD/floppy case-intro animation (`_DoInitClues` @ 1a35:0411):
+	/// game/book/nancy cycle + `_PlayInSequence` partner entrance.
+	void playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
+								   const Picture &bg, bool haveBriefingBg);
 
 	/// `screen8_handler @ 1c33:1012`. Profile selector — walks
 	/// `listProfiles()`, falls through to `doNewPlayer()` if "New" or
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 0d6cb294edb..7a5d552084e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2118,11 +2118,11 @@ void EEMEngine::doCaseSelection() {
 
 	const uint mn = stageLo + selRow;
 	if (isLondon()) {
-		// EEM2 PoC: the menu (PIC 0x41, ANI 0x15/0x16, BOOK*.NME) is shared
-		// with EEM1, but EEM2's mystery data (M*.BIN/E*.BIN) isn't ported,
-		// so stop here instead of parsing it with the EEM1 loader.
+		// EEM2: the menu (PIC 0x41, ANI 0x15/0x16, BOOK*.NME) is shared with
+		// EEM1, but loading a chosen mystery from the menu isn't wired up yet
+		// (the training case is started directly from run()), so stop here.
 		debugC(1, kDebugMystery,
-			   "London PoC: selected mystery %u (load not implemented)", mn);
+			   "London: selected mystery %u (menu load not implemented yet)", mn);
 		return;
 	}
 	if (!_mystery.load(mn, &_rng)) {


Commit: 24d75f3cc78870ad0dc7372561fa5541e3fbccf8
    https://github.com/scummvm/scummvm/commit/24d75f3cc78870ad0dc7372561fa5541e3fbccf8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:22+02:00

Commit Message:
EEM: training case in London plays renders the first site

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index fc35a14407a..8a314cac743 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -61,7 +61,8 @@ const int kJakeY  = 0x62; // 98
 const int kJennyX = 0x42; // 66
 const int kJennyY = 0x60; // 96
 
-uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
+uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock,
+								  bool isLondon) {
 	if (!clueBlock)
 		return 0;
 
@@ -69,11 +70,15 @@ uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock) {
 	if (number == 0 || number > 32)
 		return 0;
 
+	// EEM2 entries are 84 bytes (0x54) with the notebook list at entry+0x40;
+	// EEM1 entries are 62 bytes with the list at entry+0x30. See displayClue.
+	const uint stride    = isLondon ? 0x54 : 62;
+	const uint noteOffat = isLondon ? 0x3c : 0x30;
 	uint marked = 0;
 	for (uint i = 0; i < number; i++) {
-		const byte *entry = clueBlock + 4 + i * 62;
+		const byte *entry = clueBlock + 4 + i * stride;
 		for (uint j = 0; j < 5; j++) {
-			const uint16 note = READ_LE_UINT16(entry + 0x30 + j * 2);
+			const uint16 note = READ_LE_UINT16(entry + noteOffat + j * 2);
 			if (note != 0xFFFF && note < Mystery::kCluesFoundCap &&
 				mystery._cluesFound[note] == 0) {
 				mystery._cluesFound[note] = 1;
@@ -611,7 +616,8 @@ void EEMEngine::doInitClues() {
 		const byte *briefingClues = ib + 4;
 		// _DisplayClue calls _AddNotebook for each ClueEntry note list at
 		// +0x30..+0x39. Mark starting notes before the first PDA visit.
-		const uint marked = markClueBlockNotebookEntries(_mystery, briefingClues);
+		const uint marked = markClueBlockNotebookEntries(_mystery, briefingClues,
+														 isLondon());
 		if (marked != 0)
 			debugC(1, kDebugScript,
 				   "doInitClues: marked %u CD briefing notebook entries",
@@ -717,6 +723,37 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 }
 
 void EEMEngine::applyClueSideEffects(const byte *c) {
+	if (isLondon()) {
+		// EEM2 `_DisplayClue @ 2542:05bd` per-entry side effects. With the
+		// shared `c = entryBase + 4` convention the EEM2 fields land at:
+		//   onsite  entry+0x22 (= c+0x1e), 5 × u16, high bit = CONSITE flag
+		//   offsite entry+0x2c (= c+0x28), 5 × u16, clears the site
+		//   notebook entry+0x40 (= c+0x3c), 5 × u16 -> _AddNotebook
+		// (EEM2 has no gallery list here — that region is the onsite array.)
+		for (uint j = 0; j < 5; j++) {
+			const uint16 note = READ_LE_UINT16(c + 0x3c + j * 2);
+			if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
+				_mystery._cluesFound[note] = 1;
+
+			const uint16 onIdx = READ_LE_UINT16(c + 0x1e + j * 2);
+			if (onIdx != 0xFFFF) {
+				const uint16 siteVal = onIdx & 0x7FFF;
+				if (siteVal < Mystery::kVisitedSiteCap)
+					_mystery._onSites[siteVal] = 1;
+				if (onIdx & 0x8000)
+					_mystery._sawCONSITEs = true;
+			}
+
+			const uint16 offIdx = READ_LE_UINT16(c + 0x28 + j * 2);
+			if (offIdx != 0xFFFF && (offIdx & 0x8000) == 0) {
+				const uint16 siteVal = offIdx & 0x7FFF;
+				if (siteVal < Mystery::kVisitedSiteCap)
+					_mystery._onSites[siteVal] = 0;
+			}
+		}
+		return;
+	}
+
 	for (uint j = 0; j < 5; j++) {
 		const uint16 note = READ_LE_UINT16(c + 0x30 + j * 2);
 		if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
@@ -754,6 +791,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	if (number == 0 || number > 32)
 		return;
 
+	// ClueEntry stride. EEM1 `_DisplayClue @ 2404:05e6` packs 62-byte entries;
+	// EEM2 `_DisplayClue @ 2542:05bd` indexes `theClue + i*0x54` (84 bytes). The
+	// per-entry fields up to +0x1e are layout-identical (so entry 0 — at the
+	// shared base clueBlock+4 — reads the same either way); only the stride and
+	// the tail fields (KD anim, notebook, onsite) move. See applyClueSideEffects.
+	const uint stride = isLondon() ? 0x54 : 62;
+
 	// Snapshot BG so per-entry character pics don't stack.
 	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -781,11 +825,12 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	// Partner 0 always uses field 0.
 	for (uint i = 0; i < number && !shouldQuit(); i++) {
 		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
-		const byte *c = clueBlock + 4 + i * 62;
+		const byte *c = clueBlock + 4 + i * stride;
 
 		// _DisplayClue @ 2404:0635-064b: _DoKDAnim(num) runs before the
-		// speaker portrait.
-		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + 0x3a);
+		// speaker portrait. EEM1 stores the KD-anim number at +0x3a; EEM2
+		// `_DisplayClue @ 2542:05bd` reads it at entry+0x52 (= c+0x4e).
+		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + (isLondon() ? 0x4e : 0x3a));
 		if (kdAnimNum != -1) {
 			playKdAnim((uint16)kdAnimNum);
 			// _UpdateAnimations @ 172b:09c1 reactivates the wait anim.
@@ -884,10 +929,17 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			_font.drawWordWrapped(&scratch, textX, textY,
 				MAX<int>(8, textW), text, 0);
 
-			g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
-				scratch.pitch, 0, copyY, kScreenWidth,
-				MIN<int>(copyH, kScreenHeight - copyY));
-			g_system->updateScreen();
+			// Clamp to the screen: a malformed entry (e.g. a clue-format
+			// mismatch) must never hand copyRectToScreen a negative height,
+			// which would underflow to a multi-GB memcpy.
+			copyY = CLIP<int>(copyY, 0, kScreenHeight - 1);
+			const int copyRows = CLIP<int>(MIN<int>(copyH, kScreenHeight - copyY),
+										   0, kScreenHeight - copyY);
+			if (copyRows > 0) {
+				g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
+					scratch.pitch, 0, copyY, kScreenWidth, copyRows);
+				g_system->updateScreen();
+			}
 		}
 
 		// _DisplayClue @ 2404:0833-085a — per-clue voice gate. The gate is on
@@ -948,7 +1000,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (skipAll) {
 				// Apply remaining side-effects without rendering.
 				for (uint k = i; k < number; k++)
-					applyClueSideEffects(clueBlock + 4 + k * 62);
+					applyClueSideEffects(clueBlock + 4 + k * stride);
 				return;
 			}
 		}


Commit: ea2453f9bc1643313f59c51435ace96f597d70df
    https://github.com/scummvm/scummvm/commit/ea2453f9bc1643313f59c51435ace96f597d70df
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:22+02:00

Commit Message:
EEM: improved animation in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 8a314cac743..fcfe31c2f39 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -295,15 +295,23 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
 
+	// Drive the cells through the animation script (`_NewAnimation` prior
+	// 0x18 -> `_AnimationSequences[0x18]`), not a raw 0..N-1 sweep — the
+	// frame shown each tick is `script[tick]`, not `tick`. For EEM2 that
+	// script is the count-up {0..16}, but routing through `partnerFrameAtTick`
+	// keeps this faithful if the cells/script ever diverge and matches the map/
+	// site partner rendering. One pass = one script cell per ~140 ms tick.
 	bool skip = false;
 	const uint frames = haveAnim ? (uint)anim.size() : 1;
 	for (uint frame = 0; frame < frames && !shouldQuit() && !skip; frame++) {
 		if (haveBriefingBg)
 			blitAt(bg, 0, 0);
 		if (haveAnim) {
+			const uint cell =
+				partnerFrameAtTick(0x18, (uint)anim.size(), frame * 140);
 			Graphics::Surface *scr = g_system->lockScreen();
 			if (scr) {
-				blitAnimFrameAnchored(scr, anim[frame], kAnchorX, kAnchorY);
+				blitAnimFrameAnchored(scr, anim[cell], kAnchorX, kAnchorY);
 				g_system->unlockScreen();
 			}
 		}
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 9b78267ec4c..57975e48bea 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -165,10 +165,13 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 				Common::String(gameDesc->extra).contains("Floppy"))
 				 ? kVariantFloppy : kVariantCD;
 	// EEM2 ("...in London") ships as a separate detection entry (gameId
-	// "eem2"); it reuses this engine but only the opening screens work.
+	// "eem2"); it reuses this engine with London-specific data.
 	if (gameDesc && gameDesc->gameId &&
 		Common::String(gameDesc->gameId) == "eem2")
 		_variant = kVariantLondonCD;
+	// EEM2 ships its own `_AnimationSequences` — many partner/KD scripts
+	// differ from EEM1's, so route `findAnimScript` to the EEM2 table.
+	setLondonAnimScripts(isLondon());
 	_language = gameDesc ? gameDesc->language : Common::EN_ANY;
 }
 
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 90a2bc011de..466c9e13427 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -509,11 +509,67 @@ const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 // behavior but makes the feature observable during normal testing.
 const uint32 kImpatienceDelayMs = 60 * 1000;
 
+// EEM2 ("Eagle Eye Mysteries in London") animation scripts. EEM2 ships its own
+// `_AnimationSequences @ 2bca:2e2e` (read from EEM2CD.EXE) and MANY partner/KD
+// scripts differ from EEM1's — e.g. 0x18 is a plain count-up 0..16 in EEM2 but
+// "0..8, hold, 9..15" in EEM1 (`kScript18`); 0x14 is 0..10 vs 0..8; the PDA /
+// gesture / gallery scripts (0x01/0x03/0x04/0x05/0x06/0x0b/0x0c/0x0d/0x0e) are
+// all different lengths. Using EEM1's scripts on EEM2's cells plays the wrong
+// frame sequence (visible corruption). These override the EEM1 tables when the
+// London variant is active; any seqnum not listed falls through to the shared
+// EEM1 scripts below. Only the scripts that actually differ are listed.
+const uint8 kScript06London[] = {
+	0,1,2,3,4,5,6,7,8,9,10,11,11,11,5,6,7,8,9,10,11,11,11,
+	5,6,7,8,9,10,11,11,11,5,4,3,2,1,0
+};
+const AnimScript kAnimScriptsLondon[] = {
+	{ 0x01,  6, { 0,1,2,3,4,5 } },                                  // PDA idle
+	{ 0x03, 15, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14 } },           // gesture
+	{ 0x04, 23, { 0,1,2,3,4,5,5,5,6,5,5,5,5,6,5,5,5,5,4,3,2,1,0 } },// big gesture
+	{ 0x05, 11, { 0,1,2,3,4,4,4,3,2,1,0 } },
+	{ 0x0b,  6, { 0,1,2,3,4,5 } },                                  // Jenny PDA
+	{ 0x0c, 15, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14 } },           // Jenny gesture
+	{ 0x0d, 23, { 0,1,2,3,4,5,5,5,6,5,5,5,5,6,5,5,5,5,4,3,2,1,0 } },
+	{ 0x0e, 22, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,20 } },
+	{ 0x14, 11, { 0,1,2,3,4,5,6,7,8,9,10 } },                       // BigMap walk
+	{ 0x18, 17, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 } },     // case-intro entrance
+	{ 0x19,  2, { 0,1 } },
+	{ 0x1a,  4, { 0,1,2,3 } },
+};
+const AnimScriptLong kAnimScriptsLondonLong[] = {
+	{ 0x06, 38, kScript06London },
+};
+
+// Set true for the London variant so findAnimScript uses the EEM2 tables.
+bool g_londonAnimScripts = false;
+
+void setLondonAnimScripts(bool enabled) {
+	g_londonAnimScripts = enabled;
+}
+
 struct AnimScriptRef {
 	const uint8 *frames;
 	uint16 len;
 };
 AnimScriptRef findAnimScript(uint16 seqnum) {
+	if (g_londonAnimScripts) {
+		for (uint i = 0; i < ARRAYSIZE(kAnimScriptsLondon); i++) {
+			if (kAnimScriptsLondon[i].seqnum == seqnum) {
+				AnimScriptRef r;
+				r.frames = kAnimScriptsLondon[i].frames;
+				r.len = kAnimScriptsLondon[i].len;
+				return r;
+			}
+		}
+		for (uint i = 0; i < ARRAYSIZE(kAnimScriptsLondonLong); i++) {
+			if (kAnimScriptsLondonLong[i].seqnum == seqnum) {
+				AnimScriptRef r;
+				r.frames = kAnimScriptsLondonLong[i].frames;
+				r.len = kAnimScriptsLondonLong[i].len;
+				return r;
+			}
+		}
+	}
 	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++) {
 		if (kAnimScripts[i].seqnum == seqnum) {
 			AnimScriptRef r;
diff --git a/engines/eem/site.h b/engines/eem/site.h
index cc1b798e453..9f71863586e 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -43,6 +43,11 @@ class Mystery;
 /// Mirrors the looping path of `_UpdateAnimations @ 172b:09c1`.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
+/// Select the EEM2 ("London") animation-script table inside `findAnimScript`.
+/// EEM2 ships its own `_AnimationSequences`; many partner/KD scripts differ
+/// from EEM1's, so the engine must use the EEM2 sequences for that variant.
+void setLondonAnimScripts(bool enabled);
+
 /// bigMapPartnerFrameAtTick: count-up 0..8 once, then loop `_BigMapWaitSeq`
 /// (9,9,9,9,10,9,9,9,9). Mirrors `_DoBigMap @ 20fe:09e7` two-phase swap from
 /// script 0x14 (count-up @ 29be:196a) to `_BigMapWaitSeq @ 29be:1574`.


Commit: dd0a8761aaaf881825137ea7e1f6432d634db178
    https://github.com/scummvm/scummvm/commit/dd0a8761aaaf881825137ea7e1f6432d634db178
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:23+02:00

Commit Message:
EEM: added more animation from London

Changed paths:
    engines/eem/site.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 466c9e13427..0404614f1fd 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -178,6 +178,20 @@ const uint16 kKdAnimTable[6][6] = {
 	{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
 };
 
+// EEM2 `_DoKDAnim @ 1717:05bf` table @ 2bca:0238 (read from EEM2CD.EXE).
+// Same { animJake, animJenny, xJake, xJenny, yJake, yJenny } layout. Positions
+// differ (x≈2, y≈78 vs EEM1's 6/80) and Jenny's reactions 4/5 use distinct
+// anims 0x55/0x2d (EEM1 reused 0x05/0x06 for both partners). Selected by
+// `playKdAnim` when the London variant is active.
+const uint16 kKdAnimTableLondon[6][6] = {
+	{ 0x03, 0x0c, 3, 2, 66, 65 }, // 0
+	{ 0x01, 0x0b, 2, 2, 78, 78 }, // 1
+	{ 0x04, 0x0d, 2, 2, 78, 78 }, // 2
+	{ 0x02, 0x10, 2, 2, 78, 78 }, // 3
+	{ 0x05, 0x55, 2, 2, 78, 78 }, // 4 — Jenny uses 0x55
+	{ 0x06, 0x2d, 2, 2, 78, 78 }, // 5 — Jenny uses 0x2d
+};
+
 // Animation script table — mirrors `_AnimationSequences @ 29be:22d4`
 // (55-entry table of far ptrs, each pointing to a u16-frame-index
 // stream). `_NewAnimation @ 172b:06e1` reads the script via
@@ -510,17 +524,62 @@ const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 const uint32 kImpatienceDelayMs = 60 * 1000;
 
 // EEM2 ("Eagle Eye Mysteries in London") animation scripts. EEM2 ships its own
-// `_AnimationSequences @ 2bca:2e2e` (read from EEM2CD.EXE) and MANY partner/KD
-// scripts differ from EEM1's — e.g. 0x18 is a plain count-up 0..16 in EEM2 but
-// "0..8, hold, 9..15" in EEM1 (`kScript18`); 0x14 is 0..10 vs 0..8; the PDA /
-// gesture / gallery scripts (0x01/0x03/0x04/0x05/0x06/0x0b/0x0c/0x0d/0x0e) are
-// all different lengths. Using EEM1's scripts on EEM2's cells plays the wrong
+// `_AnimationSequences @ 2bca:2e2e` (read from EEM2CD.EXE); essentially every
+// non-trivial partner / KD / site script differs from EEM1's — e.g. 0x18 is a
+// plain count-up 0..16 in EEM2 vs "0..8, hold, 9..15" in EEM1 (`kScript18`),
+// 0x14 is 0..10 vs 0..8. Using EEM1's scripts on EEM2's cells plays the wrong
 // frame sequence (visible corruption). These override the EEM1 tables when the
 // London variant is active; any seqnum not listed falls through to the shared
-// EEM1 scripts below. Only the scripts that actually differ are listed.
+// EEM1 scripts below. Only the seqnums that actually differ are listed.
+//
+// NOTE: EEM2 scripts 0x27/0x2e/0x30 end with a `0x81 N` jump (loop back to
+// entry N) rather than a 0x80 restart. `frameFromScriptAtTick` has no jump
+// support (EEM1 never used it), so the flat frame list is stored: correct for a
+// one-shot play-through, but a looped play replays the intro instead of just
+// the post-jump tail. Acceptable for these site NPC fidgets; revisit if needed.
 const uint8 kScript06London[] = {
-	0,1,2,3,4,5,6,7,8,9,10,11,11,11,5,6,7,8,9,10,11,11,11,
-	5,6,7,8,9,10,11,11,11,5,4,3,2,1,0
+	0,1,2,3,4,5,6,7,8,9,10,11,11,11,5,6,7,8,9,10,
+	11,11,11,5,6,7,8,9,10,11,11,11,5,4,3,2,1,0,
+};
+const uint8 kScript1dLondon[] = {
+	0,1,0,1,1,0,0,2,3,4,5,6,7,8,9,1,1,1,1,0,
+	0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
+	2,2,2,2,2,2,
+};
+const uint8 kScript1eLondon[] = {
+	0,1,0,1,0,1,1,0,0,2,2,2,2,3,3,4,4,5,6,6,
+	4,3,2,1,0,1,0,1,0,1,1,0,0,
+};
+const uint8 kScript1fLondon[] = {
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+	0,1,1,1,1,2,1,3,3,4,5,6,5,6,4,1,1,1,
+};
+const uint8 kScript23London[] = {
+	0,0,0,0,1,1,2,2,3,3,3,4,4,3,3,3,4,4,1,0,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+};
+const uint8 kScript25London[] = {
+	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
+	19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,
+	19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,19,
+};
+const uint8 kScript27London[] = {  // 0x81 jump->22
+	0,0,0,0,0,0,0,0,0,0,1,0,1,2,3,2,4,5,6,6,
+	7,6,8,6,7,7,7,7,8,
+};
+const uint8 kScript2bLondon[] = {
+	0,1,0,1,1,1,0,1,2,2,2,2,3,4,3,4,3,2,1,0,
+	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+};
+const uint8 kScript2dLondon[] = {
+	0,1,2,3,4,5,6,7,8,9,10,11,11,11,5,6,7,8,9,10,
+	11,11,11,5,6,7,8,9,10,11,11,11,5,4,3,2,1,0,
+};
+const uint8 kScript31London[] = {
+	32,32,32,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,
+	17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,
+	37,38,39,40,41,42,43,44,45,46,32,32,32,32,32,32,32,32,32,32,
+	32,32,
 };
 const AnimScript kAnimScriptsLondon[] = {
 	{ 0x01,  6, { 0,1,2,3,4,5 } },                                  // PDA idle
@@ -535,9 +594,38 @@ const AnimScript kAnimScriptsLondon[] = {
 	{ 0x18, 17, { 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 } },     // case-intro entrance
 	{ 0x19,  2, { 0,1 } },
 	{ 0x1a,  4, { 0,1,2,3 } },
+	{ 0x1b, 12, { 0,0,1,1,2,2,3,3,4,4,5,5 } },
+	{ 0x1c, 19, { 11,11,11,0,1,2,3,4,5,6,7,8,9,10,11,11,11,11,11 } },
+	{ 0x20, 19, { 0,1,1,2,3,3,0,0,1,1,1,0,3,2,0,1,4,4,4 } },
+	{ 0x21,  5, { 0,0,1,1,2 } },
+	{ 0x22, 10, { 0,0,0,1,0,1,0,1,1,1 } },
+	{ 0x24, 18, { 0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 } },
+	{ 0x26, 20, { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,2,1,3 } },
+	{ 0x28, 16, { 0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7 } },
+	{ 0x29, 14, { 0,1,2,3,4,4,3,4,3,4,3,2,1,0 } },
+	{ 0x2a, 23, { 0,0,0,1,2,3,2,1,0,0,0,0,1,2,3,2,1,0,1,2,3,2,1 } },
+	{ 0x2c, 22, { 15,15,15,15,15,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 } },
+	{ 0x2e, 16, { 0,0,0,1,2,3,4,5,6,7,8,9,10,11,12,12 } },          // 0x81 jump->15
+	{ 0x2f,  6, { 0,1,2,3,4,5 } },
+	{ 0x30, 26, { 18,18,18,18,18,18,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,18 } }, // 0x81 jump->25
+	{ 0x32, 27, { 0,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,1 } },
+	{ 0x33, 27, { 4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,1,2,3,4,4,4,0,1,2,3 } },
+	{ 0x34,  4, { 0,1,2,3 } },
+	{ 0x35, 21, { 9,9,9,9,9,9,9,9,9,9,9,9,0,1,2,3,4,5,6,7,8 } },
+	{ 0x36, 12, { 0,0,0,0,0,1,2,3,4,5,6,6 } },
+	{ 0x55, 11, { 0,1,2,3,4,4,4,3,2,1,0 } },                        // KD Jenny reaction-4
 };
 const AnimScriptLong kAnimScriptsLondonLong[] = {
 	{ 0x06, 38, kScript06London },
+	{ 0x1d, 46, kScript1dLondon },
+	{ 0x1e, 33, kScript1eLondon },
+	{ 0x1f, 38, kScript1fLondon },
+	{ 0x23, 39, kScript23London },
+	{ 0x25, 59, kScript25London },
+	{ 0x27, 29, kScript27London },
+	{ 0x2b, 39, kScript2bLondon },
+	{ 0x2d, 38, kScript2dLondon },
+	{ 0x31, 62, kScript31London },
 };
 
 // Set true for the London variant so findAnimScript uses the EEM2 tables.
@@ -1805,14 +1893,16 @@ void EEMEngine::playKdAnim(uint16 num) {
 	// (blocking) and resume normal idle rendering when the caller
 	// returns — matches the visible effect (partner gesture finishes
 	// before the speaker portrait + speech balloon appear).
-	// `kKdAnimTable` and `kAnimScripts` live at file scope above.
+	// `kKdAnimTable` and `kAnimScripts` live at file scope above. EEM2 ships a
+	// different table (positions + Jenny reactions) — use it for London.
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
 
+	const uint16 (*kdTable)[6] = isLondon() ? kKdAnimTableLondon : kKdAnimTable;
 	const uint partner = (_partner == kPartnerJake) ? 0 : 1;
-	const uint16 animId = kKdAnimTable[num][partner];
-	const int    px     = (int)kKdAnimTable[num][2 + partner];
-	const int    py     = (int)kKdAnimTable[num][4 + partner];
+	const uint16 animId = kdTable[num][partner];
+	const int    px     = (int)kdTable[num][2 + partner];
+	const int    py     = (int)kdTable[num][4 + partner];
 
 	Animation anim;
 	if (!_aniArchive.loadAnimation(animId, anim) || anim.empty()) {


Commit: a5d0ea9233de74aed7ed1565873ce88de247609f
    https://github.com/scummvm/scummvm/commit/a5d0ea9233de74aed7ed1565873ce88de247609f
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:23+02:00

Commit Message:
EEM: mouse cursor change from London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 57975e48bea..5207a68524e 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -59,6 +59,15 @@ const uint kPalHighScore       = 0x27;
 const uint kPalStormLogo       = 0x26;  // Floppy FUN_23d2_0605
 const uint kPicMousePointer    = 0x50;  // 0x51 is the wait cursor
 
+// EEM2 cursor table — `_main @ 1abf:0faf` loads seven cursor PICs into
+// `_AnimationObjects`-adjacent slots and `_SwitchMouse @ 17ee:2c83` activates
+// one by index. Order matches the slot order: 0 arrow, 1 wait, 2/3 examine,
+// 4 Jake hand, 5 Jenny hand, 6 approach. Indexed by the site search-record
+// cursor id (row +0xc).
+const uint16 kLondonCursorPics[7] = {
+	0x50, 0x51, 0x206, 0xa1, 0x207, 0x20b, 0x35e
+};
+
 const byte kSaveBodyVer = 1;
 
 // Test switch: populate ScrapBook 1 at startup without exposing a game
@@ -634,9 +643,44 @@ void EEMEngine::setInteractiveMouseCursor(bool active) {
 }
 
 void EEMEngine::setHotspotMouseCursor(bool active) {
+	// EEM2 swaps the cursor SHAPE per hotspot (see setSiteHotspotCursorId), so
+	// the EEM1 red-outline recolor doesn't apply. The bool path only resets to
+	// the default arrow (cursor 0) when leaving a hotspot / the site loop.
+	if (isLondon()) {
+		if (!active)
+			setSiteHotspotCursorId(0);
+		return;
+	}
 	setInteractiveMouseCursor(active);
 }
 
+void EEMEngine::setSiteHotspotCursorId(int cursorId) {
+	if (!isLondon())
+		return;
+	// `_SwitchMouse @ 17ee:2c83`: cursor 4 (Jake hand) becomes 5 for Jenny.
+	if (cursorId == 4 && _partner == kPartnerJenny)
+		cursorId = 5;
+	if (cursorId < 0 || cursorId >= (int)ARRAYSIZE(kLondonCursorPics))
+		cursorId = 0;
+	if (cursorId == _siteCursorId)
+		return;
+
+	Picture cursor;
+	if (!_picsArchive.getPicture(kLondonCursorPics[cursorId], cursor) ||
+		cursor.surface.empty()) {
+		warning("EEM2: cursor %d (PIC 0x%x) missing", cursorId,
+				kLondonCursorPics[cursorId]);
+		return;
+	}
+	// Each cursor carries its own transparent colour (flags >> 8) and uses the
+	// active screen palette (no separate cursor palette, unlike the EEM1
+	// red-outline highlight). Hotspot is the top-left (rowoff/misc are 0).
+	const byte transparent = (byte)(cursor.flags >> 8);
+	CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0, transparent);
+	CursorMan.replaceCursorPalette(nullptr, 0, 0);
+	_siteCursorId = cursorId;
+}
+
 bool EEMEngine::openArchives() {
 	// _InitGraphicsSystem @ 172b:0145.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 773e6a9532c..bb504769b3d 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -198,6 +198,12 @@ public:
 	/// Interactive cursor over searchable hotspots.
 	void setHotspotMouseCursor(bool active);
 
+	/// EEM2 `_SwitchMouse @ 17ee:2c83`: swap the cursor SHAPE to one of the
+	/// seven loaded cursors by ID (0 arrow, 1 wait, 2/3 examine, 4/5 partner
+	/// hand, 6 approach). London site hotspots carry a cursor ID at row +0xc;
+	/// EEM1 cursors are all 0 so this is a no-op there.
+	void setSiteHotspotCursorId(int cursorId);
+
 	/// `_DisplayClue @ 2404:05e6`. @p clueBlock points at the u16 frame
 	/// count followed by 62-byte ClueEntries.
 	void displayClue(const byte *clueBlock);
@@ -563,6 +569,9 @@ private:
 	Graphics::ManagedSurface _partnerEraseBg;
 
 	bool _interactiveMouseCursor = false;
+	/// Active EEM2 cursor shape (index into `kLondonCursorPics`). -1 forces a
+	/// reload on the next `setSiteHotspotCursorId`.
+	int _siteCursorId = 0;
 
 	/// Site whose entrance animation has already played this mystery.
 	/// Lives on the engine because PDA/gallery destroys+recreates SiteScreen.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0404614f1fd..7598fad3163 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1777,13 +1777,31 @@ int SiteScreen::hotspotAtPoint(uint siteNum, int x, int y) const {
 	return -1;
 }
 
+// CD hotspot row +0xc..d: cursor id for `_SwitchMouse` (EEM1 ships 0; EEM2
+// uses 2/3 examine, etc.). Floppy rows are 8-byte rects with no cursor field.
+int SiteScreen::hotspotCursorId(uint siteNum, int idx) const {
+	if (idx < 0 || (_vm && _vm->isFloppy()))
+		return 0;
+	const byte *spots = _mystery->hotspots(siteNum);
+	if (!spots || (uint)idx >= _mystery->hotspotCount(siteNum))
+		return 0;
+	return (int)READ_LE_UINT16(spots + idx * 14 + 0xc);
+}
+
 void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 	if (!_vm)
 		return;
+	const int idx = hotspotAtPoint(siteNum, x, y);
+	if (_vm->isLondon()) {
+		// EEM2 (`_DoSiteLoop` @ FUN_1717_07ab) swaps the cursor shape to the
+		// hovered hotspot's own cursor id; off any hotspot it is the arrow (0).
+		_vm->setSiteHotspotCursorId(idx >= 0 ? hotspotCursorId(siteNum, idx) : 0);
+		return;
+	}
 	const bool siteControl = kPdaSiteRect.contains(x, y) ||
 							 kPdaPartnerFootMapRect.contains(x, y) ||
 							 kPdaPartnerHeadHintRect.contains(x, y);
-	_vm->setHotspotMouseCursor(siteControl || hotspotAtPoint(siteNum, x, y) >= 0);
+	_vm->setHotspotMouseCursor(siteControl || idx >= 0);
 }
 
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 9f71863586e..78513dd4d4c 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -111,6 +111,8 @@ private:
 	void renderBackground(uint siteNum);
 	void renderHotspots(uint siteNum);
 	int  hotspotAtPoint(uint siteNum, int x, int y) const;
+	/// EEM2 cursor id at CD hotspot row +0xc (0 for floppy / EEM1).
+	int  hotspotCursorId(uint siteNum, int idx) const;
 	void updateHotspotCursor(uint siteNum, int x, int y);
 	void onHotspotClicked(uint siteNum, uint hotIdx);
 	void initImpatienceCounter();


Commit: 27f48984c620e5747ff055ece33e730ebf5f57c9
    https://github.com/scummvm/scummvm/commit/27f48984c620e5747ff055ece33e730ebf5f57c9
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:23+02:00

Commit Message:
EEM: jump effect from applyClue in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index fcfe31c2f39..3fccb8ed644 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -737,6 +737,7 @@ void EEMEngine::applyClueSideEffects(const byte *c) {
 		//   onsite  entry+0x22 (= c+0x1e), 5 × u16, high bit = CONSITE flag
 		//   offsite entry+0x2c (= c+0x28), 5 × u16, clears the site
 		//   notebook entry+0x40 (= c+0x3c), 5 × u16 -> _AddNotebook
+		//   jump    entry+0x4a (= c+0x46), destination site for direct travel
 		// (EEM2 has no gallery list here — that region is the onsite array.)
 		for (uint j = 0; j < 5; j++) {
 			const uint16 note = READ_LE_UINT16(c + 0x3c + j * 2);
@@ -759,6 +760,9 @@ void EEMEngine::applyClueSideEffects(const byte *c) {
 					_mystery._onSites[siteVal] = 0;
 			}
 		}
+		const uint16 jumpSite = READ_LE_UINT16(c + 0x46);
+		if (jumpSite != 0xFFFF)
+			_mystery._pendingSiteJump = jumpSite;
 		return;
 	}
 
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 06892c0735a..bb87dbc9939 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -64,6 +64,9 @@ void Mystery::clear() {
 	_firstTry = true;
 	_searchLocationNumber = _siteNumber = 0xFFFF;
 	_lastSite = 0x1B;
+	_pendingSiteJump = 0;
+	_siteReturnDepth = 0;
+	memset(_siteReturnStack, 0, sizeof(_siteReturnStack));
 }
 
 bool Mystery::load(uint num, Common::RandomSource *rng) {
@@ -154,6 +157,9 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_firstTry = true;
 		_searchLocationNumber = _siteNumber = 0xFFFF;
 		_lastSite = 0x1B;
+		_pendingSiteJump = 0;
+		_siteReturnDepth = 0;
+		memset(_siteReturnStack, 0, sizeof(_siteReturnStack));
 		loadFloppySiteAnimData();
 
 		debugC(1, kDebugMystery,
@@ -213,6 +219,9 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_firstTry = true;
 	_searchLocationNumber = _siteNumber = 0xFFFF;
 	_lastSite = 0x1B; // _ReadMystery _LastSite sentinel.
+	_pendingSiteJump = 0;
+	_siteReturnDepth = 0;
+	memset(_siteReturnStack, 0, sizeof(_siteReturnStack));
 
 	debugC(1, kDebugMystery, "Loaded %s (%d B): %u sites, %u suspects, "
 		   "CON=%u COFF=%u, init=0x%04x site=0x%04x text=0x%04x",
@@ -606,6 +615,13 @@ void Mystery::syncState(Common::Serializer &s) {
 	s.syncAsUint16LE(_searchLocationNumber);
 	s.syncAsUint16LE(_siteNumber);
 	s.syncAsUint16LE(_lastSite);
+	if (s.isLoading())
+		_pendingSiteJump = 0;
+	s.syncAsUint16LE(_siteReturnDepth);
+	s.syncArray(_siteReturnStack, kVisitedSiteCap,
+				Common::Serializer::Uint16LE);
+	if (_siteReturnDepth > kVisitedSiteCap)
+		_siteReturnDepth = 0;
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 07545cf6e23..7f3d16589c3 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -172,6 +172,9 @@ public:
 	uint16 _searchLocationNumber = 0xFFFF;
 	uint16 _siteNumber           = 0xFFFF;
 	uint16 _lastSite             = 0xFFFF;
+	uint16 _pendingSiteJump      = 0;      ///< EEM2 _DisplayClue destination site (DAT_2bca_0282).
+	uint16 _siteReturnDepth      = 0;      ///< EEM2 nested site return stack depth (DAT_2bca_0280).
+	uint16 _siteReturnStack[kVisitedSiteCap] = {};
 
 private:
 	Common::Array<byte> _data;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 7598fad3163..dfe94eb3a02 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1028,6 +1028,9 @@ void SiteScreen::run() {
 	uint cur = _mystery->_siteNumber;
 	if (cur >= _mystery->numSites())
 		cur = 0;
+	_mystery->_pendingSiteJump = 0;
+	if (_mystery->_siteReturnDepth > Mystery::kVisitedSiteCap)
+		_mystery->_siteReturnDepth = 0;
 	_snapshotSite = -1;
 	SiteBackendActionObserverRegistration backendActionRegistration(this);
 	enter(cur);
@@ -1067,6 +1070,25 @@ void SiteScreen::run() {
 				if (kPdaPartnerFootMapRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
+					if (_vm->isLondon() && _mystery->_siteReturnDepth != 0) {
+						const uint16 depth = --_mystery->_siteReturnDepth;
+						const uint16 returnSite = _mystery->_siteReturnStack[depth];
+						_mystery->_siteReturnStack[depth] = 0;
+						if (returnSite < _mystery->numSites()) {
+							debugC(1, kDebugSite,
+								   "London: returning from site %u to site %u",
+								   cur, returnSite);
+							_mystery->_lastSite = (uint16)cur;
+							_mystery->_siteNumber = returnSite;
+							cur = returnSite;
+							enter(cur);
+							mouse = g_system->getEventManager()->getMousePos();
+							updateHotspotCursor(cur, mouse.x, mouse.y);
+							break;
+						}
+						warning("London site return target %u out of range",
+								returnSite);
+					}
 					// CD: _NextScreen=1, floppy=2.
 					_vm->setNextScreen(_vm->isFloppy() ? kScreenMapAlt
 													   : kScreenMap);
@@ -1086,9 +1108,35 @@ void SiteScreen::run() {
 				const int idx = hotspotAtPoint(cur, event.mouse.x, event.mouse.y);
 				if (idx >= 0) {
 					_vm->setHotspotMouseCursor(false);
+					_mystery->_pendingSiteJump = 0;
 					onHotspotClicked(cur, (uint)idx);
 					notePartnerActivity();
-					enter(cur, false);
+					const uint16 jumpSite = _mystery->_pendingSiteJump;
+					_mystery->_pendingSiteJump = 0;
+					if (_vm->isLondon() && jumpSite != 0) {
+						if (jumpSite < _mystery->numSites()) {
+							if (_mystery->_siteReturnDepth < Mystery::kVisitedSiteCap) {
+								_mystery->_siteReturnStack[_mystery->_siteReturnDepth++] =
+									(uint16)cur;
+							} else {
+								warning("London site return stack full; "
+										"jumping without return site");
+							}
+							debugC(1, kDebugSite,
+								   "London: hotspot jump from site %u to site %u",
+								   cur, jumpSite);
+							_mystery->_lastSite = (uint16)cur;
+							_mystery->_siteNumber = jumpSite;
+							cur = jumpSite;
+							enter(cur);
+						} else {
+							warning("London hotspot jump target %u out of range",
+									jumpSite);
+							enter(cur, false);
+						}
+					} else {
+						enter(cur, false);
+					}
 					// Use CURRENT pointer position (click pos still in rect).
 					mouse = g_system->getEventManager()->getMousePos();
 					updateHotspotCursor(cur, mouse.x, mouse.y);
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7a5d552084e..048bc040fea 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2954,6 +2954,12 @@ void EEMEngine::doBigMap() {
 	if (!_mystery.isLoaded())
 		return;
 
+	if (isLondon()) {
+		_mystery._pendingSiteJump = 0;
+		_mystery._siteReturnDepth = 0;
+		memset(_mystery._siteReturnStack, 0, sizeof(_mystery._siteReturnStack));
+	}
+
 	CursorMan.showMouse(true);
 
 	while (!shouldQuit()) {


Commit: 17cf0205f9b70d574feff551544faa77b30236fc
    https://github.com/scummvm/scummvm/commit/17cf0205f9b70d574feff551544faa77b30236fc
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:24+02:00

Commit Message:
EEM: correct parner handling in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 3fccb8ed644..e75fdbda8ff 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -865,9 +865,12 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		// ClueBlock +2; entries N>0 read (entry-1)+0x3c (last word).
 		const uint16 charX  = READ_LE_UINT16(c + (useP1 ? 4 : 0));
 		const uint16 charY  = READ_LE_UINT16(c + (useP1 ? 6 : 2));
-		const uint16 charPicId = (i == 0)
+		uint16 charPicId = (i == 0)
 			? READ_LE_UINT16(clueBlock + 2)
 			: READ_LE_UINT16(c - 2);
+		if (isLondon() && charPicId == 0x13e &&
+			_partner == kPartnerJake)
+			charPicId = 0x13f;
 		if (charPicId != 0 && charPicId != 0xFFFF) {
 			Picture charPic;
 			if (_picsArchive.getPicture(charPicId, charPic) &&
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index dfe94eb3a02..0aa84dc739b 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -192,6 +192,18 @@ const uint16 kKdAnimTableLondon[6][6] = {
 	{ 0x06, 0x2d, 2, 2, 78, 78 }, // 5 — Jenny uses 0x2d
 };
 
+// EEM2 `_WaitAnims @ 2bca:022c`. Entry 0 precedes `_DoKDAnim`'s
+// table; entries 1..6 overlap `kKdAnimTableLondon`, same as EEM1.
+const uint16 kWaitAnimsLondon[7][6] = {
+	{ 0x00, 0x0a, 2, 2, 78, 78 }, // 0
+	{ 0x03, 0x0c, 3, 2, 66, 65 }, // 1
+	{ 0x01, 0x0b, 2, 2, 78, 78 }, // 2
+	{ 0x04, 0x0d, 2, 2, 78, 78 }, // 3
+	{ 0x02, 0x10, 2, 2, 78, 78 }, // 4
+	{ 0x05, 0x55, 2, 2, 78, 78 }, // 5
+	{ 0x06, 0x2d, 2, 2, 78, 78 }, // 6
+};
+
 // Animation script table — mirrors `_AnimationSequences @ 29be:22d4`
 // (55-entry table of far ptrs, each pointing to a u16-frame-index
 // stream). `_NewAnimation @ 172b:06e1` reads the script via
@@ -1215,6 +1227,39 @@ bool SiteScreen::enterSiteAnim() {
 	bg.simpleBlitFrom(*screen);
 	g_system->unlockScreen();
 
+	if (_vm->isLondon()) {
+		// EEM2 `_EnterSiteAnim @ 17ee:27a4`: no skateboard phase.
+		// It plays the partner-specific slide-in animation at anchor
+		// (0, 0x50/0x4e), using `_CheckFrameRate` between cells.
+		const uint animId = (partner == 0) ? 7 : 0xf;
+		const int anchorY = (partner == 0) ? 0x50 : 0x4e;
+		Animation anim;
+		if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
+			return false;
+		for (uint frameIdx = 0;
+			 frameIdx < anim.size() && !_vm->shouldQuit();
+			 frameIdx++) {
+			Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+				Graphics::PixelFormat::createFormatCLUT8());
+			scratch.simpleBlitFrom(bg);
+			blitAnimFrameAnchored(scratch.surfacePtr(), anim[frameIdx],
+								  0, anchorY);
+			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+									   0, 0, kScreenWidth, kScreenHeight);
+			g_system->updateScreen();
+
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_KEYDOWN ||
+					ev.type == Common::EVENT_LBUTTONDOWN) {
+					return true;
+				}
+			}
+			g_system->delayMillis(kFramePeriodMs);
+		}
+		return false;
+	}
+
 	// Phase 1 — skateboard scroll.
 	Animation skate;
 	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
@@ -1312,9 +1357,12 @@ void SiteScreen::renderStaticDrops(uint siteNum) {
 
 	for (uint i = 0; i < numStatic; i++) {
 		const uint dropOff = 0xc + i * 6;
-		const uint16 picId = READ_LE_UINT16(site + dropOff + 0);
+		uint16 picId = READ_LE_UINT16(site + dropOff + 0);
 		const int16  x     = (int16)READ_LE_UINT16(site + dropOff + 2);
 		const int16  y     = (int16)READ_LE_UINT16(site + dropOff + 4);
+		if (_vm->isLondon() && picId == 0x140 &&
+			_vm->getPartnerIndex() == kPartnerJake)
+			picId = 0x141;
 		if (picId == 0)
 			continue;
 		Picture pic;
@@ -1562,14 +1610,16 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 		}
 	} else {
 		const uint16 speaker = READ_LE_UINT16(site + 8);
+		const uint16 (*waitTable)[6] = _vm->isLondon()
+			? kWaitAnimsLondon : kWaitAnims;
 		if (speaker >= ARRAYSIZE(kWaitAnims)) {
 			warning("renderPartner: site %u has speakerIdx=%u out of range",
 					siteNum, speaker);
 			return;
 		}
-		animId = kWaitAnims[speaker][0 + partner];
-		x      = (int)(int16)kWaitAnims[speaker][2 + partner];
-		y      = (int)(int16)kWaitAnims[speaker][4 + partner];
+		animId = waitTable[speaker][0 + partner];
+		x      = (int)(int16)waitTable[speaker][2 + partner];
+		y      = (int)(int16)waitTable[speaker][4 + partner];
 	}
 
 	Animation anim;


Commit: b8f606891d2c512bc6b67e9d81b7856acfa76ed4
    https://github.com/scummvm/scummvm/commit/b8f606891d2c512bc6b67e9d81b7856acfa76ed4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:24+02:00

Commit Message:
EEM: implemented traveling animation in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5207a68524e..36fe8d4fc8b 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -780,7 +780,8 @@ void EEMEngine::interruptAudio(bool stopMusicToo) {
 }
 
 void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
-						bool holdLastFrame, bool fadeIn) {
+						bool holdLastFrame, bool fadeIn,
+						bool setSkipIntroOnEsc) {
 	ANMDecoder anm;
 	if (!anm.open(path)) {
 		warning("playAnm: %s missing", path.toString().c_str());
@@ -832,8 +833,10 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 				}
 				if (event.type == Common::EVENT_KEYDOWN) {
 					if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						_skipIntro = true;
-						interruptAudio();
+						if (setSkipIntroOnEsc) {
+							_skipIntro = true;
+							interruptAudio();
+						}
 					}
 					aborted = true;
 					break;
@@ -864,8 +867,10 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 				}
 				if (ev.type == Common::EVENT_KEYDOWN) {
 					if (ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
-						_skipIntro = true;
-						interruptAudio();
+						if (setSkipIntroOnEsc) {
+							_skipIntro = true;
+							interruptAudio();
+						}
 					}
 					clicked = true;
 					break;
@@ -1361,6 +1366,23 @@ void EEMEngine::startTravelMusic() {
 	_music->playMus(num, /* loop= */ false);
 }
 
+void EEMEngine::startLondonTravelMusic(uint8 travelKind) {
+	// EEM2 `_DoTravel @ 1717:06ae`: travelKind * 6 indexes this table,
+	// then rand() picks one of three u16 MUS IDs.
+	static const uint16 kLondonTravelMusic[4][3] = {
+		{ 0,  0,  0 },
+		{ 3, 22, 25 },
+		{ 7, 23, 17 },
+		{ 10, 21, 24 },
+	};
+	if (!_music || !_voiceOn || travelKind == 0 ||
+		travelKind >= ARRAYSIZE(kLondonTravelMusic))
+		return;
+
+	const uint track = kLondonTravelMusic[travelKind][_rng.getRandomNumber(2)];
+	_music->playMus(track, /* loop= */ false);
+}
+
 void EEMEngine::waitForMusicDone(uint32 maxMs) {
 	if (!_music)
 		return;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index bb504769b3d..907dacb7d3a 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -400,7 +400,6 @@ private:
 
 public:
 	void waitForInput(uint32 maxMs);
-private:
 
 	/// Play a difference-encoded animation file (.ANM / .A) on the full
 	/// 320x200 screen. Mirrors the data flow of `OpenDifferenceAnimation
@@ -412,8 +411,10 @@ private:
 	/// If @p fadeIn is true the first decoded frame is copied while the
 	/// palette is black, then ramped in like `_OpenFadeIn`.
 	void playAnm(const Common::Path &path, uint frameDelayMs = 120,
-				 bool holdLastFrame = false, bool fadeIn = false);
+				 bool holdLastFrame = false, bool fadeIn = false,
+				 bool setSkipIntroOnEsc = true);
 
+private:
 	/// `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @ 20a2:0512`.
 	/// `stopMusicToo=false` keeps MIDI playing across dialog skips.
 	void interruptAudio(bool stopMusicToo = true);
@@ -519,6 +520,10 @@ public:
 	/// `Engine::syncSoundSettings` override. Re-pulls `music_volume`
 	/// into the MIDI player's `_masterVolume`.
 	void syncSoundSettings() override;
+
+	/// EEM2 `_DoTravel @ 1717:0622` transition music. The matrix entry
+	/// (1..3) chooses one of three short one-shot MUS tracks at random.
+	void startLondonTravelMusic(uint8 travelKind);
 private:
 
 	Common::String _playerName;  ///< Substituted into 0x80 placeholders.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0aa84dc739b..c07ac1df532 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -22,6 +22,9 @@
 #include "common/config-manager.h"
 #include "common/debug.h"
 #include "common/events.h"
+#include "common/file.h"
+#include "common/path.h"
+#include "common/str.h"
 #include "common/system.h"
 #include "common/textconsole.h"
 
@@ -56,6 +59,30 @@ private:
 	Common::EventObserver *_observer;
 };
 
+const uint kLondonTravelMatrixDim = 60;
+const uint kLondonTravelFrameDelayMs = 120;
+const uint16 kLondonTravelLastSiteSentinel = 0x1b;
+
+bool londonTravelSitePic(const Mystery *mystery, uint siteNum, uint16 &sitePic) {
+	if (!mystery || !mystery->isLoaded())
+		return false;
+
+	if (siteNum < mystery->numSites()) {
+		const byte *sd = mystery->siteData(siteNum);
+		if (!sd)
+			return false;
+		sitePic = READ_LE_UINT16(sd);
+		return true;
+	}
+
+	if (siteNum == kLondonTravelLastSiteSentinel || siteNum == 0xffff) {
+		sitePic = kLondonTravelLastSiteSentinel;
+		return true;
+	}
+
+	return false;
+}
+
 // Masked blit using `transp` = high byte of `pic.flags` (`_Rect_Move_Mask @ 1000:03fc`).
 void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 			   int x, int y, byte transp) {
@@ -848,6 +875,61 @@ uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
 									  numFrames, elapsedMs);
 }
 
+bool SiteScreen::playLondonTravelAnimation(uint fromSite, uint toSite) {
+	if (!_vm || !_mystery || !_vm->isLondon())
+		return false;
+
+	uint16 fromPic = 0;
+	uint16 toPic = 0;
+	if (!londonTravelSitePic(_mystery, fromSite, fromPic) ||
+		!londonTravelSitePic(_mystery, toSite, toPic))
+		return false;
+	if (fromPic >= kLondonTravelMatrixDim || toPic >= kLondonTravelMatrixDim)
+		return false;
+
+	Common::File matrix;
+	if (!matrix.open(Common::Path("TRAVEL.BIN"))) {
+		warning("London travel: TRAVEL.BIN missing");
+		return false;
+	}
+
+	const uint32 matrixOff = (uint32)fromPic * kLondonTravelMatrixDim + toPic;
+	if (!matrix.seek(matrixOff)) {
+		warning("London travel: seek to matrix offset %u failed", matrixOff);
+		return false;
+	}
+
+	byte travelKind = 0;
+	if (matrix.read(&travelKind, 1) != 1) {
+		warning("London travel: short read at matrix offset %u", matrixOff);
+		return false;
+	}
+	if (travelKind == 0)
+		return false;
+	if (travelKind > 3) {
+		warning("London travel: invalid matrix value %u for pic %u -> %u",
+				travelKind, fromPic, toPic);
+		return false;
+	}
+
+	// EEM2 `_DoTravel @ 1717:06ed`: kind 3 forces partner suffix 0,
+	// so the shipped set is TRAVEL00/01/10/11/20.ANM.
+	const uint partnerSuffix = (travelKind == 3) ? 0 : _vm->getPartnerIndex();
+	const Common::String name = Common::String::format("TRAVEL%u%u.ANM",
+		(uint)travelKind - 1, partnerSuffix);
+
+	debugC(1, kDebugSite,
+		   "London travel: site %u/pic %u -> site %u/pic %u, kind=%u, anim=%s",
+		   fromSite, fromPic, toSite, toPic, travelKind, name.c_str());
+	_vm->startLondonTravelMusic(travelKind);
+	fadeCurrentPaletteToBlack();
+	_vm->playAnm(Common::Path(name), kLondonTravelFrameDelayMs,
+				 /* holdLastFrame= */ false, /* fadeIn= */ true,
+				 /* setSkipIntroOnEsc= */ false);
+	_vm->stopMusic();
+	return true;
+}
+
 void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	if (!_mystery || !_mystery->isLoaded()) {
 		warning("SiteScreen::enter: no mystery loaded");
@@ -878,9 +960,16 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 
 	const bool playArrival = _vm->shouldPlaySiteArrival(siteNum);
 
-	// `_DoTravel @ 168d:02da` calls `_StartTravelMusic`.
-	if (playArrival)
-		_vm->startTravelMusic();
+	if (playArrival) {
+		if (_vm->isLondon()) {
+			// EEM2 `HandleLondonSiteLoop @ 1717:083a` calls `_DoTravel`
+			// before `_BuildBackground` for the destination site.
+			playLondonTravelAnimation(_mystery->_lastSite, siteNum);
+		} else {
+			// `_DoTravel @ 168d:02da` calls `_StartTravelMusic`.
+			_vm->startTravelMusic();
+		}
+	}
 
 	// `_BuildBackground` calls `GetPalette(sitenum + 1)` — sitenum is the
 	// global SITES.DBD index (per-mystery `sitepic` field).
@@ -930,7 +1019,7 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		renderAnimatedDrops(siteNum, g_system->getMillis());
 		const bool skippedArrival = enterSiteAnim();
 		_vm->markSiteArrivalPlayed(siteNum);
-		if (!_vm->isFloppy()) {
+		if (!_vm->isFloppy() && !_vm->isLondon()) {
 			if (skippedArrival)
 				_vm->stopMusic();
 			else
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 78513dd4d4c..c91f0cffeb8 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -118,6 +118,7 @@ private:
 	void initImpatienceCounter();
 	bool checkImpatienceCounter();
 	void notePartnerActivity();
+	bool playLondonTravelAnimation(uint fromSite, uint toSite);
 
 	/// Partner site-arrival sequence (when `_LastSite != _SiteNumber`).
 	/// Mirrors `_EnterSiteAnim @ 1000:9b21`: anim 6/14 (Jake/Jenny) skateboards


Commit: 279d74a5413c753078808263c59160fed3317fe6
    https://github.com/scummvm/scummvm/commit/279d74a5413c753078808263c59160fed3317fe6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:25+02:00

Commit Message:
EEM: implemented edutaintment intro video of some locations in London

Changed paths:
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 907dacb7d3a..710573ff2b9 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -517,6 +517,10 @@ public:
 	/// `_StopMIDI @ 20a2:0512`.
 	void stopMusic();
 
+	/// EEM2 `_DoApproach @ 1717:009b`: London-only place information
+	/// screen with a short VIDEOnn.A clip and text pages from Ann.BIN.
+	bool doLondonApproach(uint16 approachId);
+
 	/// `Engine::syncSoundSettings` override. Re-pulls `music_volume`
 	/// into the MIDI player's `_masterVolume`.
 	void syncSoundSettings() override;
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index c07ac1df532..5dba04a5f01 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -958,13 +958,19 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	debugC(1, kDebugSite, "Entering site %u (%u hotspots)",
 		   siteNum, _mystery->hotspotCount(siteNum));
 
+	const byte *sd = _mystery->siteData(siteNum);
 	const bool playArrival = _vm->shouldPlaySiteArrival(siteNum);
 
 	if (playArrival) {
 		if (_vm->isLondon()) {
 			// EEM2 `HandleLondonSiteLoop @ 1717:083a` calls `_DoTravel`
 			// before `_BuildBackground` for the destination site.
-			playLondonTravelAnimation(_mystery->_lastSite, siteNum);
+			bool showedApproach = false;
+			const uint16 approachId = sd ? READ_LE_UINT16(sd + 2) : 0xffff;
+			if (firstVisit && approachId != 0xffff)
+				showedApproach = _vm->doLondonApproach(approachId);
+			if (!showedApproach)
+				playLondonTravelAnimation(_mystery->_lastSite, siteNum);
 		} else {
 			// `_DoTravel @ 168d:02da` calls `_StartTravelMusic`.
 			_vm->startTravelMusic();
@@ -975,7 +981,6 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	// global SITES.DBD index (per-mystery `sitepic` field).
 	// Floppy (`_DoSiteLoop_Floppy @ 1652:03f4`): first u16 of site_data is
 	// an offset to a drops sub-struct whose byte 0 is the SITES.DBD picID.
-	const byte *sd = _mystery->siteData(siteNum);
 	uint16 sitepic = 0;
 	if (sd) {
 		if (_vm->isFloppy()) {
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 048bc040fea..82e3ee518c3 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/algorithm.h"
 #include "common/debug.h"
 #include "common/config-manager.h"
 #include "common/events.h"
@@ -50,6 +51,33 @@ const GallerySlot kGallerySlots[5] = {
 	{ 191,  90 }  // 4
 };
 
+struct LondonApproachData {
+	uint16 videoId = 0;
+	uint16 videoX = 0;
+	uint16 videoY = 0;
+	Common::Rect textRect;
+	Common::Array<Common::String> pages;
+};
+
+struct LondonApproachButton {
+	int x1;
+	int y1;
+	int x2;
+	int y2;
+	int x;
+	int y;
+	uint16 picId;
+
+	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }
+};
+
+const LondonApproachButton kLondonApproachButtons[4] = {
+	{  10, 139,  35, 157,  11, 139, 0x361 }, // Done
+	{  10, 172,  35, 190,  11, 172, 0x362 }, // Play
+	{ 287, 172, 307, 190, 287, 172, 0x35f }, // Next
+	{ 287, 139, 307, 157, 287, 139, 0x360 }, // Previous
+};
+
 byte mapVisitedMarkerColor(byte color) {
 	switch (color) {
 	case 0xf7:
@@ -87,6 +115,75 @@ void blitBigMapMarker(Graphics::ManagedSurface &dstSurface, const Picture &marke
 	}
 }
 
+bool loadLondonApproachData(uint16 approachId, LondonApproachData &out) {
+	Common::File f;
+	const Common::String name = Common::String::format("A%u.BIN", approachId);
+	if (!f.open(Common::Path(name))) {
+		warning("London approach: cannot open %s", name.c_str());
+		return false;
+	}
+
+	const uint32 size = f.size();
+	if (size < 16) {
+		warning("London approach: %s is too short", name.c_str());
+		return false;
+	}
+
+	Common::Array<byte> data;
+	data.resize(size);
+	if (f.read(data.data(), size) != size) {
+		warning("London approach: short read on %s", name.c_str());
+		return false;
+	}
+
+	out.videoId = READ_LE_UINT16(data.data() + 0);
+	out.videoX = READ_LE_UINT16(data.data() + 2);
+	out.videoY = READ_LE_UINT16(data.data() + 4);
+	const int16 x1 = (int16)READ_LE_UINT16(data.data() + 6);
+	const int16 y1 = (int16)READ_LE_UINT16(data.data() + 8);
+	const int16 x2 = (int16)READ_LE_UINT16(data.data() + 10);
+	const int16 y2 = (int16)READ_LE_UINT16(data.data() + 12);
+	out.textRect = Common::Rect(x1, y1, x2, y2);
+
+	const uint16 pageCount = READ_LE_UINT16(data.data() + 14);
+	out.pages.clear();
+	uint32 pos = 16;
+	for (uint16 i = 0; i < pageCount && pos < size; i++) {
+		const uint32 start = pos;
+		while (pos < size && data[pos] != 0)
+			pos++;
+		out.pages.push_back(Common::String((const char *)data.data() + start,
+										   pos - start));
+		if (pos < size)
+			pos++;
+	}
+	return out.videoId != 0 && !out.pages.empty();
+}
+
+bool decodeLondonApproachFirstFrame(uint16 videoId,
+									Graphics::ManagedSurface &base,
+									byte *palette) {
+	const Common::String name = Common::String::format("VIDEO%02u.A", videoId);
+	ANMDecoder anm;
+	if (!anm.open(Common::Path(name))) {
+		warning("London approach: cannot open %s", name.c_str());
+		return false;
+	}
+
+	anm.getPalette8(palette);
+	const byte *frame = anm.nextFrame();
+	if (!frame) {
+		warning("London approach: %s has no first frame", name.c_str());
+		return false;
+	}
+
+	base.clear();
+	const int w = MIN<int>(anm.width(), kScreenWidth);
+	const int h = MIN<int>(anm.height(), kScreenHeight);
+	base.copyRectToSurface(frame, anm.width(), 0, 0, w, h);
+	return true;
+}
+
 // Setup-screen highlight rects (referenced by both `doSetup` and the
 // helper `EEMEngine::setupDrawScreen`). `_SetupHighlights @ 29be:1320`.
 constexpr Common::Rect kSetupKid1Rect    (Common::Point( 99,  44),  49,  8);
@@ -2979,7 +3076,8 @@ void EEMEngine::doBigMap() {
 		const Common::Rect kBigMapWindow(0, 0, 247, 192);
 		const Common::Rect kSetupBtnRect = isFloppy()
 			? Common::Rect(251, 3, 315, 42)
-			: Common::Rect(252, 4, 315, 42);
+			: (isLondon() ? Common::Rect(252, 1, 315, 42)
+						  : Common::Rect(252, 4, 315, 42));
 
 		bool wantZoom = false;
 		int zoomX = 0;
@@ -3022,8 +3120,13 @@ void EEMEngine::doBigMap() {
 			if (now - mapLastTick >= 100) {
 				mapLastTick = now;
 				drawBigMapOverview(now - mapStartTick);
-				cyclePaletteRange(0xf7, 0xfa);
-				cyclePaletteRange(0xfb, 0xfe);
+				if (isLondon()) {
+					cyclePaletteRange(0xf4, 0xf9);
+					cyclePaletteRange(0xfa, 0xff);
+				} else {
+					cyclePaletteRange(0xf7, 0xfa);
+					cyclePaletteRange(0xfb, 0xfe);
+				}
 			}
 			g_system->updateScreen();
 			g_system->delayMillis(10);
@@ -3068,12 +3171,17 @@ void EEMEngine::doBigMap() {
 		const Common::Rect kArrowYDown(237, 163, 247, 172);
 		const Common::Rect kArrowXLeft(2, 175, 12, 185);
 		const Common::Rect kArrowXRight(224, 175, 234, 185);
-		const Common::Rect kXSlider(15, 175, 221, 185);
-		const Common::Rect kYSlider(237, 14, 247, 160);
+		const Common::Rect kXSlider = isLondon()
+			? Common::Rect(15, 176, 220, 184)
+			: Common::Rect(15, 175, 221, 185);
+		const Common::Rect kYSlider = isLondon()
+			? Common::Rect(238, 16, 246, 158)
+			: Common::Rect(237, 14, 247, 160);
 		const Common::Rect kDetailSetupBtn = isFloppy()
 			? Common::Rect(251, 3, 315, 42)
-			: Common::Rect(252, 4, 315, 42);
-		const int kArrowStep = 16;
+			: (isLondon() ? Common::Rect(251, 3, 315, 42)
+						  : Common::Rect(252, 4, 315, 42));
+		const int kArrowStep = isLondon() ? 8 : 16;
 		const int kSliderRange = mapW - kMapWinW;
 		const int kSliderRangeY = mapH - kMapWinH;
 		const Common::Point detailMouse =
@@ -3085,6 +3193,7 @@ void EEMEngine::doBigMap() {
 		while (!shouldQuit() && !returnToOverview) {
 			Common::Event ev;
 			bool dirty = false;
+			bool frameTick = false;
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_QUIT ||
 					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
@@ -3097,7 +3206,7 @@ void EEMEngine::doBigMap() {
 						dirty = true;
 						continue;
 					}
-					const int kStep = 16;
+					const int kStep = isLondon() ? 8 : 16;
 					if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
 						scrollX = MAX<int>(0, scrollX - kStep);
 						dirty = true;
@@ -3167,6 +3276,11 @@ void EEMEngine::doBigMap() {
 							   ev.mouse.y < kMapWinY + kMapWinH) {
 						// Per-site bbox from `_StampButtons` (SmallMap +8/+0xa).
 						const bool fmap = _mystery.isLoaded() && isFloppy();
+						struct DetailMapHit {
+							uint site;
+							Common::Rect rect;
+						};
+						Common::Array<DetailMapHit> hits;
 						for (uint i = 0; i < _mystery.numSites(); i++) {
 							if (!_mystery._onSites[i] &&
 								i != _mystery._siteNumber)
@@ -3197,10 +3311,23 @@ void EEMEngine::doBigMap() {
 							}
 							const int sx = (int)mx - scrollX + kMapWinX;
 							const int sy = (int)my - scrollY + kMapWinY;
-							if (ev.mouse.x >= sx && ev.mouse.x < sx + bw &&
-								ev.mouse.y >= sy && ev.mouse.y < sy + bh) {
+							const Common::Rect r(sx, sy, sx + bw, sy + bh);
+							if (r.intersects(Common::Rect(kMapWinX, kMapWinY,
+									kMapWinX + kMapWinW, kMapWinY + kMapWinH))) {
+								DetailMapHit hit = { i, r };
+								hits.push_back(hit);
+							}
+						}
+						Common::sort(hits.begin(), hits.end(),
+							[](const DetailMapHit &a, const DetailMapHit &b) {
+								if (a.rect.top != b.rect.top)
+									return a.rect.top < b.rect.top;
+								return a.rect.left < b.rect.left;
+							});
+						for (uint i = 0; i < hits.size(); i++) {
+							if (hits[i].rect.contains(ev.mouse.x, ev.mouse.y)) {
 								_mystery._lastSite = _mystery._siteNumber;
-								_mystery._siteNumber = (uint16)i;
+								_mystery._siteNumber = (uint16)hits[i].site;
 								setInteractiveMouseCursor(false);
 								return;
 							}
@@ -3216,10 +3343,15 @@ void EEMEngine::doBigMap() {
 			if (now - detailLastTick >= 100) {
 				detailLastTick = now;
 				dirty = true;
+				frameTick = true;
 			}
 			if (dirty)
 				drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH,
 					now - detailStartTick);
+			if (frameTick && isLondon()) {
+				cyclePaletteRange(0xee, 0xf2);
+				cyclePaletteRange(0xea, 0xed);
+			}
 			g_system->updateScreen();
 			g_system->delayMillis(10);
 		}
@@ -3228,6 +3360,215 @@ void EEMEngine::doBigMap() {
 	}
 }
 
+bool EEMEngine::doLondonApproach(uint16 approachId) {
+	if (!isLondon())
+		return false;
+
+	LondonApproachData data;
+	if (!loadLondonApproachData(approachId, data))
+		return false;
+
+	Graphics::ManagedSurface base(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	byte palette[768] = {};
+	const bool haveVideo =
+		decodeLondonApproachFirstFrame(data.videoId, base, palette);
+	if (!haveVideo)
+		base.clear();
+
+	Picture buttonPics[ARRAYSIZE(kLondonApproachButtons)];
+	bool haveButtons[ARRAYSIZE(kLondonApproachButtons)] = {};
+	for (uint i = 0; i < ARRAYSIZE(kLondonApproachButtons); i++)
+		haveButtons[i] = _picsArchive.getPicture(
+			kLondonApproachButtons[i].picId, buttonPics[i]);
+
+	auto drawButtonFallback = [&](Graphics::ManagedSurface &dst, uint idx) {
+		static const char *const kLabels[4] = { "OK", "PLAY", ">", "<" };
+		const Common::Rect r = kLondonApproachButtons[idx].rect();
+		dst.fillRect(r, 0);
+		_font.drawString(&dst, kLabels[idx], r.left + 1, r.top + 5,
+						 r.width(), 0x0f);
+	};
+
+	auto drawScreen = [&](uint page) {
+		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+			Graphics::PixelFormat::createFormatCLUT8());
+		scratch.simpleBlitFrom(base);
+
+		const Common::Rect textRect =
+			data.textRect.findIntersectingRect(Common::Rect(kScreenWidth, kScreenHeight));
+		if (!textRect.isEmpty()) {
+			scratch.fillRect(textRect, 0);
+			if (page < data.pages.size()) {
+				Common::Array<Common::String> wrapped;
+				_font.wordWrapText(data.pages[page], MAX<int>(8, textRect.width()),
+								   wrapped);
+				const int lineH = _font.getFontHeight();
+				const int maxLines = MAX<int>(1, textRect.height() / lineH);
+				for (uint i = 0; i < wrapped.size() && (int)i < maxLines; i++) {
+					_font.drawString(&scratch, wrapped[i], textRect.left,
+									 textRect.top + (int)i * lineH,
+									 textRect.width(), 0x0f);
+				}
+			}
+		}
+
+		for (uint i = 0; i < ARRAYSIZE(kLondonApproachButtons); i++) {
+			const LondonApproachButton &b = kLondonApproachButtons[i];
+			if (haveButtons[i]) {
+				scratch.transBlitFrom(buttonPics[i].surface,
+									  Common::Point(b.x, b.y),
+									  (uint32)(byte)(buttonPics[i].flags >> 8));
+			} else {
+				drawButtonFallback(scratch, i);
+			}
+		}
+
+		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+								   0, 0, kScreenWidth, kScreenHeight);
+		g_system->updateScreen();
+	};
+
+	auto playVideo = [&]() {
+		const Common::String name =
+			Common::String::format("VIDEO%02u.A", data.videoId);
+		ANMDecoder anm;
+		if (!anm.open(Common::Path(name))) {
+			warning("London approach: cannot open %s", name.c_str());
+			return;
+		}
+
+		// Frame 0 is already the static background. Replay frames 1..end
+		// into the upper video area, leaving text/buttons untouched.
+		(void)anm.nextFrame();
+		const int copyW = MIN<int>(anm.width(), kScreenWidth - (int)data.videoX);
+		const int copyH = MIN<int>(0x82, MIN<int>(anm.height(),
+			kScreenHeight - (int)data.videoY));
+		if (copyW <= 0 || copyH <= 0)
+			return;
+
+		while (!shouldQuit()) {
+			const byte *frame = anm.nextFrame();
+			if (!frame)
+				break;
+			g_system->copyRectToScreen(
+				frame + data.videoY * anm.width() + data.videoX,
+				anm.width(), data.videoX, data.videoY, copyW, copyH);
+			g_system->updateScreen();
+
+			const uint32 start = g_system->getMillis();
+			bool skip = false;
+			while (g_system->getMillis() - start < 120 && !skip) {
+				Common::Event ev;
+				while (g_system->getEventManager()->pollEvent(ev)) {
+					if (ev.type == Common::EVENT_QUIT ||
+						ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+						return;
+					}
+					if (ev.type == Common::EVENT_LBUTTONDOWN ||
+						ev.type == Common::EVENT_KEYDOWN) {
+						skip = true;
+						break;
+					}
+				}
+				g_system->delayMillis(5);
+			}
+			if (skip)
+				break;
+		}
+	};
+
+	fadeCurrentPaletteToBlack();
+	CursorMan.showMouse(true);
+	setSiteHotspotCursorId(6);
+	if (_music && _voiceOn)
+		_music->playMus(0x27, /* loop= */ false);
+
+	uint page = 0;
+	drawScreen(page);
+	if (haveVideo)
+		fadePaletteFromBlack(palette);
+	else
+		setSitePalette(0x3b);
+	bool done = false;
+	while (!shouldQuit() && !done) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				done = true;
+				break;
+			}
+			if (ev.type == Common::EVENT_MOUSEMOVE) {
+				bool overButton = false;
+				for (uint i = 0; i < ARRAYSIZE(kLondonApproachButtons); i++) {
+					if (kLondonApproachButtons[i].rect().contains(ev.mouse.x,
+																 ev.mouse.y)) {
+						overButton = true;
+						break;
+					}
+				}
+				setInteractiveMouseCursor(overButton);
+			}
+			if (ev.type == Common::EVENT_KEYDOWN) {
+				if (ev.kbd.keycode == Common::KEYCODE_ESCAPE ||
+					ev.kbd.keycode == Common::KEYCODE_RETURN ||
+					ev.kbd.keycode == Common::KEYCODE_KP_ENTER) {
+					done = true;
+					break;
+				}
+				if (ev.kbd.keycode == Common::KEYCODE_SPACE) {
+					playVideo();
+					drawScreen(page);
+				} else if (ev.kbd.keycode == Common::KEYCODE_RIGHT ||
+						   ev.kbd.keycode == Common::KEYCODE_DOWN) {
+					if (page + 1 < data.pages.size()) {
+						page++;
+						drawScreen(page);
+					}
+				} else if (ev.kbd.keycode == Common::KEYCODE_LEFT ||
+						   ev.kbd.keycode == Common::KEYCODE_UP) {
+					if (page > 0) {
+						page--;
+						drawScreen(page);
+					}
+				}
+			}
+			if (ev.type == Common::EVENT_LBUTTONDOWN) {
+				if (kLondonApproachButtons[0].rect().contains(ev.mouse.x,
+															 ev.mouse.y)) {
+					done = true;
+					break;
+				}
+				if (kLondonApproachButtons[1].rect().contains(ev.mouse.x,
+															 ev.mouse.y)) {
+					playVideo();
+					drawScreen(page);
+				} else if (kLondonApproachButtons[2].rect().contains(ev.mouse.x,
+																	ev.mouse.y)) {
+					if (page + 1 < data.pages.size()) {
+						page++;
+						drawScreen(page);
+					}
+				} else if (kLondonApproachButtons[3].rect().contains(ev.mouse.x,
+																	ev.mouse.y)) {
+					if (page > 0) {
+						page--;
+						drawScreen(page);
+					}
+				}
+			}
+		}
+		g_system->updateScreen();
+		g_system->delayMillis(10);
+	}
+
+	setInteractiveMouseCursor(false);
+	setSiteHotspotCursorId(0);
+	stopMusic();
+	return true;
+}
+
 void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	// PIC 0x42 + per-site Done/Crime/Site marker (`_DrawBigMapButtons @
 	// 20fe:0877`) + partner idle at (0xfd, 0x50). Idle ANI: Jake=0x14,


Commit: 6ddfb2227b29c4295c5066c48fcd203902331572
    https://github.com/scummvm/scummvm/commit/6ddfb2227b29c4295c5066c48fcd203902331572
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:25+02:00

Commit Message:
EEM: fix big map markers in London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 82e3ee518c3..63bd909451f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3581,13 +3581,14 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	if (_picsArchive.getPicture(0x42, frame))
 		scratch.simpleBlitFrom(frame.surface);
 
-	// Marker PICs from `_main @ 1a35:0f59`:
-	//   _DoneMarker = 0x20d, _SiteMarker = 0xc5, _CrimeMarker = 0xc6.
-	// 0x20d is CD-only (floppy PICS.DBX has 524 entries).
+	// Marker PICs from `_main`:
+	//   EEM1 CD @ 1a35:0f59: Done=0x20d, Site=0xc5, Crime=0xc6.
+	//   EEM2 CD @ 1abf:11a6: Done=0x006, Site=0xc5, Crime=0xc6.
+	// In EEM2, 0x20d is a normal scene/character frame, not a map marker.
 	Picture done;
 	Picture normal;
 	Picture crimeM;
-	const bool haveDone   = _picsArchive.getPicture(0x20d, done);
+	const bool haveDone   = _picsArchive.getPicture(isLondon() ? 0x006 : 0x20d, done);
 	const bool haveNormal = _picsArchive.getPicture(0xc5,  normal);
 	const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
 


Commit: a35a1dc2d51307a386a15158b9145354c21f3ac8
    https://github.com/scummvm/scummvm/commit/a35a1dc2d51307a386a15158b9145354c21f3ac8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:25+02:00

Commit Message:
EEM: implement player creation/loading in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 36fe8d4fc8b..80013325a49 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -74,6 +74,53 @@ const byte kSaveBodyVer = 1;
 // option or changing save format. Set false before release.
 const bool kDebugPopulateScrapbook1AtStartup = false;
 
+Common::String makeLondonProfileDisplayName(const Common::String &first,
+											 const Common::String &last) {
+	Common::String cleanFirst = first;
+	Common::String cleanLast = last;
+	cleanFirst.trim();
+	cleanLast.trim();
+	if (cleanLast.empty())
+		return cleanFirst;
+	if (cleanFirst.empty())
+		return cleanLast;
+	return cleanFirst + " " + cleanLast;
+}
+
+Common::String makeLondonProfileKey(const Common::String &first,
+									 const Common::String &last) {
+	// EEM2 `_GenerateFilename @ 1cd3:02fe`: copy the first 7 bytes of
+	// FirstName, pad to 8 from LastName, then replace spaces/dots with '_'.
+	Common::String cleanFirst = first;
+	Common::String cleanLast = last;
+	cleanFirst.trim();
+	cleanLast.trim();
+	Common::String key;
+	for (uint i = 0; i < cleanFirst.size() && key.size() < 7; i++)
+		key += cleanFirst[i];
+	for (uint i = 0; i < cleanLast.size() && key.size() < 8; i++)
+		key += cleanLast[i];
+	for (uint i = 0; i < key.size(); i++) {
+		if (key[i] == ' ' || key[i] == '.')
+			key.setChar('_', i);
+	}
+	key.toUppercase();
+	return key;
+}
+
+Common::String londonProfileKeyFromDisplayName(const Common::String &name) {
+	Common::String clean = name;
+	clean.trim();
+	const size_t split = clean.findFirstOf(' ');
+	if (split == Common::String::npos)
+		return makeLondonProfileKey(clean, Common::String());
+
+	Common::String first = clean.substr(0, split);
+	Common::String last = clean.substr(split + 1);
+	last.trim();
+	return makeLondonProfileKey(first, last);
+}
+
 void fadeCurrentPaletteToBlack(uint delayMs) {
 	byte start[kPalSize];
 	byte stepPal[kPalSize];
@@ -342,26 +389,22 @@ Common::Error EEMEngine::run() {
 	if (resumed)
 		goto screenLoop;
 
-	// EEM2 ("Eagle Eye Mysteries in London"): opening sequence + character
-	// creation, then start the training case (mystery 0 — what a new
-	// detective plays first) and reuse the engine's gameplay screen loop.
-	// EEM2's mystery data format matches EEM1-CD, so the shared Mystery
-	// parser + InitClues (case intro) / BigMap / Site handlers apply.
+	// EEM2 ("Eagle Eye Mysteries in London"): opening sequence, then screen
+	// 8 profile selection. A freshly-created detective starts the training
+	// case (M0); an existing profile resumes its saved menu/map state.
 	if (isLondon()) {
 		runLondonStartup();
 		if (!shouldQuit()) {
 			CursorMan.showMouse(true);
-			if (_mystery.load(0, &_rng)) {
-				resetSiteArrivalState();
-				if (_audio)
-					_audio->initMysterySounds(0);
-				debugC(1, kDebugMystery,
-					   "London: training mystery 0 loaded — %u sites, %u suspects",
-					   _mystery.numSites(), _mystery.numSuspects());
-				_nextScreen = kScreenInitClues;
+			applyStartupTestOverrides();
+			applySkipRepeatedCasesOption();
+			if (_mystery.isLoaded()) {
+				_nextScreen = kScreenMap;
+			} else if (_profileCreatedThisSession) {
+				_nextScreen = startLondonTrainingMystery()
+					? kScreenInitClues : kScreenInvalid;
 			} else {
-				warning("London: training mystery 0 (M0.BIN) failed to load");
-				_nextScreen = kScreenInvalid;
+				_nextScreen = kScreenAction;
 			}
 		}
 		goto screenLoop;
@@ -605,11 +648,17 @@ screenLoop:
 				applyStartupTestOverrides();
 			if (!shouldQuit())
 				applySkipRepeatedCasesOption();
-			if (!shouldQuit())
+			if (!shouldQuit() && !isLondon())
 				doChoosePartner();
-			if (!shouldQuit())
-				_nextScreen = _mystery.isLoaded() ? kScreenMap
-												  : kScreenAction;
+			if (!shouldQuit()) {
+				if (_mystery.isLoaded())
+					_nextScreen = kScreenMap;
+				else if (isLondon() && _profileCreatedThisSession)
+					_nextScreen = startLondonTrainingMystery()
+						? kScreenInitClues : kScreenInvalid;
+				else
+					_nextScreen = kScreenAction;
+			}
 			break;
 
 		case kScreenAccuse:
@@ -1111,6 +1160,21 @@ void EEMEngine::showLondonLogo(uint picId, uint palId, uint holdMs) {
 	fadeCurrentPaletteToBlack();
 }
 
+bool EEMEngine::startLondonTrainingMystery() {
+	if (_mystery.load(0, &_rng)) {
+		resetSiteArrivalState();
+		if (_audio)
+			_audio->initMysterySounds(0);
+		debugC(1, kDebugMystery,
+			   "London: training mystery 0 loaded — %u sites, %u suspects",
+			   _mystery.numSites(), _mystery.numSuspects());
+		return true;
+	}
+
+	warning("London: training mystery 0 (M0.BIN) failed to load");
+	return false;
+}
+
 void EEMEngine::runLondonStartup() {
 	// Full opening sequence — EEM2 `_DoOpeningAnims` @ 2721:08e6:
 	//   _ShowEAKids   @ 2721:05e3 — PIC 0x54,  palette 0x3c
@@ -1164,14 +1228,14 @@ void EEMEngine::runLondonStartup() {
 		_music->stop();
 	_skipIntro = false;
 
-	// Character creation (name + Jake/Jenny). The caller then loads the
-	// training case (mystery 0) and enters the shared gameplay screen loop
-	// (case intro -> map -> site); the case-selection menu is reachable
-	// later via the action screen.
-	if (!shouldQuit())
-		showLondonCharSelect();
+	// Screen 8 profile selection. If no profile exists or the player chooses
+	// the bottom "new player" sentinel, this opens London `_NewPlayer`.
+	if (!shouldQuit()) {
+		CursorMan.showMouse(true);
+		doProfilePicker();
+	}
 
-	debugC(1, kDebugGeneral, "EEM2 (London): intro + character creation done");
+	debugC(1, kDebugGeneral, "EEM2 (London): intro + profile selection done");
 }
 
 void EEMEngine::showLondonCharSelect() {
@@ -1202,12 +1266,15 @@ void EEMEngine::showLondonCharSelect() {
 	const bool havePal = getSitePalette(0, pal);
 
 	CursorMan.showMouse(false);
+	_profileCreatedThisSession = false;
 
 	enum { kFieldFirst = 0, kFieldLast = 1, kFieldPartner = 2 };
 	int field = kFieldFirst;
 	Common::String first, last;
 	uint8 partner = kPartnerJake;
 	bool blink = true, fadedIn = false, done = false, needRedraw = true;
+	bool partnerReady = false;
+	bool partnerCanConfirm = true;
 	uint32 blinkMs = g_system->getMillis();
 
 	while (!done && !shouldQuit()) {
@@ -1243,6 +1310,8 @@ void EEMEngine::showLondonCharSelect() {
 			}
 			g_system->updateScreen();
 			needRedraw = false;
+			if (field == kFieldPartner)
+				partnerReady = true;
 		}
 
 		Common::Event ev;
@@ -1250,6 +1319,20 @@ void EEMEngine::showLondonCharSelect() {
 			if (ev.type == Common::EVENT_QUIT ||
 				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
 				return;
+			if (field == kFieldPartner && ev.type == Common::EVENT_LBUTTONDOWN) {
+				if (!partnerReady)
+					continue;
+				partner = (ev.mouse.x < 160) ? kPartnerJake : kPartnerJenny;
+				done = true;
+				needRedraw = true;
+				break;
+			}
+			if (field == kFieldPartner && ev.type == Common::EVENT_KEYUP &&
+				(ev.kbd.keycode == Common::KEYCODE_RETURN ||
+				 ev.kbd.keycode == Common::KEYCODE_KP_ENTER)) {
+				partnerCanConfirm = true;
+				continue;
+			}
 			if (ev.type != Common::EVENT_KEYDOWN)
 				continue;
 			const Common::KeyCode k = ev.kbd.keycode;
@@ -1258,25 +1341,39 @@ void EEMEngine::showLondonCharSelect() {
 			if (field == kFieldFirst || field == kFieldLast) {
 				Common::String &buf = (field == kFieldFirst) ? first : last;
 				const uint cap = (field == kFieldFirst) ? kMaxFirst : kMaxLast;
-				if ((enter || k == Common::KEYCODE_TAB) &&
-					(field == kFieldLast || !first.empty()))
-					field++;  // advance to last name, then to partner pick
-				else if (k == Common::KEYCODE_BACKSPACE && !buf.empty())
+				if (enter || k == Common::KEYCODE_TAB) {
+					Common::String clean = buf;
+					clean.trim();
+					if (!clean.empty()) {
+						field++;  // advance to last name, then to partner pick
+						if (field == kFieldPartner) {
+							partnerReady = false;
+							partnerCanConfirm = false;
+						}
+					}
+				} else if (k == Common::KEYCODE_BACKSPACE && !buf.empty()) {
 					buf.deleteLastChar();
-				else if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
-						 buf.size() < cap)
+				} else if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
+						   buf.size() < cap) {
 					buf += (char)ev.kbd.ascii;
+				}
 			} else {  // partner pick
-				if (k == Common::KEYCODE_LEFT)
+				if (k == Common::KEYCODE_LEFT) {
 					partner = kPartnerJake;
-				else if (k == Common::KEYCODE_RIGHT)
+					partnerCanConfirm = true;
+				} else if (k == Common::KEYCODE_RIGHT) {
 					partner = kPartnerJenny;
-				else if (k == Common::KEYCODE_BACKSPACE)
+					partnerCanConfirm = true;
+				} else if (k == Common::KEYCODE_BACKSPACE) {
 					field = kFieldLast;  // back to editing
-				else if (enter)
+					partnerCanConfirm = true;
+				} else if (enter && partnerReady && partnerCanConfirm) {
 					done = true;
+				}
 			}
 			needRedraw = true;
+			if (field == kFieldPartner && !partnerReady)
+				break;
 		}
 
 		const uint32 now = g_system->getMillis();
@@ -1290,18 +1387,54 @@ void EEMEngine::showLondonCharSelect() {
 	if (shouldQuit())
 		return;
 
-	// Commit the profile so the reused case-selection menu has valid state
-	// (player name in clue text, partner greeter ANI 0x15/0x16, Junior
-	// chain stage -> BOOK1.NME, nothing solved yet).
-	if (first.empty())
-		first = "Detective";
-	_playerName = last.empty() ? first : (first + " " + last);
+	Common::Event staleEvent;
+	while (g_system->getEventManager()->pollEvent(staleEvent)) {
+		if (staleEvent.type == Common::EVENT_QUIT ||
+			staleEvent.type == Common::EVENT_RETURN_TO_LAUNCHER)
+			return;
+	}
+
+	const Common::String displayName =
+		makeLondonProfileDisplayName(first, last);
+	const Common::String profileKey = makeLondonProfileKey(first, last);
+
+	for (const SaveStateDescriptor &s : listProfiles()) {
+		if (londonProfileKeyFromDisplayName(s.getDescription()) != profileKey)
+			continue;
+
+		const Common::Error err = loadGameState(s.getSaveSlot());
+		if (err.getCode() == Common::kNoError) {
+			_profileCreatedThisSession = false;
+			debugC(1, kDebugGeneral,
+				   "London: existing player key=%s loaded from slot %d (%s)",
+				   profileKey.c_str(), s.getSaveSlot(),
+				   s.getDescription().c_str());
+			return;
+		}
+		warning("London: failed to load existing profile '%s' at slot %d",
+				s.getDescription().c_str(), s.getSaveSlot());
+	}
+
+	// New profile. EEM2 `_NewPlayer @ 1cd3:0f27` initializes the player
+	// record only when `_LoadPlayerRecord` failed, then immediately saves it.
+	_playerName = displayName.empty() ? "Detective" : displayName;
 	_partner = partner;
 	_chainStage = 1;
+	_voiceOn = true;
+	if (_audio)
+		_audio->setVoiceEnabled(_voiceOn);
 	for (uint i = 0; i < sizeof(_mysteriesSolved); i++)
 		_mysteriesSolved[i] = 0;
-	debugC(1, kDebugGeneral, "London: player='%s' partner=%s",
-		   _playerName.c_str(), partner == kPartnerJake ? "Jake" : "Jenny");
+	_mystery.clear();
+	resetSiteArrivalState();
+	_profileCreatedThisSession = true;
+	const Common::Error err = saveProfile(_playerName);
+	if (err.getCode() != Common::kNoError)
+		warning("London: failed to save new profile '%s': %s",
+				_playerName.c_str(), err.getDesc().c_str());
+	debugC(1, kDebugGeneral, "London: new player='%s' key=%s partner=%s",
+		   _playerName.c_str(), profileKey.c_str(),
+		   partner == kPartnerJake ? "Jake" : "Jenny");
 }
 
 void EEMEngine::showFloppyStormLogo() {
@@ -1565,11 +1698,15 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 		return Common::kCreatingFileFailed;
 
 	const SaveStateList saves = listProfiles();
+	const Common::String londonKey =
+		isLondon() ? londonProfileKeyFromDisplayName(name) : Common::String();
 
 	// Overwrite on matching description.
 	int slot = -1;
 	for (auto &s : saves) {
-		if (s.getDescription() == name) {
+		if (s.getDescription() == name ||
+			(isLondon() &&
+			 londonProfileKeyFromDisplayName(s.getDescription()) == londonKey)) {
 			slot = s.getSaveSlot();
 			break;
 		}
@@ -1606,8 +1743,12 @@ bool EEMEngine::loadProfile(const Common::String &name) {
 		return false;
 
 	const SaveStateList saves = listProfiles();
+	const Common::String londonKey =
+		isLondon() ? londonProfileKeyFromDisplayName(name) : Common::String();
 	for (auto &s : saves) {
-		if (s.getDescription() == name) {
+		if (s.getDescription() == name ||
+			(isLondon() &&
+			 londonProfileKeyFromDisplayName(s.getDescription()) == londonKey)) {
 			const Common::Error err = loadGameState(s.getSaveSlot());
 			if (err.getCode() == Common::kNoError) {
 				debugC(1, kDebugGeneral, "loadProfile(%s) <- slot %d",
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 710573ff2b9..786c41a089c 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -425,8 +425,10 @@ private:
 
 	// --- EEM2 ("Eagle Eye Mysteries in London") — reimplementation in progress ---
 	/// Opening sequence + character creation — EEM2's `_DoOpeningAnims`
-	/// @ 2721:08e6 then `_NewPlayer` @ 1cd3:0f27.
+	/// @ 2721:08e6 then screen 8 profile selection.
 	void runLondonStartup();
+	/// Start London mystery 0 for a freshly-created detective.
+	bool startLondonTrainingMystery();
 	/// Blit a full-screen still PIC and fade it in / hold / out using the
 	/// given SITEPALS. palette index.
 	void showLondonLogo(uint picId, uint palId, uint holdMs);
@@ -554,6 +556,10 @@ private:
 	/// partner speech, intro VO).
 	bool _voiceOn = true;
 
+	/// Set by the profile/new-player screens. London uses it to decide whether
+	/// to start the training mystery or resume the loaded profile's menu/state.
+	bool _profileCreatedThisSession = false;
+
 	Common::RandomSource _rng;
 
 	DBDArchive _picsArchive;     ///< PICS.DBD/.DBX
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 63bd909451f..d2be151d480 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -836,18 +836,25 @@ void EEMEngine::doProfilePicker() {
 	// `screen8_handler @ 1c33:1012` — walks *.PLR (max 25), reads 12-byte
 	// player-name field, hands list to `_DoChoose`. No profiles or
 	// 0xfffe/0xffff sentinel enters `_NewPlayer`.
+	_profileCreatedThisSession = false;
 
 	// `screen8_handler` does `_FadeOut(); _GetPalette(0); _GetBackground(0x104)`.
 	setSitePalette(0);
 
 	const SaveStateList saves = listProfiles();
 	if (saves.empty()) {
-		doNewPlayer();
+		if (isLondon())
+			showLondonCharSelect();
+		else
+			doNewPlayer();
 		return;
 	}
 
 	if (!_font.isLoaded()) {
-		doNewPlayer();
+		if (isLondon())
+			showLondonCharSelect();
+		else
+			doNewPlayer();
 		return;
 	}
 
@@ -1011,22 +1018,31 @@ void EEMEngine::doProfilePicker() {
 	}
 
 	if (createNew) {
-		doNewPlayer();
+		if (isLondon())
+			showLondonCharSelect();
+		else
+			doNewPlayer();
 		return;
 	}
 
 	// `_LoadPlayerRecord @ 1c33:1281`.
 	const ProfilePickerEntry &e = entries[sel];
-	if (!loadProfile(e.label)) {
+	if (loadProfile(e.label)) {
+		_profileCreatedThisSession = false;
+	} else {
 		warning("doProfilePicker: failed to load profile '%s' at slot %d",
 				e.label.c_str(), e.slot);
-		doNewPlayer();
+		if (isLondon())
+			showLondonCharSelect();
+		else
+			doNewPlayer();
 	}
 }
 
 void EEMEngine::doNewPlayer() {
 	// `_NewPlayer @ 1c33:0dda` — BG 0x104 + peek pic 0x107, prompt for
 	// up to 12 chars.
+	_profileCreatedThisSession = false;
 	if (!_font.isLoaded()) {
 		_playerName = "Detective";
 		return;
@@ -1070,7 +1086,9 @@ void EEMEngine::doNewPlayer() {
 					name = "Detective";
 				// `_NewPlayer @ 1c33:0fa0+`: `_LoadPlayerRecord` → if
 				// missing, zero state and `_SavePlayerRecord`.
-				if (!loadProfile(name)) {
+				if (loadProfile(name)) {
+					_profileCreatedThisSession = false;
+				} else {
 					_playerName = name;
 					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
 					_mystery.clear();
@@ -1078,6 +1096,7 @@ void EEMEngine::doNewPlayer() {
 					// `_NewPlayer @ 1c33:0fa3`: DAT_2d5d_3f99 = 1 (Junior).
 					_chainStage = 1;
 					saveProfile(name);
+					_profileCreatedThisSession = true;
 				}
 				done = true;
 				break;


Commit: a252c9afa9f8f0bf3406151069e11602abdebb19
    https://github.com/scummvm/scummvm/commit/a252c9afa9f8f0bf3406151069e11602abdebb19
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:26+02:00

Commit Message:
EEM: proper selection of partner in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index e75fdbda8ff..a6d7f734cd0 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -45,9 +45,10 @@ const uint kPicChooseBackground = 0x8c; // _GetBackground(0x8c)
 const uint kAniJake  = 8;
 const uint kAniJenny = 9;
 
-// _DoHappiness @ 172b:27b5 — cursor X picks one of 4 rects @ 29be:030f.
+// EEM1 `_DoHappiness` — cursor X picks one of 4 rects @ 29be:030f.
 // Past rect 3 = level 4. Constexpr (Point, w, h) form to avoid a global
-// constructor (-Wglobal-constructors).
+// constructor (-Wglobal-constructors). EEM2 uses a different 10-band table
+// at 2bca:035c, handled by `happinessLevel`.
 constexpr Common::Rect kHappyZones[4] = {
 	Common::Rect(Common::Point(  0, 0),  70, 200), // far left — Jenny very happy, Jake neutral
 	Common::Rect(Common::Point( 70, 0),  56, 200), // Jenny's column
@@ -61,6 +62,13 @@ const int kJakeY  = 0x62; // 98
 const int kJennyX = 0x42; // 66
 const int kJennyY = 0x60; // 96
 
+// EEM2 `_DoChoosePartner @ 1abf:0728`: Jake is on the left and Jennifer
+// on the right, unlike the first game's partner screen.
+const int kLondonJakeX  = 0x05;
+const int kLondonJakeY  = 0x3a;
+const int kLondonJennyX = 0xac;
+const int kLondonJennyY = 0x3a;
+
 uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock,
 								  bool isLondon) {
 	if (!clueBlock)
@@ -107,7 +115,49 @@ const uint8 kJennySeqs[5][9] = {
 	{ 0,0,0,0,0,1,0,0,0 },
 };
 
-uint happinessLevel(int x) {
+// EEM2 `FUN_17ee_26f6`: 10 rects @ 2bca:035c and 10 sequence rows for
+// Jennifer @ 2bca:03ac / Jake @ 2bca:0474. The portrait cells are 20-frame
+// ramps, so using the EEM1 5-level tables makes both characters smile in the
+// wrong places.
+const int kLondonHappyRightEdges[10] = {
+	35, 70, 98, 126, 156, 182, 208, 235, 277, 320
+};
+
+const uint8 kLondonJennySeqs[10][9] = {
+	{  0,  0,  0,  0,  0,  0,  0,  1,  0 },
+	{  2,  2,  2,  2,  2,  2,  2,  3,  2 },
+	{  4,  4,  4,  4,  4,  4,  4,  5,  4 },
+	{  6,  6,  6,  6,  6,  6,  7,  6,  6 },
+	{  8,  8,  8,  8,  8,  8,  8,  8,  9 },
+	{ 10, 10, 10, 10, 10, 10, 10, 11, 10 },
+	{ 12, 12, 12, 12, 12, 12, 12, 13, 12 },
+	{ 14, 14, 14, 14, 14, 14, 14, 15, 14 },
+	{ 16, 16, 16, 16, 16, 16, 17, 16, 16 },
+	{ 18, 18, 18, 18, 18, 18, 18, 18, 19 },
+};
+
+const uint8 kLondonJakeSeqs[10][9] = {
+	{ 18, 19, 18, 18, 18, 18, 18, 18, 18 },
+	{ 16, 16, 16, 17, 16, 16, 16, 16, 16 },
+	{ 14, 14, 15, 14, 14, 14, 14, 14, 14 },
+	{ 12, 12, 12, 12, 12, 12, 13, 12, 12 },
+	{ 10, 10, 10, 10, 10, 11, 10, 10, 10 },
+	{  8,  9,  8,  8,  8,  8,  8,  8,  8 },
+	{  6,  6,  6,  7,  6,  6,  6,  6,  6 },
+	{  4,  4,  5,  4,  4,  4,  4,  4,  4 },
+	{  2,  2,  2,  2,  2,  2,  3,  2,  2 },
+	{  0,  0,  0,  0,  0,  1,  0,  0,  0 },
+};
+
+uint happinessLevel(int x, bool london) {
+	if (london) {
+		for (uint i = 0; i < ARRAYSIZE(kLondonHappyRightEdges); i++) {
+			if (x <= kLondonHappyRightEdges[i])
+				return i;
+		}
+		return ARRAYSIZE(kLondonHappyRightEdges) - 1;
+	}
+
 	for (uint i = 0; i < ARRAYSIZE(kHappyZones); i++) {
 		if (kHappyZones[i].contains(x, 100))
 			return i;
@@ -145,10 +195,13 @@ void blitRawToScreen(const Picture &p, int x, int y) {
 							   x, y, w, h);
 }
 
-// _DoChoosePartner @ 1a35:0756. The original places Jake + Jenny animations
-// on a backdrop and polls four click rectangles (two per character); we
-// approximate with a single split at x=160 (left=Jenny, right=Jake).
+// _DoChoosePartner @ 1a35:0756 / EEM2 @ 1abf:0728. The original places
+// Jake + Jenny animations on a backdrop and polls two broad character rects;
+// we approximate those with a single split at x=160. EEM1 has Jenny left /
+// Jake right; EEM2 has Jake left / Jennifer right.
 void EEMEngine::doChoosePartner() {
+	_partner = kPartnerJake;
+
 	Picture background;
 	if (!_picsArchive.getPicture(kPicChooseBackground, background)) {
 		warning("ChoosePartner background (%u) load failed", kPicChooseBackground);
@@ -166,24 +219,31 @@ void EEMEngine::doChoosePartner() {
 		return;
 	}
 
-	setAnmPalette(Common::Path("TITLE.ANM"));
+	setSitePalette(0);
+	CursorMan.showMouse(true);
 
 	// _DoChoosePartner opens with _SetMousePos(0xa0, 0x96).
+	const int jakeX = isLondon() ? kLondonJakeX : kJakeX;
+	const int jakeY = isLondon() ? kLondonJakeY : kJakeY;
+	const int jennyX = isLondon() ? kLondonJennyX : kJennyX;
+	const int jennyY = isLondon() ? kLondonJennyY : kJennyY;
+	const uint8 (*jakeSeqs)[9] = isLondon() ? kLondonJakeSeqs : kJakeSeqs;
+	const uint8 (*jennySeqs)[9] = isLondon() ? kLondonJennySeqs : kJennySeqs;
 	int curMouseX = 0xa0;
-	uint level = happinessLevel(curMouseX);
+	uint level = happinessLevel(curMouseX, isLondon());
 	uint seqIdx = 0;
 
 	blitAt(background, 0, 0);
-	blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
-		   kJennyX, kJennyY);
-	blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
-		   kJakeX, kJakeY);
+	blitAt(jennyAnim[jennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+		   jennyX, jennyY);
+	blitAt(jakeAnim [jakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+		   jakeX, jakeY);
 	g_system->updateScreen();
 
 	debugC(1, kDebugGeneral, "ChoosePartner: %u Jake frames at (%d,%d), "
 		   "%u Jenny frames at (%d,%d)",
-		   (uint)jakeAnim.size(), kJakeX, kJakeY,
-		   (uint)jennyAnim.size(), kJennyX, kJennyY);
+		   (uint)jakeAnim.size(), jakeX, jakeY,
+		   (uint)jennyAnim.size(), jennyX, jennyY);
 
 	uint32 lastTick = g_system->getMillis();
 	while (!shouldQuit()) {
@@ -195,10 +255,10 @@ void EEMEngine::doChoosePartner() {
 			lastTick = g_system->getMillis();
 			seqIdx = (seqIdx + 1) % 9;
 			blitAt(background, 0, 0);
-			blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
-				   kJennyX, kJennyY);
-			blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
-				   kJakeX, kJakeY);
+			blitAt(jennyAnim[jennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+				   jennyX, jennyY);
+			blitAt(jakeAnim [jakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+				   jakeX, jakeY);
 			g_system->updateScreen();
 		}
 
@@ -212,20 +272,23 @@ void EEMEngine::doChoosePartner() {
 			}
 			if (ev.type == Common::EVENT_MOUSEMOVE) {
 				curMouseX = ev.mouse.x;
-				const uint newLevel = happinessLevel(curMouseX);
+				const uint newLevel = happinessLevel(curMouseX, isLondon());
 				if (newLevel != level) {
 					level = newLevel;
 					seqIdx = 0; // restart cycle so the gesture pops
 					blitAt(background, 0, 0);
-					blitAt(jennyAnim[kJennySeqs[level][seqIdx % 9] % jennyAnim.size()],
-						   kJennyX, kJennyY);
-					blitAt(jakeAnim [kJakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
-						   kJakeX, kJakeY);
+					blitAt(jennyAnim[jennySeqs[level][seqIdx % 9] % jennyAnim.size()],
+						   jennyX, jennyY);
+					blitAt(jakeAnim [jakeSeqs [level][seqIdx % 9] % jakeAnim.size()],
+						   jakeX, jakeY);
 					g_system->updateScreen();
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				_partner = (ev.mouse.x >= 160) ? kPartnerJake : kPartnerJenny;
+				const bool leftHalf = ev.mouse.x < 160;
+				_partner = isLondon()
+					? (leftHalf ? kPartnerJake : kPartnerJenny)
+					: (leftHalf ? kPartnerJenny : kPartnerJake);
 				debugC(1, kDebugGeneral, "Partner picked: %s",
 					   _partner == kPartnerJake ? "Jake" : "Jennifer");
 				done = true;
@@ -233,10 +296,12 @@ void EEMEngine::doChoosePartner() {
 			}
 			if (ev.type == Common::EVENT_KEYDOWN) {
 				if (ev.kbd.keycode == Common::KEYCODE_LEFT) {
-					_partner = kPartnerJenny; done = true; break;
+					_partner = isLondon() ? kPartnerJake : kPartnerJenny;
+					done = true; break;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_RIGHT) {
-					_partner = kPartnerJake; done = true; break;
+					_partner = isLondon() ? kPartnerJenny : kPartnerJake;
+					done = true; break;
 				}
 				if (ev.kbd.keycode == Common::KEYCODE_RETURN ||
 					ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 80013325a49..705195737c8 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -390,8 +390,9 @@ Common::Error EEMEngine::run() {
 		goto screenLoop;
 
 	// EEM2 ("Eagle Eye Mysteries in London"): opening sequence, then screen
-	// 8 profile selection. A freshly-created detective starts the training
-	// case (M0); an existing profile resumes its saved menu/map state.
+	// 8 profile selection, screen 9 partner selection. A freshly-created
+	// detective starts the training case (M0) after choosing Jake/Jennifer;
+	// an existing profile resumes its saved menu/map state.
 	if (isLondon()) {
 		runLondonStartup();
 		if (!shouldQuit()) {
@@ -400,11 +401,15 @@ Common::Error EEMEngine::run() {
 			applySkipRepeatedCasesOption();
 			if (_mystery.isLoaded()) {
 				_nextScreen = kScreenMap;
-			} else if (_profileCreatedThisSession) {
-				_nextScreen = startLondonTrainingMystery()
-					? kScreenInitClues : kScreenInvalid;
 			} else {
-				_nextScreen = kScreenAction;
+				doChoosePartner();
+				(void)saveProfile(_playerName);
+				if (_profileCreatedThisSession) {
+					_nextScreen = startLondonTrainingMystery()
+						? kScreenInitClues : kScreenInvalid;
+				} else {
+					_nextScreen = kScreenAction;
+				}
 			}
 		}
 		goto screenLoop;
@@ -648,8 +653,10 @@ screenLoop:
 				applyStartupTestOverrides();
 			if (!shouldQuit())
 				applySkipRepeatedCasesOption();
-			if (!shouldQuit() && !isLondon())
+			if (!shouldQuit() && (!isLondon() || !_mystery.isLoaded()))
 				doChoosePartner();
+			if (!shouldQuit() && isLondon() && !_mystery.isLoaded())
+				(void)saveProfile(_playerName);
 			if (!shouldQuit()) {
 				if (_mystery.isLoaded())
 					_nextScreen = kScreenMap;
@@ -1240,41 +1247,59 @@ void EEMEngine::runLondonStartup() {
 
 void EEMEngine::showLondonCharSelect() {
 	// `_NewPlayer` @ 1cd3:0f27 — character creation over background PIC 0xc
-	// (palette 0). Two text fields then a Jake/Jenny pick (left/right arrow
-	// 0x4b/0x4d -> DAT_4c4c, Enter confirms). The field/box rects are
-	// constants in EEM2's data segment (read at 2bca:0e3a); they map to the
-	// `pr` player record's FirstName[12] / LastName[20]:
+	// (palette 0). Two text fields then a male/female player-gender pick
+	// (left/right arrow 0x4b/0x4d -> DAT_4c4c, Enter confirms). The
+	// field/box rects are constants in EEM2's data segment (read at
+	// 2bca:0e3a); they map to the `pr` player record's FirstName[12] /
+	// LastName[20]:
 	//   first name : (54,75)-(151,85)    last name : (167,75)-(266,85)
-	//   Jake box   : (110,116)-(120,122) Jenny box : (190,116)-(200,122)
+	//   male       : (110,116)-(120,122) female       : (190,116)-(200,122)
 	debugC(1, kDebugGeneral, "EEM2 (London): character creation");
 
 	const Common::Rect kFirstRect(54, 75, 151, 85);
 	const Common::Rect kLastRect(167, 75, 266, 85);
-	const Common::Rect kJakeBox(110, 116, 120, 122);
-	const Common::Rect kJennyBox(190, 116, 200, 122);
+	const Common::Rect kMaleBox(110, 116, 120, 122);
+	const Common::Rect kFemaleBox(190, 116, 200, 122);
 	const uint kMaxFirst = 12, kMaxLast = 20;
 	const uint8 kInkColor = 0x0F;     // typed-name ink
-	const uint8 kHiBox    = 0x0F;     // selected-partner box highlight
 
 	Picture bg;
 	const bool haveBg = _picsArchive.getPicture(0xc, bg) && !bg.surface.empty();
+	if (!haveBg)
+		warning("London: passport background PIC 0xc failed to load");
+	const uint8 boxBlankColor = haveBg
+		? (uint8)bg.surface.getPixel(kFirstRect.left, kFirstRect.top)
+		: 0x38;
+	const uint8 boxMarkColor = haveBg
+		? (uint8)bg.surface.getPixel(109, 115)
+		: 0x22;
 
 	byte pal[kPalSize];
 	byte black[kPalSize] = {};
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
 	const bool havePal = getSitePalette(0, pal);
+	if (!havePal)
+		warning("London: passport palette 0 failed to load");
 
-	CursorMan.showMouse(false);
+	Common::Event staleEvent;
+	while (g_system->getEventManager()->pollEvent(staleEvent)) {
+		if (staleEvent.type == Common::EVENT_QUIT ||
+			staleEvent.type == Common::EVENT_RETURN_TO_LAUNCHER)
+			return;
+	}
+
+	CursorMan.showMouse(true);
+	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, true);
 	_profileCreatedThisSession = false;
 
-	enum { kFieldFirst = 0, kFieldLast = 1, kFieldPartner = 2 };
+	enum { kFieldFirst = 0, kFieldLast = 1, kFieldGender = 2 };
 	int field = kFieldFirst;
 	Common::String first, last;
-	uint8 partner = kPartnerJake;
+	bool female = false;
 	bool blink = true, fadedIn = false, done = false, needRedraw = true;
-	bool partnerReady = false;
-	bool partnerCanConfirm = true;
+	bool genderReady = false;
+	bool genderCanConfirm = true;
 	uint32 blinkMs = g_system->getMillis();
 
 	while (!done && !shouldQuit()) {
@@ -1298,9 +1323,15 @@ void EEMEngine::showLondonCharSelect() {
 									 kLastRect.top + 1, kLastRect.width(),
 									 kInkColor);
 			}
-			// Highlight the selected partner's indicator box.
-			scratch.fillRect(partner == kPartnerJake ? kJakeBox : kJennyBox,
-							 kHiBox);
+			if (field == kFieldGender) {
+				// DOS restores both boxes, then fills the active one with the
+				// darker passport border color sampled at (109,115).
+				scratch.fillRect(kMaleBox, boxBlankColor);
+				scratch.fillRect(kFemaleBox, boxBlankColor);
+				scratch.fillRect(female ? kFemaleBox : kMaleBox,
+								 boxMarkColor);
+			}
+			CursorMan.showMouse(true);
 			g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 									   0, 0, kScreenWidth, kScreenHeight);
 			if (!fadedIn) {
@@ -1310,27 +1341,30 @@ void EEMEngine::showLondonCharSelect() {
 			}
 			g_system->updateScreen();
 			needRedraw = false;
-			if (field == kFieldPartner)
-				partnerReady = true;
+			if (field == kFieldGender)
+				genderReady = true;
 		}
 
 		Common::Event ev;
 		while (g_system->getEventManager()->pollEvent(ev)) {
 			if (ev.type == Common::EVENT_QUIT ||
-				ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				CursorMan.showMouse(true);
+				g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 				return;
-			if (field == kFieldPartner && ev.type == Common::EVENT_LBUTTONDOWN) {
-				if (!partnerReady)
+			}
+			if (field == kFieldGender && ev.type == Common::EVENT_LBUTTONDOWN) {
+				if (!genderReady)
 					continue;
-				partner = (ev.mouse.x < 160) ? kPartnerJake : kPartnerJenny;
+				female = ev.mouse.x >= 160;
 				done = true;
 				needRedraw = true;
 				break;
 			}
-			if (field == kFieldPartner && ev.type == Common::EVENT_KEYUP &&
+			if (field == kFieldGender && ev.type == Common::EVENT_KEYUP &&
 				(ev.kbd.keycode == Common::KEYCODE_RETURN ||
 				 ev.kbd.keycode == Common::KEYCODE_KP_ENTER)) {
-				partnerCanConfirm = true;
+				genderCanConfirm = true;
 				continue;
 			}
 			if (ev.type != Common::EVENT_KEYDOWN)
@@ -1345,10 +1379,10 @@ void EEMEngine::showLondonCharSelect() {
 					Common::String clean = buf;
 					clean.trim();
 					if (!clean.empty()) {
-						field++;  // advance to last name, then to partner pick
-						if (field == kFieldPartner) {
-							partnerReady = false;
-							partnerCanConfirm = false;
+						field++;  // advance to last name, then to gender pick
+						if (field == kFieldGender) {
+							genderReady = false;
+							genderCanConfirm = false;
 						}
 					}
 				} else if (k == Common::KEYCODE_BACKSPACE && !buf.empty()) {
@@ -1357,42 +1391,49 @@ void EEMEngine::showLondonCharSelect() {
 						   buf.size() < cap) {
 					buf += (char)ev.kbd.ascii;
 				}
-			} else {  // partner pick
+			} else {  // gender pick
 				if (k == Common::KEYCODE_LEFT) {
-					partner = kPartnerJake;
-					partnerCanConfirm = true;
+					female = false;
+					genderCanConfirm = true;
 				} else if (k == Common::KEYCODE_RIGHT) {
-					partner = kPartnerJenny;
-					partnerCanConfirm = true;
+					female = true;
+					genderCanConfirm = true;
 				} else if (k == Common::KEYCODE_BACKSPACE) {
 					field = kFieldLast;  // back to editing
-					partnerCanConfirm = true;
-				} else if (enter && partnerReady && partnerCanConfirm) {
+					genderCanConfirm = true;
+				} else if (enter && genderReady && genderCanConfirm) {
 					done = true;
 				}
 			}
 			needRedraw = true;
-			if (field == kFieldPartner && !partnerReady)
+			if (field == kFieldGender && !genderReady)
 				break;
 		}
 
 		const uint32 now = g_system->getMillis();
-		if (field != kFieldPartner && now - blinkMs >= 400) {
+		if (field != kFieldGender && now - blinkMs >= 400) {
 			blinkMs = now;
 			blink = !blink;
 			needRedraw = true;
 		}
 		g_system->delayMillis(15);
 	}
-	if (shouldQuit())
+	if (shouldQuit()) {
+		CursorMan.showMouse(true);
+		g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 		return;
+	}
 
-	Common::Event staleEvent;
 	while (g_system->getEventManager()->pollEvent(staleEvent)) {
 		if (staleEvent.type == Common::EVENT_QUIT ||
-			staleEvent.type == Common::EVENT_RETURN_TO_LAUNCHER)
+			staleEvent.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+			CursorMan.showMouse(true);
+			g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 			return;
+		}
 	}
+	CursorMan.showMouse(true);
+	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 
 	const Common::String displayName =
 		makeLondonProfileDisplayName(first, last);
@@ -1418,7 +1459,6 @@ void EEMEngine::showLondonCharSelect() {
 	// New profile. EEM2 `_NewPlayer @ 1cd3:0f27` initializes the player
 	// record only when `_LoadPlayerRecord` failed, then immediately saves it.
 	_playerName = displayName.empty() ? "Detective" : displayName;
-	_partner = partner;
 	_chainStage = 1;
 	_voiceOn = true;
 	if (_audio)
@@ -1432,9 +1472,9 @@ void EEMEngine::showLondonCharSelect() {
 	if (err.getCode() != Common::kNoError)
 		warning("London: failed to save new profile '%s': %s",
 				_playerName.c_str(), err.getDesc().c_str());
-	debugC(1, kDebugGeneral, "London: new player='%s' key=%s partner=%s",
+	debugC(1, kDebugGeneral, "London: new player='%s' key=%s gender=%s",
 		   _playerName.c_str(), profileKey.c_str(),
-		   partner == kPartnerJake ? "Jake" : "Jenny");
+		   female ? "female" : "male");
 }
 
 void EEMEngine::showFloppyStormLogo() {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 786c41a089c..89d6f9634bf 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -427,13 +427,13 @@ private:
 	/// Opening sequence + character creation — EEM2's `_DoOpeningAnims`
 	/// @ 2721:08e6 then screen 8 profile selection.
 	void runLondonStartup();
-	/// Start London mystery 0 for a freshly-created detective.
+	/// Start London mystery 0 after a freshly-created detective chooses a partner.
 	bool startLondonTrainingMystery();
 	/// Blit a full-screen still PIC and fade it in / hold / out using the
 	/// given SITEPALS. palette index.
 	void showLondonLogo(uint picId, uint palId, uint holdMs);
 	/// EEM2 character creation (`_NewPlayer`: palette 0 + background PIC 0xc):
-	/// first/last name entry + Jake/Jenny selection.
+	/// first/last name entry + male/female player-gender selection.
 	void showLondonCharSelect();
 	/// EEM2 case-intro animation (`_DoInitClues` @ 1abf:03b3): single partner
 	/// anim (Jake 0x18 / Jenny 0x71) faded in, then phone1.voc on caseType 1.


Commit: ad1a9413609fb00f111f87ae9a01ef015dd9076e
    https://github.com/scummvm/scummvm/commit/ad1a9413609fb00f111f87ae9a01ef015dd9076e
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:26+02:00

Commit Message:
EEM: proper case selection in London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index d2be151d480..365edd16d8e 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1998,9 +1998,10 @@ void EEMEngine::doActionScreen() {
 }
 
 void EEMEngine::doCaseSelection() {
-	// `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @ 1c33:0a87` —
-	// loads BOOK<stage>.NME, draws PIC 0x41 + centered "Book N" title.
-	const uint kMaxMystery = 54;
+	// `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @ 1c33:0a87`
+	// (EEM2 @ 1abf:022a / 1cd3:0a9d): load BOOK<stage>.NME, draw PIC 0x41
+	// + centered "Book N" title, then read the chosen M*.BIN.
+	const uint kMaxMystery = isLondon() ? 50 : 54;
 
 	CursorMan.showMouse(true);
 	setSitePalette(0);
@@ -2021,20 +2022,30 @@ void EEMEngine::doCaseSelection() {
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
 
-	// stage 1 (Junior, BOOK1.NME) = 1..24, stage 2 (Senior, BOOK2.NME) =
-	// 25..48, stage 3 (Master, BOOK3.NME) = 49..54.
-	uint stageLo = 1, stageHi = 0x18;
+	// EEM1: stage 1 (Junior, BOOK1.NME) = 1..24, stage 2 (Senior,
+	// BOOK2.NME) = 25..48, stage 3 (Master, BOOK3.NME) = 49..54.
+	// EEM2/London: only BOOK1.NME and BOOK2.NME ship, each with 25 cases.
+	uint stageLo = 1, stageHi = isLondon() ? 0x19 : 0x18;
 	uint book = 1;
 	switch (_chainStage) {
 	case 2:
-		stageLo = 0x19;
-		stageHi = 0x30;
+		stageLo = isLondon() ? 0x1a : 0x19;
+		stageHi = isLondon() ? 0x32 : 0x30;
 		book = 2;
 		break;
 	case 3:
-		stageLo = 0x31;
-		stageHi = 0x36;
-		book = 3;
+		if (isLondon()) {
+			// London has no BOOK3.NME. Until the London progression path is
+			// fully split from EEM1, keep the picker on the second book rather
+			// than attempting to open missing data.
+			stageLo = 0x1a;
+			stageHi = 0x32;
+			book = 2;
+		} else {
+			stageLo = 0x31;
+			stageHi = 0x36;
+			book = 3;
+		}
 		break;
 	default:
 		break;
@@ -2233,14 +2244,6 @@ void EEMEngine::doCaseSelection() {
 		return;
 
 	const uint mn = stageLo + selRow;
-	if (isLondon()) {
-		// EEM2: the menu (PIC 0x41, ANI 0x15/0x16, BOOK*.NME) is shared with
-		// EEM1, but loading a chosen mystery from the menu isn't wired up yet
-		// (the training case is started directly from run()), so stop here.
-		debugC(1, kDebugMystery,
-			   "London: selected mystery %u (menu load not implemented yet)", mn);
-		return;
-	}
 	if (!_mystery.load(mn, &_rng)) {
 		warning("doCaseSelection: failed to load mystery %u", mn);
 		_mystery.clear();


Commit: 3f0cccf90ae9b4eed827aaf1dff922b919c239dd
    https://github.com/scummvm/scummvm/commit/3f0cccf90ae9b4eed827aaf1dff922b919c239dd
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:26+02:00

Commit Message:
EEM: more side effects for clues in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/mystery.cpp
    engines/eem/mystery.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index a6d7f734cd0..86a627d6f18 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -97,6 +97,35 @@ uint markClueBlockNotebookEntries(Mystery &mystery, const byte *clueBlock,
 	return marked;
 }
 
+void updateLondonClueSite(Mystery &mystery, uint16 rawSite, bool siteOn,
+						  uint slot) {
+	if (rawSite == 0xFFFF)
+		return;
+
+	const bool conditional = (rawSite & 0x8000) != 0;
+	const uint16 siteVal = rawSite & 0x7FFF;
+	if (!conditional) {
+		if (siteVal < Mystery::kVisitedSiteCap)
+			mystery._onSites[siteVal] = siteOn ? 1 : 0;
+		return;
+	}
+
+	uint8 &seen = siteOn ? mystery._seenCONSITEs : mystery._seenCOFFSITEs;
+	const uint8 total = siteOn ? mystery.numCONSITEs() : mystery.numCOFFSITEs();
+	if (siteOn)
+		mystery._sawCONSITEs = true;
+	else
+		mystery._sawCOFFSITEs = true;
+
+	if (slot != 0)
+		return;
+
+	if (seen != 0xFF)
+		seen++;
+	if (seen == total && siteVal < Mystery::kVisitedSiteCap)
+		mystery._onSites[siteVal] = siteOn ? 1 : 0;
+}
+
 // _DoHappiness @ 172b:27b5 — per-zone sequence scripts.
 // Jake seqs @ 29be:0337 (5 × 0x14 bytes), Jenny seqs @ 29be:039b. 9 frames each;
 // the anim cells contain 10 cells = pairs of (neutral, smile) at 5 intensities.
@@ -800,30 +829,26 @@ void EEMEngine::applyClueSideEffects(const byte *c) {
 		// EEM2 `_DisplayClue @ 2542:05bd` per-entry side effects. With the
 		// shared `c = entryBase + 4` convention the EEM2 fields land at:
 		//   onsite  entry+0x22 (= c+0x1e), 5 × u16, high bit = CONSITE flag
-		//   offsite entry+0x2c (= c+0x28), 5 × u16, clears the site
+		//   offsite entry+0x2c (= c+0x28), 5 × u16, high bit = COFFSITE flag
+		//   gallery entry+0x36 (= c+0x32), 5 × u16 -> _InGallery[_newOrder[idx]]
 		//   notebook entry+0x40 (= c+0x3c), 5 × u16 -> _AddNotebook
 		//   jump    entry+0x4a (= c+0x46), destination site for direct travel
-		// (EEM2 has no gallery list here — that region is the onsite array.)
 		for (uint j = 0; j < 5; j++) {
 			const uint16 note = READ_LE_UINT16(c + 0x3c + j * 2);
 			if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
 				_mystery._cluesFound[note] = 1;
 
-			const uint16 onIdx = READ_LE_UINT16(c + 0x1e + j * 2);
-			if (onIdx != 0xFFFF) {
-				const uint16 siteVal = onIdx & 0x7FFF;
-				if (siteVal < Mystery::kVisitedSiteCap)
-					_mystery._onSites[siteVal] = 1;
-				if (onIdx & 0x8000)
-					_mystery._sawCONSITEs = true;
+			const uint16 galIdx = READ_LE_UINT16(c + 0x32 + j * 2);
+			if (galIdx != 0xFFFF && galIdx < Mystery::kGalleryCap) {
+				const uint8 phys = _mystery._newOrder[galIdx];
+				if (phys < Mystery::kGalleryCap)
+					_mystery._inGallery[phys] = 1;
 			}
 
-			const uint16 offIdx = READ_LE_UINT16(c + 0x28 + j * 2);
-			if (offIdx != 0xFFFF && (offIdx & 0x8000) == 0) {
-				const uint16 siteVal = offIdx & 0x7FFF;
-				if (siteVal < Mystery::kVisitedSiteCap)
-					_mystery._onSites[siteVal] = 0;
-			}
+			updateLondonClueSite(_mystery, READ_LE_UINT16(c + 0x1e + j * 2),
+								 true, j);
+			updateLondonClueSite(_mystery, READ_LE_UINT16(c + 0x28 + j * 2),
+								 false, j);
 		}
 		const uint16 jumpSite = READ_LE_UINT16(c + 0x46);
 		if (jumpSite != 0xFFFF)
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index bb87dbc9939..3469a5d350e 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -61,6 +61,7 @@ void Mystery::clear() {
 	memset(_visitedSite, 0, sizeof(_visitedSite));
 	memset(_onSites, 0, sizeof(_onSites));
 	_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+	_seenCOFFSITEs = _seenCONSITEs = 0;
 	_firstTry = true;
 	_searchLocationNumber = _siteNumber = 0xFFFF;
 	_lastSite = 0x1B;
@@ -154,6 +155,7 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		memset(_visitedSite, 0, sizeof(_visitedSite));
 		memset(_onSites, 0, sizeof(_onSites));
 		_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+		_seenCOFFSITEs = _seenCONSITEs = 0;
 		_firstTry = true;
 		_searchLocationNumber = _siteNumber = 0xFFFF;
 		_lastSite = 0x1B;
@@ -216,6 +218,7 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	memset(_visitedSite, 0, sizeof(_visitedSite));
 	memset(_onSites, 0, sizeof(_onSites));
 	_sawCOFFSITEs = _sawCONSITEs = _sawHelpHint = _solvedPuzzle = false;
+	_seenCOFFSITEs = _seenCONSITEs = 0;
 	_firstTry = true;
 	_searchLocationNumber = _siteNumber = 0xFFFF;
 	_lastSite = 0x1B; // _ReadMystery _LastSite sentinel.
@@ -622,6 +625,8 @@ void Mystery::syncState(Common::Serializer &s) {
 				Common::Serializer::Uint16LE);
 	if (_siteReturnDepth > kVisitedSiteCap)
 		_siteReturnDepth = 0;
+	s.syncAsByte(_seenCOFFSITEs);
+	s.syncAsByte(_seenCONSITEs);
 }
 
 } // End of namespace EEM
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 7f3d16589c3..32c22d7f806 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -166,6 +166,8 @@ public:
 	uint16 _onSites[kVisitedSiteCap]     = {};
 	bool   _sawCOFFSITEs = false;
 	bool   _sawCONSITEs  = false;
+	uint8  _seenCOFFSITEs = 0;
+	uint8  _seenCONSITEs  = 0;
 	bool   _sawHelpHint  = false;
 	bool   _solvedPuzzle = false;
 	bool   _firstTry     = true;


Commit: 66393f6f85a5f8a9a34eef14f51bc44cdc5b0a57
    https://github.com/scummvm/scummvm/commit/66393f6f85a5f8a9a34eef14f51bc44cdc5b0a57
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:27+02:00

Commit Message:
EEM: implement game progression in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 705195737c8..2b4ad77c7a7 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -240,8 +240,11 @@ void EEMEngine::applyStartupTestOverrides() {
 	if (!kDebugPopulateScrapbook1AtStartup)
 		return;
 
-	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++)
-		_mysteriesSolved[i] = 1;
+	uint lo = 0, hi = 0;
+	if (mysteryTierRange(1, lo, hi)) {
+		for (uint i = lo; i <= hi && i < sizeof(_mysteriesSolved); i++)
+			_mysteriesSolved[i] = 1;
+	}
 
 	debugC(1, kDebugGeneral,
 		   "startup test override: populated ScrapBook 1 mystery flags");
@@ -257,47 +260,68 @@ bool EEMEngine::areMysteriesSolved(uint lo, uint hi) const {
 	return true;
 }
 
+bool EEMEngine::anyMysterySolved(uint lo, uint hi) const {
+	for (uint i = lo; i <= hi && i < sizeof(_mysteriesSolved); i++) {
+		if (_mysteriesSolved[i] != 0)
+			return true;
+	}
+	return false;
+}
+
+bool EEMEngine::mysteryTierRange(uint stage, uint &lo, uint &hi) const {
+	if (isLondon()) {
+		// EEM2/London: two 25-case books, no BOOK3.NME
+		// (`_DisplayCorrect @ 1ea1:0619`).
+		switch (stage) {
+		case 1: lo = 0x01; hi = 0x19; return true;  //  1..25
+		case 2: lo = 0x1a; hi = 0x32; return true;  // 26..50
+		default: return false;
+		}
+	}
+	// EEM1: Junior 1..24, Senior 25..48, Master 49..54
+	// (`_DisplayCorrect @ 1df2:073c`).
+	switch (stage) {
+	case 1: lo = 0x01; hi = 0x18; return true;  //  1..24
+	case 2: lo = 0x19; hi = 0x30; return true;  // 25..48
+	case 3: lo = 0x31; hi = 0x36; return true;  // 49..54
+	default: return false;
+	}
+}
+
 void EEMEngine::advanceChainStageAfterSolve(uint mysteryNum) {
 	if (mysteryNum == 0 || _chainStage >= 4)
 		return;
 
-	uint lo = 0;
-	uint hi = 0;
-	switch (_chainStage) {
-	case 1:
-		lo = 1;
-		hi = 0x18;
-		break;
-	case 2:
-		lo = 0x19;
-		hi = 0x30;
-		break;
-	case 3:
-		lo = 0x31;
-		hi = 0x36;
-		break;
-	default:
+	uint lo = 0, hi = 0;
+	if (!mysteryTierRange(_chainStage, lo, hi))
 		return;
-	}
-
 	if (!areMysteriesSolved(lo, hi))
 		return;
 
 	const uint oldStage = _chainStage;
-	// Book 2 repeats the Book 1 cases; this option keeps the original solve
-	// state but jumps the profile's active chain straight to Book 3.
-	if (_chainStage == 1 && ConfMan.getBool("skip_repeated_cases"))
+	// EEM1 only: Book 2 repeats the Book 1 cases, so this option keeps the
+	// original solve state but jumps the active chain straight to Book 3.
+	// London has 50 distinct cases and no Book 3, so the option does not apply.
+	if (!isLondon() && _chainStage == 1 &&
+		ConfMan.getBool("skip_repeated_cases"))
 		_chainStage = 3;
 	else
 		_chainStage++;
 
+	// London has only two books: `_DisplayCorrect @ 1ea1:0619` collapses
+	// chainStage 3 straight to 4 (every case solved — game complete).
+	if (isLondon() && _chainStage == 3)
+		_chainStage = 4;
+
 	debugC(1, kDebugMystery,
 		   "chainStage advanced from %u to %u after solving mystery %u",
 		   oldStage, _chainStage, mysteryNum);
 }
 
 void EEMEngine::applySkipRepeatedCasesOption() {
-	if (!ConfMan.getBool("skip_repeated_cases"))
+	// EEM1 only — the option jumps past the repeated Book 2 to Book 3, which
+	// London does not have (see `advanceChainStageAfterSolve`).
+	if (isLondon() || !ConfMan.getBool("skip_repeated_cases"))
 		return;
 	if (_mystery.isLoaded())
 		return;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 89d6f9634bf..8bc4136c4a4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -302,6 +302,22 @@ public:
 private:
 	void applyStartupTestOverrides();
 	bool areMysteriesSolved(uint lo, uint hi) const;
+
+	/// True if *any* mystery in the inclusive 1-based range [lo, hi] is
+	/// marked solved.
+	bool anyMysterySolved(uint lo, uint hi) const;
+
+	/// Number of case "books"/tiers in this variant. EEM1 ships three
+	/// (BOOK1..3.NME — 24/24/6 cases); EEM2/London ships two (BOOK1..2.NME —
+	/// 25 cases each, no BOOK3.NME). See `mysteryTierRange`.
+	uint mysteryTierCount() const { return isLondon() ? 2 : 3; }
+
+	/// Inclusive 1-based mystery range [lo, hi] for chain `stage` (1-based).
+	/// Returns false when `stage` is not a real tier in this variant.
+	/// EEM1 `_DisplayCorrect @ 1df2:073c` (1..24 / 25..48 / 49..54);
+	/// EEM2/London `_DisplayCorrect @ 1ea1:0619` (1..25 / 26..50).
+	bool mysteryTierRange(uint stage, uint &lo, uint &hi) const;
+
 	void advanceChainStageAfterSolve(uint mysteryNum);
 	void applySkipRepeatedCasesOption();
 
@@ -467,9 +483,10 @@ private:
 	///   +1 → next mystery (RIGHT/SPACE/Enter/click on last page).
 	int doShowEnding(uint num, bool firstPage = true);
 
-	/// `_ShowScrapbook(stage, 0) @ 1f78:0642`. Mystery range
-	/// `(stage-1)*0x18+1 .. (stage-1)*0x18+0x18`; skips unsolved
-	/// entries in the current chain stage.
+	/// EEM1 `_ShowScrapbook(stage, 0) @ 1f78:0642`; EEM2/London scrapbook
+	/// `FUN_2046_09dd`. Walks the `mysteryTierRange(stage)` cases, skipping
+	/// unsolved entries in the current chain stage. Tier sizes are
+	/// variant-specific (EEM1 24/24/6; London 25/25, no stage 3).
 	void doShowScrapbook(uint stage);
 
 	void doActionScreen();
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 365edd16d8e..2613a680aee 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -612,6 +612,7 @@ struct ActionMenuView {
 	const char *const *pickLabel;
 	const bool *pickEnabled;
 	uint pick;
+	uint numPicks;  ///< Visible entries (London drops ScrapBook 3 → 4).
 };
 
 // `_DoChooseMystery @ 1a35:02b7` — opens BOOK%u.NME (CRLF strings, up
@@ -812,9 +813,10 @@ void drawActionMenuFrame(const ActionMenuView &v) {
 		const int kListY0 = 35;
 		const int kLineH  = 10;
 
-		// 11 rows: separator/item pairs (0=sep, 1=Choose A Mystery, ...,
-		// 9=See Scrapbook 3, 10=sep).
-		for (int r = 0; r < 11; r++) {
+		// Separator/item pairs (0=sep, 1=Choose A Mystery, ..., trailing sep):
+		// 11 rows for EEM1's five picks, 9 for London's four (no ScrapBook 3).
+		const int kRows = (int)(2 * v.numPicks + 1);
+		for (int r = 0; r < kRows; r++) {
 			const int y = kListY0 + r * kLineH;
 			if ((r & 1) == 0) {
 				v.vm->getFont().drawString(&scratch, v.separator,
@@ -1410,15 +1412,18 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 }
 
 void EEMEngine::doShowScrapbook(uint stage) {
-	// `_ShowScrapbook(stage, 0) @ 1f78:0642`. 24-entry tiers; ending
-	// viewer returns -1/0/+1 for prev/close/next. Current tier skips
-	// unsolved entries.
-	if (stage < 1 || stage > 3)
+	// EEM1 `_ShowScrapbook(stage, 0) @ 1f78:0642`; EEM2/London scrapbook
+	// `FUN_2046_09dd`. Ending viewer returns -1/0/+1 for prev/close/next;
+	// current tier skips unsolved entries. Tier sizes are variant-specific
+	// (EEM1: 24/24/6 cases over three books; London: 25/25 over two books,
+	// no BOOK3.NME) — `mysteryTierRange` returns false for London stage 3.
+	uint tierLo = 0, tierHi = 0;
+	if (stage < 1 || !mysteryTierRange(stage, tierLo, tierHi))
 		return;
 	const int solvedCount =
 		(int)(sizeof(_mysteriesSolved) / sizeof(_mysteriesSolved[0]));
-	const int lo = (int)(stage - 1) * 0x18 + 1;
-	const int hi = MIN<int>(lo + 0x18, solvedCount);
+	const int lo = (int)tierLo;
+	const int hi = MIN<int>((int)tierHi + 1, solvedCount);
 	if (lo >= hi)
 		return;
 	const bool currentTier = (stage == _chainStage);
@@ -1664,7 +1669,9 @@ void EEMEngine::doSetup() {
 				dirty = true;
 				continue;
 			}
-			if (kScrap3Btn.contains(mx, my) && _chainStage >= 3) {
+			// London has no third book (`mysteryTierRange` rejects stage 3).
+			if (kScrap3Btn.contains(mx, my) && !isLondon() &&
+				_chainStage >= 3) {
 				doShowScrapbook(3);
 				setSitePalette(0);
 				dirty = true;
@@ -1787,32 +1794,22 @@ void EEMEngine::doActionScreen() {
 		"         Ver Recortes  3"
 	};
 	const char * const *kPickLabel = isSpanish() ? kPickLabelES : kPickLabelEN;
-	// Gating @ 1c33:19d1-1a70 by chain stage + per-tier solves:
+	// London has no BOOK3.NME, so its action menu offers four entries
+	// (Choose / Practice / ScrapBook 1 / ScrapBook 2). EEM1 adds ScrapBook 3.
+	const uint numPicks = isLondon() ? (uint)kPickScrap3 : (uint)kNumPicks;
+
+	// Gating @ EEM1 1c33:19d1-1a70 by chain stage + per-tier solves:
 	//   stage 1: grey SB2/3; SB1 needs any tier-1 solve
 	//   stage 2: grey Practice + SB3; SB2 needs tier-2 solve
-	//   stage 3: grey Practice; SB3 needs tier-3 solve
+	//   stage 3: grey Practice; SB3 needs tier-3 solve (EEM1 only)
 	//   stage 4: grey Choose + Practice
-	bool anySolved1 = false;
-	for (uint i = 1; i <= 0x18 && i < sizeof(_mysteriesSolved); i++) {
-		if (_mysteriesSolved[i]) {
-			anySolved1 = true;
-			break;
-		}
-	}
-	bool anySolved2 = false;
-	for (uint i = 0x19; i <= 0x30 && i < sizeof(_mysteriesSolved); i++) {
-		if (_mysteriesSolved[i]) {
-			anySolved2 = true;
-			break;
-		}
-	}
-	bool anySolved3 = false;
-	for (uint i = 0x31; i <= 0x36 && i < sizeof(_mysteriesSolved); i++) {
-		if (_mysteriesSolved[i]) {
-			anySolved3 = true;
-			break;
-		}
-	}
+	uint loT = 0, hiT = 0;
+	const bool anySolved1 = mysteryTierRange(1, loT, hiT) &&
+							anyMysterySolved(loT, hiT);
+	const bool anySolved2 = mysteryTierRange(2, loT, hiT) &&
+							anyMysterySolved(loT, hiT);
+	const bool haveTier3 = mysteryTierRange(3, loT, hiT);
+	const bool anySolved3 = haveTier3 && anyMysterySolved(loT, hiT);
 
 	const bool chooseOn   = _chainStage < 4;
 	const bool practiceOn = _chainStage <= 1;
@@ -1821,13 +1818,13 @@ void EEMEngine::doActionScreen() {
 	const bool scrap2On =
 		_chainStage >= 3 || (_chainStage == 2 && anySolved2);
 	const bool scrap3On =
-		_chainStage >= 4 || (_chainStage == 3 && anySolved3);
+		haveTier3 && (_chainStage >= 4 || (_chainStage == 3 && anySolved3));
 	const bool kPickEnabled[kNumPicks] = {
 		chooseOn, practiceOn, scrap1On, scrap2On, scrap3On
 	};
 	// Seed selection on first enabled entry.
 	uint pick = 0;
-	for (uint i = 0; i < kNumPicks; i++) {
+	for (uint i = 0; i < numPicks; i++) {
 		if (kPickEnabled[i]) {
 			pick = i;
 			break;
@@ -1862,6 +1859,7 @@ void EEMEngine::doActionScreen() {
 	v.pickLabel = kPickLabel;
 	v.pickEnabled = kPickEnabled;
 	v.pick = pick;
+	v.numPicks = numPicks;
 
 	drawActionMenuFrame(v);
 	uint32 chooserLastTick = g_system->getMillis();
@@ -1895,7 +1893,7 @@ void EEMEngine::doActionScreen() {
 					const int row = (ev.mouse.y - kListRect.top) / kLineH;
 					if ((row & 1) == 1) {
 						const uint mp = (uint)(row >> 1);
-						if (mp < kNumPicks && kPickEnabled[mp]) {
+						if (mp < numPicks && kPickEnabled[mp]) {
 							pick = mp;
 							v.pick = pick;
 							drawActionMenuFrame(v);
@@ -1919,8 +1917,8 @@ void EEMEngine::doActionScreen() {
 			}
 			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
 				// `_DoChoose` arrow handlers @ 1c33:0514, bounded loop.
-				for (int i = 0; i < (int)kNumPicks; i++) {
-					pick = (pick == 0) ? (uint)(kNumPicks - 1) : pick - 1;
+				for (int i = 0; i < (int)numPicks; i++) {
+					pick = (pick == 0) ? (numPicks - 1) : pick - 1;
 					if (kPickEnabled[pick])
 						break;
 				}
@@ -1930,8 +1928,8 @@ void EEMEngine::doActionScreen() {
 			}
 			if (k == Common::KEYCODE_DOWN || k == Common::KEYCODE_RIGHT ||
 				k == Common::KEYCODE_TAB) {
-				for (int i = 0; i < (int)kNumPicks; i++) {
-					pick = (pick + 1) % kNumPicks;
+				for (int i = 0; i < (int)numPicks; i++) {
+					pick = (pick + 1) % numPicks;
 					if (kPickEnabled[pick])
 						break;
 				}
@@ -2022,34 +2020,20 @@ void EEMEngine::doCaseSelection() {
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
 
-	// EEM1: stage 1 (Junior, BOOK1.NME) = 1..24, stage 2 (Senior,
-	// BOOK2.NME) = 25..48, stage 3 (Master, BOOK3.NME) = 49..54.
-	// EEM2/London: only BOOK1.NME and BOOK2.NME ship, each with 25 cases.
-	uint stageLo = 1, stageHi = isLondon() ? 0x19 : 0x18;
-	uint book = 1;
+	// Tier → BOOK<n>.NME. EEM1 ships three books (Junior 1..24, Senior
+	// 25..48, Master 49..54); EEM2/London ships two (1..25 / 26..50). London
+	// has no BOOK3.NME, and its stage 3 is transient (`_DisplayCorrect @
+	// 1ea1:0619` collapses it to 4), so should it ever be reached here it
+	// reuses BOOK2.NME rather than opening a missing file. `mysteryTierRange`
+	// is the single source of truth for the per-tier mystery ranges.
+	uint book;
 	switch (_chainStage) {
-	case 2:
-		stageLo = isLondon() ? 0x1a : 0x19;
-		stageHi = isLondon() ? 0x32 : 0x30;
-		book = 2;
-		break;
-	case 3:
-		if (isLondon()) {
-			// London has no BOOK3.NME. Until the London progression path is
-			// fully split from EEM1, keep the picker on the second book rather
-			// than attempting to open missing data.
-			stageLo = 0x1a;
-			stageHi = 0x32;
-			book = 2;
-		} else {
-			stageLo = 0x31;
-			stageHi = 0x36;
-			book = 3;
-		}
-		break;
-	default:
-		break;
+	case 2:  book = 2; break;
+	case 3:  book = isLondon() ? 2 : 3; break;
+	default: book = 1; break;
 	}
+	uint stageLo = 0, stageHi = 0;
+	mysteryTierRange(book, stageLo, stageHi);
 	if (stageHi > kMaxMystery)
 		stageHi = kMaxMystery;
 
@@ -3889,10 +3873,11 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 }
 
 bool EEMEngine::doAccuseNotes() {
-	// `_DoAccuse @ 1df2:0bdd` head. BG PIC 0x1A7. `_AccuseNoteRect @
-	// 29be:1048` = (79, 27, 304, 159). Counter @ (209, 11) shows
-	// `6 - chainStage` (stage 1=5, 2=4, 3=3 clues). Unselected color 1
-	// (red), selected 0x3c. Click toggles selection; `_NoteButtons[4]`
+	// `_DoAccuse @ 1df2:0bdd` (EEM2 `@ 1ea1:0c03`) head. BG PIC 0x1A7.
+	// `_AccuseNoteRect @ 29be:1048` = (79, 27, 304, 159). Counter @ (209, 11)
+	// shows the required clue count (EEM1 `6 - chainStage` = 5/4/3 by tier;
+	// London a flat 5 — see `expected` below). Unselected color 1 (red),
+	// selected 0x3c. Click toggles selection; `_NoteButtons[4]`
 	// (180,174,201,190) SOLVE → `_HandleAccuseNoteButton` returns 2.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return false;
@@ -3907,9 +3892,13 @@ bool EEMEngine::doAccuseNotes() {
 	// `FUN_1d40_0e07 @ 1d40:0e34` zeroes _NoteSelected_Floppy on entry.
 	memset(_mystery._noteSelected, 0, sizeof(_mystery._noteSelected));
 
-	const uint expected = (_chainStage >= 1 && _chainStage <= 3)
-		? (uint)(6 - _chainStage)
-		: 5;
+	// EEM1 `_DoAccuse @ 1df2:0bdd` scales the required clue count by tier
+	// (`6 - chainStage`: stage 1=5, 2=4, 3=3). London `_DoAccuse @ 1ea1:0c03`
+	// hardcodes 5 (`local_a = 5`) for both books.
+	const uint expected = isLondon()
+		? 5
+		: ((_chainStage >= 1 && _chainStage <= 3) ? (uint)(6 - _chainStage)
+												  : 5);
 
 	// `_DrawNotes(NULL, 100, ...)` walks `_CluesFound[]`.
 	Common::Array<uint> found;
@@ -4199,7 +4188,7 @@ void EEMEngine::doAccuse() {
 	}
 
 	// `_DoAccuse @ 1df2:0bdd` + `_DoAccuseGallery @ 1df2:0a31`:
-	//   1. Accuse-notes (PIC 0x1A7) — pick `6 - chainStage` clues.
+	//   1. Accuse-notes (PIC 0x1A7) — pick `6 - chainStage` clues (London: 5).
 	//      Pass `_SolvedCheck` → gallery; fail → hint + return.
 	//   2. KD intro balloon (`KDTextIndex[+8]` + `_SayKDDigital(4)`).
 	//   3. PIC 0x3f + `_DrawGallery` portraits at 5 slots (29be:0x116).
@@ -5083,7 +5072,8 @@ void EEMEngine::doAccuseFloppy() {
 	}
 
 	// Clue selection — `FUN_1d40_0e07 @ 1d40:0e07`. Pick `6 - chainStage`
-	// clues (1d40:0e34: `local_c = 6 - DAT_28da_3052`).
+	// clues (1d40:0e34: `local_c = 6 - DAT_28da_3052`); London a flat 5.
+	// Count handled inside `doAccuseNotes`.
 	if (!doAccuseNotes()) {
 		if (_nextScreen == kScreenAccuse) {
 			_nextScreen = _lastScreen != kScreenInvalid


Commit: 7ca4b9f330a53a1f958f6322ba5b2a5355eff4c7
    https://github.com/scummvm/scummvm/commit/7ca4b9f330a53a1f958f6322ba5b2a5355eff4c7
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:27+02:00

Commit Message:
EEM: added missing musical cue in London

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 86a627d6f18..2887d99ab96 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -34,6 +34,7 @@
 #include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/music.h"
 #include "eem/site.h"
 
 // Clue / briefing pipeline (SCRIPT.C + KD.C).
@@ -929,6 +930,18 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
 		const byte *c = clueBlock + 4 + i * stride;
 
+		// EEM2 `_DisplayClue @ 2542:05bd` opens each clue with a per-entry MIDI
+		// cue read at entry+0x20 (= c+0x1c, the u16 right after the two voice
+		// slots), replacing any current track (`playMus`→`playFile` calls
+		// `stop()`). 0 = no cue; EEM1 clue entries have no such field. Gated on
+		// `_voiceOn` like `startTravelMusic` — the DOS gate is the separate
+		// music-on / MIDI-available flags (DAT_3036_4cc0 && DAT_2bca_146a).
+		if (isLondon() && _music && _voiceOn) {
+			const uint16 clueMusic = READ_LE_UINT16(c + 0x1c);
+			if (clueMusic != 0)
+				_music->playMus(clueMusic, /* loop= */ false);
+		}
+
 		// _DisplayClue @ 2404:0635-064b: _DoKDAnim(num) runs before the
 		// speaker portrait. EEM1 stores the KD-anim number at +0x3a; EEM2
 		// `_DisplayClue @ 2542:05bd` reads it at entry+0x52 (= c+0x4e).


Commit: 53af04e55c17668bf39c9b907ad0bd0379462dc1
    https://github.com/scummvm/scummvm/commit/53af04e55c17668bf39c9b907ad0bd0379462dc1
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:27+02:00

Commit Message:
EEM: added missing conversation opcodes in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 2887d99ab96..0abed334700 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -729,22 +729,23 @@ void EEMEngine::doInitClues() {
 	}
 }
 
-// _ParseString @ 1b66:07c3 (jump table @ 1b66:0cbe). Each handler reads
-// _Partner (u16 @ 0x7918) and indexes the name table @ 29be:0c28
-// ({Jake, Jennifer, he, she, him, her, his} as far pointers).
+// _ParseString @ EEM1 1b66:07c3 / EEM2 1bff:07c4 (jump table @ EEM2
+// CS:0xd2f). Each handler indexes a name table of far pointers
+// ({Jake, Jennifer, he, she, him, her, his}).
 //   0x80 player name (auto-cap word starts, uses _PlayerRecord)
 //   0x81 _Partner == 0 ? "Jake"     : "Jennifer"  (chosen detective)
 //   0x82 _Partner == 0 ? "Jennifer" : "Jake"      (the OTHER one)
 //   0x83 _Partner == 0 ? "he"       : "she"
 //   0x84 _Partner == 0 ? "him"      : "her"
 //   0x85 _Partner == 0 ? "his"      : "her"
-//   0x86..0x88 mirror 0x83..0x85 but branch on a separate gender flag
-//     (DAT_29be_7985, handlers @ 1b66:0ad2/0b41/0bb0). That flag has no
-//     writers anywhere in EEMCD.EXE — only the three handlers read it —
-//     and no shipping mystery text (CD or floppy) contains the 0x86/0x87
-//     /0x88 bytes inside parseString-formatted strings. The opcodes are
-//     dead in the original engine and dead in our data, so we drop them
-//     silently (which matches the always-0 flag path in DOS: he/him/his).
+//   0x86..0x88 emit the same he/him/his vs she/her/her strings as 0x83..0x85
+//     but branch on a SEPARATE gender flag, not _Partner (EEM2 handlers
+//     @ 1bff:0ad3/0b42/0bb1 test [0x930c]; the partner handlers test
+//     [0x9294]). They are the PLAYER's pronouns: EEM2/London sets that flag
+//     from the passport gender pick (`_NewPlayer` DAT_3036_4c4c), and London
+//     dialogue uses them (e.g. M13/M50.BIN "You and <86>..."). EEM1 never
+//     writes the flag (DAT_29be_7985) and ships no text using these bytes, so
+//     its always-male path is reproduced by `_playerFemale` defaulting false.
 //   0x89 KD hint placeholder (caller handles).
 Common::String EEMEngine::parseString(const Common::String &raw,
 									  const Common::String &playerName,
@@ -773,11 +774,16 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 			out += isJake ? "his" : "her";
 			break;
 		case 0x86:
+			// Player pronoun (passport gender), mirror of 0x83.
+			out += _playerFemale ? "she" : "he";
+			break;
 		case 0x87:
+			// Player pronoun (passport gender), mirror of 0x84.
+			out += _playerFemale ? "her" : "him";
+			break;
 		case 0x88:
-			// Stubbed suspect-gender pronouns; the DOS flag they branch
-			// on is never written and no mystery msg uses these bytes
-			// (see jumptable comment above).
+			// Player pronoun (passport gender), mirror of 0x85.
+			out += _playerFemale ? "her" : "his";
 			break;
 		case 0x89:
 			// KD hint placeholder (caller handles before this point).
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 2b4ad77c7a7..660c2ffe547 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1484,6 +1484,10 @@ void EEMEngine::showLondonCharSelect() {
 	// record only when `_LoadPlayerRecord` failed, then immediately saves it.
 	_playerName = displayName.empty() ? "Detective" : displayName;
 	_chainStage = 1;
+	// `_NewPlayer @ 1cd3:0f27` stores the passport gender pick (`DAT_3036_4c4c`).
+	// Drives the player pronouns 0x86-0x88 in `parseString`. (Existing profiles
+	// take the gender from their save instead, via the load path above.)
+	_playerFemale = female;
 	_voiceOn = true;
 	if (_audio)
 		_audio->setVoiceEnabled(_voiceOn);
@@ -1666,6 +1670,10 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	s.syncAsByte(_chainStage);
 	s.syncAsByte(_voiceOn);
 
+	// London passport gender (player pronouns 0x86-0x88).
+	byte playerFemale = _playerFemale ? 1 : 0;
+	s.syncAsByte(playerFemale);
+
 	// Mid-case resume: persist in-progress mystery (no equivalent in
 	// _LoadGame @ 2404:0dc7).
 	bool hasMystery = _mystery.isLoaded();
@@ -1706,6 +1714,11 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 	if (_audio)
 		_audio->setVoiceEnabled(_voiceOn);
 
+	// London passport gender (player pronouns 0x86-0x88).
+	byte playerFemale = 0;
+	s.syncAsByte(playerFemale);
+	_playerFemale = (playerFemale != 0);
+
 	bool hasMystery = false;
 	s.syncAsByte(hasMystery);
 	if (hasMystery) {
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 8bc4136c4a4..162a041f1c4 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -551,6 +551,13 @@ private:
 
 	Common::String _playerName;  ///< Substituted into 0x80 placeholders.
 
+	/// London passport gender (EEM2 `_NewPlayer @ 1cd3:0f27` gender pick,
+	/// `DAT_3036_4c4c`: left/0 = male, right/1 = female). Drives the player
+	/// pronoun opcodes 0x86/0x87/0x88 in `parseString` (he·him·his / she·her·
+	/// her). EEM1 has no passport, so it stays false (male) — matching that
+	/// engine's never-written gender flag.
+	bool _playerFemale = false;
+
 	/// `_PlayerRecord.SolvedMysteries[55]`. 0=unsolved, 1=solved, 2=first-try.
 	uint8 _mysteriesSolved[55] = {};
 


Commit: 076d0f0633ade0b876e9a7851fe427f08340d15b
    https://github.com/scummvm/scummvm/commit/076d0f0633ade0b876e9a7851fe427f08340d15b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:28+02:00

Commit Message:
EEM: improved TRAVIS UI in London

Changed paths:
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 3469a5d350e..edf8261849f 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -402,6 +402,12 @@ uint16 Mystery::noteIndexCount() const {
 	return (uint16)((_galleryOffset - _noteOffset) / stride);
 }
 
+uint Mystery::noteSectionSize() const {
+	if (!isLoaded() || _galleryOffset <= _noteOffset)
+		return 0;
+	return _galleryOffset - _noteOffset;
+}
+
 bool Mystery::noteHasNotebookText(uint clueId) const {
 	const byte *ni = noteIndex();
 	const uint16 cnt = noteIndexCount();
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 32c22d7f806..54830f70e50 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -83,11 +83,19 @@ public:
 	/// u8 nameLen, nameLen bytes of name.
 	const byte *floppySuspectEntry(uint suspectIdx) const;
 
-	/// NoteIndex array (4 bytes per entry: u16 textOff + u16 pts).
+	/// NoteIndex array. EEM1 CD: 4 bytes/entry (u16 textOff + u16 pts).
+	/// EEM2/London CD: 2 bytes/entry (u16 textOff only — no points field;
+	/// `_DrawNotes @ 16a0:01de` reads `noteIndex[clueId*2]`). Floppy: 7
+	/// bytes/entry.
 	const byte *noteIndex() const;
 
 	uint16 noteIndexCount() const;
 
+	/// Raw byte size of the CD NoteIndex section [_noteOffset, _galleryOffset).
+	/// The notebook divides this by the variant stride (EEM1 4 / London 2) to
+	/// get the clue count, since `noteIndexCount()` assumes the 4-byte stride.
+	uint noteSectionSize() const;
+
 	/// True when clueId has a notebook text entry. Floppy dialog records
 	/// may be spoken-only with zero notebook offset, skipped by _DrawNotes_Floppy.
 	bool noteHasNotebookText(uint clueId) const;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2613a680aee..ed11bb57e91 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -271,6 +271,10 @@ constexpr Common::Rect kPdaAccuseRect(Common::Point(180, 174), 21, 16);
 constexpr Common::Rect kPdaPageNextRect(Common::Point(204, 174), 20, 16);
 constexpr Common::Rect kPdaPagePrevRect(Common::Point(226, 174), 21, 16);
 constexpr Common::Rect kPdaHelp2Rect(Common::Point(267, 174), 21, 16);
+// London-only: `_NoteButtons[9]` = (0,0,66,79) → site (EEM1's slot 9 is a
+// dead 0-rect). A secondary "close the PDA" hotspot over the device's
+// top-left corner. EEM2 `_HandleNoteButton` slot 9 → screen 3.
+constexpr Common::Rect kPdaLondonCloseRect(Common::Point(0, 0), 66, 79);
 
 constexpr uint16 kProfilePickerRevealPic = 0x105;
 constexpr int kProfilePickerRevealX = 0x3e;
@@ -2257,6 +2261,21 @@ void EEMEngine::doNotebook() {
 	//   [9] (  0,  0,  0,  0)  same exit as [8]
 	//   [10] (267,174,288,190) → `_InterfaceHelp(0)` again          (0x3f9)
 	// BG PIC 0x3f; partner ANI 1 (Jake) / 0xb (Jenny) at (5, 80).
+	//
+	// EEM2/London (`_DoNotebook @ 16a0:0517`, `_HandleNoteButton @ 16a0:03dd`,
+	// jumptable @ 16a0:0503) reuses the SAME button rect table (2bca:0151) and
+	// handler set, but reassigns two slots and revives slot 9 (verified by
+	// disassembly — `[0x9292]` is the next-screen code):
+	//   [1] ( 93,174,115,190) MAP  → screen 2  (EEM1: a 2nd InterfaceHelp).
+	//                         London's dedicated map button.
+	//   [7] (  7,177, 57,200) DOS EEM2 → SITE, but we keep it as MAP (→ 2) in
+	//                         both variants — the EEM1 partner-foot map shortcut
+	//                         (a player convenience alongside button [1]).
+	//   [9] (  0,  0, 66, 79) SITE → screen 3  (EEM1: dead 0-rect)
+	// Everything else (gallery, accuse, host hint, page next/prev, help [10],
+	// site [8]) is identical. The note rendering, pagination, partner ANI
+	// (same 1/0xb), and gizmo colour-cycle are shared as-is; only button [1]
+	// (London → map) and the extra close area [9] are gated on `isLondon()`.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
@@ -2307,12 +2326,20 @@ void EEMEngine::doNotebook() {
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				// Earlier rects win on overlap (matches `_FindButton`).
-				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y)) {
+				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y) ||
+					(isLondon() &&
+					 kPdaLondonCloseRect.contains(ev.mouse.x, ev.mouse.y))) {
 					_nextScreen = kScreenSite;
 					exitFlag = true;
 					break;  // back to site
 				}
 				if (kPdaPartnerFootMapRect.contains(ev.mouse.x, ev.mouse.y)) {
+					// EEM1 `_NoteButtons[7]` → map (screen 2). DOS EEM2 reassigns
+					// this slot to SITE, but we intentionally keep the partner-
+					// foot hotspot as a map shortcut in BOTH variants for parity
+					// with EEM1. London also has its own dedicated map button
+					// (`kPdaHelpRect` below) and can still close the PDA via the
+					// site button (35,111) or the top-left corner (0,0,66,79).
 					_nextScreen = kScreenMapAlt;
 					exitFlag = true;
 					break;
@@ -2334,7 +2361,14 @@ void EEMEngine::doNotebook() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// rect 1 → `_InterfaceHelp(0)` (PICs 0x63 / 0x1ae).
+					// `_NoteButtons[1]`. EEM1: a 2nd `_InterfaceHelp(0)` button
+					// (PICs 0x63 / 0x1ae). London reassigns this slot to MAP
+					// (`_HandleNoteButton` slot 1 → screen 2).
+					if (isLondon()) {
+						_nextScreen = kScreenMapAlt;
+						exitFlag = true;
+						break;
+					}
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
@@ -2404,7 +2438,11 @@ Common::String EEMEngine::notebookNoteText(uint clueId, const byte *ni,
 		return parseString(Common::String(p, len),
 						   _playerName, _partner);
 	}
-	const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
+	// EEM1 CD: 4-byte entries (textOff at +0, points at +2). EEM2/London CD:
+	// 2-byte entries (textOff only) — `_DrawNotes @ 16a0:01de` reads
+	// `noteIndex[clueId*2]`.
+	const uint stride = isLondon() ? 2 : 4;
+	const uint16 textOff = READ_LE_UINT16(ni + clueId * stride);
 	return parseString(_mystery.textAt(textOff),
 					   _playerName, _partner);
 }
@@ -2428,13 +2466,24 @@ void EEMEngine::drawNotebookFrame(int &page) {
 
 	// `_DrawNotes` walks `_NoteIndex` for current page; word-wraps each
 	// found clue in `_NotebookRect`. Selected = color 0x3c.
+	// EEM2/London NoteIndex entries are 2 bytes (no points field), so its real
+	// clue count is the section size / 2. `noteIndexCount()` assumes the EEM1
+	// 4-byte stride (and stays that way for the accuse-scoring path, which is a
+	// separate London concern), so derive the London-correct count here — else
+	// high-id notes get dropped.
+	const uint16 londonCount = isLondon()
+		? (uint16)(_mystery.noteSectionSize() / 2) : 0;
 	Common::Array<uint> found;
 	for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
-		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
+		const bool hasText = isLondon()
+			? (i < londonCount)
+			: _mystery.noteHasNotebookText(i);
+		if (_mystery._cluesFound[i] && hasText)
 			found.push_back(i);
 	}
 	const byte *ni = _mystery.noteIndex();
-	const uint16 niCount = _mystery.noteIndexCount();
+	const uint16 niCount = isLondon()
+		? londonCount : _mystery.noteIndexCount();
 
 	const int kRectX = kNotebookRect.left;
 	const int kRectY = kNotebookRect.top;
@@ -2619,7 +2668,15 @@ void EEMEngine::doGallery() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// rect 1 → `_InterfaceHelp(0)` (158f:0625).
+					// EEM1 slot 1 → a 2nd `_InterfaceHelp(0)` (158f:0625).
+					// EEM2 gallery `_HandleGalleryButton @ 160e:05c7` slot 1 →
+					// MAP (`MOV [0x9292],2` @ 160e:05fe) — London's dedicated
+					// map button, same as the notebook/accuse PDA bar.
+					if (isLondon()) {
+						_nextScreen = kScreenMapAlt;
+						exitFlag = true;
+						break;
+					}
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					lastDraw = 0;
@@ -2877,6 +2934,14 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 						back = true;
 						break;
 					}
+					if (isLondon() && kPdaHelpRect.contains(mx, my)) {
+						// EEM2 gallery slot 1 → MAP (dedicated map button);
+						// only kPdaHelp2Rect (267,174) stays help in London.
+						_nextScreen = kScreenMapAlt;
+						exitGallery = true;
+						back = true;
+						break;
+					}
 					if (kPdaHelpRect.contains(mx, my) ||
 						kPdaHelp2Rect.contains(mx, my)) {
 						setInteractiveMouseCursor(false);
@@ -4011,6 +4076,13 @@ bool EEMEngine::doAccuseNotes() {
 					_nextScreen = kScreenGallery;
 					return false;
 				}
+				if (isLondon() && kPdaHelpRect.contains(mx, my)) {
+					// EEM2 accuse `_HandleAccuseNoteButton @ 1ea1:0873` slot 1
+					// → MAP (`MOV [0x9292],2` @ 1ea1:089b) — London's dedicated
+					// map button; only kPdaHelp2Rect (267,174) stays help.
+					_nextScreen = kScreenMapAlt;
+					return false;
+				}
 				if (kPdaHelpRect.contains(mx, my) ||
 					kPdaHelp2Rect.contains(mx, my)) {
 					setInteractiveMouseCursor(false);


Commit: 1f0ad1bf77d4d333c2bf3d8272a2a1f829e458db
    https://github.com/scummvm/scummvm/commit/1f0ad1bf77d4d333c2bf3d8272a2a1f829e458db
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:28+02:00

Commit Message:
EEM: improved hints voices/text in London

Changed paths:
    engines/eem/audio.cpp
    engines/eem/audio.h
    engines/eem/graphics.cpp
    engines/eem/mystery.h


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 1a553b47c00..14570379b84 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -346,19 +346,37 @@ void AudioPlayer::stopSpool() {
 		_mixer->stopHandle(_spoolHandle);
 }
 
-// _SayKDDigital @ 2404:0fbc.
-//   slot = kdspeak * 2 + (partner == Jake ? 1 : 0); sound = digital[slot+1] - 1
-// KDDigitalIndex = KDTextIndex + 0x12 (set by _ReadMystery @ 2404:0163-0167).
+// _SayKDDigital @ EEM1 2404:0fbc / EEM2 2542:1845. KDDigitalIndex =
+// KDTextIndex + 0x12. EEM1 reads index `kdspeak*2 + (Jake?1:0) + 1`; EEM2
+// drops the trailing +1 (`kdspeak*2 + (Jake?1:0)`) — a real per-variant
+// difference, so using EEM1's +1 for London shifts every KD line by one slot.
 void AudioPlayer::sayKDDigital(const byte *kdTextIndex, uint kdspeak,
 							   uint partner) {
 	if (!kdTextIndex || _currentMystery < 0)
 		return;
 	const byte *digital = kdTextIndex + 0x12;
-	const uint slot = (kdspeak * 2) + (partner == 0 ? 1u : 0u) + 1u;
+	const uint slot = (kdspeak * 2) + (partner == 0 ? 1u : 0u) +
+					  (_vm && _vm->isLondon() ? 0u : 1u);
 	const uint16 raw = READ_LE_UINT16(digital + slot * 2);
 	if (raw == 0 || raw == 0xFFFF)
 		return;
 	spoolSound((uint)(raw - 1));
 }
 
+// _SayKDHintDigital @ 2542:187e — EEM2/London partner chain-hint voice.
+// Identical to EEM2 `_SayKDDigital` but indexes the table 0x3a bytes after
+// KDTextIndex (vs 0x12); `slot` is the 0..4 hint-chain slot. EEM1 voices its
+// chain hints through `sayKDDigital(slot + 10)` instead.
+void AudioPlayer::sayKDHintDigital(const byte *kdTextIndex, uint slot,
+								   uint partner) {
+	if (!kdTextIndex || _currentMystery < 0)
+		return;
+	const byte *table = kdTextIndex + 0x3a;
+	const uint idx = (slot * 2) + (partner == 0 ? 1u : 0u);
+	const uint16 raw = READ_LE_UINT16(table + idx * 2);
+	if (raw == 0 || raw == 0xFFFF)
+		return;
+	spoolSound((uint)(raw - 1));
+}
+
 } // End of namespace EEM
diff --git a/engines/eem/audio.h b/engines/eem/audio.h
index 1d986ac47bb..fa901b971a8 100644
--- a/engines/eem/audio.h
+++ b/engines/eem/audio.h
@@ -112,6 +112,11 @@ public:
 	/// header slot). Pass the mystery's kdTextIndex() pointer.
 	void sayKDDigital(const byte *kdTextIndex, uint kdspeak, uint partner);
 
+	/// `_SayKDHintDigital @ 2542:187e` — EEM2/London partner chain-hint voice.
+	/// Like `sayKDDigital` but indexes the table 0x3a after KDTextIndex (vs
+	/// 0x12) with no +1 bias. @p slot is the 0..4 hint-chain slot.
+	void sayKDHintDigital(const byte *kdTextIndex, uint slot, uint partner);
+
 	/// _QuitSounds @ 1ff1:03c5.
 	void stopAll();
 
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 81c553eb35f..c6bd5278563 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -350,23 +350,38 @@ void EEMEngine::doHelp() {
 		return;
 
 	uint16 chosenText = 0xFFFF;
-	int    soundNum   = 0;
+	int    soundNum   = 0;       // _SayKDDigital line: EEM1 chain 10/11; fallback 7/8.
+	int    hintVoiceSlot = -1;   // London chain hint → _SayKDHintDigital(slot).
 	bool   anyHintDefined = false;
 
+	// Required-clue chain walk. EEM1 `_KDHelp @ 1560:010a` checks the first two
+	// entries of chain A. EEM2 `_DoKDHelp @ 15c1:020b` walks all THREE chains
+	// (A,B,C @ header words 16-20/21-25/26-30) × 5 slots, indexes the 15-entry
+	// hint-text table `hintBlock()[chain*5 + slot]`, and voices a chain hint
+	// with `_SayKDHintDigital(slot)` (table kdTextIndex()+0x3a) instead of
+	// EEM1's `_SayKDDigital(slot + 10)`. Gate is the same: clue not yet found.
+	const uint kChains = isLondon() ? 3u : 1u;
+	const uint kSlots  = isLondon() ? Mystery::kChainLen : 2u;
 	if (hb) {
-		for (uint i = 0; i < 2; i++) {
-			const uint16 chainClue = _mystery.aChain(i);
-			if (chainClue == 0xFFFF)
-				continue;
-			const uint16 hintOff = READ_LE_UINT16(hb + i * 2);
-			if (hintOff == 0xFFFF)
-				continue;
-			anyHintDefined = true;
-			if (chainClue < Mystery::kCluesFoundCap &&
-				_mystery._cluesFound[chainClue] == 0) {
-				chosenText = hintOff;
-				soundNum   = (int)i + 10;
-				break;
+		for (uint c = 0; c < kChains && chosenText == 0xFFFF; c++) {
+			for (uint slot = 0; slot < kSlots; slot++) {
+				const uint16 chainClue = _mystery.hintChain(c, slot);
+				if (chainClue == 0xFFFF)
+					continue;
+				const uint16 hintOff = READ_LE_UINT16(
+					hb + (c * Mystery::kChainLen + slot) * 2);
+				if (hintOff == 0xFFFF)
+					continue;
+				anyHintDefined = true;
+				if (chainClue < Mystery::kCluesFoundCap &&
+					_mystery._cluesFound[chainClue] == 0) {
+					chosenText = hintOff;
+					if (isLondon())
+						hintVoiceSlot = (int)slot;
+					else
+						soundNum = (int)slot + 10;
+					break;
+				}
 			}
 		}
 	}
@@ -463,9 +478,14 @@ void EEMEngine::doHelp() {
 	// partner-specific voice line keyed to which hint type fired:
 	//   10 = first chain hint, 11 = second chain hint,
 	//    7 = generic KD (first), 8 = generic KD (second).
-	if (_audio && _mystery.kdTextIndex() && soundNum > 0)
-		_audio->sayKDDigital(_mystery.kdTextIndex(), (uint)soundNum,
-							 _partner);
+	if (_audio && _mystery.kdTextIndex()) {
+		if (hintVoiceSlot >= 0)
+			_audio->sayKDHintDigital(_mystery.kdTextIndex(),
+									 (uint)hintVoiceSlot, _partner);
+		else if (soundNum > 0)
+			_audio->sayKDDigital(_mystery.kdTextIndex(), (uint)soundNum,
+								 _partner);
+	}
 
 	waitForInput(60000);
 }
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 54830f70e50..1536a61360f 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -113,6 +113,20 @@ public:
 		return i < kChainLen ? _aChain[i] : 0xFFFF;
 	}
 
+	/// Entry @p slot of hint chain @p chainIdx (0 = A, 1 = B, 2 = C; header
+	/// words 16-20 / 21-25 / 26-30). EEM2 `_DoKDHelp @ 15c1:020b` walks all
+	/// three chains × 5 slots; EEM1 `_KDHelp` only chain A slots 0..1.
+	uint16 hintChain(uint chainIdx, uint slot) const {
+		if (slot >= kChainLen)
+			return 0xFFFF;
+		switch (chainIdx) {
+		case 0: return _aChain[slot];
+		case 1: return _bChain[slot];
+		case 2: return _cChain[slot];
+		default: return 0xFFFF;
+		}
+	}
+
 	/// MapData entry for siteNum: 14 bytes; first u16 = sitepic, +4..7 = (x, y).
 	const byte *mapEntry(uint siteNum) const;
 


Commit: d7a7f626717147457245f120a62262b1fff064fc
    https://github.com/scummvm/scummvm/commit/d7a7f626717147457245f120a62262b1fff064fc
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:28+02:00

Commit Message:
EEM: new doPuzzle implementation for London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/graphics.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 0abed334700..e3c589691ce 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -1130,6 +1130,26 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		}
 
 		applyClueSideEffects(c);
+
+		// EEM2 `_DisplayClue @ 2542:05bd`: a clue entry can gate the rest of
+		// the clue behind a "check the manual / a real map" puzzle (the id is
+		// entry+0x54 = c+0x50, a `P<id>.BIN`). Run it once per mystery
+		// (`_mystery._solvedPuzzle` = `DAT_3036_6d5c`); a wrong answer aborts
+		// the remaining entries and re-prompts on the next visit.
+		if (isLondon() && !_mystery._solvedPuzzle) {
+			const uint16 puzzleId = READ_LE_UINT16(c + 0x50);
+			if (puzzleId != 0xFFFF) {
+				// Restore the clean site BG (drop this clue's bubble) first —
+				// the DOS _Repaint before `_DoPuzzle` — so the puzzle (and its
+				// own hint) render on a clean background, not over the bubble.
+				g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0,
+										   kScreenWidth, kScreenHeight);
+				g_system->updateScreen();
+				_mystery._solvedPuzzle = doPuzzle(puzzleId);
+				if (!_mystery._solvedPuzzle)
+					break;  // gate: block the rest of this clue
+			}
+		}
 	}
 
 	// _StopTheVoice @ 1ff1:0283 effect (voice only, keep MIDI). Diverges
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 162a041f1c4..524c5d1e497 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -208,6 +208,15 @@ public:
 	/// count followed by 62-byte ClueEntries.
 	void displayClue(const byte *clueBlock);
 
+	/// EEM2/London `_DoPuzzle @ 2542:1482`. A clue entry can gate the rest of
+	/// itself behind a "check the manual / a real map" puzzle (the id is the
+	/// entry+0x54 field). Opens `P<id>.BIN` — a header + question, then either
+	/// a typed answer (type 0) or a click-a-region multiple choice (type 1).
+	/// Returns true when answered correctly (and true if the file is missing,
+	/// so the clue is never permanently blocked); a wrong answer replays the
+	/// partner's scolding hint, and the caller re-prompts on the next visit.
+	bool doPuzzle(uint puzzleId);
+
 	/// Floppy hotspot click. `FUN_22dc_0b80 + FUN_1652_00e6 + FUN_1652_006c`.
 	/// Locates dialog records in site_data[+6] and dispatches them.
 	void displayFloppyHotspotDialog(uint siteNum, uint hotIdx);
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index c6bd5278563..02ed69a8ef7 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -170,6 +170,250 @@ bool EEMEngine::floppyHotspotSearched(uint siteIdx, uint hotspotIdx) const {
 		   _mystery._cluesFound[textIdx] != 0;
 }
 
+// fgets-style line read (until newline / NUL / EOF) from a puzzle file.
+static Common::String readPuzzleLine(Common::File &f) {
+	Common::String s;
+	while (!f.eos()) {
+		const byte c = f.readByte();
+		if (f.eos() || c == '\n' || c == 0)
+			break;
+		if (c != '\r')
+			s += (char)c;
+	}
+	return s;
+}
+
+bool EEMEngine::doPuzzle(uint puzzleId) {
+	// `_DoPuzzle @ 2542:1482`. File `P<id>.BIN` (LE u16 fields):
+	//   type(0=typed,1=choice); mainPic{id,x,y}; extraCount; extra{id,x,y}*;
+	//   qx; qy; qw; voiceAlt(Jenny); voiceMain(Jake); question line.
+	//   type 0: answerRect{x1,y1,x2,y2}; answer line (stored UPPERCASE).
+	//   type 1: choiceCount; rects{x1,y1,x2,y2}* — correct = rect 0.
+	Common::File f;
+	const Common::String fname = Common::String::format("P%u.BIN", puzzleId);
+	if (!f.open(Common::Path(fname))) {
+		// Fail open: never let a missing puzzle file permanently block a clue.
+		warning("doPuzzle: %s missing — leaving the clue ungated", fname.c_str());
+		return true;
+	}
+
+	const uint16 type = f.readUint16LE();
+
+	// The caller (displayClue) restored the clean site background, so the
+	// current screen has no clue bubbles. Keep that as `cleanBg` and build the
+	// puzzle on a copy; `cleanBg` is restored after the answer (DOS `_DoPuzzle`
+	// _Repaint) and after the wrong-answer hint (`_KDHelp` _Repaint) so neither
+	// the puzzle pics nor any bubble is left on the background.
+	Graphics::ManagedSurface cleanBg(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	cleanBg.clear();
+	{
+		Graphics::Surface *cur = g_system->lockScreen();
+		if (cur) {
+			cleanBg.simpleBlitFrom(*cur);
+			g_system->unlockScreen();
+		}
+	}
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.simpleBlitFrom(cleanBg);
+
+	// Main picture, then `extraCount` more — each {id, x, y}, masked blit.
+	for (int phase = 0; phase < 2; phase++) {
+		uint16 n = 1;
+		if (phase == 1)
+			n = f.readUint16LE();
+		for (uint16 i = 0; i < n; i++) {
+			const uint16 id = f.readUint16LE();
+			const int16 px = (int16)f.readUint16LE();
+			const int16 py = (int16)f.readUint16LE();
+			Picture pic;
+			if (_picsArchive.getPicture(id, pic) && !pic.surface.empty())
+				scratch.transBlitFrom(pic.surface, Common::Point(px, py),
+									  (uint32)(byte)(pic.flags >> 8));
+		}
+	}
+
+	const int16 qx = (int16)f.readUint16LE();
+	const int16 qy = (int16)f.readUint16LE();
+	const int16 qw = (int16)f.readUint16LE();
+	const uint16 voiceAlt  = f.readUint16LE();  // partner != Jake
+	const uint16 voiceMain = f.readUint16LE();  // partner == Jake
+	const Common::String question =
+		parseString(readPuzzleLine(f), _playerName, _partner);
+	if (_font.isLoaded() && !question.empty())
+		_font.drawWordWrapped(&scratch, qx, qy, MAX<int>(8, (int)qw),
+							  question, 0);
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, kScreenWidth, kScreenHeight);
+	g_system->updateScreen();
+
+	// Partner reads the question (`_SpoolSound(id - 1)`, gated on audio).
+	if (_audio && _voiceOn) {
+		const uint16 v = (_partner == kPartnerJake) ? voiceMain : voiceAlt;
+		if (v != 0 && v != 0xFFFF)
+			_audio->spoolSound((uint)(v - 1));
+	}
+
+	bool correct = false;
+	CursorMan.showMouse(true);
+
+	if (type == 0) {
+		// Typed answer (`_CheckTypedAnswer` → `_GetNameString`, toupper+strcmp).
+		const int16 ax1 = (int16)f.readUint16LE();
+		const int16 ay1 = (int16)f.readUint16LE();
+		const int16 ax2 = (int16)f.readUint16LE();
+		const int16 ay2 = (int16)f.readUint16LE();
+		const Common::Rect rect(ax1, ay1, ax2, ay2);
+		Common::String answer = readPuzzleLine(f);  // stored uppercase
+		const uint maxLen = answer.size() + 2;
+
+		Common::String input;
+		bool done = false, blink = true;
+		uint32 blinkMs = g_system->getMillis();
+		g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, true);
+		while (!done && !shouldQuit()) {
+			Graphics::ManagedSurface fld(kScreenWidth, kScreenHeight,
+				Graphics::PixelFormat::createFormatCLUT8());
+			fld.simpleBlitFrom(scratch);
+			Common::String shown = input;
+			if (blink)
+				shown += "_";
+			if (_font.isLoaded())
+				_font.drawString(&fld, shown, rect.left + 2, rect.top + 1,
+								 MAX<int>(8, rect.width()), 0x0F);
+			g_system->copyRectToScreen(fld.getPixels(), fld.pitch, 0, 0,
+									   kScreenWidth, kScreenHeight);
+			g_system->updateScreen();
+
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+					input.clear();
+					done = true;
+					break;
+				}
+				if (ev.type != Common::EVENT_KEYDOWN)
+					continue;
+				const Common::KeyCode k = ev.kbd.keycode;
+				if (k == Common::KEYCODE_RETURN || k == Common::KEYCODE_KP_ENTER) {
+					if (!input.empty())
+						done = true;
+				} else if (k == Common::KEYCODE_ESCAPE) {
+					input.clear();  // cancel → empty → fails the compare
+					done = true;
+				} else if (k == Common::KEYCODE_BACKSPACE) {
+					if (!input.empty())
+						input.deleteLastChar();
+				} else if (ev.kbd.ascii >= ' ' && ev.kbd.ascii < 127 &&
+						   input.size() < maxLen) {
+					input += (char)ev.kbd.ascii;
+				}
+			}
+			const uint32 now = g_system->getMillis();
+			if (now - blinkMs >= 400) {
+				blink = !blink;
+				blinkMs = now;
+			}
+			g_system->delayMillis(15);
+		}
+		g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
+		input.toUppercase();
+		correct = input.equals(answer);
+	} else {
+		// Multiple choice: click a region; correct = the FIRST rect.
+		const uint16 count = f.readUint16LE();
+		Common::Array<Common::Rect> rects;
+		for (uint16 i = 0; i < count; i++) {
+			const int16 cx1 = (int16)f.readUint16LE();
+			const int16 cy1 = (int16)f.readUint16LE();
+			const int16 cx2 = (int16)f.readUint16LE();
+			const int16 cy2 = (int16)f.readUint16LE();
+			rects.push_back(Common::Rect(cx1, cy1, cx2, cy2));
+		}
+		int picked = -1;
+		while (picked == -1 && !shouldQuit()) {
+			Common::Event ev;
+			while (g_system->getEventManager()->pollEvent(ev)) {
+				if (ev.type == Common::EVENT_QUIT ||
+					ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+					(ev.type == Common::EVENT_KEYDOWN &&
+					 ev.kbd.keycode == Common::KEYCODE_ESCAPE)) {
+					picked = -2;  // ESC / quit → wrong
+					break;
+				}
+				if (ev.type == Common::EVENT_LBUTTONDOWN) {
+					for (uint i = 0; i < rects.size(); i++) {
+						if (rects[i].contains(ev.mouse.x, ev.mouse.y)) {
+							picked = (int)i;
+							break;
+						}
+					}
+				}
+			}
+			g_system->updateScreen();
+			g_system->delayMillis(15);
+		}
+		correct = (picked == 0);
+	}
+	f.close();
+
+	// _Repaint after the answer: drop the puzzle pics back to the clean site.
+	g_system->copyRectToScreen(cleanBg.getPixels(), cleanBg.pitch, 0, 0,
+							   kScreenWidth, kScreenHeight);
+	g_system->updateScreen();
+
+	if (!correct && !shouldQuit()) {
+		// Wrong: partner scolds via the KD hint (`_MIDIPlay(0x28)` +
+		// `_KDHelp(TextBlock + KDTextIndex[+0xc], voice 6)`), drawn on the
+		// clean site (the puzzle has already been _Repaint-ed away).
+		const byte *kd = _mystery.kdTextIndex();
+		const uint16 hintOff = kd ? READ_LE_UINT16(kd + 0x0c) : 0xFFFF;
+		if (hintOff != 0xFFFF) {
+			Common::String hint =
+				parseString(_mystery.textAt(hintOff), _playerName, _partner);
+			Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
+				Graphics::PixelFormat::createFormatCLUT8());
+			ms.simpleBlitFrom(cleanBg);
+			const byte firstChar = hint.empty() ? (byte)0 : (byte)hint[0];
+			uint16 bubNum = getKDTextBalloon(firstChar);
+			if (firstChar >= '0' && firstChar <= '9')
+				hint.deleteChar(0);
+			bubNum = fitBalloonToText(bubNum, hint);
+			Picture balloon;
+			const bool haveBalloon = _balloonArchive.size() > (bubNum & 0x7F) &&
+				_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
+			const int balloonX = 0x21;
+			int balloonY = 1;
+			if (haveBalloon && balloon.surface.h < 0x4e)
+				balloonY = (0x50 - balloon.surface.h) / 2;
+			if (haveBalloon)
+				ms.transBlitFrom(balloon.surface,
+								 Common::Point(balloonX, balloonY),
+								 (uint32)(byte)(balloon.flags >> 8));
+			uint16 tx = 5, ty = 4, tw = 155;
+			getBalloonInsets(bubNum, tx, ty, tw);
+			if (_font.isLoaded())
+				_font.drawWordWrapped(&ms, balloonX + tx, balloonY + ty, tw,
+									  hint, haveBalloon ? 0 : 0xF);
+			g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0,
+									   kScreenWidth, kScreenHeight);
+			g_system->updateScreen();
+			if (_audio && _voiceOn && kd)
+				_audio->sayKDDigital(kd, 6, _partner);
+			waitForInput(60000);
+
+			// `_KDHelp` ends with _Repaint: clear the hint balloon too.
+			g_system->copyRectToScreen(cleanBg.getPixels(), cleanBg.pitch,
+									   0, 0, kScreenWidth, kScreenHeight);
+			g_system->updateScreen();
+		}
+	}
+	setInteractiveMouseCursor(false);
+	return correct;
+}
+
 void EEMEngine::doHelp() {
 	// Floppy per-mystery H<n>.BIN hint files. Loader FUN_1503_0001 @ 1503:0001
 	// (format string "h%d.bin" @ 2608:0154), consumer FUN_1503_01a5 @ 1503:01a5.


Commit: a029ae2241c3e5b70231e488f8a5fc66452724c3
    https://github.com/scummvm/scummvm/commit/a029ae2241c3e5b70231e488f8a5fc66452724c3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:29+02:00

Commit Message:
EEM: doPuzzle highlights for London

Changed paths:
    engines/eem/graphics.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index 02ed69a8ef7..ec1f5d089ce 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -34,6 +34,7 @@
 #include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/site.h"
 
 namespace EEM {
 
@@ -332,8 +333,55 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 			const int16 cy2 = (int16)f.readUint16LE();
 			rects.push_back(Common::Rect(cx1, cy1, cx2, cy2));
 		}
+		// `_GetPuzzleChoice @ 2542:11a9`: the choice rects are the only
+		// clickable areas (no done/exit hotspot) and ESC = wrong; the Tab/arrow
+		// keys hit a global handler, not choice selection. Highlight the
+		// options two ways, both reusing the existing engine mechanisms:
+		//   * Boxes: outline each option in the marching-ants ramp — palette
+		//     0xF9..0xFE, the SAME original yellow as the site hotspots, not a
+		//     puzzle-specific colour. Gated like the DOS `_DrawRect`
+		//     (`DAT_3036_4c4a` → port `hide_highlight_boxes`).
+		//   * Cursor: recolor the default arrow over an option
+		//     (`setInteractiveMouseCursor`, the EEM1 red-pixel cursor for
+		//     "otherwise invisible" hotspots). These options carry no per-hotspot
+		//     cursor shape, so the default cursor is what's shown — exactly the
+		//     case that recolor is meant for.
+		applyHotspotGlowPalette();
+		const bool showBoxes = !ConfMan.getBool("hide_highlight_boxes");
 		int picked = -1;
+		uint32 lastPhase = (uint32)-1;
+		bool overOption = false;
 		while (picked == -1 && !shouldQuit()) {
+			if (showBoxes) {
+				const uint32 phase = g_system->getMillis() / 80;
+				if (phase != lastPhase) {
+					lastPhase = phase;
+					Graphics::ManagedSurface fr(kScreenWidth, kScreenHeight,
+						Graphics::PixelFormat::createFormatCLUT8());
+					fr.simpleBlitFrom(scratch);
+					for (uint i = 0; i < rects.size(); i++) {
+						const byte color =
+							(byte)(0xF9 + ((i + phase) & 0x07) % 6);
+						fr.frameRect(rects[i], color);
+					}
+					g_system->copyRectToScreen(fr.getPixels(), fr.pitch, 0, 0,
+											   kScreenWidth, kScreenHeight);
+				}
+			}
+			// Red default-cursor while hovering a clickable option; plain arrow
+			// off all of them (mirrors `updateHotspotCursor`).
+			const Common::Point mp = g_system->getEventManager()->getMousePos();
+			bool nowOver = false;
+			for (uint i = 0; i < rects.size(); i++) {
+				if (rects[i].contains(mp.x, mp.y)) {
+					nowOver = true;
+					break;
+				}
+			}
+			if (nowOver != overOption) {
+				overOption = nowOver;
+				setInteractiveMouseCursor(nowOver);
+			}
 			Common::Event ev;
 			while (g_system->getEventManager()->pollEvent(ev)) {
 				if (ev.type == Common::EVENT_QUIT ||
@@ -355,6 +403,8 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 			g_system->updateScreen();
 			g_system->delayMillis(15);
 		}
+		// Restore the plain arrow before leaving the puzzle.
+		setInteractiveMouseCursor(false);
 		correct = (picked == 0);
 	}
 	f.close();
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 5dba04a5f01..7c4884ac7d6 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -179,6 +179,23 @@ void cyclePaletteRangeReverse(uint8 start, uint8 end) {
 	g_system->getPaletteManager()->setPalette(buf, start, count);
 }
 
+void applyHotspotGlowPalette() {
+	// SITEPALS ships palette 0xF9..0xFE as uniform yellow (3F 3E 00), so the
+	// original marching-ants was a placeholder. Override with a 6-step yellow
+	// ramp so `cyclePaletteRange(0xF9, 0xFE)` (and the per-index draw colour)
+	// produce a visible pulse. Shared by site hotspots and the clue puzzle so
+	// both highlight clickable areas with the same (original) colours.
+	static const byte kAntsGlow[6 * 3] = {
+		0x40, 0x40, 0x00, // F9 — dim
+		0x80, 0x80, 0x00, // FA
+		0xC0, 0xC0, 0x00, // FB
+		0xFF, 0xFF, 0x40, // FC — peak
+		0xC0, 0xC0, 0x00, // FD
+		0x80, 0x80, 0x00, // FE
+	};
+	g_system->getPaletteManager()->setPalette(kAntsGlow, 0xF9, 6);
+}
+
 // `_WaitAnims @ 29be:021c`. 12 bytes per entry, indexed by `siteData[+8]`:
 //   +0..1 anim Jake, +2..3 anim Jenny,
 //   +4..5 x    Jake, +6..7 x    Jenny,
@@ -994,21 +1011,9 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	}
 	_vm->setSitePaletteForSite(sitepic);
 
-	// SITEPALS ships palette 0xF9..0xFE as uniform yellow (3F 3E 00),
-	// so original marching-ants was a placeholder. Override with a
-	// 6-step yellow ramp so `cyclePaletteRange(0xF9, 0xFE)` produces a
-	// visible pulse on unsearched hotspots.
-	{
-		static const byte kAntsGlow[6 * 3] = {
-			0x40, 0x40, 0x00, // F9 — dim
-			0x80, 0x80, 0x00, // FA
-			0xC0, 0xC0, 0x00, // FB
-			0xFF, 0xFF, 0x40, // FC — peak
-			0xC0, 0xC0, 0x00, // FD
-			0x80, 0x80, 0x00, // FE
-		};
-		g_system->getPaletteManager()->setPalette(kAntsGlow, 0xF9, 6);
-	}
+	// Override SITEPALS' uniform-yellow 0xF9..0xFE with the marching-ants
+	// glow ramp (shared with the clue puzzle).
+	applyHotspotGlowPalette();
 
 	renderBackground(siteNum);
 
diff --git a/engines/eem/site.h b/engines/eem/site.h
index c91f0cffeb8..9bdbc88aa4a 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -82,6 +82,12 @@ void cyclePaletteRange(uint8 start, uint8 end);
 /// where the cycle shifts END→START rather than START→END.
 void cyclePaletteRangeReverse(uint8 start, uint8 end);
 
+/// Load the 6-step yellow marching-ants ramp into palette 0xF9..0xFE
+/// (SITEPALS ships these as uniform yellow). Shared by site hotspots and the
+/// clue puzzle so both outline clickable areas in the same original colours;
+/// `cyclePaletteRange(0xF9, 0xFE)` then pulses them.
+void applyHotspotGlowPalette();
+
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
 struct Hotspot {
 	int16  x1, y1, x2, y2;     ///< rectangle in screen coordinates


Commit: c53f9e018e04683f0979fa72eb33fcc6dc825acc
    https://github.com/scummvm/scummvm/commit/c53f9e018e04683f0979fa72eb33fcc6dc825acc
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:29+02:00

Commit Message:
EEM: acusation workflow for London

Changed paths:
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/ui.cpp


diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index edf8261849f..715971eb77c 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -571,6 +571,61 @@ int Mystery::selectedPoints() const {
 	return total;
 }
 
+bool Mystery::londonSolved() const {
+	// `_SolvedCheck @ 1ea1:0b1a`. Reuses the already-loaded hint chains
+	// (_aChain/_bChain/_cChain) as the three answer sets and the shared
+	// _noteSelected accuse-selection array — see londonSolved() doc in the
+	// header. EEM1/floppy keep the points model via solvedCheck().
+	for (uint chain = 0; chain < 3; chain++) {
+		int remaining = (int)kChainLen;
+		int wild = 0;
+		for (uint slot = 0; slot < kChainLen; slot++) {
+			const uint16 clue = hintChain(chain, slot);
+			if (clue == 0xFFFF) {
+				remaining--;
+				wild++;
+			} else if (clue < kCluesFoundCap && _noteSelected[clue]) {
+				remaining--;
+			}
+		}
+		if (wild == (int)kChainLen)
+			continue;            // all-wildcard set is unused
+		if (remaining == 0)
+			return true;         // every slot wildcard-or-selected
+	}
+	return false;
+}
+
+int Mystery::minCluesRemaining() const {
+	// `_GetMinRemaining @ 1ea1:1056`. Parallel to londonSolved() but tests the
+	// three answer sets against _cluesFound (discovered in the world). Tracks
+	// whether any non-wildcard answer clue has been found at all; if none, the
+	// player has nothing relevant yet (returns kChainLen).
+	int best = (int)kChainLen;
+	int foundReal = 0;
+	for (uint chain = 0; chain < 3; chain++) {
+		int remaining = (int)kChainLen;
+		int wild = 0;
+		for (uint slot = 0; slot < kChainLen; slot++) {
+			const uint16 clue = hintChain(chain, slot);
+			if (clue == 0xFFFF) {
+				remaining--;
+				wild++;
+			} else if (clue < kCluesFoundCap && _cluesFound[clue]) {
+				remaining--;
+				foundReal++;
+			}
+		}
+		if (wild == (int)kChainLen)
+			remaining = (int)kChainLen;   // unused set must not lower the min
+		if (remaining < best)
+			best = remaining;
+	}
+	if (foundReal == 0)
+		return (int)kChainLen;
+	return best;
+}
+
 int Mystery::foundPoints() const {
 	const byte *ni = noteIndex();
 	const uint16 cnt = noteIndexCount();
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 1536a61360f..3793debedec 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -167,6 +167,23 @@ public:
 
 	bool solvedCheck() const { return selectedPoints() > 99; }
 
+	/// EEM2/London `_SolvedCheck @ 1ea1:0b1a`. London dropped EEM1's points
+	/// model for set matching: the three hint chains (_aChain/_bChain/_cChain,
+	/// = Ghidra g_dwSolveSet1..3, mystery header words 16/21/26) are the
+	/// accepted answer sets of up to 5 clue ids. A set is satisfied when every
+	/// slot is either 0xFFFF (wildcard / unused) or has its accuse-selected
+	/// flag (_noteSelected, = g_awSelectedClue) set; the accusation is solved
+	/// if ANY set is satisfied. An all-wildcard set is unused and never counts.
+	bool londonSolved() const;
+
+	/// EEM2/London `_GetMinRemaining @ 1ea1:1056`. Same three sets as
+	/// londonSolved() but tested against _cluesFound (clues discovered in the
+	/// world, = _CluesFound): returns the fewest clues still to find across the
+	/// usable sets — 0 means a full answer set has been discovered and the
+	/// accusation can begin. Returns 5 when no relevant clue has been found yet.
+	/// Drives the London accuse-readiness gate (`_AccuseEntry @ 1ea1:115c`).
+	int minCluesRemaining() const;
+
 	/// _WITCH @ 1df2:089f. GalleryData[i*0x46 + 0x02] == 0xFFFF marks the
 	/// guilty suspect; innocent suspects store their alibi TextBlock offset.
 	bool isGuilty(uint suspectIdx) const;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index ed11bb57e91..2de366f5994 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -4177,32 +4177,58 @@ void EEMEngine::doAccuse() {
 	const byte *entryKdIdx = _mystery.kdTextIndex();
 	if (!entryKdIdx)
 		return;
-	const int foundPoints = _mystery.foundPoints();
+	// Readiness tier (0 nothing yet .. 3 ready-to-accuse). EEM1 `_AccuseEntry
+	// @ 1df2:0ff8` tiers on the top-5 found-clue score; EEM2 `_AccuseEntry @
+	// 1ea1:115c` tiers on minCluesRemaining() (jumptable @ 1ea1:1333: 0 ->
+	// ready, 1 -> almost, 2..4 -> keep looking, 5 -> nothing yet — only the 0
+	// case sets the can-accuse flag). Both variants then pick the same four KD
+	// lines, so map onto one shared tier.
+	int readyTier;
+	if (isLondon()) {
+		const int minRem = _mystery.minCluesRemaining();
+		readyTier = (minRem >= (int)Mystery::kChainLen) ? 0
+				  : (minRem >= 2)                       ? 1
+				  : (minRem == 1)                       ? 2
+														: 3;
+	} else {
+		const int foundPoints = _mystery.foundPoints();
+		readyTier = (foundPoints == 0)    ? 0
+				  : (foundPoints < 0x32)  ? 1
+				  : (foundPoints < 0x65)  ? 2
+											: 3;
+	}
+
 	uint entryKDSpeak = 0;
 	uint16 entryTextOff = 0xFFFF;
 	uint16 entryVoiceOverride = 0xFFFF;
 	bool canAccuse = false;
 	Common::String entryText;
-	if (foundPoints == 0) {
+	switch (readyTier) {
+	case 0:
 		entryKDSpeak = 9;
 		entryText = "3We're not ready to solve this mystery yet.  "
 					"Let's keep investigating until we have some more solid "
 					"evidence to make our case!";
 		// Practice mystery M0 ships the matching ZeroText takes as the
 		// final two SDB entries, but its KD digital table points kdspeak 9
-		// at earlier hint clips. Use the otherwise unreferenced pair.
-		if (_mystery.number() == 0)
+		// at earlier hint clips. Use the otherwise unreferenced pair. EEM1
+		// SDB indices, so EEM1 only.
+		if (!isLondon() && _mystery.number() == 0)
 			entryVoiceOverride = (_partner == kPartnerJake) ? 105 : 104;
-	} else if (foundPoints < 0x32) {
+		break;
+	case 1:
 		entryKDSpeak = 0;
 		entryTextOff = READ_LE_UINT16(entryKdIdx + 0);
-	} else if (foundPoints < 0x65) {
+		break;
+	case 2:
 		entryKDSpeak = 1;
 		entryTextOff = READ_LE_UINT16(entryKdIdx + 2);
-	} else {
+		break;
+	default:
 		entryKDSpeak = 2;
 		entryTextOff = READ_LE_UINT16(entryKdIdx + 4);
 		canAccuse = true;
+		break;
 	}
 	if (entryText.empty() && entryTextOff != 0xFFFF) {
 		entryText = parseString(_mystery.textAt(entryTextOff),
@@ -4280,8 +4306,12 @@ void EEMEngine::doAccuse() {
 		return;
 	}
 
-	// `_DoAccuse @ 1df2:0c75` — `_SolvedCheck` gate.
-	if (!_mystery.solvedCheck()) {
+	// `_DoAccuse @ 1df2:0c75` (EEM1) / `1ea1:0c75` (EEM2) — `_SolvedCheck` gate.
+	// London matches the selected notes against the answer sets; EEM1/floppy
+	// sum clue points.
+	const bool accuseSolved =
+		isLondon() ? _mystery.londonSolved() : _mystery.solvedCheck();
+	if (!accuseSolved) {
 		const byte *kdIdx = _mystery.kdTextIndex();
 		const int16 hintOff = kdIdx
 			? (int16)READ_LE_UINT16(kdIdx + 6)
@@ -4297,7 +4327,12 @@ void EEMEngine::doAccuse() {
 				hint = "Necesitamos buscar pistas antes de resolver "
 					   "el misterio. Investiguemos un poco mas!";
 			} else {
-				hint = (_mystery.selectedPoints() == 0)
+				// "nothing yet" vs "almost": London has no points, so judge by
+				// whether any answer clue has been found (minCluesRemaining).
+				const bool nothingYet = isLondon()
+					? _mystery.minCluesRemaining() >= (int)Mystery::kChainLen
+					: _mystery.selectedPoints() == 0;
+				hint = nothingYet
 					? "We're not ready to solve this mystery yet. "
 					  "Let's keep investigating until we have some "
 					  "more solid evidence."


Commit: aca47008bdbc7a8f04dc57c736f9fa6fcd21cf22
    https://github.com/scummvm/scummvm/commit/aca47008bdbc7a8f04dc57c736f9fa6fcd21cf22
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:30+02:00

Commit Message:
EEM: doApproach fixes for London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 2de366f5994..a099e78f2fd 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -148,14 +148,18 @@ bool loadLondonApproachData(uint16 approachId, LondonApproachData &out) {
 	const uint16 pageCount = READ_LE_UINT16(data.data() + 14);
 	out.pages.clear();
 	uint32 pos = 16;
+	// `_DoApproach @ 1717:009b` reads the pages with `_fgets` into 255-byte
+	// slots, so each page is one NEWLINE-terminated record — NOT NUL-separated.
+	// (A*.BIN carries no NULs; splitting on '\0' lumps every page into page 0,
+	// which is why the Next/Prev arrows had nothing to page through.)
 	for (uint16 i = 0; i < pageCount && pos < size; i++) {
 		const uint32 start = pos;
-		while (pos < size && data[pos] != 0)
+		while (pos < size && data[pos] != '\n')
 			pos++;
 		out.pages.push_back(Common::String((const char *)data.data() + start,
 										   pos - start));
 		if (pos < size)
-			pos++;
+			pos++;  // skip the '\n' separator
 	}
 	return out.videoId != 0 && !out.pages.empty();
 }
@@ -3469,7 +3473,6 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 		const Common::Rect textRect =
 			data.textRect.findIntersectingRect(Common::Rect(kScreenWidth, kScreenHeight));
 		if (!textRect.isEmpty()) {
-			scratch.fillRect(textRect, 0);
 			if (page < data.pages.size()) {
 				Common::Array<Common::String> wrapped;
 				_font.wordWrapText(data.pages[page], MAX<int>(8, textRect.width()),
@@ -3477,9 +3480,15 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 				const int lineH = _font.getFontHeight();
 				const int maxLines = MAX<int>(1, textRect.height() / lineH);
 				for (uint i = 0; i < wrapped.size() && (int)i < maxLines; i++) {
+					// `_DoApproach @ 1717:029e`:
+					// `_WordWrap(x, y, w, page, fontColor=1, dropColor=-1)`.
+					// Font colour is palette index 1 (NOT 0x0F white), drawn
+					// straight onto the restored video-frame background — the
+					// DOS `vga_fvidvid` calls just restore the clean bg, they
+					// don't paint a panel, so don't fill the rect here either.
 					_font.drawString(&scratch, wrapped[i], textRect.left,
 									 textRect.top + (int)i * lineH,
-									 textRect.width(), 0x0f);
+									 textRect.width(), 1);
 				}
 			}
 		}
@@ -3562,6 +3571,10 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 	else
 		setSitePalette(0x3b);
 	bool done = false;
+	// `_DoApproach` idle loop (1717:02d4) shimmers palette 0xF3..0xF7 via
+	// `_ColorCycle(0xf3, 0xf7)` each frame-rate tick; mirror it on the video
+	// palette (the diff-animation ramp those entries belong to).
+	uint32 lastShimmer = g_system->getMillis();
 	while (!shouldQuit() && !done) {
 		Common::Event ev;
 		while (g_system->getEventManager()->pollEvent(ev)) {
@@ -3630,6 +3643,13 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 				}
 			}
 		}
+		if (haveVideo) {
+			const uint32 now = g_system->getMillis();
+			if (now - lastShimmer >= 70) {
+				lastShimmer = now;
+				cyclePaletteRange(0xF3, 0xF7);
+			}
+		}
 		g_system->updateScreen();
 		g_system->delayMillis(10);
 	}


Commit: b749d688e8933fc49220856b57a7ebec294af6e5
    https://github.com/scummvm/scummvm/commit/b749d688e8933fc49220856b57a7ebec294af6e5
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:30+02:00

Commit Message:
EEM: enter the name screen fixes for London

Changed paths:
    engines/eem/eem.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 660c2ffe547..7fe9458d44f 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1285,7 +1285,10 @@ void EEMEngine::showLondonCharSelect() {
 	const Common::Rect kMaleBox(110, 116, 120, 122);
 	const Common::Rect kFemaleBox(190, 116, 200, 122);
 	const uint kMaxFirst = 12, kMaxLast = 20;
-	const uint8 kInkColor = 0x0F;     // typed-name ink
+	// `_GetNameString @ 1cd3:0ddc` draws both the typed name and the caret in
+	// colour 0x22 (the passport-field ink); the old 0x0F was a guess and the
+	// underscore caret in it was effectively invisible.
+	const uint8 kInkColor = 0x22;     // typed-name ink + caret
 
 	Picture bg;
 	const bool haveBg = _picsArchive.getPicture(0xc, bg) && !bg.surface.empty();
@@ -1334,18 +1337,27 @@ void EEMEngine::showLondonCharSelect() {
 			if (haveBg)
 				scratch.simpleBlitFrom(bg.surface);
 			if (getFont().isLoaded()) {
-				Common::String f = first;
-				if (field == kFieldFirst && blink)
-					f += "_";
-				Common::String l = last;
-				if (field == kFieldLast && blink)
-					l += "_";
-				getFont().drawString(&scratch, f, kFirstRect.left + 2,
+				getFont().drawString(&scratch, first, kFirstRect.left + 2,
 									 kFirstRect.top + 1, kFirstRect.width(),
 									 kInkColor);
-				getFont().drawString(&scratch, l, kLastRect.left + 2,
+				getFont().drawString(&scratch, last, kLastRect.left + 2,
 									 kLastRect.top + 1, kLastRect.width(),
 									 kInkColor);
+				// Caret = solid block `_FillRect(x+1, y, 6, 0xb, 0x22)` at the
+				// input point (`_GetNameString @ 1cd3:0ddc`), NOT an underscore.
+				if (blink && (field == kFieldFirst || field == kFieldLast)) {
+					const Common::Rect &fr =
+						(field == kFieldFirst) ? kFirstRect : kLastRect;
+					const Common::String &buf =
+						(field == kFieldFirst) ? first : last;
+					const int caretX =
+						fr.left + 2 + getFont().getStringWidth(buf);
+					Common::Rect caret(caretX, fr.top, caretX + 6,
+									   fr.top + 0xb);
+					caret.clip(Common::Rect(kScreenWidth, kScreenHeight));
+					if (!caret.isEmpty())
+						scratch.fillRect(caret, kInkColor);
+				}
 			}
 			if (field == kFieldGender) {
 				// DOS restores both boxes, then fills the active one with the
@@ -1440,6 +1452,11 @@ void EEMEngine::showLondonCharSelect() {
 			blink = !blink;
 			needRedraw = true;
 		}
+		// Flush every frame so the mouse cursor tracks smoothly. The scratch
+		// rebuild + copyRectToScreen above is gated on needRedraw (and mouse
+		// motion doesn't set it), but the cursor is only re-composited by
+		// updateScreen(), so without this it moved only on blink/keypress.
+		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
 	if (shouldQuit()) {


Commit: f490186308e1a088e423ab6dfd60b06cca7bd2ec
    https://github.com/scummvm/scummvm/commit/f490186308e1a088e423ab6dfd60b06cca7bd2ec
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:30+02:00

Commit Message:
EEM: make sure the screen is updated during animations

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index e3c589691ce..2ed40630967 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -431,6 +431,10 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 					break;
 				}
 			}
+			// Re-composite the cursor every ~10 ms so the mouse stays smooth
+			// between the 140 ms animation frames (the frame-rebuild above is
+			// throttled, but the cursor is only flushed by updateScreen()).
+			g_system->updateScreen();
 			g_system->delayMillis(10);
 		}
 	}
@@ -514,6 +518,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 						break;
 					}
 				}
+				g_system->updateScreen();  // keep the cursor smooth between frames
 				g_system->delayMillis(10);
 			}
 		}
@@ -648,6 +653,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 							break;
 						}
 					}
+					g_system->updateScreen();  // keep the cursor smooth between frames
 					g_system->delayMillis(10);
 				}
 			}


Commit: e9c44065c0f45b0f9724158370b40917044c51ef
    https://github.com/scummvm/scummvm/commit/e9c44065c0f45b0f9724158370b40917044c51ef
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:31+02:00

Commit Message:
EEM: added missing NPC in London cases intro

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 2ed40630967..68255504730 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -382,6 +382,27 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 	const bool haveAnim =
 		_aniArchive.loadAnimation(introAni, anim) && !anim.empty();
 
+	// `_DoInitClues @ 1abf:03b3` registers a SECOND, fixed briefing character
+	// (e.g. Nigel) on the LEFT, gated on caseType (jumptable @ CS:0x720):
+	//   caseType 0 -> _GetAnimation(0x70) @ (0, 0x3e)
+	//   caseType 2 -> _GetAnimation(0x0e) @ (0, 0x36)
+	//   caseType 3 -> _GetAnimation(0x74) @ (0, 0x1a)
+	//   caseType 1 and >= 4 -> partner only (no second character).
+	// Both animate during the entrance and the last frame is left on screen, so
+	// the following `displayClue` snapshot keeps them through the briefing
+	// dialogue. Without this the NPC is never drawn (Nigel invisible in M2).
+	uint npcAni = 0;
+	int npcX = 0, npcY = 0;
+	switch (caseType) {
+	case 0: npcAni = 0x70; npcY = 0x3e; break;
+	case 2: npcAni = 0x0e; npcY = 0x36; break;
+	case 3: npcAni = 0x74; npcY = 0x1a; break;
+	default: break;
+	}
+	Animation npc;
+	const bool haveNpc = npcAni != 0 &&
+		_aniArchive.loadAnimation(npcAni, npc) && !npc.empty();
+
 	// _AllBlack(): blank the palette so the _FadeIn after frame 0 reveals the
 	// briefing (palette 0x39 — EEM2's 63-entry SITEPALS. shifts EEM1's UI set).
 	byte pal[kPalSize];
@@ -397,18 +418,26 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 	// keeps this faithful if the cells/script ever diverge and matches the map/
 	// site partner rendering. One pass = one script cell per ~140 ms tick.
 	bool skip = false;
-	const uint frames = haveAnim ? (uint)anim.size() : 1;
+	uint frames = haveAnim ? (uint)anim.size() : 1;
+	if (haveNpc)
+		frames = MAX<uint>(frames, (uint)npc.size());
 	for (uint frame = 0; frame < frames && !shouldQuit() && !skip; frame++) {
 		if (haveBriefingBg)
 			blitAt(bg, 0, 0);
-		if (haveAnim) {
-			const uint cell =
-				partnerFrameAtTick(0x18, (uint)anim.size(), frame * 140);
-			Graphics::Surface *scr = g_system->lockScreen();
-			if (scr) {
+		Graphics::Surface *scr = g_system->lockScreen();
+		if (scr) {
+			if (haveAnim) {
+				const uint cell =
+					partnerFrameAtTick(0x18, (uint)anim.size(), frame * 140);
 				blitAnimFrameAnchored(scr, anim[cell], kAnchorX, kAnchorY);
-				g_system->unlockScreen();
 			}
+			if (haveNpc) {
+				// NPC frame script = 0x0e (the `_NewAnimation` animId arg).
+				const uint ncell =
+					partnerFrameAtTick(0x0e, (uint)npc.size(), frame * 140);
+				blitAnimFrameAnchored(scr, npc[ncell], npcX, npcY);
+			}
+			g_system->unlockScreen();
 		}
 		if (frame == 0 && havePal)
 			fadePaletteFromBlack(pal);  // _FadeIn @ 1abf:03b3
@@ -991,6 +1020,18 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (_picsArchive.getPicture(charPicId, charPic) &&
 				charX < kScreenWidth && charY < kScreenHeight) {
 				blitMaskedToScreen(charPic, charX, charY);
+				// `_DisplayClue` bakes the speaker portrait into the BACKGROUND
+				// via `_AddPicBackground`; the DOS never restores a clean BG
+				// mid-dialogue, so the portrait persists across the following
+				// clue entries. Bake it into the `bg` snapshot too — otherwise
+				// the per-entry full restore (top of this loop) wipes a fixed
+				// NPC portrait (e.g. London's Nigel) on every entry that carries
+				// no portrait of its own, leaving only the balloon ("rendered,
+				// then refreshed over").
+				const byte transp = (byte)(charPic.flags >> 8);
+				bg.transBlitFrom(charPic.surface,
+								 Common::Point((int)charX, (int)charY),
+								 (uint32)transp);
 			}
 		}
 


Commit: 2372aaf0180a1be5ae58f68786fd8e4cc1ad9cdb
    https://github.com/scummvm/scummvm/commit/2372aaf0180a1be5ae58f68786fd8e4cc1ad9cdb
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:31+02:00

Commit Message:
EEM: implement setup screen in London

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 68255504730..b49c45539db 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -977,7 +977,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		// `stop()`). 0 = no cue; EEM1 clue entries have no such field. Gated on
 		// `_voiceOn` like `startTravelMusic` — the DOS gate is the separate
 		// music-on / MIDI-available flags (DAT_3036_4cc0 && DAT_2bca_146a).
-		if (isLondon() && _music && _voiceOn) {
+		if (isLondon() && _music && _musicOn) {
 			const uint16 clueMusic = READ_LE_UINT16(c + 0x1c);
 			if (clueMusic != 0)
 				_music->playMus(clueMusic, /* loop= */ false);
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 7fe9458d44f..4f455d22562 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -661,9 +661,13 @@ screenLoop:
 			break;
 
 		case kScreenSetup:
-			// Handler 6 @ 1a35:0e48 -> _DoSetup @ 1f78:044e. Entered
-			// from BigMap setup button (_NextScreen=6 @ 20fe:0c33).
-			doSetup();
+			// Handler 6 @ 1a35:0e48 -> _DoSetup. EEM1 = seg-1f78 (`doSetup`);
+			// EEM2/London = seg-2046 (`doSetupLondon`, 4 toggles + own layout).
+			// Entered from BigMap setup button (_NextScreen=6 @ 20fe:0c33).
+			if (isLondon())
+				doSetupLondon();
+			else
+				doSetup();
 			break;
 
 		case kScreenProfile:
@@ -1578,7 +1582,8 @@ void EEMEngine::startTravelMusic() {
 	// doesn't write it; combined with `_DoSiteLoop @ 168d:06c0` calling
 	// `_StopMIDI()` before the interactive phase, travel music plays
 	// ONCE during the entrance animation only.
-	if (!_music || !_mystery.isLoaded() || !_voiceOn)
+	if (!_music || !_mystery.isLoaded() ||
+		(isLondon() ? !_musicOn : !_voiceOn))
 		return;
 	const uint num = _mystery._siteNumber % 5;
 	_music->playMus(num, /* loop= */ false);
@@ -1593,7 +1598,7 @@ void EEMEngine::startLondonTravelMusic(uint8 travelKind) {
 		{ 7, 23, 17 },
 		{ 10, 21, 24 },
 	};
-	if (!_music || !_voiceOn || travelKind == 0 ||
+	if (!_music || !_musicOn || travelKind == 0 ||
 		travelKind >= ARRAYSIZE(kLondonTravelMusic))
 		return;
 
@@ -1686,6 +1691,8 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	s.syncAsByte(_partner);
 	s.syncAsByte(_chainStage);
 	s.syncAsByte(_voiceOn);
+	// EEM2/London music (MIDI) toggle — `DAT_3036_4cc0`, separate from voice.
+	s.syncAsByte(_musicOn);
 
 	// London passport gender (player pronouns 0x86-0x88).
 	byte playerFemale = _playerFemale ? 1 : 0;
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 524c5d1e497..9f4d98c7856 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -512,6 +512,12 @@ private:
 	Common::KeyCode setupShowFullscreenPic(uint16 picId, bool transparent);
 	void setupLeave();
 
+	/// EEM2/London setup screen — `_DoSetup @ 2046:067b` (distinct from EEM1's
+	/// seg-1f78 screen): 4 toggles (partner / voice / music / highlight boxes),
+	/// rearranged 13-button layout, scrapbook paging. Reuses the shared helpers.
+	void doSetupLondon();
+	void setupDrawScreenLondon();
+
 	/// `_DoInitClues @ 1a35:0411` (minus live ANI sequence playback).
 	void doInitClues();
 
@@ -589,6 +595,12 @@ private:
 	/// partner speech, intro VO).
 	bool _voiceOn = true;
 
+	/// EEM2/London music (MIDI) on/off — `DAT_3036_4cc0`, toggled by the music
+	/// button in `_DoSetup @ 2046:067b`. Separate from `_voiceOn` (EEM2 has both
+	/// a voice and a music toggle; EEM1 only had one sound toggle). Gates the
+	/// briefing / travel MIDI for London.
+	bool _musicOn = true;
+
 	/// Set by the profile/new-player screens. London uses it to decide whether
 	/// to start the training mystery or resume the loaded profile's menu/state.
 	bool _profileCreatedThisSession = false;
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index a099e78f2fd..6f068ed710a 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -195,6 +195,18 @@ constexpr Common::Rect kSetupKid2Rect    (Common::Point( 99,  54),  49,  8);
 constexpr Common::Rect kSetupSoundOnRect (Common::Point(106,  86),  19,  8);
 constexpr Common::Rect kSetupSoundOffRect(Common::Point(106,  96),  19,  8);
 
+// EEM2/London setup highlights — `_SetupSettings @ 2046:0008` (_SwapColors,
+// key 0xFE). On/off label pairs for the 4 toggles (rects @ 2bca:13ec; drawn
+// in the DOS order ON-then-OFF). Partner reuses Jake/Jenny.
+constexpr Common::Rect kLonSetJake    (Common::Point( 99,  44), 49, 8); // 0x13ec
+constexpr Common::Rect kLonSetJenny   (Common::Point( 99,  54), 49, 8); // 0x13f4
+constexpr Common::Rect kLonSetVoiceOn (Common::Point(106,  68), 20, 8); // 0x13fc
+constexpr Common::Rect kLonSetVoiceOff(Common::Point(106,  68), 40, 8); // 0x1404
+constexpr Common::Rect kLonSetMusicOn (Common::Point(106,  84), 20, 8); // 0x141c
+constexpr Common::Rect kLonSetMusicOff(Common::Point(128,  85), 18, 7); // 0x1424
+constexpr Common::Rect kLonSetHiOn    (Common::Point(106, 110), 29, 8); // 0x1414
+constexpr Common::Rect kLonSetHiOff   (Common::Point(106, 100), 29, 8); // 0x140c
+
 // `_SwapColors @ 172b:1d2a` — replace pixels in r where value==from
 // with to. 0xFE = BG text-key; 0x15 = active palette index, 0x00 =
 // inactive (set by `_SetupSettings @ 1f78:000d`).
@@ -1774,6 +1786,188 @@ void EEMEngine::setupLeave() {
 	saveProfile(_playerName);
 }
 
+void EEMEngine::setupDrawScreenLondon() {
+	// `_SetupSettings @ 2046:0008`: BG PIC 0x40 + _SwapColors highlights for the
+	// 4 toggles (key 0xFE, 0x15 active / 0x00 dim), drawn ON-then-OFF.
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	scratch.clear();
+	Picture bg;
+	if (_picsArchive.getPicture(0x40, bg))
+		scratch.simpleBlitFrom(bg.surface);
+
+	const byte kKey = 0xFE, kBright = 0x15, kDim = 0x00;
+	// Partner (DAT_3036_4bd4).
+	swapColors(scratch, kLonSetJake,  kKey, _partner == kPartnerJake  ? kBright : kDim);
+	swapColors(scratch, kLonSetJenny, kKey, _partner == kPartnerJenny ? kBright : kDim);
+	// Voice (DAT_3036_4c4e).
+	swapColors(scratch, kLonSetVoiceOn,  kKey, _voiceOn ? kBright : kDim);
+	swapColors(scratch, kLonSetVoiceOff, kKey, _voiceOn ? kDim : kBright);
+	// Music (DAT_3036_4cc0).
+	swapColors(scratch, kLonSetMusicOn,  kKey, _musicOn ? kBright : kDim);
+	swapColors(scratch, kLonSetMusicOff, kKey, _musicOn ? kDim : kBright);
+	// Highlight boxes (DAT_3036_4c4a; hide_highlight_boxes is the inverse).
+	const bool hiOn = !ConfMan.getBool("hide_highlight_boxes");
+	swapColors(scratch, kLonSetHiOn,  kKey, hiOn ? kBright : kDim);
+	swapColors(scratch, kLonSetHiOff, kKey, hiOn ? kDim : kBright);
+
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, kScreenWidth, kScreenHeight);
+	g_system->updateScreen();
+}
+
+void EEMEngine::doSetupLondon() {
+	// `_DoSetup @ 2046:067b`. BG PIC 0x40; 13 click rects (`_SetupButtons @
+	// 2bca:12ce`); `HandleSetupButton @ 2046:01d9` dispatch (verified from the
+	// jumptable @ 2046:0661). EEM2 has FOUR toggles vs EEM1's two and a
+	// rearranged left column:
+	//   [0]( 20, 44) Partner    [1]( 20, 63) Voice    [2]( 20,101) Highlight boxes
+	//   [3]( 20,127) Profile(8) [12](20, 82) Music    [4](281, 43) ScrapBook +
+	//   [5](281, 62) ScrapBook- [6](281,108) Save     [7](281,127) New case(0xa)
+	//   [8]( 53,153) Done       [9](145,163) Quit      [10](212,153) Help
+	//   [11]( 81, 25) Credits (PIC 0x208).  _SavePlayerRecord on exit (setupLeave).
+	if (!_font.isLoaded()) {
+		_nextScreen = (ScreenId)_lastScreen;
+		return;
+	}
+	const Common::Rect kPartnerBtn ( 20,  44,  39,  61); // [0]
+	const Common::Rect kVoiceBtn   ( 20,  63,  39,  80); // [1]
+	const Common::Rect kHiBtn      ( 20, 101,  39, 118); // [2]
+	const Common::Rect kProfileBtn ( 20, 127,  39, 144); // [3]
+	const Common::Rect kScrapNext  (281,  43, 299,  60); // [4]
+	const Common::Rect kScrapPrev  (281,  62, 299,  79); // [5]
+	const Common::Rect kSaveBtn    (281, 108, 299, 125); // [6]
+	const Common::Rect kNewCaseBtn (281, 127, 299, 144); // [7]
+	const Common::Rect kDoneBtn    ( 53, 153, 108, 183); // [8]
+	const Common::Rect kQuitBtn    (145, 163, 174, 187); // [9]
+	const Common::Rect kHelpBtn    (212, 153, 266, 184); // [10]
+	const Common::Rect kCreditsBtn ( 81,  25, 238,  37); // [11]
+	const Common::Rect kMusicBtn   ( 20,  82,  38,  99); // [12]
+
+	setupDrawScreenLondon();
+	_nextScreen = kScreenSetup;  // sentinel — setupLeave picks the target
+	while (!shouldQuit()) {
+		Common::Event ev;
+		bool dirty = false;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER) {
+				_nextScreen = kScreenInvalid;
+				return;
+			}
+			if (ev.type == Common::EVENT_KEYDOWN &&
+				(ev.kbd.keycode == Common::KEYCODE_ESCAPE ||
+				 ev.kbd.keycode == Common::KEYCODE_RETURN)) {
+				setupLeave();
+				return;
+			}
+			if (ev.type != Common::EVENT_LBUTTONDOWN)
+				continue;
+			const int mx = ev.mouse.x, my = ev.mouse.y;
+
+			// --- toggles (stay on screen, re-render) ---
+			if (kPartnerBtn.contains(mx, my)) {
+				_partner = (_partner == kPartnerJake) ? kPartnerJenny
+													  : kPartnerJake;
+				dirty = true;
+				continue;
+			}
+			if (kVoiceBtn.contains(mx, my)) {
+				_voiceOn = !_voiceOn;
+				if (_audio)
+					_audio->setVoiceEnabled(_voiceOn);
+				dirty = true;
+				continue;
+			}
+			if (kMusicBtn.contains(mx, my)) {
+				_musicOn = !_musicOn;
+				if (!_musicOn)
+					stopMusic();
+				dirty = true;
+				continue;
+			}
+			if (kHiBtn.contains(mx, my)) {
+				ConfMan.setBool("hide_highlight_boxes",
+								!ConfMan.getBool("hide_highlight_boxes"));
+				dirty = true;
+				continue;
+			}
+
+			// --- navigation (leave the screen) ---
+			if (kProfileBtn.contains(mx, my)) {
+				saveProfile(_playerName);
+				_nextScreen = kScreenProfile;
+				return;
+			}
+			if (kNewCaseBtn.contains(mx, my)) {
+				saveProfile(_playerName);
+				_nextScreen = kScreenChooseMystery;
+				return;
+			}
+			if (kDoneBtn.contains(mx, my)) {
+				setupLeave();
+				return;
+			}
+			if (kQuitBtn.contains(mx, my)) {
+				if (areYouSure()) {
+					_nextScreen = kScreenInvalid;
+					return;
+				}
+				dirty = true;  // restore BG under the prompt
+				continue;
+			}
+
+			// --- actions that stay on screen ---
+			if (kSaveBtn.contains(mx, my)) {
+				// `_SaveGame` is gated on a game being active ([0x1966]).
+				if (_mystery.isLoaded())
+					saveProfile(_playerName);
+				continue;
+			}
+			if (kHelpBtn.contains(mx, my)) {
+				// `_InterfaceHelp(0)`. Reuse the EEM1 help-card mechanism.
+				static const uint16 kHelpPics[] = { 0x0192, 0x01B1 };
+				CursorMan.showMouse(false);
+				for (uint i = 0; i < ARRAYSIZE(kHelpPics); i++) {
+					setupDrawScreenLondon();
+					if (setupShowFullscreenPic(kHelpPics[i], true) ==
+						Common::KEYCODE_ESCAPE)
+						break;
+				}
+				CursorMan.showMouse(true);
+				dirty = true;
+				continue;
+			}
+			if (kCreditsBtn.contains(mx, my)) {
+				CursorMan.showMouse(false);
+				setupShowFullscreenPic(0x208, /* transparent= */ false);
+				CursorMan.showMouse(true);
+				setSitePalette(0);
+				dirty = true;
+				continue;
+			}
+			// ScrapBook +/- — London has 2 books (stage 1 / 2). The DOS pages
+			// next/prev (FUN_2046_0874); map to the two available books.
+			if (kScrapNext.contains(mx, my)) {
+				doShowScrapbook(1);
+				setSitePalette(0);
+				dirty = true;
+				continue;
+			}
+			if (kScrapPrev.contains(mx, my) && _chainStage >= 2) {
+				doShowScrapbook(2);
+				setSitePalette(0);
+				dirty = true;
+				continue;
+			}
+		}
+		if (dirty)
+			setupDrawScreenLondon();
+		g_system->updateScreen();
+		g_system->delayMillis(15);
+	}
+}
+
 void EEMEngine::doActionScreen() {
 	// `_ActionScreen @ 1c33:195b` — BG PIC 0x104 + PIC 9 @ (10, 0x87),
 	// `_DoChoose` with ActionNames @ 29be:0d6a. 5 picks alternating with


Commit: 44e61cbf7f00b8da6c7f915cdd60d7aca8bef2a6
    https://github.com/scummvm/scummvm/commit/44e61cbf7f00b8da6c7f915cdd60d7aca8bef2a6
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:31+02:00

Commit Message:
EEM: fixed regression on clean background for London

Changed paths:
    engines/eem/clues.cpp


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index b49c45539db..75d3d723f21 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -1019,19 +1019,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			Picture charPic;
 			if (_picsArchive.getPicture(charPicId, charPic) &&
 				charX < kScreenWidth && charY < kScreenHeight) {
+				// Draw over the per-entry clean BG (restored at the top of the
+				// loop), NOT into the persistent `bg` snapshot — baking it in
+				// makes successive speaker portraits stack instead of refresh
+				// (duplicate NPC portraits + labels). The briefing's fixed NPC
+				// persists via the entrance animation captured in the snapshot
+				// (playLondonInitCluesAnim), not via the per-entry portrait.
 				blitMaskedToScreen(charPic, charX, charY);
-				// `_DisplayClue` bakes the speaker portrait into the BACKGROUND
-				// via `_AddPicBackground`; the DOS never restores a clean BG
-				// mid-dialogue, so the portrait persists across the following
-				// clue entries. Bake it into the `bg` snapshot too — otherwise
-				// the per-entry full restore (top of this loop) wipes a fixed
-				// NPC portrait (e.g. London's Nigel) on every entry that carries
-				// no portrait of its own, leaving only the balloon ("rendered,
-				// then refreshed over").
-				const byte transp = (byte)(charPic.flags >> 8);
-				bg.transBlitFrom(charPic.surface,
-								 Common::Point((int)charX, (int)charY),
-								 (uint32)transp);
 			}
 		}
 


Commit: a79edd41f2b937a82e573bceac6e2f0a71c0a665
    https://github.com/scummvm/scummvm/commit/a79edd41f2b937a82e573bceac6e2f0a71c0a665
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:32+02:00

Commit Message:
EEM: missing music cue in London scrapbook

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6f068ed710a..6acea3e5d58 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1448,6 +1448,14 @@ void EEMEngine::doShowScrapbook(uint stage) {
 		return;
 	const bool currentTier = (stage == _chainStage);
 
+	// `_ShowScrapbook @ 2046:0874` (EEM2) opens with the scrapbook/newspaper
+	// MIDI tune 0x5d (same one `_ShowOneScrap` plays after a correct accusation),
+	// gated on the voice flag + MIDI availability (DOS `DAT_3036_4c4e`/`146a` —
+	// the original gates this music on the *voice* toggle, not the music one).
+	// London only; EEM1's seg-1f78 scrapbook was not verified to play it.
+	if (isLondon() && _music && _voiceOn)
+		_music->playMus(0x5d, /* loop= */ false);
+
 	int mystery = lo;
 	if (currentTier) {
 		while (mystery < hi && _mysteriesSolved[mystery] == 0)
@@ -1485,6 +1493,9 @@ void EEMEngine::doShowScrapbook(uint stage) {
 			break;
 		}
 	}
+	// `_ShowScrapbook` / `_ShowOneScrap` stop the MIDI on the way out.
+	if (isLondon() && _music)
+		stopMusic();
 }
 
 void EEMEngine::doSetup() {


Commit: 3555a18bdbf4f1bab57d86df0e2e334bab9d31a2
    https://github.com/scummvm/scummvm/commit/3555a18bdbf4f1bab57d86df0e2e334bab9d31a2
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:32+02:00

Commit Message:
EEM: reduce the verbosity of the comments accross the engine

Changed paths:
    engines/eem/audio.cpp
    engines/eem/clues.cpp
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/eem.h
    engines/eem/font.cpp
    engines/eem/font.h
    engines/eem/graphics.cpp
    engines/eem/music.cpp
    engines/eem/music.h
    engines/eem/mystery.cpp
    engines/eem/mystery.h
    engines/eem/resource.cpp
    engines/eem/resource.h
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/audio.cpp b/engines/eem/audio.cpp
index 14570379b84..d791fc1883f 100644
--- a/engines/eem/audio.cpp
+++ b/engines/eem/audio.cpp
@@ -52,7 +52,6 @@ void AudioPlayer::stopAll() {
 }
 
 void AudioPlayer::playVoc(const Common::Path &vocPath) {
-	// `_voiceEnabled` mirrors DAT_2d5d_3f97 (setup-screen voice toggle).
 	if (!_voiceEnabled) {
 		debugC(2, kDebugSound, "AudioPlayer: voice disabled, skipping %s",
 			   vocPath.toString().c_str());
@@ -75,7 +74,6 @@ void AudioPlayer::playVoc(const Common::Path &vocPath) {
 		return;
 	}
 
-	// _PlayVoice @ 1ff1:023e — route through kSpeechSoundType for the launcher slider.
 	_mixer->playStream(Audio::Mixer::kSpeechSoundType, &_voiceHandle,
 					   stream, -1, Audio::Mixer::kMaxChannelVolume,
 					   0, DisposeAfterUse::YES);
@@ -87,9 +85,9 @@ bool AudioPlayer::isVoicePlaying() const {
 	return _mixer->isSoundHandleActive(_voiceHandle);
 }
 
+// _WaitForVoiceDone @ 1ff1:0221 — pumps events while the AIL voice channel
+// drains, so animations + abort-on-click keep working during the wait.
 void AudioPlayer::waitForVoiceDone(uint32 maxMs) {
-	// _WaitForVoiceDone @ 1ff1:0221 — pumps events while the AIL voice channel
-	// drains, so animations + abort-on-click keep working during the wait.
 	const uint32 startMs = g_system->getMillis();
 	while (isVoicePlaying() && !_vm->shouldQuit() &&
 		   g_system->getMillis() - startMs < maxMs) {
diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 75d3d723f21..9fc34c93c95 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -127,7 +127,7 @@ void updateLondonClueSite(Mystery &mystery, uint16 rawSite, bool siteOn,
 		mystery._onSites[siteVal] = siteOn ? 1 : 0;
 }
 
-// _DoHappiness @ 172b:27b5 — per-zone sequence scripts.
+// _DoHappiness @ 172b:27b5 per-zone sequence scripts.
 // Jake seqs @ 29be:0337 (5 × 0x14 bytes), Jenny seqs @ 29be:039b. 9 frames each;
 // the anim cells contain 10 cells = pairs of (neutral, smile) at 5 intensities.
 const uint8 kJakeSeqs[5][9] = {
@@ -146,9 +146,7 @@ const uint8 kJennySeqs[5][9] = {
 };
 
 // EEM2 `FUN_17ee_26f6`: 10 rects @ 2bca:035c and 10 sequence rows for
-// Jennifer @ 2bca:03ac / Jake @ 2bca:0474. The portrait cells are 20-frame
-// ramps, so using the EEM1 5-level tables makes both characters smile in the
-// wrong places.
+// Jennifer @ 2bca:03ac / Jake @ 2bca:0474.
 const int kLondonHappyRightEdges[10] = {
 	35, 70, 98, 126, 156, 182, 208, 235, 277, 320
 };
@@ -196,9 +194,6 @@ uint happinessLevel(int x, bool london) {
 }
 
 // Masked blit: transparent colour = high byte of p.flags.
-// _AddPicBackground @ 172b:0ed4 pushes `word ptr ES:[BX] >> 8` as
-// _Rect_Move_Mask's mask byte (the on-disk u16 at file offset 0 maps to
-// Picture::flags).
 void blitMaskedToScreen(const Picture &p, int x, int y) {
 	Graphics::Surface *screen = g_system->lockScreen();
 	if (!screen)
@@ -225,10 +220,7 @@ void blitRawToScreen(const Picture &p, int x, int y) {
 							   x, y, w, h);
 }
 
-// _DoChoosePartner @ 1a35:0756 / EEM2 @ 1abf:0728. The original places
-// Jake + Jenny animations on a backdrop and polls two broad character rects;
-// we approximate those with a single split at x=160. EEM1 has Jenny left /
-// Jake right; EEM2 has Jake left / Jennifer right.
+// _DoChoosePartner @ 1a35:0756 / EEM2 @ 1abf:0728.
 void EEMEngine::doChoosePartner() {
 	_partner = kPartnerJake;
 
@@ -277,10 +269,6 @@ void EEMEngine::doChoosePartner() {
 
 	uint32 lastTick = g_system->getMillis();
 	while (!shouldQuit()) {
-		// 100ms tick: matches _CheckFrameRate's ~10 fps cadence. The seq
-		// is short and loops; _UpdateAnimations restarts at curIdx=0 on
-		// the 0x80 marker. _DoHappiness rewrites curIdx=0xFFFF on zone
-		// change (here we restart seqIdx).
 		if (g_system->getMillis() - lastTick > 100) {
 			lastTick = g_system->getMillis();
 			seqIdx = (seqIdx + 1) % 9;
@@ -345,14 +333,9 @@ void EEMEngine::doChoosePartner() {
 		g_system->delayMillis(20);
 	}
 
-	// Partner intro VOC (jake.voc / jen.voc @ 29be:0af9 / 29be:0af1),
-	// tail of _DoChoosePartner @ 1a35:097f.
 	if (_audio) {
 		if (isFloppy()) {
-			// Floppy _DoChoosePartner_Floppy @ 19bb:0a8e calls
-			// _LoadSoundName_Floppy(0x14) (slot 20); per-partner tables at
-			// 2608:0f0e / 2608:0f76 resolve to m-0113sl.voc (Jake) or
-			// f-0140sl.voc (Jenny).
+			// Floppy _DoChoosePartner_Floppy @ 19bb:0a8e 
 			_audio->playFloppyVoiceSlot(0x14, _partner);
 		} else {
 			_audio->playVoc(Common::Path(
@@ -362,18 +345,7 @@ void EEMEngine::doChoosePartner() {
 	}
 }
 
-// EEM2 case-intro animation — `_DoInitClues` @ 1abf:03b3. Ghidra-confirmed
-// flow (decompiled prologue):
-//   _AllBlack(); _GetBackground(0x52); _GetPalette(0x39);
-//   anim = _GetAnimation(DAT_4bd4 ? 0x71 : 0x18);   // Jake 0x18 / Jenny 0x71
-//   _NewAnimation(0xd2, 0x3f, anim, ...);            // registered at (210,63)
-//   _UpdateAnimations(); _FadeIn();                  // draw frame 0, then fade
-//   while (i != _max()) { _CheckFrameRate(); _UpdateAnimations(); kbhit->skip }
-//   if (caseType == 1) { _LoadSoundName("phone1.voc"); _PlayVoice; _Wait... }
-// So, unlike EEM1's game/book/nancy cycle + `_PlayInSequence`, EEM2 registers
-// ONE partner animation drawn by `_UpdateAnimations` — hence we anchor frames
-// with `blitAnimFrameAnchored` ((0xd2 - miscflags, 0x3f - rowoff), per
-// `_UpdateAnimations @ 172b:09c1`), the same helper the map/site screens use.
+// EEM2 case-intro animation — `_DoInitClues` @ 1abf:03b3.
 void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 										bool haveBriefingBg) {
 	const uint introAni = (_partner == kPartnerJake) ? 0x18 : 0x71;
@@ -383,14 +355,7 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 		_aniArchive.loadAnimation(introAni, anim) && !anim.empty();
 
 	// `_DoInitClues @ 1abf:03b3` registers a SECOND, fixed briefing character
-	// (e.g. Nigel) on the LEFT, gated on caseType (jumptable @ CS:0x720):
-	//   caseType 0 -> _GetAnimation(0x70) @ (0, 0x3e)
-	//   caseType 2 -> _GetAnimation(0x0e) @ (0, 0x36)
-	//   caseType 3 -> _GetAnimation(0x74) @ (0, 0x1a)
-	//   caseType 1 and >= 4 -> partner only (no second character).
-	// Both animate during the entrance and the last frame is left on screen, so
-	// the following `displayClue` snapshot keeps them through the briefing
-	// dialogue. Without this the NPC is never drawn (Nigel invisible in M2).
+	// (Nigel) on the LEFT, gated on caseType (jumptable @ CS:0x720):
 	uint npcAni = 0;
 	int npcX = 0, npcY = 0;
 	switch (caseType) {
@@ -403,20 +368,12 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 	const bool haveNpc = npcAni != 0 &&
 		_aniArchive.loadAnimation(npcAni, npc) && !npc.empty();
 
-	// _AllBlack(): blank the palette so the _FadeIn after frame 0 reveals the
-	// briefing (palette 0x39 — EEM2's 63-entry SITEPALS. shifts EEM1's UI set).
 	byte pal[kPalSize];
 	const bool havePal = getSitePalette(0x39, pal);
 	byte black[kPalSize] = {};
 	g_system->getPaletteManager()->setPalette(black, 0, 256);
 	g_system->updateScreen();
 
-	// Drive the cells through the animation script (`_NewAnimation` prior
-	// 0x18 -> `_AnimationSequences[0x18]`), not a raw 0..N-1 sweep — the
-	// frame shown each tick is `script[tick]`, not `tick`. For EEM2 that
-	// script is the count-up {0..16}, but routing through `partnerFrameAtTick`
-	// keeps this faithful if the cells/script ever diverge and matches the map/
-	// site partner rendering. One pass = one script cell per ~140 ms tick.
 	bool skip = false;
 	uint frames = haveAnim ? (uint)anim.size() : 1;
 	if (haveNpc)
@@ -460,9 +417,6 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 					break;
 				}
 			}
-			// Re-composite the cursor every ~10 ms so the mouse stays smooth
-			// between the 140 ms animation frames (the frame-rebuild above is
-			// throttled, but the cursor is only flushed by updateScreen()).
 			g_system->updateScreen();
 			g_system->delayMillis(10);
 		}
@@ -475,13 +429,7 @@ void EEMEngine::playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 	}
 }
 
-// EEM1 CD/floppy case-intro animation — `_DoInitClues @ 1a35:0411`:
-//   anims registered: game @ (0xcd,0x6c) [0x17 Jake / 0x3b Jenny],
-//   book @ (0,99) [0x18 / 0x3c], nancy @ (0x68,0x8b) [0x19, caseType 1 only];
-//   cycle the game anim once (click skips), then `_PlayInSequence @ 172b:2d03`
-//   plays the partner entrance per partner+caseType. A phone/news voice rings
-//   first on CD caseType 2 / floppy caseType 2-3. (London: see
-//   playLondonInitCluesAnim.)
+// EEM1 CD/floppy case-intro animation — `_DoInitClues @ 1a35:0411`
 void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 										  const Picture &bg, bool haveBriefingBg) {
 	const uint gameAni = _partner == kPartnerJake ? 0x17 : 0x3b;
@@ -494,12 +442,6 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 						  && _aniArchive.loadAnimation(0x19, nancy)
 						  && !nancy.empty();
 
-	// Cycle the game animation once at _CheckFrameRate cadence (~7 fps, 140 ms
-	// per tick; _InitFrameCounter @ 1a35:01ae). The original loop @ 1a35:0507
-	// makes `gameNum - 1` _UpdateAnimations calls, each advancing every
-	// registered slot by one script tick. _DoInitClues @ 1a35:0507/0541
-	// hard-codes Jake's script IDs (0x17/0x18/0x19) regardless of partner, so
-	// we look up scripts by those IDs unconditionally.
 	if (haveGame || haveBook || haveNancy) {
 		const uint kCheckFrameRateMs = 140;
 		const uint baseFrames = haveGame ? game.size() : 8;
@@ -555,9 +497,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 
 	// _VidramRectCopy(0, 0x5a, 0x28, 0x6d, 16000, 48000/32000): freeze the
 	// lower-left book/Nancy band the original bakes into BG buffers before
-	// clearing registered animations. Width is in mode-X cols (0x28 = 160px).
-	// Intentionally drops the right-side game anim; _PlayInSequence redraws
-	// that character over a clean BG next.
+	// clearing registered animations.
 	Graphics::ManagedSurface briefingBase(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	briefingBase.clear();
@@ -586,15 +526,6 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 		}
 	}
 
-	// Setup voice — _DoInitClues @ 1a35:061d runs this BEFORE _PlayInSequence
-	// (i.e. the phone rings, the player hears it ring out, and only then
-	// does Jake/Jenny reach for the receiver):
-	//   CD:     caseType 2 -> PHONE.VOC then _WaitForVoiceDone
-	//   Floppy (_DoInitClues_Floppy @ 19bb:042f):
-	//     caseType 2 -> slot 0xc (PHONESL.VOC)
-	//     caseType 3 -> slot 3   (NEWSCAN.VOC, news-anchor variant)
-	// While the voice plays the screen continues showing the game anim's
-	// last frame (saved into briefingBase above).
 	if (_audio) {
 		if (caseType == 2) {
 			if (floppy)
@@ -608,12 +539,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 		}
 	}
 
-	// _PlayInSequence @ 172b:2d03. Anim selection per partner + caseType:
-	//   Jake:  caseType 1 -> 0x38 @ (0xcd, 0x6d)
-	//          caseType 2 -> 0x37 @ (0xcd, 0x6c)
-	//          caseType 3 -> 0x39 @ (0xcd, 0x6c)
-	//   Jenny: caseType 2 -> 0x3a @ (0xcd, 0x6c)
-	//          caseType 3 -> 0x3d @ (0xcd, 0x6c)
+	// _PlayInSequence @ 172b:2d03.
 	uint16 seqAni = 0xFFFF;
 	uint16 seqY   = 0x6c;
 	if (_partner == kPartnerJake) {
@@ -657,10 +583,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 				g_system->copyRectToScreen(briefingBase.getPixels(),
 										   briefingBase.pitch, 0, 0,
 										   kScreenWidth, kScreenHeight);
-				// _PlayInSequence @ 172b:2d35-2d50:
-				//   dstX = sx - cell[+0x8]   ; signed X anchor (miscflags)
-				//   dstY = sy - cell[+0x6]   ; signed Y anchor (rowoff)
-				// asm `SUB AX, ES:[BX+0x8]` — NOT the cell width.
+				// _PlayInSequence @ 172b:2d35-2d50
 				const int dstX = (int)0xcd - (int)(int16)fr.miscflags;
 				const int dstY = (int)seqY - (int)(int16)fr.rowoff;
 				blitMaskedToScreen(fr, dstX, dstY);
@@ -690,11 +613,7 @@ void EEMEngine::playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 	}
 }
 
-// _DoInitClues @ 1a35:0411 (EEM1) / 1abf:03b3 (EEM2). Sequence:
-//   1. mark the start site / visited sites from the InitBlock
-//   2. BG PIC 0x52 + briefing palette (EEM1 0x22 / EEM2 0x39)
-//   3. partner entrance animation (CD/floppy or London variant)
-//   4. _DisplayClue(InitBlock + 2) — briefing dialogue
+// _DoInitClues @ 1a35:0411 (EEM1) / 1abf:03b3 (EEM2)
 void EEMEngine::doInitClues() {
 	if (!_mystery.isLoaded())
 		return;
@@ -703,9 +622,6 @@ void EEMEngine::doInitClues() {
 	if (!ib)
 		return;
 
-	// CD InitBlock: u16 caseType; u16 startSite; <clue block>.
-	// Floppy InitBlock (FUN_19bb_042f): u8 caseType; u8 nSubjects;
-	// subjects[]; u8 nDialog; dialog_records[]. No startSite.
 	const bool floppy = isFloppy();
 	const uint16 caseType = floppy ? (uint16)ib[0] : READ_LE_UINT16(ib);
 
@@ -716,8 +632,6 @@ void EEMEngine::doInitClues() {
 		_mystery._siteNumber = startSite;
 		_mystery._lastSite = startSite;
 	} else {
-		// Floppy _DoMapScreen @ 1fed:1060 (FUN_1fed_07ed) walks every
-		// site unconditionally — mirror that by marking all visible.
 		const uint sites = _mystery.numSites();
 		for (uint s = 0; s < sites && s < Mystery::kVisitedSiteCap; s++)
 			_mystery._onSites[s] = 1;
@@ -725,21 +639,12 @@ void EEMEngine::doInitClues() {
 		_mystery._lastSite = 0;
 	}
 
-	// Case-briefing palette. EEM1 `_DoInitClues` uses SITEPALS index 0x22;
-	// EEM2 `_DoInitClues` @ 1abf:03b3 does `_GetBackground(0x52)` then
-	// `_GetPalette(0x39)` (its 63-entry SITEPALS. shifts the UI palettes).
-	// The briefing partner animation is an ANI sprite drawn under the screen
-	// palette, so the same index fixes both the background and the anim.
 	setSitePalette(isLondon() ? 0x39 : 0x22);
 	Picture bg;
 	const bool haveBriefingBg = _picsArchive.getPicture(0x52, bg);
 	if (haveBriefingBg)
 		blitAt(bg, 0, 0);
 
-	// Case-intro partner animation. EEM2 (`_DoInitClues` @ 1abf:03b3) plays a
-	// single partner anim (Jake 0x18 / Jenny 0x71); EEM1 CD/floppy runs a
-	// game/book/nancy cycle + `_PlayInSequence` entrance. Both then feed the
-	// shared briefing dialogue below.
 	if (isLondon())
 		playLondonInitCluesAnim(caseType, bg, haveBriefingBg);
 	else
@@ -831,13 +736,7 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 			// `_WordWrap @ 1b03:0456` treats `^` as a forced line
 			// break (sets `cur_width = max_width`, forcing the next
 			// loop turn to wrap at the previous space and skip the
-			// `^` itself). Without this conversion the caret falls
-			// through the default case and renders as a literal,
-			// pushing the line past the balloon's visual edge — the
-			// "bubbles aren't large enough" symptom. Promote it to
-			// `\n` so `Font::wordWrapText` (which honours
-			// embedded newlines) picks the same break point the
-			// original engine did.
+			// `^` itself).
 			out += '\n';
 			break;
 		default:
@@ -849,11 +748,6 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 	// Strip leading spaces at the start of each emitted line. Mirrors
 	// `_DoWordWrap @ 1b66:04a7`, which advances past spaces at the
 	// start of every output line via `for (; str[last] == ' '; last++)`.
-	// ~60% of mystery-text strings carry 1-2 leading spaces in the data
-	// of the original WordWrap
-	// discards them, so we do the same before the text reaches
-	// `Font::wordWrapText` (which only trims at wrap-induced line
-	// boundaries, not at start-of-input or after an embedded '\n').
 	Common::String cleaned;
 	bool atLineStart = true;
 	for (uint i = 0; i < out.size(); i++) {
@@ -866,15 +760,9 @@ Common::String EEMEngine::parseString(const Common::String &raw,
 	return cleaned;
 }
 
+// EEM2 `_DisplayClue @ 2542:05bd` per-entry side effects
 void EEMEngine::applyClueSideEffects(const byte *c) {
 	if (isLondon()) {
-		// EEM2 `_DisplayClue @ 2542:05bd` per-entry side effects. With the
-		// shared `c = entryBase + 4` convention the EEM2 fields land at:
-		//   onsite  entry+0x22 (= c+0x1e), 5 × u16, high bit = CONSITE flag
-		//   offsite entry+0x2c (= c+0x28), 5 × u16, high bit = COFFSITE flag
-		//   gallery entry+0x36 (= c+0x32), 5 × u16 -> _InGallery[_newOrder[idx]]
-		//   notebook entry+0x40 (= c+0x3c), 5 × u16 -> _AddNotebook
-		//   jump    entry+0x4a (= c+0x46), destination site for direct travel
 		for (uint j = 0; j < 5; j++) {
 			const uint16 note = READ_LE_UINT16(c + 0x3c + j * 2);
 			if (note != 0xFFFF && note < Mystery::kCluesFoundCap)
@@ -935,11 +823,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	if (number == 0 || number > 32)
 		return;
 
-	// ClueEntry stride. EEM1 `_DisplayClue @ 2404:05e6` packs 62-byte entries;
-	// EEM2 `_DisplayClue @ 2542:05bd` indexes `theClue + i*0x54` (84 bytes). The
-	// per-entry fields up to +0x1e are layout-identical (so entry 0 — at the
-	// shared base clueBlock+4 — reads the same either way); only the stride and
-	// the tail fields (KD anim, notebook, onsite) move. See applyClueSideEffects.
 	const uint stride = isLondon() ? 0x54 : 62;
 
 	// Snapshot BG so per-entry character pics don't stack.
@@ -964,32 +847,19 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 	//   +0x1a: Jake  voice (1-based)
 	//   +0x30..+0x39: 5 notebook entries (-1 terminated)
 	//   +0x3a..+0x3b: KD-anim number (-1 = none)
-	// _DisplayClue @ 2404:05e6: partner 1 uses its own field set only when
-	// bubText1 != -1; otherwise falls back to partner 0 fields entirely.
-	// Partner 0 always uses field 0.
 	for (uint i = 0; i < number && !shouldQuit(); i++) {
 		g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
 		const byte *c = clueBlock + 4 + i * stride;
 
-		// EEM2 `_DisplayClue @ 2542:05bd` opens each clue with a per-entry MIDI
-		// cue read at entry+0x20 (= c+0x1c, the u16 right after the two voice
-		// slots), replacing any current track (`playMus`→`playFile` calls
-		// `stop()`). 0 = no cue; EEM1 clue entries have no such field. Gated on
-		// `_voiceOn` like `startTravelMusic` — the DOS gate is the separate
-		// music-on / MIDI-available flags (DAT_3036_4cc0 && DAT_2bca_146a).
 		if (isLondon() && _music && _musicOn) {
 			const uint16 clueMusic = READ_LE_UINT16(c + 0x1c);
 			if (clueMusic != 0)
 				_music->playMus(clueMusic, /* loop= */ false);
 		}
 
-		// _DisplayClue @ 2404:0635-064b: _DoKDAnim(num) runs before the
-		// speaker portrait. EEM1 stores the KD-anim number at +0x3a; EEM2
-		// `_DisplayClue @ 2542:05bd` reads it at entry+0x52 (= c+0x4e).
 		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + (isLondon() ? 0x4e : 0x3a));
 		if (kdAnimNum != -1) {
 			playKdAnim((uint16)kdAnimNum);
-			// _UpdateAnimations @ 172b:09c1 reactivates the wait anim.
 			g_system->copyRectToScreen(bg.getPixels(), bg.pitch,
 									   0, 0, kScreenWidth, kScreenHeight);
 		}
@@ -1022,9 +892,7 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				// Draw over the per-entry clean BG (restored at the top of the
 				// loop), NOT into the persistent `bg` snapshot — baking it in
 				// makes successive speaker portraits stack instead of refresh
-				// (duplicate NPC portraits + labels). The briefing's fixed NPC
-				// persists via the entrance animation captured in the snapshot
-				// (playLondonInitCluesAnim), not via the per-entry portrait.
+				// (duplicate NPC portraits + labels).
 				blitMaskedToScreen(charPic, charX, charY);
 			}
 		}
@@ -1058,21 +926,13 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (haveBalloon) {
 				const int bw = MIN<int>(balloon.surface.w, kScreenWidth - bubX);
 				const int bh = MIN<int>(balloon.surface.h, kScreenHeight - bubY);
-				// _AddPicBackground: transparent colour = pic->miscflags >> 8.
 				const byte transp = (byte)(balloon.flags >> 8);
-				// _GetBalloon @ 172b:1d7d mirrors horizontally when
-				// (bubNum & 0x80) — for right-side speakers.
 				const bool flipBalloon = (fittedBubNum & 0x80) != 0;
 				if (bw > 0 && bh > 0) {
 					scratch.transBlitFrom(balloon.surface,
 										  Common::Point(bubX, bubY),
 										  transp, flipBalloon);
 				}
-				// Per-balloon insets at 29be:0875 (52 x 10 bytes,
-				// indexed by bubNum & 0x7F). _DisplayClue does
-				// _WordWrap(bubX + table[bub].x, bubY + table[bub].y,
-				//           table[bub].w, ...).
-				// Fallback (5, 4, 155) = entry-23 inset.
 				uint16 insetX = 5;
 				uint16 insetY = 4;
 				uint16 insetW = 155;
@@ -1082,21 +942,15 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				textW = insetW;
 				copyH = bh;
 			} else {
-				// No balloon: clear band.
 				const Common::Rect band(0, bubY, kScreenWidth,
 					MIN<int>(bubY + copyH, kScreenHeight));
 				scratch.fillRect(band, 0);
 				copyY = bubY;
 			}
 
-			// _DisplayClue @ 2404:07fe passes fontColor=0 (palette
-			// index 0 of case-briefing palette 0x22).
 			_font.drawWordWrapped(&scratch, textX, textY,
 				MAX<int>(8, textW), text, 0);
 
-			// Clamp to the screen: a malformed entry (e.g. a clue-format
-			// mismatch) must never hand copyRectToScreen a negative height,
-			// which would underflow to a multi-GB memcpy.
 			copyY = CLIP<int>(copyY, 0, kScreenHeight - 1);
 			const int copyRows = CLIP<int>(MIN<int>(copyH, kScreenHeight - copyY),
 										   0, kScreenHeight - copyY);
@@ -1107,9 +961,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			}
 		}
 
-		// _DisplayClue @ 2404:0833-085a — per-clue voice gate. The gate is on
-		// the Jenny slot regardless of partner; entries with +0x18 == 0 but
-		// +0x1a set are text-only.
 		if (_audio) {
 			const uint16 voiceJenny = READ_LE_UINT16(c + 0x18);
 			if (voiceJenny != 0 && voiceJenny != 0xFFFF) {
@@ -1143,7 +994,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						ev.kbd.keycode == Common::KEYCODE_ESCAPE) {
 						advance = true;
 						skipAll = true;
-						// Cut voice + spool only (keep MIDI).
 						interruptAudio(/* stopMusicToo= */ false);
 						break;
 					}
@@ -1163,7 +1013,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 				g_system->delayMillis(10);
 			}
 			if (skipAll) {
-				// Apply remaining side-effects without rendering.
 				for (uint k = i; k < number; k++)
 					applyClueSideEffects(clueBlock + 4 + k * stride);
 				return;
@@ -1172,17 +1021,9 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 
 		applyClueSideEffects(c);
 
-		// EEM2 `_DisplayClue @ 2542:05bd`: a clue entry can gate the rest of
-		// the clue behind a "check the manual / a real map" puzzle (the id is
-		// entry+0x54 = c+0x50, a `P<id>.BIN`). Run it once per mystery
-		// (`_mystery._solvedPuzzle` = `DAT_3036_6d5c`); a wrong answer aborts
-		// the remaining entries and re-prompts on the next visit.
 		if (isLondon() && !_mystery._solvedPuzzle) {
 			const uint16 puzzleId = READ_LE_UINT16(c + 0x50);
 			if (puzzleId != 0xFFFF) {
-				// Restore the clean site BG (drop this clue's bubble) first —
-				// the DOS _Repaint before `_DoPuzzle` — so the puzzle (and its
-				// own hint) render on a clean background, not over the bubble.
 				g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0,
 										   kScreenWidth, kScreenHeight);
 				g_system->updateScreen();
@@ -1193,8 +1034,6 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		}
 	}
 
-	// _StopTheVoice @ 1ff1:0283 effect (voice only, keep MIDI). Diverges
-	// from _DisplayClue @ 2404:05e6 which lets voice bleed past dismissal.
 	interruptAudio(/* stopMusicToo= */ false);
 }
 
@@ -1245,9 +1084,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	//   u8  sound    @ +9     (high bit = play voice, low 7 bits = slot)
 	//   u8  textCount@ +10
 	//   u8  textIdx[]@ +11    (1 byte per — low 7 bits = NOTES idx)
-	// NOTES text offsets are absolute byte offsets into the mystery
-	// buffer; read via mystery.blobAt(noteEntry[+2..3]) for Jake,
-	// +4..5 for Jenny.
 	if (!rec || !isFloppy() || !_font.isLoaded() || count == 0)
 		return;
 
@@ -1270,9 +1106,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 	const uint32 dsz       = _mystery.dataSize();
 	const uint32 notesBase = (uint32)(notes - bufBase);
 
-	// _DisplayHotspotClue_Floppy @ 22dc:05c8 writes _TextSeen_Floppy[idx]=1
-	// inline per text. Pre-mark all up front so ESC-fast-forward still
-	// records every clue in the notebook.
 	{
 		const byte *r = rec;
 		for (uint i = 0; i < count; i++) {
@@ -1301,12 +1134,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			const uint slot = rec[9] & 0x7f;
 			_audio->playFloppyVoiceSlot(slot, _partner);
 		}
-		// _DisplayHotspotClue_Floppy @ 22dc:0908..0922 suspect-found:
-		//   if ((rec[9] & 0x80) == 0 && rec[9] != 0)
-		//     _InGallery_Floppy[_GalleryShuffleSeed_Floppy[rec[9]]] = 1;
-		// _GalleryShuffleSeed (DS:0x2d65) overlaps _NewOrder (DS:0x2d66)
-		// by one byte: GalleryShuffleSeed[i+1] == NewOrder[i]. So:
-		// _inGallery[_newOrder[b9 - 1]].
+
 		const uint8 b9 = rec[9];
 		if ((b9 & 0x80) == 0 && b9 != 0) {
 			const uint logicalIdx = (uint)b9 - 1;
@@ -1379,9 +1207,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 		const int textX = ballX + textXIns;
 		const int lineH    = _font.getFontHeight();
 
-		// FUN_22dc_05c8 pagination via local_1c (set from previous text's
-		// flag bit): 0 = fresh page (redraw balloon, Y at top),
-		// 1 = continuation (append below previous lines).
 		bool firstPage  = true;
 		int  cursorY    = ballY + textYIns;
 		bool skipAll    = false;
@@ -1413,7 +1238,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			scratch.simpleBlitFrom(*bg.surfacePtr());
 
 			if (firstPage) {
-				// Original only redraws balloon + portrait on local_1c == 0.
 				if (picID != 0 && picID != 0xFFFF) {
 					Picture pic;
 					if (_picsArchive.getPicture(picID, pic)) {
@@ -1445,10 +1269,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 			const bool isLastText  = (t + 1 == textCount);
 			const bool isLastRec   = (i + 1 == count);
 
-			// "Click to continue" indicator: PIC 0xa0 ("more" arrow) or
-			// PIC 0xa1 (end). _DisplayHotspotClue_Floppy @ 22dc:08aa
-			// (mid-page) / @ 22dc:08c0 (end-of-record). lastIndicator == 0
-			// matches the original param_2 == 0 "no indicator" case.
 			bool waitNeeded   = false;
 			bool drawArrow    = false;
 			bool useEndPic    = false;
@@ -1457,13 +1277,12 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 					firstPage = false;
 				} else {
 					waitNeeded = true;
-					drawArrow  = true;          // PIC 0xa0
+					drawArrow  = true;
 					useEndPic  = false;
 				}
 			} else {
 				waitNeeded = true;
 				if (!isLastRec) {
-					// More records follow → param_2 = 1 → PIC 0xa0.
 					drawArrow = true;
 					useEndPic = false;
 				} else {
@@ -1472,7 +1291,7 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 						useEndPic = false;
 					} else if (lastIndicator == 2) {
 						drawArrow = true;
-						useEndPic = true;       // PIC 0xa1
+						useEndPic = true;
 					}
 				}
 			}
@@ -1509,8 +1328,6 @@ void EEMEngine::displayFloppyDialogRecords(const byte *rec, uint count,
 }
 
 void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
-	// FUN_19bb_042f @ 19bb:042f: walks nDialog = ib[2 + nSubjects] records
-	// starting at ib + 3 + nSubjects, dispatching each to FUN_22dc_05c8.
 	if (!initBlock || !isFloppy())
 		return;
 	const uint8 nSubjects = initBlock[1];
@@ -1520,12 +1337,6 @@ void EEMEngine::displayFloppyBriefing(const byte *initBlock) {
 }
 
 void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
-	// FUN_22dc_0b80 @ 22dc:0b80 + FUN_1652_00e6 @ 1652:00e6 +
-	// FUN_1652_006c @ 1652:006c. site_data[+6..7] -> per-hotspot list:
-	//   for each hotspot:
-	//     main record (11 + textCount bytes)
-	//     u8 contFlags  (low 7 = cont count, high bit = partner pose)
-	//     contCount x { record (11 + textCount bytes) }
 	if (!_mystery.isLoaded() || !isFloppy())
 		return;
 	const byte *site = _mystery.siteData(siteNum);
@@ -1548,8 +1359,7 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 	}
 	if (off >= _mystery.dataSize())
 		return;
-	// Snapshot clean site BG and restore between main + continuation calls
-	// so each displayFloppyDialogRecords sees a bubble-free background.
+
 	Graphics::ManagedSurface siteBG(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	{
@@ -1567,10 +1377,7 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 		contFlagsByte = bufBase[off + mainLen];
 		contCount = contFlagsByte & 0x7F;
 	}
-	// _HandleHotspotClick_Floppy @ 1652:00e6 derives param_2:
-	//   contFlagsByte == 0  -> 0 (no indicator)
-	//   high bit set        -> 1 (PIC 0xa0, "more")
-	//   low 7 bits non-zero -> 2 (PIC 0xa1, alt end)
+
 	uint mainIndicator = 0;
 	if (contFlagsByte != 0) {
 		mainIndicator = (contFlagsByte & 0x80) ? 1 : 2;
@@ -1581,12 +1388,10 @@ void EEMEngine::displayFloppyHotspotDialog(uint siteNum, uint hotIdx) {
 	const uint32 contOff = off + mainLen + 1;
 	if (contOff >= _mystery.dataSize())
 		return;
-	// Wipe main bubble so the continuation chain snapshots a clean BG.
+
 	g_system->copyRectToScreen(siteBG.getPixels(), siteBG.pitch,
 							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
-	// _DisplayDialogContinuations_Floppy @ 1652:006c: lastIndicator=0
-	// means no indicator on the final continuation.
 	displayFloppyDialogRecords(bufBase + contOff, contCount, 0);
 }
 
@@ -1599,10 +1404,6 @@ bool EEMEngine::areYouSure() {
 		g_system->unlockScreen();
 	}
 
-	// _AreYouSure @ 1a35:0a5c (CD) / FUN_19bb_0b43 (floppy):
-	//   PIC 0x136 = dialog body; PIC 0x1fd/0x1fe = YES/NO pressed buttons.
-	//   YES hit: (x+0x0c, y+0x23) .. (x+0x20, y+0x32)
-	//   NO  hit: (x+0x60, y+0x23) .. (x+0x74, y+0x32)
 	Picture dialogPic;
 	Picture yesPic;
 	Picture noPic;
diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index 714b2d81821..c27f37a7bfb 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -68,10 +68,7 @@ const ADGameDescription gameDescriptions[] = {
 		GUI_OPTIONS_EEM_FLOPPY
 	},
 	{
-		// Eagle Eye Mysteries in London (EEM2CD.EXE), the sequel.
-		// It reuses this engine's resource formats (DBD/DBX archives,
-		// SITEPALS palettes, FONT.FNT); reimplementation is in progress,
-		// so it stays flagged unstable until fully supported.
+		// Eagle Eye Mysteries in London 
 		"eem2",
 		"CD",
 		AD_ENTRY2s("EEM2CD.EXE", "211a376b23a1b6259d0c36cf46d26ed4", 172560,
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 4f455d22562..136a45ab278 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -48,8 +48,6 @@
 
 namespace EEM {
 
-const uint kNumSitePals = 40;  // SITEPALS: 40 * 768 = 30720
-
 // 1-based picture/palette IDs.
 const uint kPicEAKidsLogo      = 0x54;  // _ShowEAKids
 const uint kPicHighScoreLogo   = 0x20c; // _ShowHScoreLogo
@@ -89,8 +87,6 @@ Common::String makeLondonProfileDisplayName(const Common::String &first,
 
 Common::String makeLondonProfileKey(const Common::String &first,
 									 const Common::String &last) {
-	// EEM2 `_GenerateFilename @ 1cd3:02fe`: copy the first 7 bytes of
-	// FirstName, pad to 8 from LastName, then replace spaces/dots with '_'.
 	Common::String cleanFirst = first;
 	Common::String cleanLast = last;
 	cleanFirst.trim();
@@ -220,13 +216,9 @@ EEMEngine::EEMEngine(OSystem *syst, const ADGameDescription *gameDesc)
 	_variant = (gameDesc && gameDesc->extra &&
 				Common::String(gameDesc->extra).contains("Floppy"))
 				 ? kVariantFloppy : kVariantCD;
-	// EEM2 ("...in London") ships as a separate detection entry (gameId
-	// "eem2"); it reuses this engine with London-specific data.
 	if (gameDesc && gameDesc->gameId &&
 		Common::String(gameDesc->gameId) == "eem2")
 		_variant = kVariantLondonCD;
-	// EEM2 ships its own `_AnimationSequences` — many partner/KD scripts
-	// differ from EEM1's, so route `findAnimScript` to the EEM2 table.
 	setLondonAnimScripts(isLondon());
 	_language = gameDesc ? gameDesc->language : Common::EN_ANY;
 }
@@ -270,7 +262,7 @@ bool EEMEngine::anyMysterySolved(uint lo, uint hi) const {
 
 bool EEMEngine::mysteryTierRange(uint stage, uint &lo, uint &hi) const {
 	if (isLondon()) {
-		// EEM2/London: two 25-case books, no BOOK3.NME
+		// EEM2/London: two 25-case books
 		// (`_DisplayCorrect @ 1ea1:0619`).
 		switch (stage) {
 		case 1: lo = 0x01; hi = 0x19; return true;  //  1..25
@@ -299,17 +291,12 @@ void EEMEngine::advanceChainStageAfterSolve(uint mysteryNum) {
 		return;
 
 	const uint oldStage = _chainStage;
-	// EEM1 only: Book 2 repeats the Book 1 cases, so this option keeps the
-	// original solve state but jumps the active chain straight to Book 3.
-	// London has 50 distinct cases and no Book 3, so the option does not apply.
 	if (!isLondon() && _chainStage == 1 &&
 		ConfMan.getBool("skip_repeated_cases"))
 		_chainStage = 3;
 	else
 		_chainStage++;
 
-	// London has only two books: `_DisplayCorrect @ 1ea1:0619` collapses
-	// chainStage 3 straight to 4 (every case solved — game complete).
 	if (isLondon() && _chainStage == 3)
 		_chainStage = 4;
 
@@ -319,8 +306,6 @@ void EEMEngine::advanceChainStageAfterSolve(uint mysteryNum) {
 }
 
 void EEMEngine::applySkipRepeatedCasesOption() {
-	// EEM1 only — the option jumps past the repeated Book 2 to Book 3, which
-	// London does not have (see `advanceChainStageAfterSolve`).
 	if (isLondon() || !ConfMan.getBool("skip_repeated_cases"))
 		return;
 	if (_mystery.isLoaded())
@@ -335,7 +320,6 @@ void EEMEngine::applySkipRepeatedCasesOption() {
 }
 
 Common::Error EEMEngine::run() {
-	// _SetMode13X @ 1000:0358 — VGA mode 13h.
 	initGraphics(kScreenWidth, kScreenHeight);
 
 	if (!isFloppy() && ConfMan.getBool("restored_content")) {
@@ -367,13 +351,6 @@ Common::Error EEMEngine::run() {
 	_audio->setVoiceEnabled(_voiceOn);
 	syncSoundSettings();
 
-	// CD `_main @ 1a35:0f59` and floppy `_main_Floppy @ 19bb:1012` both
-	// load `_GetPicture(0x50)` as the active mouse pointer before
-	// `_InitMouse`. PIC 0x51 is present in both archives but has no
-	// executable xrefs and appears to be the wait cursor.
-	// CD's `_SwitchMouse` supports swapping to a hotspot cursor ID stored
-	// at search record +0x0c, but the shipped CD mystery data only uses
-	// cursor 0; floppy search records have no cursor-id field.
 	installMouseCursor(_picsArchive, false);
 	CursorMan.showMouse(false);
 
@@ -413,10 +390,6 @@ Common::Error EEMEngine::run() {
 	if (resumed)
 		goto screenLoop;
 
-	// EEM2 ("Eagle Eye Mysteries in London"): opening sequence, then screen
-	// 8 profile selection, screen 9 partner selection. A freshly-created
-	// detective starts the training case (M0) after choosing Jake/Jennifer;
-	// an existing profile resumes its saved menu/map state.
 	if (isLondon()) {
 		runLondonStartup();
 		if (!shouldQuit()) {
@@ -439,37 +412,8 @@ Common::Error EEMEngine::run() {
 		goto screenLoop;
 	}
 
-	// _DoOpeningAnims @ 2520:082a:
-	//   EA Kids logo (PIC) -> HighScore logo (PIC) -> Storm logo
-	//   (BOLT.ANM) -> [music starts] -> 20 character-intro anims
-	//   (ANIM01.A..ANIM20.A) -> [music restarts] -> TITLE.ANM. Click /
-	//   any key skips one clip; ESC raises _skipIntro so each step bails
-	//   out.
-	// Music timing (2520:0883 + 2520:0918):
-	//   - The three logos and `_InitMysterySounds(0x3c)` run BEFORE any
-	//     `_MIDIPlayFile` — those segments are voice-only.
-	//   - Theme starts with `_LoopMIDI = 0x7fff` right before the
-	//     ANIM01..ANIM20 loop (2520:0883).
-	//   - After the loop the original calls `_CleanMysterySounds` then
-	//     `_MIDIPlayFile("theme.xmi")` again with `_LoopMIDI = 0xffff`
-	//     (2520:0918) to restart for TITLE.ANM.
-	//   - `_StopMIDI()` runs on keypress at the title screen (2520:094c).
 	_skipIntro = false;
 	if (isFloppy()) {
-		// Floppy opening — `FUN_23d2_039c @ 23d2:039c`:
-		//   FUN_23d2_0170()  — clear palette
-		//   FUN_23d2_004b()  — set up timer
-		//   FUN_23d2_050c()  — PIC 0x54 (EA Kids, palette 0x25)
-		//   FUN_23d2_06c6()  — PIC 0x20c (High Score, palette 0x27)
-		//   FUN_23d2_0605()  — PIC 0x20b (Storm, palette 0x26) AND
-		//                      voice slot 25 = "thunder.voc" (via Jake
-		//                      voice table 2608:0f0e slot 25 →
-		//                      2608:11ac).
-		//   _MIDIPlayFile("theme.xmi", loop=1)
-		//   _PlayANM(0) — CHAT.ANM (filename table 2608:14fe[0] →
-		//                  "chat.anm" at 2608:150a)
-		//   _PlayANM(1) — MOVIE.ANM (table[1] → 2608:1513)
-		// TITLE.ANM is shown later by screen-0xb handler @ 19bb:0ebc.
 		if (!shouldQuit() && !_skipIntro)
 			showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
@@ -488,10 +432,6 @@ Common::Error EEMEngine::run() {
 		showEAKidsLogo();
 		if (!shouldQuit() && !_skipIntro)
 			showHighScoreLogo();
-		// Storm Software logo: voice + animation. `_ShowStormLogo @
-		// 2520:0707` calls `_LoadSoundName("thunder.voc")` (29be:177d)
-		// and passes the buffer to `OpenDifferenceAnimation_Sound` so
-		// the thunder roar plays alongside the lightning-bolt BOLT.ANM.
 		if (!shouldQuit() && !_skipIntro) {
 			if (_audio)
 				_audio->playVoc(Common::Path("THUNDER.VOC"));
@@ -502,7 +442,7 @@ Common::Error EEMEngine::run() {
 			if (_audio)
 				_audio->stopVoice();
 		}
-		// _InitMysterySounds(0x3c) @ 2520:086a — load M60.SDX/SDB.
+
 		if (!shouldQuit() && !_skipIntro && _audio)
 			_audio->initMysterySounds(60);
 		if (!shouldQuit() && !_skipIntro && _music)
@@ -514,17 +454,13 @@ Common::Error EEMEngine::run() {
 			Common::String name = Common::String::format("ANIM%02d.A", i);
 			playAnm(Common::Path(name), 120,
 					/* holdLastFrame= */ false, fadeIn);
-			// _SpoolSound(uVar3 - 1) @ 2520:08c2 — per-anim VO, skipped
-			// when uVar3 == 0x14 @ 2520:08a8.
 			if (!shouldQuit() && !_skipIntro && i != 20 && _audio) {
 				_audio->spoolSound((uint)(i - 1));
 				_audio->waitForSpoolDone();
 			}
 		}
-		// _CleanMysterySounds @ 2520:0903.
 		if (_audio)
 			_audio->cleanMysterySounds();
-		// _MIDIPlayFile("theme.xmi") @ 2520:0918.
 		if (!shouldQuit() && !_skipIntro && _music)
 			_music->playFile(Common::Path("THEME.XMI"), /* loop= */ true);
 		if (!shouldQuit() && !_skipIntro)
@@ -539,13 +475,11 @@ Common::Error EEMEngine::run() {
 		goto screenLoop;
 	}
 
-	// Title(B) -> screen 8 (profile) -> 9 (partner) -> C (action) ->
-	// A (case selection) -> site loop.
 	CursorMan.showMouse(true);
 
 	if (_music)
 		_music->stop();
-	// screen8_handler @ 1c33:1012.
+
 	if (!shouldQuit())
 		doProfilePicker();
 	if (!shouldQuit())
@@ -555,16 +489,6 @@ Common::Error EEMEngine::run() {
 	if (!shouldQuit())
 		doChoosePartner();
 
-	// Drop into the screen-driver state machine — same pattern as
-	// `_ScreenDriver @ 1a35:0dc1` + the per-screen handler table at
-	// 1a35:0e5e. Sentinel `kScreenInvalid` (0xFFFF) ends the loop.
-	// `_DoChoosePartner @ 1a35:099d` writes `_NextScreen = 0xc` (the
-	// original `_ActionScreen`, which is separate from handler 10's
-	// `_DoChooseMystery` / `_CaseSelection`).
-	// Mid-mystery resume: if the loaded save had `hasMystery` set,
-	// drop straight to MAP rather than the action menu so the player
-	// doesn't walk back through the case picker (which would
-	// `_mystery.load()` fresh and discard site / clue progress).
 	if (!shouldQuit() && !resumed)
 		_nextScreen = _mystery.isLoaded() ? kScreenMap : kScreenAction;
 screenLoop:
@@ -574,9 +498,6 @@ screenLoop:
 
 		switch (current) {
 		case kScreenTitle:
-			// Floppy handler 0xb _HandleScreen11_Title_Floppy ->
-			// _DoTitle_Floppy -> _PlayTitleANM_Floppy(1)=TITLE.ANM.
-			// Writes _NextScreen=8 (profile picker).
 			_nextScreen = kScreenProfile;
 			if (isFloppy()) {
 				CursorMan.showMouse(false);
@@ -588,10 +509,6 @@ screenLoop:
 			break;
 
 		case kScreenAction:
-			// Top-level post-profile / post-mystery menu. `_ActionScreen
-			// @ 1c33:195b` shows the 5-entry "Choose A Mystery /
-			// Practice / ScrapBook 1..3" picker; writes _NextScreen=0xa
-			// only when the player picks "Choose A Mystery".
 			_nextScreen = kScreenInvalid;
 			doActionScreen();
 			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
@@ -599,8 +516,6 @@ screenLoop:
 			break;
 
 		case kScreenChooseMystery:
-			// Handler 10 @ 1a35:0e0e -> _DoChooseMystery (presets
-			// _NextScreen=0 INIT_CLUES) -> _CaseSelection.
 			_nextScreen = kScreenInvalid;
 			doCaseSelection();
 			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
@@ -608,8 +523,6 @@ screenLoop:
 			break;
 
 		case kScreenInitClues:
-			// Handler 0 @ 1a35:0e14 -> _PreLoad + _DoInitClues, writes
-			// _NextScreen=1 (MAP).
 			doInitClues();
 			_nextScreen = _mystery.isLoaded() ? kScreenMap
 											  : kScreenAction;
@@ -617,11 +530,6 @@ screenLoop:
 
 		case kScreenMap:
 		case kScreenMapAlt:
-			// Handler 1/2 @ 1a35:0e25 -> `_DoMapScreen @ 20fe:120b`
-			// (floppy 19bb:0ef3 -> 1fed map code), which manages its
-			// own _NextScreen writes: 3 (site clicked), 6 (setup), or
-			// 0xffff (quit). After `doBigMap` returns the natural
-			// next state is SITE.
 			doBigMap();
 			if (!_mystery.isLoaded())
 				_nextScreen = kScreenAction;
@@ -630,9 +538,6 @@ screenLoop:
 			break;
 
 		case kScreenSite:
-			// Handler 3 @ 1a35:0e2c -> `_DoSiteLoop @ 168d:03f4`
-			// (floppy dispatches through 1652). Site writes _NextScreen
-			// for PDA / map rather than entering those as nested modals.
 			doSiteLoop();
 			if (!_mystery.isLoaded())
 				_nextScreen = kScreenAction;
@@ -641,8 +546,6 @@ screenLoop:
 			break;
 
 		case kScreenNotebook:
-			// Handler 4 — PDA notebook screen. Button handler writes
-			// 2 (map), 3 (site), 5 (gallery), or 7 (accuse).
 			doNotebook();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -651,8 +554,6 @@ screenLoop:
 			break;
 
 		case kScreenGallery:
-			// Handler 5 — suspect gallery. ESC and the site button
-			// write 3, the map button writes 2, the PDA button writes 4.
 			doGallery();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -661,9 +562,6 @@ screenLoop:
 			break;
 
 		case kScreenSetup:
-			// Handler 6 @ 1a35:0e48 -> _DoSetup. EEM1 = seg-1f78 (`doSetup`);
-			// EEM2/London = seg-2046 (`doSetupLondon`, 4 toggles + own layout).
-			// Entered from BigMap setup button (_NextScreen=6 @ 20fe:0c33).
 			if (isLondon())
 				doSetupLondon();
 			else
@@ -671,9 +569,6 @@ screenLoop:
 			break;
 
 		case kScreenProfile:
-			// Handler 8: CD screen8_handler @ 1c33:1012 ->
-			// _NewPlayer; floppy _HandleScreen8_NewPlayer_Floppy @
-			// 19bb:0ec2 writes screen 9.
 			_nextScreen = kScreenInvalid;
 			_mystery.clear();
 			doProfilePicker();
@@ -697,8 +592,6 @@ screenLoop:
 			break;
 
 		case kScreenAccuse:
-			// Handler 7 — accusation flow. Failed accusation returns to
-			// `_LastScreen`; a correct solution writes 0xc (ACTION).
 			doAccuse();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
@@ -727,9 +620,6 @@ void EEMEngine::setInteractiveMouseCursor(bool active) {
 }
 
 void EEMEngine::setHotspotMouseCursor(bool active) {
-	// EEM2 swaps the cursor SHAPE per hotspot (see setSiteHotspotCursorId), so
-	// the EEM1 red-outline recolor doesn't apply. The bool path only resets to
-	// the default arrow (cursor 0) when leaving a hotspot / the site loop.
 	if (isLondon()) {
 		if (!active)
 			setSiteHotspotCursorId(0);
@@ -741,7 +631,6 @@ void EEMEngine::setHotspotMouseCursor(bool active) {
 void EEMEngine::setSiteHotspotCursorId(int cursorId) {
 	if (!isLondon())
 		return;
-	// `_SwitchMouse @ 17ee:2c83`: cursor 4 (Jake hand) becomes 5 for Jenny.
 	if (cursorId == 4 && _partner == kPartnerJenny)
 		cursorId = 5;
 	if (cursorId < 0 || cursorId >= (int)ARRAYSIZE(kLondonCursorPics))
@@ -766,7 +655,6 @@ void EEMEngine::setSiteHotspotCursorId(int cursorId) {
 }
 
 bool EEMEngine::openArchives() {
-	// _InitGraphicsSystem @ 172b:0145.
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"))) {
 		warning("PICS archive missing");
 		return false;
@@ -779,10 +667,6 @@ bool EEMEngine::openArchives() {
 		warning("SITES archive missing — site backgrounds disabled");
 	if (!_balloonArchive.open(Common::Path("BALLOON.DBD"), Common::Path("BALLOON.DBX")))
 		warning("BALLOON archive missing — clue text will lack balloons");
-	// `_GetButton @ 172b:199d` reads from this archive (see strings
-	// 'button.dbd' / 'Button.DBX' at 29be:06bf / 29be:04bb). Each
-	// per-site map marker (used by `_StampButtons @ 20fe:0d2f` and
-	// looked up via MapData[+0]) lives here.
 	if (!_buttonArchive.open(Common::Path("BUTTON.DBD"), Common::Path("BUTTON.DBX")))
 		warning("BUTTON archive missing — map markers will be unlabelled");
 	return true;
@@ -790,8 +674,6 @@ bool EEMEngine::openArchives() {
 
 bool EEMEngine::loadSitePalettes() {
 	Common::File f;
-	// EEM1 ships "SITEPALS" (40 palettes); EEM2/London ships "SITEPALS."
-	// with a trailing dot (63 palettes). _ReadPalettes @ 17ee:0cdb.
 	const char *palFile = isLondon() ? "SITEPALS." : "SITEPALS";
 	if (!f.open(Common::Path(palFile))) {
 		warning("%s missing", palFile);
@@ -808,12 +690,8 @@ bool EEMEngine::loadSitePalettes() {
 }
 
 bool EEMEngine::getSitePalette(uint num, byte *out) const {
-	// EEM1 SITEPALS has kNumSitePals (40) entries; EEM2/London SITEPALS.
-	// has 63. Validate against the actual loaded buffer so both work.
 	if (_sitePals.size() < (num + 1) * kPalSize)
 		return false;
-	// SITEPALS stores 6-bit VGA-DAC values (0..63); ScummVM expects
-	// 8-bit (0..255), so left-shift by 2 like the original VGA hardware.
 	const byte *src = _sitePals.data() + num * kPalSize;
 	for (uint i = 0; i < kPalSize; i++)
 		out[i] = (byte)(src[i] << 2);
@@ -848,13 +726,6 @@ bool EEMEngine::setAnmPalette(const Common::Path &anmPath) {
 }
 
 void EEMEngine::interruptAudio(bool stopMusicToo) {
-	// Mirrors `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @
-	// 20a2:0512` — both fire when the player aborts the opening-anim
-	// chain or dismisses the title (`_DoOpeningAnims` writes
-	// `_LoopMIDI = 0; _StopMIDI();` after the title-input loop).
-	// Conversation / clue-dialog skip paths pass `stopMusicToo = false`
-	// so the site / briefing MIDI keeps going across an ESC — only the
-	// per-line voice + spool need to stop.
 	if (_audio) {
 		_audio->stopVoice();
 		_audio->stopSpool();
@@ -902,8 +773,6 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 		}
 		g_system->updateScreen();
 
-		// Original uses _CheckFrameRate / _kbhit; fixed delay here.
-		// ESC sets _skipIntro and interrupts audio.
 		const uint32 frameStart = g_system->getMillis();
 		bool aborted = false;
 		while (g_system->getMillis() - frameStart < frameDelayMs && !aborted) {
@@ -926,10 +795,6 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 					break;
 				}
 			}
-			// Refresh cursor overlay every tick — otherwise
-			// the cursor only redraws when the next frame is blitted
-			// (~8 Hz at 120 ms), perceived as choppy during long
-			// animations like SCRAPBK.ANI.
 			g_system->updateScreen();
 			g_system->delayMillis(5);
 		}
@@ -938,7 +803,6 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 	}
 
 	if (holdLastFrame && !shouldQuit() && !_skipIntro) {
-		// _DoOpeningAnims tail: while (!keyDataAvailable).
 		while (!shouldQuit()) {
 			Common::Event ev;
 			bool clicked = false;
@@ -965,14 +829,12 @@ void EEMEngine::playAnm(const Common::Path &path, uint frameDelayMs,
 			g_system->updateScreen();
 			g_system->delayMillis(20);
 		}
-		// _DoOpeningAnims @ 2520:0945: _LoopMIDI=0; _StopMIDI().
 		if (_music)
 			_music->stop();
 	}
 }
 
 void EEMEngine::blitAt(const Picture &pic, int x, int y) {
-	// Clip against the 320x200 frame buffer.
 	const int w = MIN<int>(pic.surface.w, kScreenWidth - x);
 	const int h = MIN<int>(pic.surface.h, kScreenHeight - y);
 	if (w <= 0 || h <= 0)
@@ -982,9 +844,6 @@ void EEMEngine::blitAt(const Picture &pic, int x, int y) {
 }
 
 void EEMEngine::waitForInput(uint32 maxMs) {
-	// ESC: _skipIntro + interruptAudio (matches _CleanMysterySounds +
-	// _StopMIDI around _DoOpeningAnims title wait).
-	// Only Return/KP-Enter/Space/Escape advance.
 	setInteractiveMouseCursor(false);
 	const uint32 startMs = g_system->getMillis();
 	while (!shouldQuit() && (g_system->getMillis() - startMs < maxMs)) {
@@ -1017,12 +876,7 @@ void EEMEngine::waitForInput(uint32 maxMs) {
 	}
 }
 
-// _OpenColorCycle @ 2520:04f7. Rotate `fpal[start..end]` by one slot:
-//   saved = fpal[end]
-//   for u = end..start+1: fpal[u] = fpal[u-1]
-//   fpal[start] = saved
-// If `show`, upload `end - start` entries (fpal[start..end-1]) — note that
-// fpal[end] is rotated in memory but intentionally not uploaded each tick.
+// _OpenColorCycle @ 2520:04f7. Rotate `fpal[start..end]` by one slot
 void openColorCycle(byte *fpal, uint8 start, uint8 end, bool show) {
 	if (end <= start)
 		return;
@@ -1042,32 +896,8 @@ void openColorCycle(byte *fpal, uint8 start, uint8 end, bool show) {
 												   end - start);
 	}
 }
-
+// _ShowEAKids @ 2520:05f0:
 void EEMEngine::showEAKidsLogo() {
-	// _ShowEAKids @ 2520:05f0:
-	//   _GetPicture(0x54) + memcpy to 0xa000 (VGA).
-	//   _GetPalette(0x25) loads pal 0x25 into _fpal (NOT uploaded to DAC).
-	//   FRAME_RATE = 0x19 (25 fps); _InitFrameReg.
-	//   for j in 0..1: show = j;
-	//     for u in 0..0x37 (= 55):
-	//       if (show) wait for next 25-fps tick (abort on key/click).
-	//       _OpenColorCycle(0x01, 0x6e, show)   // bg / outer ring shimmer
-	//       _OpenColorCycle(0x81, 0xee, show)   // inner gradient shimmer
-	//       if (--delay == 0) {
-	//         delay = 8;
-	//         _OpenColorCycle(0x70, 0x80, show) // mid band
-	//       }
-	//   if (!abort) {
-	//     for i in 0..5: _OpenColorCycle(0x70, 0x80, 1);
-	//     for i in 0..0x23: wait one frame;
-	//   }
-	//   _OpenFadeOut().
-	//
-	// Pass 1 (j=0, show=0) pre-rolls _fpal 55 frames in memory only — no
-	// DAC upload, no frame sync. Pass 2 (j=1, show=1) uploads each shift
-	// at 25 fps. Without the pre-roll, the logo first appears at the
-	// unrotated palette-0x25 phase instead of the intended "55-shifts-in"
-	// phase.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicEAKidsLogo, pic)) {
 		warning("EA Kids logo (%u) load failed", kPicEAKidsLogo);
@@ -1075,16 +905,13 @@ void EEMEngine::showEAKidsLogo() {
 	}
 	blitAt(pic, 0, 0);
 
-	// _GetPalette(0x25) — load into our shadow buffer; do not upload.
-	// The logo bitmap is on screen but invisible until the first
-	// _OpenColorCycle(..., show=1) upload in pass 2 lights it up.
 	byte fpal[kPalSize];
 	if (!getSitePalette(kPalEAKids, fpal)) {
 		warning("EA Kids palette (%u) load failed", kPalEAKids);
 		return;
 	}
 
-	const uint kFrameMs = 40;  // FRAME_RATE = 0x19 (25 fps).
+	const uint kFrameMs = 40;
 	bool aborted = false;
 
 	for (uint j = 0; j < 2 && !aborted && !shouldQuit(); j++) {
@@ -1138,13 +965,11 @@ void EEMEngine::showEAKidsLogo() {
 	g_system->updateScreen();
 	waitForInput(0x23 * kFrameMs);
 
-	// _OpenFadeOut @ 2520:0093 — 16 linear steps from current palette to black.
 	fadeCurrentPaletteToBlack();
 }
 
+// _ShowHScoreLogo @ 2520:0799: PIC 0x20c + palette 0x27;
 void EEMEngine::showHighScoreLogo() {
-	// _ShowHScoreLogo @ 2520:0799: PIC 0x20c + palette 0x27;
-	// _OpenFadeIn; 50-tick wait @ 25 fps; _OpenFadeOut.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicHighScoreLogo, pic)) {
 		warning("HighScore logo (%u) load failed", kPicHighScoreLogo);
@@ -1152,7 +977,6 @@ void EEMEngine::showHighScoreLogo() {
 	}
 	blitAt(pic, 0, 0);
 
-	// Force black before fade-in to avoid a 1-frame full-logo flash.
 	byte target[kPalSize];
 	if (!getSitePalette(kPalHighScore, target)) {
 		warning("HighScore palette (%u) load failed", kPalHighScore);
@@ -1163,17 +987,12 @@ void EEMEngine::showHighScoreLogo() {
 	g_system->updateScreen();
 	fadePaletteFromBlack(target);
 
-	// 50 ticks @ 25 fps.
 	waitForInput(2000);
 
 	fadeCurrentPaletteToBlack();
 }
 
 void EEMEngine::showLondonLogo(uint picId, uint palId, uint holdMs) {
-	// Parameterised twin of `showHighScoreLogo` for EEM2's opening logos
-	// (`_ShowEAKids` @ 2721:05e3 and `_ShowHScoreLogo` @ 2721:084d both do
-	// _GetPicture(pic) -> blit -> _GetPalette(pal) -> fade in -> hold ->
-	// fade out).
 	Picture pic;
 	if (!_picsArchive.getPicture(picId, pic) || pic.surface.empty()) {
 		warning("London logo PIC 0x%x load failed", picId);
@@ -1211,15 +1030,6 @@ bool EEMEngine::startLondonTrainingMystery() {
 }
 
 void EEMEngine::runLondonStartup() {
-	// Full opening sequence — EEM2 `_DoOpeningAnims` @ 2721:08e6:
-	//   _ShowEAKids   @ 2721:05e3 — PIC 0x54,  palette 0x3c
-	//   FUN_2721_07be @ 2721:07be — PIC 0x20c, palette 0x3e (publisher logo)
-	//   _ShowStormLogo@ 2721:0729 — anim 0 ("bolt.anm") + "thunder.voc"
-	//   _MIDIPlay(0x65)=MUS00101.XMI; anim 1 ("movie.anm")
-	//   _MIDIPlay(0x66)=MUS00102.XMI (loop); anim 2 ("wave.anm"), looped
-	//   _MIDIPlay(0x67)=MUS00103.XMI; fade out
-	// Anim names come from the table @ DS:1b54 -> {bolt,movie,wave}.anm;
-	// _MIDIPlay(n) maps to MUS%05d.XMI (n).
 	const uint32 kHoldForever = 0xFFFFFFFFu;
 	CursorMan.showMouse(false);
 	_skipIntro = false;
@@ -1273,15 +1083,8 @@ void EEMEngine::runLondonStartup() {
 	debugC(1, kDebugGeneral, "EEM2 (London): intro + profile selection done");
 }
 
+// `_NewPlayer` @ 1cd3:0f27 — character creation over background PIC 0xc
 void EEMEngine::showLondonCharSelect() {
-	// `_NewPlayer` @ 1cd3:0f27 — character creation over background PIC 0xc
-	// (palette 0). Two text fields then a male/female player-gender pick
-	// (left/right arrow 0x4b/0x4d -> DAT_4c4c, Enter confirms). The
-	// field/box rects are constants in EEM2's data segment (read at
-	// 2bca:0e3a); they map to the `pr` player record's FirstName[12] /
-	// LastName[20]:
-	//   first name : (54,75)-(151,85)    last name : (167,75)-(266,85)
-	//   male       : (110,116)-(120,122) female       : (190,116)-(200,122)
 	debugC(1, kDebugGeneral, "EEM2 (London): character creation");
 
 	const Common::Rect kFirstRect(54, 75, 151, 85);
@@ -1289,10 +1092,7 @@ void EEMEngine::showLondonCharSelect() {
 	const Common::Rect kMaleBox(110, 116, 120, 122);
 	const Common::Rect kFemaleBox(190, 116, 200, 122);
 	const uint kMaxFirst = 12, kMaxLast = 20;
-	// `_GetNameString @ 1cd3:0ddc` draws both the typed name and the caret in
-	// colour 0x22 (the passport-field ink); the old 0x0F was a guess and the
-	// underscore caret in it was effectively invisible.
-	const uint8 kInkColor = 0x22;     // typed-name ink + caret
+	const uint8 kInkColor = 0x22;
 
 	Picture bg;
 	const bool haveBg = _picsArchive.getPicture(0xc, bg) && !bg.surface.empty();
@@ -1347,8 +1147,6 @@ void EEMEngine::showLondonCharSelect() {
 				getFont().drawString(&scratch, last, kLastRect.left + 2,
 									 kLastRect.top + 1, kLastRect.width(),
 									 kInkColor);
-				// Caret = solid block `_FillRect(x+1, y, 6, 0xb, 0x22)` at the
-				// input point (`_GetNameString @ 1cd3:0ddc`), NOT an underscore.
 				if (blink && (field == kFieldFirst || field == kFieldLast)) {
 					const Common::Rect &fr =
 						(field == kFieldFirst) ? kFirstRect : kLastRect;
@@ -1364,8 +1162,6 @@ void EEMEngine::showLondonCharSelect() {
 				}
 			}
 			if (field == kFieldGender) {
-				// DOS restores both boxes, then fills the active one with the
-				// darker passport border color sampled at (109,115).
 				scratch.fillRect(kMaleBox, boxBlankColor);
 				scratch.fillRect(kFemaleBox, boxBlankColor);
 				scratch.fillRect(female ? kFemaleBox : kMaleBox,
@@ -1456,10 +1252,6 @@ void EEMEngine::showLondonCharSelect() {
 			blink = !blink;
 			needRedraw = true;
 		}
-		// Flush every frame so the mouse cursor tracks smoothly. The scratch
-		// rebuild + copyRectToScreen above is gated on needRedraw (and mouse
-		// motion doesn't set it), but the cursor is only re-composited by
-		// updateScreen(), so without this it moved only on blink/keypress.
 		g_system->updateScreen();
 		g_system->delayMillis(15);
 	}
@@ -1501,13 +1293,8 @@ void EEMEngine::showLondonCharSelect() {
 				s.getDescription().c_str(), s.getSaveSlot());
 	}
 
-	// New profile. EEM2 `_NewPlayer @ 1cd3:0f27` initializes the player
-	// record only when `_LoadPlayerRecord` failed, then immediately saves it.
 	_playerName = displayName.empty() ? "Detective" : displayName;
 	_chainStage = 1;
-	// `_NewPlayer @ 1cd3:0f27` stores the passport gender pick (`DAT_3036_4c4c`).
-	// Drives the player pronouns 0x86-0x88 in `parseString`. (Existing profiles
-	// take the gender from their save instead, via the load path above.)
 	_playerFemale = female;
 	_voiceOn = true;
 	if (_audio)
@@ -1525,14 +1312,8 @@ void EEMEngine::showLondonCharSelect() {
 		   _playerName.c_str(), profileKey.c_str(),
 		   female ? "female" : "male");
 }
-
+// Floppy storm-logo splash — `23d2:0605`:
 void EEMEngine::showFloppyStormLogo() {
-	// Floppy storm-logo splash — `FUN_23d2_0605 @ 23d2:0605`:
-	//   GetPicture(0x20b); BlitToVGA;
-	//   if (sound) { LoadVOC(slot 25 = "thunder.voc"); PlayVOC(...); }
-	//   GetPalette(0x26); FadeIn; wait 50 ticks; FadeOut.
-	// CD plays `BOLT.ANM` here with `THUNDER.VOC` overlaid; floppy uses
-	// a static still + the same VOC.
 	Picture pic;
 	if (!_picsArchive.getPicture(kPicStormLogo, pic)) {
 		warning("Storm logo (%u) load failed", kPicStormLogo);
@@ -1561,27 +1342,13 @@ void EEMEngine::showFloppyStormLogo() {
 }
 
 void EEMEngine::doSiteLoop() {
-	// Per-mystery site loop. SiteScreen::run() handles hotspot clicks
-	// plus M (map), N (notebook), G (gallery), A (accuse), Tab (next
-	// site), ESC (exit).
 	SiteScreen screen(this, &_mystery);
 	screen.run();
 	setHotspotMouseCursor(false);
 }
 
+// _StartTravelMusic @ 20a2:0595:
 void EEMEngine::startTravelMusic() {
-	// _StartTravelMusic @ 20a2:0595:
-	//   for (num = _SiteNumber; num > 4; num -= 5) {}
-	//   if (_MIDIAvailable && _MusicEnabled) {
-	//       if (_IsMIDIPlaying()) _StopMIDI();
-	//       _MIDIPlay(num);
-	//   }
-	// Five travel tracks (MUS00000.XMI..MUS00004.XMI), picked by
-	// `_SiteNumber % 5`. ONE-SHOT — `_DoOpeningAnims @ 2520:0945` resets
-	// `_LoopMIDI = 0` after the title-screen wait, and the function
-	// doesn't write it; combined with `_DoSiteLoop @ 168d:06c0` calling
-	// `_StopMIDI()` before the interactive phase, travel music plays
-	// ONCE during the entrance animation only.
 	if (!_music || !_mystery.isLoaded() ||
 		(isLondon() ? !_musicOn : !_voiceOn))
 		return;
@@ -1590,8 +1357,6 @@ void EEMEngine::startTravelMusic() {
 }
 
 void EEMEngine::startLondonTravelMusic(uint8 travelKind) {
-	// EEM2 `_DoTravel @ 1717:06ae`: travelKind * 6 indexes this table,
-	// then rand() picks one of three u16 MUS IDs.
 	static const uint16 kLondonTravelMusic[4][3] = {
 		{ 0,  0,  0 },
 		{ 3, 22, 25 },
@@ -1644,10 +1409,6 @@ void EEMEngine::syncSoundSettings() {
 }
 
 bool EEMEngine::hasFeature(EngineFeature f) const {
-	// We support saving any time but loading only at startup (via the
-	// `--save-slot=N` resume path or a slot picked from the launcher).
-	// Runtime loads would replace `_mystery._data` while pointers into
-	// it are alive on the stack inside `displayClue` etc.
 	return f == kSupportsSavingDuringRuntime ||
 		   f == kSupportsReturnToLauncher;
 }
@@ -1675,7 +1436,7 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	//   +0x00..+0x0b : player name (12 chars, null-padded)
 	//   +0x0c..+0x1f : random ID bytes for `_GenerateFilename`
 	//                  (29be:0dbf "C:\EEMCDSAV\%s.PLR") — irrelevant to
-	//                  ScummVM saves which key on slot, not filename.
+	//                  us since saves which key on slot, not filename.
 	//   +0x20..+0x28 : derived 8-char .PLR basename — likewise unused.
 	//   +0x2d        : voice-enable flag (`DAT_2d5d_3f97`, default 1)
 	//   +0x2f        : chain stage (`DAT_2d5d_3f99`, 1=A, 2=B, 3=C;
@@ -1691,15 +1452,11 @@ Common::Error EEMEngine::saveGameStream(Common::WriteStream *stream,
 	s.syncAsByte(_partner);
 	s.syncAsByte(_chainStage);
 	s.syncAsByte(_voiceOn);
-	// EEM2/London music (MIDI) toggle — `DAT_3036_4cc0`, separate from voice.
 	s.syncAsByte(_musicOn);
 
-	// London passport gender (player pronouns 0x86-0x88).
 	byte playerFemale = _playerFemale ? 1 : 0;
 	s.syncAsByte(playerFemale);
 
-	// Mid-case resume: persist in-progress mystery (no equivalent in
-	// _LoadGame @ 2404:0dc7).
 	bool hasMystery = _mystery.isLoaded();
 	s.syncAsByte(hasMystery);
 	if (hasMystery) {
@@ -1738,7 +1495,6 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 	if (_audio)
 		_audio->setVoiceEnabled(_voiceOn);
 
-	// London passport gender (player pronouns 0x86-0x88).
 	byte playerFemale = 0;
 	s.syncAsByte(playerFemale);
 	_playerFemale = (playerFemale != 0);
@@ -1753,13 +1509,6 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 			resetSiteArrivalState();
 			return Common::kReadingFailed;
 		}
-		// `_ReadMystery @ 2404:008f` tail-calls `_InitMysterySounds`
-		// (2404:0298) so the SDB index is in place for clue and
-		// partner-speech spool sounds. Floppy ships individual
-		// `M-XXXX.VOC` files instead of the bundled SDB/SDX archive,
-		// so we skip the init there to avoid "missing" warnings;
-		// `spoolSound` then no-ops via its `_currentMystery < 0` guard
-		// until the per-voice VOC mapping is wired up.
 		if (_audio && !isFloppy())
 			_audio->initMysterySounds(mysteryNum);
 		_mystery.syncState(s);
@@ -1781,9 +1530,6 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 }
 
 SaveStateList EEMEngine::listProfiles() const {
-	// _findfirst("*.PLR") in screen8_handler @ 1c33:1012.
-	// Filter out slot 0 (autosave) to match the original which
-	// has no autosave concept.
 	SaveStateList saves = getMetaEngine()->listSaves(_targetName.c_str());
 	for (uint i = 0; i < saves.size(); ) {
 		if (saves[i].getSaveSlot() == 0)
@@ -1813,8 +1559,7 @@ Common::Error EEMEngine::saveProfile(const Common::String &name) {
 		}
 	}
 
-	// New profile: pick lowest unused slot >=1 (slot 0 is autosave;
-	// DOS limit was 25 per screen8_handler local_8c[0x19][2]).
+	// New profile: pick lowest unused slot >=1 (slot 0 is autosave);
 	if (slot < 0) {
 		const int maxSlot = getMetaEngine()->getMaximumSaveSlot();
 		Common::Array<bool> used(maxSlot + 1);
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 9f4d98c7856..4ea98b5aa65 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -47,18 +47,14 @@ namespace EEM {
 class AudioPlayer;
 class MusicPlayer;
 
-/// VGA palette size in bytes (256 colours × RGB). Defined in eem.cpp.
+/// VGA palette size in bytes (256 colours × RGB)
 const uint kPalSize = 768;
 
-/// Palette fade helpers (defined in eem.cpp): ramp the VGA palette to / from
-/// black over 16 steps, matching `_FadeToBlack` / `_OpenFadeIn`.
 void fadeCurrentPaletteToBlack(uint delayMs = 8);
 void fadePaletteFromBlack(const byte *target, uint delayMs = 8);
 
 /// _ScreenDriver dispatch table @ 1a35:0e5e (fallback @ 1a35:0e54).
-/// 14 entries total: each is a screen ID + near fn ptr at +0x1c; driver
-/// tail-calls via `JMP word ptr CS:[BX + 0x1c]`. Handler bodies update
-/// _NextScreen before returning. Handlers @ 1a35:0dec..0e4f:
+/// 14 entries total: 
 ///
 ///   0  INIT_CLUES → `_PreLoad` + `_DoInitClues`, writes _NextScreen=1
 ///   1  MAP        → `_DoMapScreen @ 20fe:120b` (writes 3=site clicked,
@@ -81,10 +77,6 @@ void fadePaletteFromBlack(const byte *target, uint delayMs = 8);
 ///   0xc ACTION    → `_ActionScreen @ 1c33:195b` — Choose A Mystery /
 ///                   Practice / See ScrapBook 1..3. Action 1 sets =0xa.
 ///   0xFFFF SENTINEL → exit loop
-///
-/// State writes (via xrefs to `_NextScreen @ 2d5d:3f26`): `_DisplayCorrect`
-/// writes 0xc, `_DisplayAlibi` writes `_LastScreen`, `_DoSiteLoop` writes
-/// 1/3/4 plus 0xffff on ESC.
 enum ScreenId {
 	kScreenInvalid        = 0xFFFF,
 	kScreenInitClues      = 0x00,
@@ -103,15 +95,11 @@ enum ScreenId {
 };
 
 /// Distribution variant from `ADGameDescription::extra` (set by
-/// `gameDescriptions[]` in `detection.cpp`). Gates filename selection
-/// (TRAVEL-N.XMI vs MUS%05u.XMI, FANFARE2.XMI vs MUS00005.XMI,
-/// PHONESL.VOC vs PHONE.VOC), opening-anim flow (MOVIE.ANM vs
-/// ANIM01..20.A), and per-variant SFX (DING.VOC / NEWSCAN.VOC ship only
-/// with floppy).
+/// `gameDescriptions[]` in `detection.cpp`).
 enum Variant {
 	kVariantCD       = 0,
 	kVariantFloppy   = 1,
-	kVariantLondonCD = 2, ///< Eagle Eye Mysteries in London (EEM2CD.EXE).
+	kVariantLondonCD = 2,
 };
 
 /// `_Partner @ 29be:7918`. Selected at the partner-pick screen
@@ -121,13 +109,9 @@ enum Partner {
 	kPartnerJenny = 1,
 };
 
-/// VGA mode 13h dimensions (initGraphics(320, 200) in `EEMEngine::run`).
 constexpr int kScreenWidth  = 320;
 constexpr int kScreenHeight = 200;
 
-/// Shared PDA-frame navigation rects (PIC 0x3f) — reachable from Site,
-/// Notebook, Gallery, Accuse, and MoreInfo. Original `_NoteButtons` table
-/// @ 29be:0147 + the site-screen hit tests in `_DoSiteLoop @ 168d:03f4`.
 constexpr Common::Rect kPdaSiteRect             (Common::Point(35, 111), 21, 25);
 constexpr Common::Rect kPdaPartnerFootMapRect   (Common::Point( 7, 177), 50, 23);
 constexpr Common::Rect kPdaPartnerHeadHintRect  (Common::Point( 5,  80), 39, 30);
@@ -143,37 +127,22 @@ public:
 	Common::Platform getPlatform() const;
 	Variant getVariant() const { return _variant; }
 	bool isFloppy() const { return _variant == kVariantFloppy; }
-	/// EEM2 ("Eagle Eye Mysteries in London") — reimplementation in progress.
-	/// Shares the EEM1 DBD/palette/font formats, but ships a 63-entry
-	/// "SITEPALS." file and uses different picture/palette IDs per screen.
 	bool isLondon() const { return _variant == kVariantLondonCD; }
 
 	bool hasFeature(EngineFeature f) const override;
 	bool canLoadGameStateCurrently(Common::U32String *msg = nullptr) override;
 	bool canSaveGameStateCurrently(Common::U32String *msg = nullptr) override;
 
-	// Autosave disabled: profile picker (screen8_handler @ 1c33:1012)
-	// lists `*.PLR` and a slot-0 autosave would appear as a profile.
 	int getAutosaveSlot() const override { return -1; }
 
 	Common::Error saveGameStream(Common::WriteStream *stream,
 								  bool isAutosave = false) override;
 	Common::Error loadGameStream(Common::SeekableReadStream *stream) override;
 
-	// Per-profile saves: `_PlayerRecord` @ 2d5d:3f6a (159 bytes), written
-	// by `_SavePlayerRecord @ 1c33:034f` to `C:\EEMCDSAV\<name>.PLR`.
-	// Each save slot maps to one profile (slot description = the
-	// player name) — same approach Wetlands uses. `screen8_handler @
-	// 1c33:1012` walks `*.PLR`, lets the player pick a profile, and calls
-	// `_LoadPlayerRecord`.
-
-	/// `_SavePlayerRecord @ 1c33:034f`.
 	Common::Error saveProfile(const Common::String &name);
 
-	/// `_LoadPlayerRecord @ 1c33:03a6`.
 	bool loadProfile(const Common::String &name);
 
-	/// `_findfirst("*.PLR")` walk inside `screen8_handler`.
 	SaveStateList listProfiles() const;
 
 	const ADGameDescription *_gameDescription;
@@ -204,25 +173,15 @@ public:
 	/// EEM1 cursors are all 0 so this is a no-op there.
 	void setSiteHotspotCursorId(int cursorId);
 
-	/// `_DisplayClue @ 2404:05e6`. @p clueBlock points at the u16 frame
-	/// count followed by 62-byte ClueEntries.
+	/// `_DisplayClue @ 2404:05e6`. 
 	void displayClue(const byte *clueBlock);
 
 	/// EEM2/London `_DoPuzzle @ 2542:1482`. A clue entry can gate the rest of
-	/// itself behind a "check the manual / a real map" puzzle (the id is the
-	/// entry+0x54 field). Opens `P<id>.BIN` — a header + question, then either
-	/// a typed answer (type 0) or a click-a-region multiple choice (type 1).
-	/// Returns true when answered correctly (and true if the file is missing,
-	/// so the clue is never permanently blocked); a wrong answer replays the
-	/// partner's scolding hint, and the caller re-prompts on the next visit.
+	/// itself behind a "check the manual / a real map" puzzle
 	bool doPuzzle(uint puzzleId);
 
-	/// Floppy hotspot click. `FUN_22dc_0b80 + FUN_1652_00e6 + FUN_1652_006c`.
-	/// Locates dialog records in site_data[+6] and dispatches them.
 	void displayFloppyHotspotDialog(uint siteNum, uint hotIdx);
 
-	/// `_HotspotSearched_Floppy @ 22dc:096c`. Walks the per-hotspot dialog
-	/// records and returns whether the hotspot's searched text index was seen.
 	bool floppyHotspotSearched(uint siteNum, uint hotspotIdx) const;
 
 	/// Active player name (= profile-save description).
@@ -231,19 +190,10 @@ public:
 	/// Apply a ClueEntry's side effects (notebook, gallery, site flags).
 	void applyClueSideEffects(const byte *entry);
 
-	/// `_DrawNotes @ 161e:01d0`.
 	void doNotebook();
-
-	/// `_DrawGallery @ 158f:0046`.
 	void doGallery();
-
-	/// `MoreInfo @ 158f:0419`. Suspect-detail view inside the gallery; button
-	/// dispatch via `_HandleMoreButton @ 158f:027d`. Returns true if the
-	/// caller should exit `doGallery` (NOTEBOOK / ACCUSE / MAP).
 	bool moreInfo(const byte *gd, uint suspectIdx,
 				   const Picture &galBg, bool haveBg);
-
-	/// `_DoBigMap @ 20fe:09e7`.
 	void doBigMap();
 
 	/// Accuse flow. `_DoAccuseGallery @ 1df2:0a31` + `_DisplayEnding @ 1df2:0548`.
@@ -254,38 +204,26 @@ public:
 	/// Returns true if the player committed (SOLVE clicked), false on ESC.
 	bool doAccuseNotes();
 
-	/// `_KDHelp @ 1560:010a` + `_DisplayHint @ 1560:0009`. Cycles two
-	/// hint slots tracked via `_SawHelpHint`.
+	/// `_KDHelp @ 1560:010a` + `_DisplayHint @ 1560:0009`. 
 	void doHelp();
 
 	/// `_InterfaceHelp @ 1560:0205`. Walks `HelpData @ 29be:00c8`.
 	void doInterfaceHelp(uint num = 0);
 
-	/// `_GetKDTextBalloon @ 1df2:0105`. Digits (0..9) → table @ 29be:1064;
-	/// otherwise `*(u16*)29be:1068 = 0x17`.
+	/// `_GetKDTextBalloon @ 1df2:0105`.
 	uint16 getKDTextBalloon(byte firstChar) const;
 
 	/// Pick a shorter balloon sibling when wrapped text leaves empty lines.
 	uint16 fitBalloonToText(uint16 bubNum, const Common::String &text);
 
-	/// `_ParseString @ 1b66:07c3`. Substitutes 0x80..0x89 control bytes;
-	/// jump table @ 1b66:0cbe.
+	/// `_ParseString @ 1b66:07c3`. 
 	Common::String parseString(const Common::String &raw,
 							   const Common::String &playerName,
 							   uint partner) const;
 
-	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`. Per-partner
-	/// (animId, x, y) from `_WaitAnims[1+num] @ 29be:0228`. Blocks until
-	/// the script's first 0x80 marker.
+	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`.
 	void playKdAnim(uint16 num);
 
-	/// Provide a "clean" 320x200 backdrop (site BG + static drops, no
-	/// NPCs / partner) for the next `playKdAnim` to use as the
-	/// background-erase source. Without this, the camera animation would
-	/// composite on top of the static partner sprite and the previous
-	/// resting frame would bleed through transparent pixels. `SiteScreen`
-	/// passes its `_bgSnapshot` before `displayClue` from a hotspot click.
-	/// Pass `nullptr` to clear.
 	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
 
 	/// Balloon-text-inset metadata. 52-entry table @ 29be:0875 (CD) /
@@ -299,13 +237,10 @@ public:
 	bool getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
 								 uint16 &dy) const;
 
-	/// `FUN_22dc_05c8 @ 22dc:08aa` (mid-page) / `@ 22dc:08c0` (end).
-	/// PIC 0xa0 if !endIndicator else PIC 0xa1.
 	void drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
 								   uint16 bubNum, int ballX, int ballY,
 								   bool endIndicator);
 
-	/// `_AreYouSure @ 1a35:0a5c`. Returns true on YES.
 	bool areYouSure();
 
 private:
@@ -323,17 +258,11 @@ private:
 
 	/// Inclusive 1-based mystery range [lo, hi] for chain `stage` (1-based).
 	/// Returns false when `stage` is not a real tier in this variant.
-	/// EEM1 `_DisplayCorrect @ 1df2:073c` (1..24 / 25..48 / 49..54);
-	/// EEM2/London `_DisplayCorrect @ 1ea1:0619` (1..25 / 26..50).
 	bool mysteryTierRange(uint stage, uint &lo, uint &hi) const;
 
 	void advanceChainStageAfterSolve(uint mysteryNum);
 	void applySkipRepeatedCasesOption();
 
-	/// Central dispatch loop matching `_ScreenDriver @ 1a35:0dc1`. Each
-	/// iteration calls the handler that matches `_nextScreen`; handlers
-	/// update `_lastScreen` / `_nextScreen` and return. Loop exits when
-	/// `_nextScreen == kScreenInvalid`.
 	void screenDriver();
 
 	/// Re-render helpers for the corresponding `doX()` modal screens.
@@ -427,10 +356,7 @@ public:
 	void waitForInput(uint32 maxMs);
 
 	/// Play a difference-encoded animation file (.ANM / .A) on the full
-	/// 320x200 screen. Mirrors the data flow of `OpenDifferenceAnimation
-	/// @ 2520:0337` → `Load_Sequence` + `Play_Sequence`. Audio cues are
-	/// skipped for now. The default 120 ms frame delay matches the
-	/// original `FRAME_RATE = 0x78` used by `_DoOpeningAnims`.
+	/// 320x200 screen. 
 	/// If @p holdLastFrame is true the call blocks on the final frame
 	/// until the user clicks or hits a key — used for the title screen.
 	/// If @p fadeIn is true the first decoded frame is copied while the
@@ -440,40 +366,24 @@ public:
 				 bool setSkipIntroOnEsc = true);
 
 private:
-	/// `_CleanMysterySounds @ 202f:05a5` + `_StopMIDI @ 20a2:0512`.
-	/// `stopMusicToo=false` keeps MIDI playing across dialog skips.
 	void interruptAudio(bool stopMusicToo = true);
 
 	void showEAKidsLogo();
 	void showHighScoreLogo();
 	void showFloppyStormLogo();
 
-	// --- EEM2 ("Eagle Eye Mysteries in London") — reimplementation in progress ---
-	/// Opening sequence + character creation — EEM2's `_DoOpeningAnims`
-	/// @ 2721:08e6 then screen 8 profile selection.
 	void runLondonStartup();
 	/// Start London mystery 0 after a freshly-created detective chooses a partner.
 	bool startLondonTrainingMystery();
-	/// Blit a full-screen still PIC and fade it in / hold / out using the
-	/// given SITEPALS. palette index.
 	void showLondonLogo(uint picId, uint palId, uint holdMs);
-	/// EEM2 character creation (`_NewPlayer`: palette 0 + background PIC 0xc):
-	/// first/last name entry + male/female player-gender selection.
 	void showLondonCharSelect();
-	/// EEM2 case-intro animation (`_DoInitClues` @ 1abf:03b3): single partner
-	/// anim (Jake 0x18 / Jenny 0x71) faded in, then phone1.voc on caseType 1.
 	void playLondonInitCluesAnim(uint16 caseType, const Picture &bg,
 								 bool haveBriefingBg);
-	/// EEM1 CD/floppy case-intro animation (`_DoInitClues` @ 1a35:0411):
-	/// game/book/nancy cycle + `_PlayInSequence` partner entrance.
 	void playCdFloppyInitCluesAnim(uint16 caseType, bool floppy,
 								   const Picture &bg, bool haveBriefingBg);
 
-	/// `screen8_handler @ 1c33:1012`. Profile selector — walks
-	/// `listProfiles()`, falls through to `doNewPlayer()` if "New" or
-	/// no profiles exist (1c33:1170: `if (saves == 0) _NewPlayer();`).
 	void doProfilePicker();
-	void doNewPlayer();          ///< `_NewPlayer @ 1c33:0dda`.
+	void doNewPlayer();
 	void doChoosePartner();
 
 	/// Display the per-mystery ending pages from `E<num>.BIN`. Mirrors
@@ -481,11 +391,6 @@ private:
 	/// File format: u16 page count, then N pages of
 	/// `{ u16 picNum, u16 x1, u16 y1, u16 x2, u16 y2,
 	///    char text[] (null-terminated, ParseString placeholders) }`.
-	/// `_ShowOneScrap @ 1f78:0773` is just `_DisplayEnding(num, 1)`,
-	/// so this call also covers the post-mystery scrapbook view.
-	/// `firstPage=true` opens at page 0; `false` opens at the last page
-	/// (back-nav from `doShowScrapbook`, mirrors the `local_8 = 0` write
-	/// at `_ShowScrapbook @ 1f78:067e`).
 	/// Returns the caller's nav direction (per `[BP-0x18]` @ 1df2:0723):
 	///   -1 → previous mystery (LEFT on first page or click in PrevPageRect),
 	///    0 → exit scrapbook (ESC / quit),
@@ -493,28 +398,20 @@ private:
 	int doShowEnding(uint num, bool firstPage = true);
 
 	/// EEM1 `_ShowScrapbook(stage, 0) @ 1f78:0642`; EEM2/London scrapbook
-	/// `FUN_2046_09dd`. Walks the `mysteryTierRange(stage)` cases, skipping
-	/// unsolved entries in the current chain stage. Tier sizes are
-	/// variant-specific (EEM1 24/24/6; London 25/25, no stage 3).
+	/// `2046:09dd`. Walks the `mysteryTierRange(stage)` cases, skipping
+	/// unsolved entries in the current chain stage. 
 	void doShowScrapbook(uint stage);
 
 	void doActionScreen();
 	void doCaseSelection();
 	void doSiteLoop();
 
-	/// `_DoSetup @ 1f78:044e`. Voice on/off (`DAT_2d5d_3f97`), partner
-	/// pick via SwapColors on Kid1/Kid2 rects.
 	void doSetup();
 
-	/// `doSetup` helpers: redraw BG + label highlights, render a help/
-	/// credits card with blocking input wait, and the shared exit fallback.
 	void setupDrawScreen();
 	Common::KeyCode setupShowFullscreenPic(uint16 picId, bool transparent);
 	void setupLeave();
 
-	/// EEM2/London setup screen — `_DoSetup @ 2046:067b` (distinct from EEM1's
-	/// seg-1f78 screen): 4 toggles (partner / voice / music / highlight boxes),
-	/// rearranged 13-button layout, scrapbook paging. Reuses the shared helpers.
 	void doSetupLondon();
 	void setupDrawScreenLondon();
 
@@ -569,36 +466,14 @@ private:
 	/// London passport gender (EEM2 `_NewPlayer @ 1cd3:0f27` gender pick,
 	/// `DAT_3036_4c4c`: left/0 = male, right/1 = female). Drives the player
 	/// pronoun opcodes 0x86/0x87/0x88 in `parseString` (he·him·his / she·her·
-	/// her). EEM1 has no passport, so it stays false (male) — matching that
-	/// engine's never-written gender flag.
+	/// her). EEM1 has no gender selection, so it stays false (male)
 	bool _playerFemale = false;
 
 	/// `_PlayerRecord.SolvedMysteries[55]`. 0=unsolved, 1=solved, 2=first-try.
 	uint8 _mysteriesSolved[55] = {};
-
-	/// Current chain/tier the player is at — `DAT_2d5d_3f99`
-	/// (`_PlayerRecord +0x2f`):
-	///   1 = Junior detective  (mysteries  1..24, "A chain")
-	///   2 = Senior detective  (mysteries 25..48, "B chain")
-	///   3 = Master detective  (mysteries 49..54, "C chain")
-	/// Initialized to 1 in `_NewPlayer @ 1c33:0fa3`; bumped by
-	/// `_DisplayCorrect @ 1df2:0853` once every mystery in the current
-	/// tier is solved (range checks @ 1df2:080d / 0824 / 0837). Also
-	/// gates `_CaseSelection`'s book label and selection list
-	/// (1c33:0a87 onwards).
 	uint8 _chainStage = 1;
 
-	/// Voice / digital-audio enable flag. `DAT_2d5d_3f97` (`_PlayerRecord
-	/// +0x2d`). Set to 1 by `_NewPlayer @ 1c33:0fa3`, toggled by the
-	/// SoundOn / SoundOff hot-rects in `_DoSetup @ 1f78:044e`. Gates
-	/// every `_PlayVoice` / `_SpoolSound` call site (clue voices,
-	/// partner speech, intro VO).
 	bool _voiceOn = true;
-
-	/// EEM2/London music (MIDI) on/off — `DAT_3036_4cc0`, toggled by the music
-	/// button in `_DoSetup @ 2046:067b`. Separate from `_voiceOn` (EEM2 has both
-	/// a voice and a music toggle; EEM1 only had one sound toggle). Gates the
-	/// briefing / travel MIDI for London.
 	bool _musicOn = true;
 
 	/// Set by the profile/new-player screens. London uses it to decide whether
@@ -625,7 +500,7 @@ private:
 	bool _skipIntro = false;
 
 	/// Clean BG (no partner/NPC) used by `playKdAnim` between camera-anim
-	/// cells. See `setPartnerEraseBg`.
+	/// cells. 
 	Graphics::ManagedSurface _partnerEraseBg;
 
 	bool _interactiveMouseCursor = false;
@@ -643,8 +518,6 @@ private:
 	/// `_StartTravelMusic` @ 20a2:00e2-05c9). Constructed lazily in `run()`.
 	MusicPlayer *_music = nullptr;
 
-	/// `SOUND.C` / `SPOOLSND.C`. `_PlayVoice @ 1ff1:023e`,
-	/// `_SpoolSound @ 202f:068d`, `_InitMysterySounds @ 202f:05cb`.
 public:
 	AudioPlayer *_audio = nullptr;
 
diff --git a/engines/eem/font.cpp b/engines/eem/font.cpp
index a5fba38d7f1..7a6fb6006e3 100644
--- a/engines/eem/font.cpp
+++ b/engines/eem/font.cpp
@@ -95,8 +95,7 @@ bool EEMFont::load(const Common::Path &path) {
 			_maxWidth = g.widthBits;
 	}
 
-	// _LoadFont @ 1b03:0220 sets line stride to the first glyph's
-	// height (DAT_28da_30ca = DAT_28da_30ce, space glyph height).
+	// _LoadFont @ 1b03:0220 sets line stride to the first glyph's height
 	// Descenders ('g','j','p','q','y') intentionally overhang into
 	// the next row.
 	_lineHeight = !_glyphs.empty() ? _glyphs[0].height : _maxHeight;
diff --git a/engines/eem/font.h b/engines/eem/font.h
index 56275b8018e..8c77e97c1ea 100644
--- a/engines/eem/font.h
+++ b/engines/eem/font.h
@@ -55,7 +55,6 @@ public:
 	bool load(const Common::Path &path);
 	bool isLoaded() const { return !_glyphs.empty(); }
 
-	// --- Graphics::Font overrides ---
 	int getFontHeight() const override {
 		return _lineHeight ? _lineHeight : _maxHeight;
 	}
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index ec1f5d089ce..da96f26668c 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -38,12 +38,6 @@
 
 namespace EEM {
 
-// HelpData @ 29be:00c8, read by _InterfaceHelp @ 1560:0205. 5-byte entries:
-// u8 count, then up to 2 u16 picIds. Verified bytes:
-//   entry 0 (PDA / gallery HELP button): count=2, picIds = 0x0063, 0x01ae
-//   entry 1:                              count=2, picIds = 0x0192, 0x01b1
-// Only entry 0 is reachable from the PDA notebook (rect 1) and the gallery
-// (rect 1) — both call _InterfaceHelp(0).
 const uint16 kHelpPics[][2] = {
 	{ 0x0063, 0x01ae },
 	{ 0x0192, 0x01b1 },
@@ -114,8 +108,6 @@ bool findBalloonFamily(uint16 balloonId, uint16 &first, uint16 &last) {
 	return false;
 }
 
-// indDY is the artist-intended last text line, not just the indicator Y:
-// families 3, 4, 6 and singletons have shadow/tail decoration below indDY.
 uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 	const uint idx = balloonId & 0x7F;
 	if (idx >= ARRAYSIZE(kBalloonInsetTable) || lineH <= 0)
@@ -126,9 +118,6 @@ uint getBalloonLineCapacity(uint16 balloonId, int lineH) {
 }
 
 bool EEMEngine::floppyHotspotSearched(uint siteIdx, uint hotspotIdx) const {
-	// FUN_22dc_096c @ 22dc:096c: walks per-site dialog records at
-	// site_data[+6] to skip hotspotIdx hotspots, then returns _TextSeen for
-	// the selected hotspot's searched text index.
 	const byte *site = _mystery.siteData(siteIdx);
 	if (!site)
 		return false;
@@ -183,13 +172,8 @@ static Common::String readPuzzleLine(Common::File &f) {
 	}
 	return s;
 }
-
+// `_DoPuzzle @ 2542:1482`. 
 bool EEMEngine::doPuzzle(uint puzzleId) {
-	// `_DoPuzzle @ 2542:1482`. File `P<id>.BIN` (LE u16 fields):
-	//   type(0=typed,1=choice); mainPic{id,x,y}; extraCount; extra{id,x,y}*;
-	//   qx; qy; qw; voiceAlt(Jenny); voiceMain(Jake); question line.
-	//   type 0: answerRect{x1,y1,x2,y2}; answer line (stored UPPERCASE).
-	//   type 1: choiceCount; rects{x1,y1,x2,y2}* — correct = rect 0.
 	Common::File f;
 	const Common::String fname = Common::String::format("P%u.BIN", puzzleId);
 	if (!f.open(Common::Path(fname))) {
@@ -200,10 +184,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 
 	const uint16 type = f.readUint16LE();
 
-	// The caller (displayClue) restored the clean site background, so the
-	// current screen has no clue bubbles. Keep that as `cleanBg` and build the
-	// puzzle on a copy; `cleanBg` is restored after the answer (DOS `_DoPuzzle`
-	// _Repaint) and after the wrong-answer hint (`_KDHelp` _Repaint) so neither
 	// the puzzle pics nor any bubble is left on the background.
 	Graphics::ManagedSurface cleanBg(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -219,7 +199,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.simpleBlitFrom(cleanBg);
 
-	// Main picture, then `extraCount` more — each {id, x, y}, masked blit.
 	for (int phase = 0; phase < 2; phase++) {
 		uint16 n = 1;
 		if (phase == 1)
@@ -249,7 +228,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
-	// Partner reads the question (`_SpoolSound(id - 1)`, gated on audio).
 	if (_audio && _voiceOn) {
 		const uint16 v = (_partner == kPartnerJake) ? voiceMain : voiceAlt;
 		if (v != 0 && v != 0xFFFF)
@@ -260,13 +238,12 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 	CursorMan.showMouse(true);
 
 	if (type == 0) {
-		// Typed answer (`_CheckTypedAnswer` → `_GetNameString`, toupper+strcmp).
 		const int16 ax1 = (int16)f.readUint16LE();
 		const int16 ay1 = (int16)f.readUint16LE();
 		const int16 ax2 = (int16)f.readUint16LE();
 		const int16 ay2 = (int16)f.readUint16LE();
 		const Common::Rect rect(ax1, ay1, ax2, ay2);
-		Common::String answer = readPuzzleLine(f);  // stored uppercase
+		Common::String answer = readPuzzleLine(f);
 		const uint maxLen = answer.size() + 2;
 
 		Common::String input;
@@ -302,7 +279,7 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 					if (!input.empty())
 						done = true;
 				} else if (k == Common::KEYCODE_ESCAPE) {
-					input.clear();  // cancel → empty → fails the compare
+					input.clear();
 					done = true;
 				} else if (k == Common::KEYCODE_BACKSPACE) {
 					if (!input.empty())
@@ -333,19 +310,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 			const int16 cy2 = (int16)f.readUint16LE();
 			rects.push_back(Common::Rect(cx1, cy1, cx2, cy2));
 		}
-		// `_GetPuzzleChoice @ 2542:11a9`: the choice rects are the only
-		// clickable areas (no done/exit hotspot) and ESC = wrong; the Tab/arrow
-		// keys hit a global handler, not choice selection. Highlight the
-		// options two ways, both reusing the existing engine mechanisms:
-		//   * Boxes: outline each option in the marching-ants ramp — palette
-		//     0xF9..0xFE, the SAME original yellow as the site hotspots, not a
-		//     puzzle-specific colour. Gated like the DOS `_DrawRect`
-		//     (`DAT_3036_4c4a` → port `hide_highlight_boxes`).
-		//   * Cursor: recolor the default arrow over an option
-		//     (`setInteractiveMouseCursor`, the EEM1 red-pixel cursor for
-		//     "otherwise invisible" hotspots). These options carry no per-hotspot
-		//     cursor shape, so the default cursor is what's shown — exactly the
-		//     case that recolor is meant for.
 		applyHotspotGlowPalette();
 		const bool showBoxes = !ConfMan.getBool("hide_highlight_boxes");
 		int picked = -1;
@@ -368,8 +332,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 											   kScreenWidth, kScreenHeight);
 				}
 			}
-			// Red default-cursor while hovering a clickable option; plain arrow
-			// off all of them (mirrors `updateHotspotCursor`).
 			const Common::Point mp = g_system->getEventManager()->getMousePos();
 			bool nowOver = false;
 			for (uint i = 0; i < rects.size(); i++) {
@@ -409,15 +371,11 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 	}
 	f.close();
 
-	// _Repaint after the answer: drop the puzzle pics back to the clean site.
 	g_system->copyRectToScreen(cleanBg.getPixels(), cleanBg.pitch, 0, 0,
 							   kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
 	if (!correct && !shouldQuit()) {
-		// Wrong: partner scolds via the KD hint (`_MIDIPlay(0x28)` +
-		// `_KDHelp(TextBlock + KDTextIndex[+0xc], voice 6)`), drawn on the
-		// clean site (the puzzle has already been _Repaint-ed away).
 		const byte *kd = _mystery.kdTextIndex();
 		const uint16 hintOff = kd ? READ_LE_UINT16(kd + 0x0c) : 0xFFFF;
 		if (hintOff != 0xFFFF) {
@@ -454,7 +412,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 				_audio->sayKDDigital(kd, 6, _partner);
 			waitForInput(60000);
 
-			// `_KDHelp` ends with _Repaint: clear the hint balloon too.
 			g_system->copyRectToScreen(cleanBg.getPixels(), cleanBg.pitch,
 									   0, 0, kScreenWidth, kScreenHeight);
 			g_system->updateScreen();
@@ -465,14 +422,6 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 }
 
 void EEMEngine::doHelp() {
-	// Floppy per-mystery H<n>.BIN hint files. Loader FUN_1503_0001 @ 1503:0001
-	// (format string "h%d.bin" @ 2608:0154), consumer FUN_1503_01a5 @ 1503:01a5.
-	// Format:
-	//   byte numChainHints; numChainHints × { byte siteIdx; byte hotspotIdx; }
-	//   byte numExtraHints; numExtraHints × { byte siteIdx; byte hotspotIdx; }
-	//   asciiz str1, str2, str3 (post-solve, score >= 100)
-	// Selection: any chain hotspot unsearched -> str1; else any extra
-	// unsearched -> str2; else selectedPoints() >= 100 -> str3.
 	if (isFloppy() && _mystery.isLoaded()) {
 		const Common::String filename = Common::String::format("H%u.BIN",
 															   _mystery.number());
@@ -545,9 +494,6 @@ void EEMEngine::doHelp() {
 		if (!chosen || *chosen == 0)
 			return;
 
-		// _GetKDTextBalloon @ 1df2:0105 (floppy FUN_1d40_009f) indexes the
-		// per-character table at 2608:0c14 by the literal byte. Bytes at
-		// 2608:0c44 (= 0xc14 + '0') give the '0'..'9' -> balloon-id mapping:
 		static const uint8 kFloppyDigitToBalloon[10] = {
 			0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
 		};
@@ -612,27 +558,7 @@ void EEMEngine::doHelp() {
 		return;
 	}
 
-	// Mirrors _KDHelp @ 1560:010a. Walks the first two _AChain entries
-	// (the puzzle's required-clue chain — the "spine" of evidence the
-	// player must collect):
-	//
-	//   for (i = 0; i < 2; i++) {
-	//       if (_AChain[i] != -1 && _HintBlock[i] != -1 &&
-	//           _CluesFound[_AChain[i]] == 0) {
-	//           _DisplayHint(TextBlock + _HintBlock[i], i + 10);
-	//           shown++; break;
-	//       }
-	//       if (_HintBlock[i] != -1) defined++;
-	//   }
-	//   if (!shown) {
-	//       // Generic KD hint: KDTextIndex[+0xe] (first time) /
-	//       // KDTextIndex[+0x10] (second time, toggled by _SawHelpHint).
-	//       // If no chain hint was ever defined, render the "no hints"
-	//       // sentinel instead.
-	//       _DisplayHint(...);
-	//   }
-	//
-	// SMART per-puzzle hint: partner points at whichever chain clue the
+	// per-puzzle hint: partner points at whichever chain clue the
 	// player hasn't found yet, only falling back to the generic line once
 	// every chain hint has been triggered.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
@@ -644,16 +570,10 @@ void EEMEngine::doHelp() {
 		return;
 
 	uint16 chosenText = 0xFFFF;
-	int    soundNum   = 0;       // _SayKDDigital line: EEM1 chain 10/11; fallback 7/8.
-	int    hintVoiceSlot = -1;   // London chain hint → _SayKDHintDigital(slot).
+	int    soundNum   = 0;
+	int    hintVoiceSlot = -1;
 	bool   anyHintDefined = false;
 
-	// Required-clue chain walk. EEM1 `_KDHelp @ 1560:010a` checks the first two
-	// entries of chain A. EEM2 `_DoKDHelp @ 15c1:020b` walks all THREE chains
-	// (A,B,C @ header words 16-20/21-25/26-30) × 5 slots, indexes the 15-entry
-	// hint-text table `hintBlock()[chain*5 + slot]`, and voices a chain hint
-	// with `_SayKDHintDigital(slot)` (table kdTextIndex()+0x3a) instead of
-	// EEM1's `_SayKDDigital(slot + 10)`. Gate is the same: clue not yet found.
 	const uint kChains = isLondon() ? 3u : 1u;
 	const uint kSlots  = isLondon() ? Mystery::kChainLen : 2u;
 	if (hb) {
@@ -681,7 +601,6 @@ void EEMEngine::doHelp() {
 	}
 
 	if (chosenText == 0xFFFF) {
-		// Second arm of _KDHelp (1560:0152-019b): generic KD hint fallback.
 		if (anyHintDefined) {
 			const uint16 hintFirst  = READ_LE_UINT16(kd + 0x0e);
 			const uint16 hintSecond = READ_LE_UINT16(kd + 0x10);
@@ -704,20 +623,6 @@ void EEMEngine::doHelp() {
 
 	const Common::String raw  = _mystery.textAt(chosenText);
 	Common::String text = parseString(raw, _playerName, _partner);
-
-	// Render as a speech-balloon overlay, mirroring _DisplayHint @ 1560:0009:
-	//
-	//   _GetKDTextBalloon(text, &bub);             // first-char dispatch
-	//   _GetBalloon(bub);                          // load balloon pic
-	//   y = (h < 0x4e) ? (0x50 - h) >> 1 : 1;      // vertical centre
-	//   _AddPicBackground(balloon, 0x21, y);       // overlay on screen
-	//   _WordWrap(0x21+tbl[bub].x, y+tbl[bub].y,   // text inside balloon
-	//             tbl[bub].w, text, -1, color=0);
-	//   _SayKDDigital(snd);                        // partner voice
-	//   _Wait();
-	//
-	// BG is the caller's CURRENT screen (site / PDA / gallery), not a cleared
-	// scratch.
 	Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	ms.clear();
@@ -729,13 +634,6 @@ void EEMEngine::doHelp() {
 		}
 	}
 
-	// Balloon shape dispatch via _GetKDTextBalloon @ 1df2:0105 — based on
-	// the first char of the parsed text. Digits select a specific balloon
-	// variant; non-digit defaults to 0x17. The digit, when present, is
-	// THEN consumed from the displayed text — mirrors _DisplayAlibi
-	// @ 1df2:0145's `str = pbVar7 + 1` advance after reading `*str` for
-	// bindx. _GetKDTextBalloon itself doesn't strip it (1df2:0105 just
-	// reads `*str`), so the caller has to.
 	const byte firstChar =
 		text.empty() ? (byte)0 : (byte)text[0];
 	uint16 bubNum = getKDTextBalloon(firstChar);
@@ -768,10 +666,6 @@ void EEMEngine::doHelp() {
 							   0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
-	// _DisplayHint @ 1560:0009 plays _SayKDDigital(soundnum) — a
-	// partner-specific voice line keyed to which hint type fired:
-	//   10 = first chain hint, 11 = second chain hint,
-	//    7 = generic KD (first), 8 = generic KD (second).
 	if (_audio && _mystery.kdTextIndex()) {
 		if (hintVoiceSlot >= 0)
 			_audio->sayKDHintDigital(_mystery.kdTextIndex(),
@@ -783,17 +677,8 @@ void EEMEngine::doHelp() {
 
 	waitForInput(60000);
 }
-
+// _InterfaceHelp(num) @ 1560:0205
 void EEMEngine::doInterfaceHelp(uint num) {
-	// Mirrors _InterfaceHelp(num) @ 1560:0205. The original walks
-	// HelpData @ 29be:00c8 (5-byte entries: u8 count, then up to 2 u16
-	// picIds), _GetPictures each one, blits via _Rect_Move_Mask(0, 0, ...)
-	// (a MASKED blit on top of the existing screen — transparent pixels
-	// show the caller's BG), and waits for click / key. ESC at 1560:02b3
-	// skips to the end. The function hides the cursor at the top
-	// (MOV [0x3a00], 0 @ 1560:0216 + _RemoveMouse @ 1000:542f at
-	// 1560:021c) and restores it at the tail (_DrawMouse @ 1000:5429
-	// at 1560:02e8). See kHelpPics comment for HelpData decoding.
 	if (num >= ARRAYSIZE(kHelpPics))
 		return;
 
@@ -824,8 +709,6 @@ void EEMEngine::doInterfaceHelp(uint num) {
 		debugC(1, kDebugScript, "doInterfaceHelp: pic 0x%x = %dx%d flags=0x%x",
 			   picId, pic.surface.w, pic.surface.h, pic.flags);
 
-		// transBlitFrom transp = pic.flags >> 8 matches _Rect_Move_Mask param_10
-		// @ 1000:03fc. Explicit (0,0) destPos: no-arg overload stretches to fill.
 		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
@@ -884,7 +767,7 @@ void EEMEngine::setPartnerEraseBg(const Graphics::ManagedSurface *bg) {
 
 uint16 EEMEngine::fitBalloonToText(uint16 bubNum,
 								   const Common::String &text) {
-	// Opt-in via "fit_dialog_balloons", CD only (floppy table unvalidated).
+	// Opt-in via "fit_dialog_balloons", CD only
 	if (isFloppy() || !ConfMan.getBool("fit_dialog_balloons"))
 		return bubNum;
 
@@ -969,12 +852,6 @@ bool EEMEngine::getBalloonIndicatorPos(uint16 bubNum, uint16 &dx,
 void EEMEngine::drawFloppyBubbleIndicator(Graphics::ManagedSurface &dst,
 										   uint16 bubNum, int ballX, int ballY,
 										   bool endIndicator) {
-	// Mirrors _DisplayHotspotClue_Floppy @ 22dc:08c0 (end-of-record) and
-	// @ 22dc:08aa (mid-pagination). Both grab a pre-loaded PIC:
-	//   DAT_28da_3034 = PIC 0xa0  "more pages" indicator
-	//   DAT_28da_3030 = PIC 0xa1  "end" indicator
-	// and stamp it at (ballX + insetTable[bubNum].indDX,
-	//                  ballY + insetTable[bubNum].indDY) via _AddPicBackground.
 	uint16 dx = 0;
 	uint16 dy = 0;
 	if (!getBalloonIndicatorPos(bubNum, dx, dy))
diff --git a/engines/eem/music.cpp b/engines/eem/music.cpp
index 31c51f225c5..80f20aa9bda 100644
--- a/engines/eem/music.cpp
+++ b/engines/eem/music.cpp
@@ -48,12 +48,7 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 	case MT_ADLIB:
 		// _MIDIPlayFile @ 20a2:024c opens SAMPLE.AD (string at 29be:14d6)
 		// and installs every patch the sequence requests via
-		// `_AIL_install_timbre`. ScummVM's Miles AdLib driver does the
-		// same on-demand install from SAMPLE.AD, which is what makes
-		// the notes match the 1993 release; the generic AdLib fallback
-		// would use ScummVM's built-in timbres instead. SAMPLE.OPL would
-		// be the OPL3 variant — the game only ships SAMPLE.AD, so the
-		// empty second path falls back to OPL2.
+		// `_AIL_install_timbre`.
 		_milesAudioMode = true;
 		_driver = Audio::MidiDriver_Miles_AdLib_create(
 			Common::Path("SAMPLE.AD"), Common::Path());
@@ -94,11 +89,7 @@ MusicPlayer::MusicPlayer(bool isFloppy) : _isFloppy(isFloppy) {
 void MusicPlayer::send(uint32 b) {
 	// Miles drivers (both AdLib and MT-32) implement their own per-
 	// source-channel mixing and timbre installation, so forward the raw
-	// event. Going through `MidiPlayer::send` would re-wrap CC 7 against
-	// `_masterVolume` AND remap the source channel via
-	// `sendToChannel` / `allocateChannel`, both of which the Miles
-	// driver already handles internally (double-applying breaks the
-	// timbre selection).
+	// event.
 	if (_milesAudioMode) {
 		_driver->send(b);
 		return;
@@ -113,7 +104,7 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 	Common::StackLock lock(_mutex);
 	stop();
 
-	// _MIDIPlayFile @ 20a2:024c-029e (_fopen + _fread).
+
 	Common::File f;
 	if (!f.open(xmiPath)) {
 		warning("MusicPlayer: %s missing", xmiPath.toString().c_str());
@@ -147,15 +138,10 @@ void MusicPlayer::playFile(const Common::Path &xmiPath, bool loop) {
 		return;
 	}
 
-	// _LoopMIDI = 0xFFFF in _DoOpeningAnims.
 	_isLooping = loop;
 	_parser->property(MidiParser::mpAutoLoop, loop ? 1 : 0);
 	_parser->setTrack(0);
 
-	// Pull the launcher's music_volume slider into `_masterVolume` so
-	// the non-Miles `Audio::MidiPlayer::send` path scales correctly.
-	// (Miles drivers handle volume themselves but also honour
-	// `MidiDriver::syncSoundSettings` via `Engine::syncSoundSettings`.)
 	syncVolume();
 	_isPlaying = true;
 	debugC(1, kDebugSound, "MusicPlayer: playing %s (%u bytes, loop=%d, miles=%d)",
@@ -168,7 +154,7 @@ void MusicPlayer::playMus(uint num, bool loop) {
 	//   0..4 → travel music. Table at 2608:1399-13cd holds 5 entries
 	//          (Travel-6, Travel-4, Travel-7, Travel-1, Travel-8) used by
 	//          `_StartTravelMusic` via `siteNumber % 5`.
-	//   5    → FANFARE2.XMI (winner). String at 2608:0c64.
+	//   5    → FANFARE2.XMI (winner).
 	//   6    → no equivalent on floppy (loser sting in `_DisplayAlibi`
 	//          is CD-only); skip.
 	if (_isFloppy) {
diff --git a/engines/eem/music.h b/engines/eem/music.h
index 11001a4280a..8209df47d89 100644
--- a/engines/eem/music.h
+++ b/engines/eem/music.h
@@ -53,7 +53,6 @@ public:
 	explicit MusicPlayer(bool isFloppy = false);
 
 	/// _MIDIPlayFile @ 20a2:024c. loop=true mirrors
-	/// _LoopMIDI = 0xFFFF in _DoOpeningAnims.
 	void playFile(const Common::Path &xmiPath, bool loop = false);
 
 	/// _MIDIPlay(num) @ 20a2:047d. CD: "MUS%05u.XMI";
diff --git a/engines/eem/mystery.cpp b/engines/eem/mystery.cpp
index 715971eb77c..1734c804175 100644
--- a/engines/eem/mystery.cpp
+++ b/engines/eem/mystery.cpp
@@ -175,7 +175,6 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		return true;
 	}
 
-	// Header is 16-bit-word indexed (matches `int *piVar1 = __Mystery; piVar1[N]`).
 	_initOffset      = readU16(0  * 2);
 	_mapOffset       = readU16(2  * 2);
 	_siteIndexOffset = readU16(3  * 2);
@@ -191,10 +190,6 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 	_numCONSITEs = (uint8)readU16(14 * 2);
 	_numCOFFSITEs = (uint8)readU16(15 * 2);
 
-	// Defensive clamp: the floppy header layout differs (M0.BIN CD has
-	// numSites=readU16(0x14)=3; floppy has readU16(0x14)=0x1925, clearly
-	// not a count). Cap to `_onSites` / `_visitedSite` array capacity so
-	// downstream loops don't walk off the end on malformed/floppy files.
 	if (_numSites > kVisitedSiteCap)
 		_numSites = kVisitedSiteCap;
 
@@ -204,10 +199,6 @@ bool Mystery::load(uint num, Common::RandomSource *rng) {
 		_cChain[i] = readU16((26 + i) * 2);
 	}
 
-	// Per-mystery runtime state — _ReadMystery zeroes these at load.
-	// _newOrder uses identity mapping; original randomly cycles gallery
-	// positions but requires matching changes in both clue side-effect
-	// and rendering paths.
 	memset(_cluesFound, 0, sizeof(_cluesFound));
 	memset(_noteSelected, 0, sizeof(_noteSelected));
 	memset(_hotSpotsSeen, 0, sizeof(_hotSpotsSeen));
@@ -326,9 +317,6 @@ void Mystery::loadFloppySiteAnimData() {
 
 const byte *Mystery::hotspots(uint siteNum) const {
 	if (_isFloppy) {
-		// Floppy hotspot table inside per-site sub-blob (FUN_22dc_0b80 @ 22dc:0b80).
-		// site_data[+4..5] = u16 file offset to count byte + N x 8-byte rects
-		// (x1, y1, x2, y2 as u16s).
 		const byte *site = siteData(siteNum);
 		if (!site || (size_t)(site - _data.data()) + 6 > _data.size())
 			return nullptr;
@@ -395,7 +383,6 @@ uint16 Mystery::noteIndexCount() const {
 	// NoteIndex runs from _noteOffset to start of GalleryData.
 	// CD entries: 4 bytes (u16 textOff; u16 points).
 	// Floppy entries: 7 bytes (u16 ?; u16 jakeOff; u16 jennyOff; u8 score)
-	// per FUN_22dc_05c8 @ 22dc:0843.
 	if (_galleryOffset <= _noteOffset)
 		return 0;
 	const uint stride = _isFloppy ? 7 : 4;
@@ -474,9 +461,6 @@ const byte *Mystery::floppySuspectEntry(uint suspectIdx) const {
 }
 
 bool Mystery::isGuilty(uint suspectIdx) const {
-	// _WITCH @ 1df2:089f (CD): GalleryData[i*0x46 + 0x02] == 0xFFFF marks
-	// guilty; innocent suspects store their alibi TextBlock offset there.
-	// Floppy uses same convention at suspect entry +2..3 (variable stride).
 	if (_isFloppy) {
 		const byte *e = floppySuspectEntry(suspectIdx);
 		return e && READ_LE_UINT16(e + 2) == 0xFFFF;
@@ -490,9 +474,6 @@ bool Mystery::isGuilty(uint suspectIdx) const {
 
 uint16 Mystery::alibiTextOffset(uint suspectIdx) const {
 	if (_isFloppy) {
-		// Floppy alibi (_DisplayAlibi_Floppy @ 1d40:0145): u16 at suspect +2..3.
-		// 0xFFFF = guilty; else high byte indexes the TEXT_BLOCK table at
-		// header[+0xc], each entry u16 = absolute alibi-text offset.
 		const byte *e = floppySuspectEntry(suspectIdx);
 		if (!e)
 			return 0xFFFF;
@@ -570,12 +551,11 @@ int Mystery::selectedPoints() const {
 	}
 	return total;
 }
-
+// `_SolvedCheck @ 1ea1:0b1a`. Reuses the already-loaded hint chains
+// (_aChain/_bChain/_cChain) as the three answer sets and the shared
+// _noteSelected accuse-selection array — see londonSolved() doc in the
+// header. EEM1/floppy keep the points model via solvedCheck().
 bool Mystery::londonSolved() const {
-	// `_SolvedCheck @ 1ea1:0b1a`. Reuses the already-loaded hint chains
-	// (_aChain/_bChain/_cChain) as the three answer sets and the shared
-	// _noteSelected accuse-selection array — see londonSolved() doc in the
-	// header. EEM1/floppy keep the points model via solvedCheck().
 	for (uint chain = 0; chain < 3; chain++) {
 		int remaining = (int)kChainLen;
 		int wild = 0;
@@ -595,12 +575,11 @@ bool Mystery::londonSolved() const {
 	}
 	return false;
 }
-
+// `_GetMinRemaining @ 1ea1:1056`. Parallel to londonSolved() but tests the
+// three answer sets against _cluesFound (discovered in the world). Tracks
+// whether any non-wildcard answer clue has been found at all; if none, the
+// player has nothing relevant yet (returns kChainLen).
 int Mystery::minCluesRemaining() const {
-	// `_GetMinRemaining @ 1ea1:1056`. Parallel to londonSolved() but tests the
-	// three answer sets against _cluesFound (discovered in the world). Tracks
-	// whether any non-wildcard answer clue has been found at all; if none, the
-	// player has nothing relevant yet (returns kChainLen).
 	int best = (int)kChainLen;
 	int foundReal = 0;
 	for (uint chain = 0; chain < 3; chain++) {
diff --git a/engines/eem/mystery.h b/engines/eem/mystery.h
index 3793debedec..86f13dc7aeb 100644
--- a/engines/eem/mystery.h
+++ b/engines/eem/mystery.h
@@ -84,9 +84,7 @@ public:
 	const byte *floppySuspectEntry(uint suspectIdx) const;
 
 	/// NoteIndex array. EEM1 CD: 4 bytes/entry (u16 textOff + u16 pts).
-	/// EEM2/London CD: 2 bytes/entry (u16 textOff only — no points field;
-	/// `_DrawNotes @ 16a0:01de` reads `noteIndex[clueId*2]`). Floppy: 7
-	/// bytes/entry.
+	/// Floppy: 7 bytes/entry. EEM2/London CD: 2 bytes/entry. 
 	const byte *noteIndex() const;
 
 	uint16 noteIndexCount() const;
@@ -181,7 +179,6 @@ public:
 	/// world, = _CluesFound): returns the fewest clues still to find across the
 	/// usable sets — 0 means a full answer set has been discovered and the
 	/// accusation can begin. Returns 5 when no relevant clue has been found yet.
-	/// Drives the London accuse-readiness gate (`_AccuseEntry @ 1ea1:115c`).
 	int minCluesRemaining() const;
 
 	/// _WITCH @ 1df2:089f. GalleryData[i*0x46 + 0x02] == 0xFFFF marks the
@@ -197,7 +194,7 @@ public:
 
 	/// Per-mystery runtime state, zeroed at load time.
 	uint8  _cluesFound[kCluesFoundCap]   = {};
-	uint8  _noteSelected[kCluesFoundCap] = {};  ///< _NoteSelected
+	uint8  _noteSelected[kCluesFoundCap] = {};
 	uint16 _hotSpotsSeen[kHotSpotsCap]   = {};
 	uint16 _inGallery[kGalleryCap]       = {};
 	uint8  _newOrder[kGalleryCap]        = {};
@@ -213,8 +210,8 @@ public:
 	uint16 _searchLocationNumber = 0xFFFF;
 	uint16 _siteNumber           = 0xFFFF;
 	uint16 _lastSite             = 0xFFFF;
-	uint16 _pendingSiteJump      = 0;      ///< EEM2 _DisplayClue destination site (DAT_2bca_0282).
-	uint16 _siteReturnDepth      = 0;      ///< EEM2 nested site return stack depth (DAT_2bca_0280).
+	uint16 _pendingSiteJump      = 0;
+	uint16 _siteReturnDepth      = 0;
 	uint16 _siteReturnStack[kVisitedSiteCap] = {};
 
 private:
@@ -240,7 +237,6 @@ private:
 	uint16 _bChain[kChainLen] = {};
 	uint16 _cChain[kChainLen] = {};
 
-	// Floppy variant — see Mystery::load. _ReadMystery_Floppy @ 22dc:0178.
 	bool   _isFloppy = false;
 	uint16 _floppySuspectsOff = 0;   ///< header[+4]    suspects
 	uint16 _floppyHintBlockOff = 0;  ///< header[+6]    hint -> clue table
diff --git a/engines/eem/resource.cpp b/engines/eem/resource.cpp
index d8722ceb598..1371cd177e7 100644
--- a/engines/eem/resource.cpp
+++ b/engines/eem/resource.cpp
@@ -53,7 +53,6 @@ bool DBDArchive::open(const Common::Path &dbdName, const Common::Path &dbxName)
 		return false;
 	}
 
-	// _InitGraphicsSystem @ 172b:0145: 10-byte entries until EOF.
 	const int32 dbxSize = dbx.size();
 	_index.reserve(dbxSize / 10);
 	while (dbx.pos() + 10 <= dbxSize) {
@@ -113,11 +112,7 @@ bool readFrame(Common::SeekableReadStream &stream, bool compressed, Picture &out
 
 bool DBDArchive::loadEntry(uint num, Picture &out) {
 	if (num >= _index.size()) {
-		// Out-of-range picture IDs are non-fatal — every caller already
-		// checks the return value (e.g. `haveDone` / `haveCrime` in
-		// `drawBigMapOverview`). Floppy PICS.DBD ships fewer entries than
-		// CD (e.g. BigMap done-marker 0x20D is CD-only), so this fires
-		// routinely on floppy and stays at debug level rather than warning.
+		// Out-of-range picture IDs are non-fatal
 		debugC(2, kDebugGfx,
 			   "DBDArchive::loadEntry: %u out of range (max %u)",
 			   num, (uint)_index.size());
@@ -130,7 +125,7 @@ bool DBDArchive::loadEntry(uint num, Picture &out) {
 		return false;
 	}
 
-	// _GetFromDB @ 172b:105d. Leading u16 = frame count (always 1 for pictures).
+	// Leading u16 = frame count (always 1 for pictures).
 	_dbd.skip(2);
 	return readFrame(_dbd, entry.compressed != 0, out);
 }
@@ -147,7 +142,6 @@ bool DBDArchive::loadAnimation(uint num, Animation &out) {
 		return false;
 	}
 
-	// _GetAnimation @ 172b:163a: u16 frame count, then N frames.
 	const uint16 frameCount = _dbd.readUint16LE();
 	if (frameCount == 0 || frameCount > 256) {
 		warning("DBDArchive::loadAnimation: %u has implausible frame count %u",
diff --git a/engines/eem/resource.h b/engines/eem/resource.h
index a984f323d35..2e19340ff4a 100644
--- a/engines/eem/resource.h
+++ b/engines/eem/resource.h
@@ -31,7 +31,6 @@
 
 namespace EEM {
 
-/// dbi struct from _InitGraphicsSystem @ 172b:0145 (10 bytes per entry).
 struct DBEntry {
 	uint32 offset;     ///< Byte offset in the .DBD file.
 	uint16 compressed; ///< Non-zero = PKWARE DCL ("Implode") packed payload.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 7c4884ac7d6..ce2b9d89ab4 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -91,7 +91,6 @@ void blitFrame(Graphics::ManagedSurface &dst, const Picture &p,
 
 // Masked top-left blit onto a locked screen surface. Clips both src and
 // dst against the screen, then delegates to copyRectToSurfaceWithKey
-// (Graphics::Surface's transparent-key blit).
 void keyBlitToScreen(Graphics::Surface *screen, const Picture &p,
 							int x, int y) {
 	if (!screen || p.surface.empty())
@@ -107,21 +106,12 @@ void keyBlitToScreen(Graphics::Surface *screen, const Picture &p,
 									 src, (uint32)(byte)(p.flags >> 8));
 }
 
-// Top-left masked blit. `_AddDrop @ 172b:1a77` calls
-// `_Rect_Move_Mask(..., x, y, ...)` with the raw (x, y) and IGNORES
-// per-frame anchor offsets — so this is the correct path for static
-// drops and any non-animated overlay. Animations must route through
-// `blitAnimFrameAnchored` instead so per-frame anchor offsets
-// (miscflags = X, rowoff = Y) apply correctly.
 void blitMaskedSurface(Graphics::Surface *screen, const Picture &p,
 					   int x, int y) {
 	keyBlitToScreen(screen, p, x, y);
 }
 
-// `_UpdateAnimations @ 172b:09c1`: blit at
-//   (anchor_x - puVar5[4], anchor_y - puVar5[3])
-// where puVar5[3]/[4] are per-frame rowoff/miscflags (signed int16) from
-// the 16-byte PicData header. Transparency = flags >> 8.
+// `_UpdateAnimations @ 172b:09c1`
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY) {
 	keyBlitToScreen(screen, p,
@@ -131,9 +121,7 @@ void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 
 // `_ColorCycle @ 172b:2015` — rotate `_fpal[start..end]` by one slot:
 // save [start], shift [start..end-1] = [start+1..end], restore saved at
-// [end], then re-upload via `_Set_Palette`. We do the same against
-// ScummVM's palette manager. Used by per-site Loop-1 ColorCycle entries
-// and the always-on hotspot marching-ants range 0xF9..0xFE.
+// [end], then re-upload via `_Set_Palette`. 
 void cyclePaletteRange(uint8 start, uint8 end) {
 	if (end <= start)
 		return;
@@ -153,12 +141,8 @@ void cyclePaletteRange(uint8 start, uint8 end) {
 	buf[(count - 1) * 3 + 2] = savedB;
 	g_system->getPaletteManager()->setPalette(buf, start, count);
 }
-
+// `_OpenColorCycle @ 2520:04f7`
 void cyclePaletteRangeReverse(uint8 start, uint8 end) {
-	// `_OpenColorCycle @ 2520:04f7`: save END, shift every entry up by
-	// one (END-1 → END, ...), wrap saved END to START. Visually colors
-	// march from start toward end — opposite direction from
-	// `cyclePaletteRange`. Used by the EA Kids / HighScore logo cycles.
 	if (end <= start)
 		return;
 	const uint count = (uint)end - (uint)start + 1;
@@ -180,11 +164,6 @@ void cyclePaletteRangeReverse(uint8 start, uint8 end) {
 }
 
 void applyHotspotGlowPalette() {
-	// SITEPALS ships palette 0xF9..0xFE as uniform yellow (3F 3E 00), so the
-	// original marching-ants was a placeholder. Override with a 6-step yellow
-	// ramp so `cyclePaletteRange(0xF9, 0xFE)` (and the per-index draw colour)
-	// produce a visible pulse. Shared by site hotspots and the clue puzzle so
-	// both highlight clickable areas with the same (original) colours.
 	static const byte kAntsGlow[6 * 3] = {
 		0x40, 0x40, 0x00, // F9 — dim
 		0x80, 0x80, 0x00, // FA
@@ -211,8 +190,6 @@ const uint16 kWaitAnims[7][6] = {
 	{ 0x06, 0x06, 0x06, 0x06, 0x50, 0x50 }, // 6
 };
 
-// `_DoKDAnim` table @ 29be:0228. 6 entries (kdAnimNum 0..5).
-// Layout: { animJake, animJenny, xJake, xJenny, yJake, yJenny }.
 const uint16 kKdAnimTable[6][6] = {
 	{ 0x03, 0x0c, 6, 6, 80, 80 }, // 0 — speaker idx 1 wait anim
 	{ 0x01, 0x0b, 6, 6, 80, 80 }, // 1 — same as PDA idle
@@ -222,11 +199,7 @@ const uint16 kKdAnimTable[6][6] = {
 	{ 0x06, 0x06, 6, 6, 80, 80 }, // 5 — same anim both partners
 };
 
-// EEM2 `_DoKDAnim @ 1717:05bf` table @ 2bca:0238 (read from EEM2CD.EXE).
-// Same { animJake, animJenny, xJake, xJenny, yJake, yJenny } layout. Positions
-// differ (x≈2, y≈78 vs EEM1's 6/80) and Jenny's reactions 4/5 use distinct
-// anims 0x55/0x2d (EEM1 reused 0x05/0x06 for both partners). Selected by
-// `playKdAnim` when the London variant is active.
+// EEM2 `_DoKDAnim @ 1717:05bf` table @ 2bca:0238 
 const uint16 kKdAnimTableLondon[6][6] = {
 	{ 0x03, 0x0c, 3, 2, 66, 65 }, // 0
 	{ 0x01, 0x0b, 2, 2, 78, 78 }, // 1
@@ -236,8 +209,7 @@ const uint16 kKdAnimTableLondon[6][6] = {
 	{ 0x06, 0x2d, 2, 2, 78, 78 }, // 5 — Jenny uses 0x2d
 };
 
-// EEM2 `_WaitAnims @ 2bca:022c`. Entry 0 precedes `_DoKDAnim`'s
-// table; entries 1..6 overlap `kKdAnimTableLondon`, same as EEM1.
+// EEM2 `_WaitAnims @ 2bca:022c`.
 const uint16 kWaitAnimsLondon[7][6] = {
 	{ 0x00, 0x0a, 2, 2, 78, 78 }, // 0
 	{ 0x03, 0x0c, 3, 2, 66, 65 }, // 1
@@ -248,13 +220,7 @@ const uint16 kWaitAnimsLondon[7][6] = {
 	{ 0x06, 0x2d, 2, 2, 78, 78 }, // 6
 };
 
-// Animation script table — mirrors `_AnimationSequences @ 29be:22d4`
-// (55-entry table of far ptrs, each pointing to a u16-frame-index
-// stream). `_NewAnimation @ 172b:06e1` reads the script via
-// `_AnimationSequences[anim_id]` and stores the pointer in
-// `DAT_2d5d_3eaf[i*0xb]`; `_UpdateAnimations @ 172b:09c1` then walks it
-// one entry per `_CheckFrameRate` tick (~140 ms).
-//
+// Animation script table (`_AnimationSequences @ 29be:22d4`)
 // Script byte format:
 //   0x80         = restart (loop back to index 0; terminator for one-shots)
 //   0x81 N       = jump to byte N (not used in partner subset)
@@ -353,8 +319,6 @@ const AnimScript kAnimScripts[] = {
 	{ 0x35, 11, { 0,1,2,3,4,5,6,7,8,9,10 } },
 };
 
-// Scripts longer than 28 frames (>fits-inline limit). `findAnimScript`
-// checks both this array and `kAnimScripts`. Longest is 0x22 (115 frames).
 struct AnimScriptLong {
 	uint16 seqnum;
 	uint16 len;
@@ -362,9 +326,7 @@ struct AnimScriptLong {
 };
 
 // Briefing animations — `_DoInitClues @ 1a35:0411` always uses anim ID
-// 0x17 (game) / 0x18 (book) / 0x19 (nancy) regardless of partner; the
-// per-partner ANI.DBD cells come from a separate entry (e.g. 0x3b for
-// Jenny's briefing).
+// 0x17 (game) / 0x18 (book) / 0x19 (nancy) regardless of partner
 const uint8 kScript17[] = {
 	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	20,21,22,23,24,25,26,27,28,29
@@ -378,10 +340,7 @@ const uint8 kScript19[] = {
 	1,2,3,4,5,6,7,8,9,10,11,12
 };
 
-// Site / NPC drop scripts (29be:22d4 entries 0x1a..0x36). Repeated
-// frames are the original's frame-hold mechanism (one entry per tick).
-
-// 0x1a (29be:19a4) — count-up 0..7, idle hold, repeat 1..7, idle, mirror 7..0, idle.
+// Site / NPC drop scripts
 const uint8 kScript1a[] = {
 	0,1,2,3,4,5,6,7,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
@@ -390,7 +349,7 @@ const uint8 kScript1a[] = {
 	7,6,5,4,3,2,1,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x1e (29be:1a40) — slow walk-stutter with idle tail (76 entries).
+
 const uint8 kScript1e[] = {
 	0,1,2,3,3,3,3,4,4,3,4,4,4,4,4,3,
 	5,5,5,5,5,5,5,5,4,4,4,4,4,4,4,4,
@@ -398,8 +357,7 @@ const uint8 kScript1e[] = {
 	7,8,7,7,7,7,7,8,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x1f (29be:1ada) — 0..5, idle, 0..5, idle, 6..8 alternation,
-// idle (50 entries).
+
 const uint8 kScript1f[] = {
 	0,1,2,3,4,5,
 	0,0,0,0,
@@ -410,13 +368,12 @@ const uint8 kScript1f[] = {
 	6,
 	0,0,0,0,0
 };
-// 0x20 (29be:1b40) — count-up 0..33 (34 frames).
+
 const uint8 kScript20[] = {
 	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
 	20,21,22,23,24,25,26,27,28,29,30,31,32,33
 };
-// 0x22 (29be:1ba4) — long held-frame walker 0..22 with idle tail
-// (115 entries; most frames held 4-7 ticks each).
+
 const uint8 kScript22[] = {
 	0,
 	1,1,1,1,1,
@@ -443,8 +400,7 @@ const uint8 kScript22[] = {
 	22,22,22,22,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x23 (29be:1c8c) — 29 entries: 0, 6 holds of 1, count-up 2..4,
-// down-up gesture, 5 idle frames.
+
 const uint8 kScript23[] = {
 	0,1,1,1,1,1,1,
 	2,3,4,3,2,
@@ -452,39 +408,34 @@ const uint8 kScript23[] = {
 	2,3,4,3,3,3,3,3,
 	0,0,0,0,0,0
 };
-// 0x24 (29be:1cc8) — bell-curve hold (58 entries): 0,0, 1,1, 2,2,
-// 3 held for 26 ticks, mirror back, idle.
+
 const uint8 kScript24[] = {
 	0,0,1,1,2,2,
 	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
 	2,2,1,1,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x28 (29be:1d7e) — gentle hold 0..3 with long hold on 3, mirror
-// back, idle (45 entries).
+
 const uint8 kScript28[] = {
 	0,1,1,2,2,
 	3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
 	2,2,1,1,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x29 (29be:1dda) — paired-step count-up 0..21 plus idle
-// (58 entries).
+
 const uint8 kScript29[] = {
 	0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,
 	11,11,12,12,13,13,14,14,15,15,16,16,17,17,18,18,19,19,
 	20,20,21,21,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x2b (29be:1e5c) — count-up 0..11 with each frame held 4 ticks
-// (48 entries).
+
 const uint8 kScript2b[] = {
 	0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,
 	4,4,4,4,5,5,5,5,6,6,6,6,7,7,7,7,
 	8,8,8,8,9,9,9,9,10,10,10,10,11,11,11,11
 };
-// 0x2c (29be:1ebe) — alternation walk 0..19 with idle tail
-// (54 entries).
+
 const uint8 kScript2c[] = {
 	0,1,2,3,4,5,
 	0,
@@ -495,8 +446,7 @@ const uint8 kScript2c[] = {
 	16,17,18,19,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x2d (29be:1f2c) — count-up 0..11 with each frame held 8 ticks
-// (96 entries).
+
 const uint8 kScript2d[] = {
 	0,0,0,0,0,0,0,0,
 	1,1,1,1,1,1,1,1,
@@ -511,8 +461,7 @@ const uint8 kScript2d[] = {
 	10,10,10,10,10,10,10,10,
 	11,11,11,11,11,11,11,11
 };
-// 0x30 (29be:1fee) — 0,0, count-up 1..19, idle, mirror down, extra
-// idle (86 entries).
+
 const uint8 kScript30[] = {
 	0,0,
 	1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,
@@ -521,7 +470,7 @@ const uint8 kScript30[] = {
 	5,4,4,3,3,2,2,1,1,
 	0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
 };
-// 0x31 (29be:209c) — paired-step idle alternations (57 entries).
+
 const uint8 kScript31[] = {
 	0,0,0,1,1,1,
 	0,0,0,1,1,1,
@@ -535,8 +484,7 @@ const uint8 kScript31[] = {
 	0,0,0,
 	1,1,1
 };
-// 0x36 (29be:2110) — 0..8 forward, 1..8 forward, frame 1 held 20
-// ticks, 8..0 mirror, idle tail (60 entries).
+
 const uint8 kScript36[] = {
 	0,1,2,3,4,5,6,7,8,
 	1,2,3,4,5,6,7,8,
@@ -567,10 +515,9 @@ const AnimScriptLong kAnimScriptsLong[] = {
 };
 
 // `_PatientSequence` and `_ImpatientSequence` are standalone script
-// pointers, not entries in `_AnimationSequences`. CD has the data but
-// never calls the switchers; floppy calls them from `_DoSiteLoop_Floppy`
-// (via `_Switch2Patient` / `_Switch2Impatient`). We intentionally enable
-// the same switch for both builds.
+// pointers. CD has the data but never calls the switchers; floppy calls 
+// them from `_DoSiteLoop_Floppy` (via `_Switch2Patient` / `_Switch2Impatient`). 
+// We intentionally enable the same switch for both builds.
 const uint8 kPatientSequence[]   = { 0,0,0,0,0,0,0,0,0,2 };
 const uint8 kImpatientSequence[] = { 0,1,0,1,0,1,0,1,2,1 };
 
@@ -737,11 +684,6 @@ AnimScriptRef findAnimScript(uint16 seqnum) {
 }
 
 // Original frame period from `_InitFrameCounter @ 1a35:01ae`:
-//   LastFrame    = cs_within_hour + 0xe
-//   cs_within_hour = ((ti_min * 60) + ti_sec) * 100 + ti_hund
-// (Borland C `struct time` memory order is min, hour, hund, sec.)
-// `+ 0xe` is 14 centiseconds → ~140 ms per frame, matching
-// `_CheckFrameRate @ 1a35:0204`.
 const uint kFramePeriodMs = 140;
 
 uint frameFromScriptAtTick(const uint8 *frames, uint len,
@@ -753,109 +695,15 @@ uint frameFromScriptAtTick(const uint8 *frames, uint len,
 	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
 }
 
-void auditPartnerAnims(EEMEngine *vm) {
-	// Cross-check every script against the ANI.DBD entry it references;
-	// warn on out-of-range frame requests.
-	if (!vm)
-		return;
-	DBDArchive &ani = vm->getAni();
-
-	struct Walker {
-		static void check(DBDArchive &ani, uint16 id, const uint8 *frames, uint8 len) {
-			if (len == 0)
-				return;
-			Animation a;
-			if (!ani.loadAnimation(id, a) || a.empty()) {
-				debugC(1, kDebugSite,
-					   "auditPartnerAnims: anim 0x%02x failed to load", id);
-				return;
-			}
-			uint maxRequested = 0;
-			for (uint j = 0; j < len; j++)
-				if (frames[j] > maxRequested)
-					maxRequested = frames[j];
-			if (maxRequested >= a.size()) {
-				warning("anim 0x%02x: script wants frame %u but ANI.DBD has "
-						"only %u — frames will be clamped",
-						id, maxRequested, (uint)a.size());
-			} else {
-				debugC(2, kDebugSite,
-					   "anim 0x%02x: %u cells, script max=%u, len=%u",
-					   id, (uint)a.size(), maxRequested, len);
-			}
-		}
-	};
-
-	for (uint i = 0; i < ARRAYSIZE(kAnimScripts); i++)
-		Walker::check(ani, kAnimScripts[i].seqnum,
-					  kAnimScripts[i].frames, kAnimScripts[i].len);
-	for (uint i = 0; i < ARRAYSIZE(kAnimScriptsLong); i++)
-		Walker::check(ani, kAnimScriptsLong[i].seqnum,
-					  kAnimScriptsLong[i].frames, kAnimScriptsLong[i].len);
-
-	// Per-frame anchor-offset audit (`_UpdateAnimations @ 172b:09c1`
-	// applies (anchor_x - miscflags, anchor_y - rowoff)). Log non-zero
-	// anchors so we know which anims need `blitAnimFrameAnchored`.
-	const uint16 partnerIds[] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
-								   0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
-								   0x0d, 0x0f, 0x10, 0x11, 0x12, 0x13,
-								   0x14, 0x15, 0x16, 0x17, 0x18, 0x19 };
-	for (uint i = 0; i < ARRAYSIZE(partnerIds); i++) {
-		const uint16 id = partnerIds[i];
-		Animation a;
-		if (!ani.loadAnimation(id, a) || a.empty())
-			continue;
-		bool anyAnchor = false;
-		int rowMin = 0, rowMax = 0, miscMin = 0, miscMax = 0;
-		for (uint f = 0; f < a.size(); f++) {
-			const Picture &fr = a[f];
-			const int sRow  = (int)(int16)fr.rowoff;
-			const int sMisc = (int)(int16)fr.miscflags;
-			if (sRow != 0 || sMisc != 0) {
-				if (!anyAnchor) {
-					rowMin = rowMax = sRow;
-					miscMin = miscMax = sMisc;
-					anyAnchor = true;
-				} else {
-					rowMin = MIN(rowMin, sRow);
-					rowMax = MAX(rowMax, sRow);
-					miscMin = MIN(miscMin, sMisc);
-					miscMax = MAX(miscMax, sMisc);
-				}
-			}
-		}
-		if (anyAnchor) {
-			// Signed int16 (puVar5[3]/[4] in `_UpdateAnimations`).
-			debugC(1, kDebugSite,
-				   "anim 0x%02x: per-frame anchor (rowoff [%d..%d], "
-				   "miscflags [%d..%d]) — handled by "
-				   "`blitAnimFrameAnchored`",
-				   id, rowMin, rowMax, miscMin, miscMax);
-		}
-	}
-}
-
 // Looping path of `_UpdateAnimations`: walk the script one entry per
 // `_CheckFrameRate` tick (`kFramePeriodMs` ~= 140 ms), wrap on 0x80.
-// If `seqnum` has no registered script, falls back to flipbook
-// (`tick % numFrames`) so unknown anims still move. The script can in
-// theory request a frame past the asset's actual cell count (misencoded
-// script) — `frameFromScriptAtTick` clamps to `numFrames - 1` so the
-// caller doesn't read past `anim[]`. Exported (non-static) so BigMap,
-// CaseSelection greeter, Notebook, and Gallery render paths in
-// `ui.cpp` use the same cadence.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	const AnimScriptRef s = findAnimScript(seqnum);
 	return frameFromScriptAtTick(s.frames, s.len, numFrames, tickMs);
 }
 
 // Play `unfold` once, then loop `waitSeq` forever. Mirrors the
-// original's slot-script-swap idiom: the entrance script runs to its
-// 0x80 terminator, then the slot's script pointer is rewritten to a
-// looping wait sequence (e.g. `_BigMapWaitSeq @ 29be:1574`,
-// `_SmallMapWaitSeq @ 29be:1548`). `partnerFrameAtTick` can't model
-// that swap on its own (it always wraps on the same script), hence
-// this helper.
+// original's slot-script-swap idiom
 uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 									   const uint8 *waitSeq, uint waitSeqLen,
 									   uint numFrames, uint32 elapsedMs) {
@@ -867,10 +715,6 @@ uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 }
 
 uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
-	// Slot starts on script 0x14 (count-up 0..8 @ 29be:196a). On 0x80
-	// terminator, `_DoBigMap` rewrites the slot's script pointer to
-	// `_BigMapWaitSeq @ 29be:1574` = (9,9,9,9,10,9,9,9,9, 0x80) — the
-	// open-map hold with a fidget.
 	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
 	static const uint8 kWaitSeq[] = { 9, 9, 9, 9, 10, 9, 9, 9, 9 };
 	return oneShotThenLoopFrameAtTick(kUnfold, ARRAYSIZE(kUnfold),
@@ -879,10 +723,6 @@ uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
 }
 
 uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
-	// Slot starts on script 0x13 (count-up 0..7 @ 29be:1992). On 0x80
-	// terminator, `_DoMapScreen @ 20fe:1390` rewrites the slot pointer
-	// (`MOV [BX+0x789f],0x1548`) to `_SmallMapWaitSeq @ 29be:1548` =
-	// 18 entries holding cell 7 with a single cell-10 fidget (~1.8 s).
 	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7 };
 	static const uint8 kWaitSeq[] = {
 		7, 7, 7, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7
@@ -929,8 +769,6 @@ bool SiteScreen::playLondonTravelAnimation(uint fromSite, uint toSite) {
 		return false;
 	}
 
-	// EEM2 `_DoTravel @ 1717:06ed`: kind 3 forces partner suffix 0,
-	// so the shipped set is TRAVEL00/01/10/11/20.ANM.
 	const uint partnerSuffix = (travelKind == 3) ? 0 : _vm->getPartnerIndex();
 	const Common::String name = Common::String::format("TRAVEL%u%u.ANM",
 		(uint)travelKind - 1, partnerSuffix);
@@ -958,16 +796,12 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		return;
 	}
 
-	// `_DoSiteLoop @ 168d:0436`: `_NewAnimation` sets the new slot's
-	// frame index to 0xffff (-1) → starts at script[0].
 	_waitPhaseAnchor = g_system->getMillis();
 	if (resetPartnerMood) {
 		_partnerWaitMood = kPartnerWaitDefault;
 		initImpatienceCounter();
 	}
 
-	// `_DoSiteLoop @ 168d:03f4`:
-	//   if (_VisitedSite[_SiteNumber] == 0) _DisplayClue(...);
 	const bool firstVisit = (siteNum < Mystery::kVisitedSiteCap)
 							 && (_mystery->_visitedSite[siteNum] == 0);
 
@@ -980,8 +814,6 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 
 	if (playArrival) {
 		if (_vm->isLondon()) {
-			// EEM2 `HandleLondonSiteLoop @ 1717:083a` calls `_DoTravel`
-			// before `_BuildBackground` for the destination site.
 			bool showedApproach = false;
 			const uint16 approachId = sd ? READ_LE_UINT16(sd + 2) : 0xffff;
 			if (firstVisit && approachId != 0xffff)
@@ -989,15 +821,10 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 			if (!showedApproach)
 				playLondonTravelAnimation(_mystery->_lastSite, siteNum);
 		} else {
-			// `_DoTravel @ 168d:02da` calls `_StartTravelMusic`.
 			_vm->startTravelMusic();
 		}
 	}
 
-	// `_BuildBackground` calls `GetPalette(sitenum + 1)` — sitenum is the
-	// global SITES.DBD index (per-mystery `sitepic` field).
-	// Floppy (`_DoSiteLoop_Floppy @ 1652:03f4`): first u16 of site_data is
-	// an offset to a drops sub-struct whose byte 0 is the SITES.DBD picID.
 	uint16 sitepic = 0;
 	if (sd) {
 		if (_vm->isFloppy()) {
@@ -1011,17 +838,11 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 	}
 	_vm->setSitePaletteForSite(sitepic);
 
-	// Override SITEPALS' uniform-yellow 0xF9..0xFE with the marching-ants
-	// glow ramp (shared with the clue puzzle).
 	applyHotspotGlowPalette();
 
 	renderBackground(siteNum);
 
-	// `_DoSiteLoop @ 168d:03f4` plays `_EnterSiteAnim` when
-	// `_LastSite != _SiteNumber`. Guard lives on the engine so PDA/gallery
-	// re-entry doesn't replay arrival.
 	if (playArrival) {
-		// `_EnterSiteAnim` snapshots the screen, so populate the BG first.
 		if (_vm->isFloppy())
 			renderFloppyDrops(siteNum);
 		else
@@ -1038,7 +859,6 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 		renderBackground(siteNum);
 	}
 
-	// Loop 2 from `_DoSiteLoop`: static drops (baked into snapshot).
 	if (_vm->isFloppy())
 		renderFloppyDrops(siteNum);
 	else
@@ -1049,7 +869,6 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 
 	scanColorCycles(siteNum);
 
-	// Loop 1 (animated NPCs) + partner on top of the snapshot.
 	const uint32 now = g_system->getMillis();
 	renderAnimatedDrops(siteNum, now);
 	renderPartner(siteNum, now);
@@ -1110,8 +929,6 @@ bool SiteScreen::checkImpatienceCounter() {
 }
 
 void SiteScreen::notePartnerActivity() {
-	// `_Switch2Patient(WaitHandle)` + `_InitImpatientCounter()` from
-	// the floppy site loop. Triggered on clicks/keys only (not mouse-move).
 	const bool wasImpatient = _partnerWaitMood == kPartnerWaitImpatient;
 	_partnerWaitMood = kPartnerWaitPatient;
 	initImpatienceCounter();
@@ -1135,7 +952,6 @@ void SiteScreen::run() {
 	if (!_mystery || !_mystery->isLoaded())
 		return;
 
-	// Caller seeds `_siteNumber` (via map pick or save restore).
 	uint cur = _mystery->_siteNumber;
 	if (cur >= _mystery->numSites())
 		cur = 0;
@@ -1163,14 +979,6 @@ void SiteScreen::run() {
 				break;
 
 			case Common::EVENT_LBUTTONDOWN: {
-				// `_DoSiteLoop @ 168d:03f4` calls
-				//   _FindButton(&SiteButtons, 2, MouseX, MouseY)
-				// `SiteButtons` @ 29be:0274 — two 8-byte rects:
-				//   Button 0: (35,111)-(56,136)  notebook (_NextScreen=4)
-				//   Button 1: (7,177)-(57,200)   map (CD=1, floppy=2)
-				// Partner-head click is port-only: `_KDHelp` shortcut
-				// mirroring `_HandleNoteButton[3]` (0x0403) /
-				// `_HandleGalleryButton[3]` (0x061e). Rect = (5,80,44,110).
 				if (kPdaSiteRect.contains(event.mouse.x, event.mouse.y)) {
 					notePartnerActivity();
 					_vm->setHotspotMouseCursor(false);
@@ -1259,8 +1067,6 @@ void SiteScreen::run() {
 
 			case Common::EVENT_KEYDOWN:
 				notePartnerActivity();
-				// `_DoSiteLoop @ 168d:07e1` routes ESC via `_ESCHit` →
-				// "Are you sure?" → MAP. Port reroutes to ScummVM menu.
 				if (event.kbd.keycode == Common::KEYCODE_ESCAPE) {
 					_vm->setHotspotMouseCursor(false);
 					_vm->openMainMenuDialog();
@@ -1275,15 +1081,11 @@ void SiteScreen::run() {
 			}
 		}
 
-		// Hotspot side effects can invalidate the mystery; exit before
-		// ticking another frame against stale snapshots.
 		if (!_mystery || !_mystery->isLoaded()) {
 			_vm->stopMusic();
 			return;
 		}
 
-		// Per-tick frame pump: `_CheckFrameRate` + `_UpdateAnimations`
-		// at the top of `_DoSiteLoop`'s main loop. 14 cs (~140 ms).
 		const uint32 now = g_system->getMillis();
 		if (_snapshotSite == (int)cur &&
 			now - _lastTickMs >= kFramePeriodMs) {
@@ -1295,7 +1097,6 @@ void SiteScreen::run() {
 			renderAnimatedDrops(cur, now);
 			renderPartner(cur, now);
 			renderHotspots(cur);
-			// `_ColorCycle(start, end)` per tick (`_DoSiteLoop @ 168d:03f4`).
 			applyColorCycles();
 			_lastTickMs = now;
 		}
@@ -1303,14 +1104,12 @@ void SiteScreen::run() {
 		g_system->delayMillis(10);
 	}
 }
-
+// `_EnterSiteAnim @ 1000:9b21`. Two phases (partner-dependent):
+//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny).
+//             Slides from (320-w, 199-h) leftward off-screen.
+//   Phase 2 — KD slide-in: anim 7 (Jake) / 0xf (Jenny).
+//             Slides from x=-w at y=0x8b/0x8e until x=0.
 bool SiteScreen::enterSiteAnim() {
-	// `_EnterSiteAnim @ 1000:9b21`. Two phases (partner-dependent):
-	//   Phase 1 — skateboard scroll: anim 6 (Jake) / 0xe (Jenny).
-	//             Slides from (320-w, 199-h) leftward off-screen.
-	//   Phase 2 — KD slide-in: anim 7 (Jake) / 0xf (Jenny).
-	//             Slides from x=-w at y=0x8b/0x8e until x=0.
-	// Frame rate uses `_MoveSkateBoardPixels` (runtime); we use 4 px/tick.
 	if (!_vm || !_mystery)
 		return false;
 	const uint8 partner = _vm->getPartnerIndex();
@@ -1327,9 +1126,6 @@ bool SiteScreen::enterSiteAnim() {
 	g_system->unlockScreen();
 
 	if (_vm->isLondon()) {
-		// EEM2 `_EnterSiteAnim @ 17ee:27a4`: no skateboard phase.
-		// It plays the partner-specific slide-in animation at anchor
-		// (0, 0x50/0x4e), using `_CheckFrameRate` between cells.
 		const uint animId = (partner == 0) ? 7 : 0xf;
 		const int anchorY = (partner == 0) ? 0x50 : 0x4e;
 		Animation anim;
@@ -1359,10 +1155,8 @@ bool SiteScreen::enterSiteAnim() {
 		return false;
 	}
 
-	// Phase 1 — skateboard scroll.
 	Animation skate;
 	if (_vm->getAni().loadAnimation(kSkateAni, skate) && !skate.empty()) {
-		// `iVar4 = 199 - sprite_h`, `uVar5 = kScreenWidth - sprite_w` (frame 0).
 		const int spriteH = skate[0].surface.h;
 		const int spriteW = skate[0].surface.w;
 		int x = (kScreenWidth - spriteW) & ~3;            // 4-px aligned (mode-X)
@@ -1400,11 +1194,6 @@ bool SiteScreen::enterSiteAnim() {
 		}
 	}
 
-	// Phase 2 — KD slide-in. `_EnterSiteAnim` per-frame:
-	//   destX = -frame.miscflags    (signed int16; byte 8 = anchor X)
-	//   destY = kKDY - frame.rowoff (signed int16; byte 6 = anchor Y)
-	// 16-bit negation: miscflags=-2 (0xFFFE) → destX=+2.
-	// ~80 ms per frame (~12 FPS, one `_CheckFrameRate` tick).
 	Animation kd;
 	if (_vm->getAni().loadAnimation(kKDAni, kd) && !kd.empty()) {
 		for (uint frameIdx = 0;
@@ -1437,10 +1226,6 @@ bool SiteScreen::enterSiteAnim() {
 }
 
 void SiteScreen::renderStaticDrops(uint siteNum) {
-	// Loop 2 from `_DoSiteLoop @ 168d:03f4`:
-	//   count @ siteData[+0x4], entries @ siteData[+0xc + i*6]: {picId, x, y}.
-	//   `_AddDrop @ 172b:1a77` loads PIC picId-1 from PICS.DBD,
-	//   blits with miscflags high-byte as transparency.
 	if (!_mystery)
 		return;
 	const byte *site = _mystery->siteData(siteNum);
@@ -1474,11 +1259,6 @@ void SiteScreen::renderStaticDrops(uint siteNum) {
 }
 
 void SiteScreen::renderFloppyDrops(uint siteNum) {
-	// Floppy drops: drops sub-struct pointed to by `*site_data` u16 offset.
-	// `_DoSiteLoop_Floppy @ 1652:0418` → `FUN_16e2_18eb @ 16e2:18eb`:
-	//   entry stride 5: u16 picID @ +0, u16 X @ +2, u8 Y @ +4.
-	//   picID-1 indexes PICS.DBX table @ 2608:4537.
-	// drops_struct[0] = BG picID (separate), drops_struct[1] = drop count.
 	if (!_mystery)
 		return;
 	const byte *site = _mystery->siteData(siteNum);
@@ -1501,7 +1281,6 @@ void SiteScreen::renderFloppyDrops(uint siteNum) {
 		const int16  y     = (int16)e[4];
 		if (picID == 0)
 			continue;
-		// `getPicture(num)` does `loadEntry(num - 1)` (resource.h:100).
 		Picture pic;
 		if (!_vm->getPics().getPicture((uint)picID, pic))
 			continue;
@@ -1511,20 +1290,10 @@ void SiteScreen::renderFloppyDrops(uint siteNum) {
 }
 
 void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
-	// Loop 1 from `_DoSiteLoop @ 168d:03f4`:
-	//   count @ siteData[+0xa], entries @ siteData[+0x48 + i*6]: {animId, x, y}.
-	//   animId == -1 → `_ColorCycle(x, y)` (palette rotation, separate path).
-	//   else → `_GetAnimation` + `_NewAnimation` + `_UpdateAnimations @ 172b:09c1`
-	//          walks a sequence script (0x80=loop, 0x81=jump).
 	if (!_mystery)
 		return;
 
 	if (_vm && _vm->isFloppy()) {
-		// Floppy site anims live in ANI.BIN (per-case block from
-		// `_GetSiteAnimData_Floppy`). Per-site:
-		//   u8 cycleCount, cycleCount × {u8 start, u8 end},
-		//   u8 animCount, animCount × {u8 animId, u16 x, u8 y}.
-		// `_DoSiteLoop_Floppy` caps at 4 slots.
 		const byte *siteAnim = _mystery->floppySiteAnimData(siteNum);
 		if (!siteAnim)
 			return;
@@ -1588,8 +1357,6 @@ void SiteScreen::renderAnimatedDrops(uint siteNum, uint32 tickMs) {
 }
 
 void SiteScreen::scanColorCycles(uint siteNum) {
-	// `_DoSiteLoop @ 168d:03f4`: Loop 1 entries with animId==-1 are
-	// ColorCycle palette ranges (start @ +2, end @ +4). Max 5 slots.
 	_colorCycles.clear();
 	if (!_mystery)
 		return;
@@ -1628,8 +1395,6 @@ void SiteScreen::scanColorCycles(uint siteNum) {
 }
 
 void SiteScreen::applyColorCycles() {
-	// `_ColorCycle @ 172b:2015` per range. Always rotate 0xF9..0xFE
-	// (hotspot marching ants — `_ColorCycle(0xf9, 0xfe)` in `_DoSiteLoop`).
 	for (uint i = 0; i < _colorCycles.size(); i++) {
 		cyclePaletteRange(_colorCycles[i].start, _colorCycles[i].end);
 	}
@@ -1655,11 +1420,6 @@ void SiteScreen::restoreBgSnapshot() {
 }
 
 void SiteScreen::syncCompositedScreen() {
-	// OpenGL screenshots read the current backbuffer. After a buffer swap,
-	// that backbuffer can contain an older site frame unless the engine keeps
-	// presenting the full composited screen, even while no animation tick
-	// fired. Copying the current game surface through copyRectToScreen keeps
-	// screenshots in sync without backend-specific changes.
 	Graphics::ManagedSurface snapshot(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 
@@ -1674,14 +1434,6 @@ void SiteScreen::syncCompositedScreen() {
 }
 
 void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
-	// `_DoSiteLoop @ 168d:03f4` reads `siteData[+8]` as the speaker
-	// table index, then for each (speaker x partner) loads:
-	//   anim = WaitAnims[speakerIdx].anim[partner]
-	//   x    = WaitAnims[speakerIdx].x[partner]
-	//   y    = WaitAnims[speakerIdx].y[partner]
-	// from `_WaitAnims @ 29be:021c` (see `kWaitAnims` at file scope for
-	// 12-byte / 6-u16 entry layout). Rendering caps at speaker < 7
-	// (entries past 6 are `_SiteButtons` rect data in the binary).
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
 		return;
@@ -1690,10 +1442,6 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	int    x;
 	int    y;
 	if (_vm->isFloppy()) {
-		// `_DoSiteLoop_Floppy @ 1652:042b`: site_data+8 is a u16 OFFSET
-		// to `_SpeakerInfo_Floppy`; the first 10 bytes are the idle pair:
-		//   +0..1 Jake anim, +2..3 Jake X, +4 Jake Y,
-		//   +5..6 Jenny anim, +7..8 Jenny X, +9 Jenny Y.
 		const uint16 spkOff = READ_LE_UINT16(site + 8);
 		const byte *spk = _mystery->blobAt(spkOff);
 		if (!spk)
@@ -1725,9 +1473,6 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
 		return;
 
-	// Relative phase anchor (not raw tickMs) so wait anim resumes
-	// from script[0] after each kdAnim one-shot — matches
-	// `_PlayAnimation @ 172b:1f5d` reset to frame 0xffff.
 	const uint32 elapsed = (tickMs >= _waitPhaseAnchor)
 							? (tickMs - _waitPhaseAnchor)
 							: tickMs;
@@ -1760,8 +1505,6 @@ bool SiteScreen::renderFloppyHotspotPartnerPose(uint siteNum) {
 		return false;
 
 	const uint16 spkOff = READ_LE_UINT16(site + 8);
-	// `_OnSiteHotspotClicked_Floppy @ 1652:017a`: after restoring the site
-	// it loads the active pair from `_SpeakerInfo_Floppy + 0x28/0x2d`.
 	const uint poseOff = (_vm->getPartnerIndex() == kPartnerJake)
 		? 0x28 : 0x2d;
 	if ((uint32)spkOff + poseOff + 5 > _mystery->dataSize())
@@ -1790,12 +1533,6 @@ bool SiteScreen::renderFloppyHotspotPartnerPose(uint siteNum) {
 }
 
 void SiteScreen::renderBackground(uint siteNum) {
-	// `_BuildBackground(sitepic, 0x42, 0x14)` from `_DoSiteLoop @
-	// 168d:03f4` / `_DisplayCorrect`:
-	//   1. `_GetFromDB(_PicIndex, 0x3d)` — DBI entry 0x3d (0-based).
-	//   2. SITES.DBD entry `sitepic` (0-based, SiteData[+0..+1]).
-	//   3. `_Rect_Move(..., 0x42, 0x14, 48000, ...)` composes at (66, 20).
-	//   4. `_GetPalette(sitepic + 1)`.
 	Picture frame;
 	if (_vm->getPics().loadEntry(0x3d, frame)) {
 		g_system->copyRectToScreen(frame.surface.getPixels(),
@@ -1803,11 +1540,6 @@ void SiteScreen::renderBackground(uint siteNum) {
 								   0, 0, frame.surface.w, frame.surface.h);
 	}
 
-	// `_BuildBackground @ 172b:13e2` → `_GetFromDB(_siteFile,
-	// &_SiteDBIndex, sitenum)` uses SiteData[+0] as a 0-based SITES.DBD
-	// index (stride 10, asm `IMUL BX,BX,0xa` @ 172b:14c8; no -1 adjust).
-	// Floppy: site_data[0] is u16 offset to drops sub-struct; drops[0]
-	// byte is the SITES.DBD index (`FUN_16e2_12fd @ 16e2:12fd`).
 	const byte *site = _mystery->siteData(siteNum);
 	uint16 sitepic = 0;
 	if (site) {
@@ -1855,7 +1587,6 @@ byte currentWhitePaletteIndex(byte fallback) {
 }
 
 void SiteScreen::renderHotspots(uint siteNum) {
-	// `_DrawSearchButtons`. Port adds optional "hide hint" setting.
 	if (ConfMan.getBool("hide_highlight_boxes"))
 		return;
 
@@ -1868,21 +1599,6 @@ void SiteScreen::renderHotspots(uint siteNum) {
 	if (!screen)
 		return;
 
-	// `_DrawSearchButtons @ 2404:0a8f`:
-	//   for each hotspot:
-	//     if _Sawit(theSite, loc) == 0 (NOT seen yet):
-	//       _DrawRect(rect)       — outline in cycling colors
-	//                                0xF9..0xFE; `_ColorCycle(0xF9, 0xFE)`
-	//                                rotates them every tick → "marching
-	//                                ants" glow on unsearched spots.
-	//     else (seen):
-	//       _DrawSolidRect(rect)  — outline in solid colour 0xFF.
-	// (Branch order per asm `2404:0af6 OR AX,AX; 2404:0af8 JZ` — the
-	// C-level decompile mis-reordered the if/else.)
-	// Palette 0xF9..0xFE is already rotated by `applyColorCycles` each
-	// tick, so drawing a single colour from that range pulses on its
-	// own. Phase the start colour by hotspot index so adjacent spots
-	// don't glow in lock-step.
 	const uint32 tickMs = g_system->getMillis();
 
 	// CD hotspot row = 14 bytes:
@@ -1918,12 +1634,8 @@ void SiteScreen::renderHotspots(uint siteNum) {
 				   _mystery->_hotSpotsSeen[seenKey];
 		}
 		if (seen) {
-			// `_DrawSolidRect @ 172b:0506` — solid, non-cycling outline.
 			screen->frameRect(rect, searchedColor);
 		} else {
-			// `_DrawRect @ 172b:03e2` — walk all four edges incrementing
-			// the colour per pixel through palette indices 0xF9..0xFE,
-			// which `_ColorCycle(0xF9, 0xFE)` rotates every tick.
 			byte color = (byte)(0xF9 + ((i + (tickMs / 80)) & 0x07) % 6);
 			// Top edge
 			for (int x = rect.left; x < rect.right; x++) {
@@ -2069,12 +1781,10 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 			// Snapshot `_cluesFound` → detect new-clue 0→1 → autosave.
 			byte before[Mystery::kCluesFoundCap];
 			memcpy(before, _mystery->_cluesFound, sizeof(before));
-			// Partner-less BG for `_DoKDAnim` / `playKdAnim` to erase
-			// the resting idle (fires when ClueEntry +0x3a != -1).
 			_vm->setPartnerEraseBg(&_bgSnapshot);
 			_vm->displayClue(clueBlock);
 			_vm->setPartnerEraseBg(nullptr);
-			// Port-only autosave (original = manual `_SaveGame @ 2404:0c87`).
+			// New feature: autosave on new clue.
 			bool foundNewClue = false;
 			for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
 				if (!before[i] && _mystery->_cluesFound[i]) {
@@ -2092,24 +1802,16 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 		}
 	}
 }
-
+// `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
+//   _SuspendAnimation(WaitHandle);
+//   anim = kKdAnimTable[num].anim[partner]   (@ 29be:0228)
+//   x    = kKdAnimTable[num].x[partner]
+//   y    = kKdAnimTable[num].y[partner]
+//   _PlayAnimation(anim, x, y, WaitHandle)
+//     -> registers a state-4 (one-shot) animation slot and lets
+//        `_UpdateAnimations` walk the script until 0x80, then
+//        frees the slot and re-activates `WaitHandle`.
 void EEMEngine::playKdAnim(uint16 num) {
-	// Mirrors `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
-	//   _SuspendAnimation(WaitHandle);
-	//   anim = kKdAnimTable[num].anim[partner]   (@ 29be:0228)
-	//   x    = kKdAnimTable[num].x[partner]
-	//   y    = kKdAnimTable[num].y[partner]
-	//   _PlayAnimation(anim, x, y, WaitHandle)
-	//     -> registers a state-4 (one-shot) animation slot and lets
-	//        `_UpdateAnimations` walk the script until 0x80, then
-	//        frees the slot and re-activates `WaitHandle`.
-	// Port renders the partner's idle inline in each redraw rather
-	// than via a slot system, so we play the one-shot synchronously
-	// (blocking) and resume normal idle rendering when the caller
-	// returns — matches the visible effect (partner gesture finishes
-	// before the speaker portrait + speech balloon appear).
-	// `kKdAnimTable` and `kAnimScripts` live at file scope above. EEM2 ships a
-	// different table (positions + Jenny reactions) — use it for London.
 	if (num >= ARRAYSIZE(kKdAnimTable))
 		return;
 
@@ -2158,19 +1860,12 @@ void EEMEngine::playKdAnim(uint16 num) {
 		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(bg);
-		// Anchor-aware: kdAnim cells (0x03/0x04/0x0c/0x0d ...) have
-		// non-zero per-frame `miscflags`/`rowoff` (anim 0x03 has rowoff
-		// up to 9, anim 0x04 has miscflags = -2). Routes through
-		// `blitAnimFrameAnchored` so the gesture translates across
-		// cells instead of pinning to a fixed pixel.
 		(void)transp;  // anchored blitter recomputes from p.flags
 		blitAnimFrameAnchored(scratch.surfacePtr(), fr, px, py);
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
-		// 100 ms per frame (~10 fps). Pump updateScreen inside the wait
-		// so cursor overlay refreshes at 100 Hz.
 		const uint32 wakeup = g_system->getMillis() + 100;
 		while (g_system->getMillis() < wakeup && !shouldQuit()) {
 			Common::Event ev;
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 9bdbc88aa4a..30567b2339d 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -36,11 +36,10 @@ class EEMEngine;
 class Mystery;
 
 /// partnerFrameAtTick: frame index for `seqnum` at `tickMs`. Walks `kAnimScripts`
-/// at `kFramePeriodMs` (~140 ms = `_CheckFrameRate` cadence) per entry; wraps
+/// at `kFramePeriodMs` per entry; wraps
 /// on the script's 0x80 terminator. Falls back to flipbook (`tick % numFrames`)
 /// when no script is registered. `numFrames` is the ANI.DBD entry's cell count,
 /// used both for the fallback and to clamp script values past the asset.
-/// Mirrors the looping path of `_UpdateAnimations @ 172b:09c1`.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
 /// Select the EEM2 ("London") animation-script table inside `findAnimScript`.
@@ -49,43 +48,25 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 void setLondonAnimScripts(bool enabled);
 
 /// bigMapPartnerFrameAtTick: count-up 0..8 once, then loop `_BigMapWaitSeq`
-/// (9,9,9,9,10,9,9,9,9). Mirrors `_DoBigMap @ 20fe:09e7` two-phase swap from
-/// script 0x14 (count-up @ 29be:196a) to `_BigMapWaitSeq @ 29be:1574`.
 uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
 
 /// bigMapDetailPartnerFrameAtTick: zoomed-view partner frame. Same two-phase shape
-/// as `bigMapPartnerFrameAtTick`. `_DoMapScreen @ 20fe:120b` runs script 0x13
-/// (count-up 0..7 @ 29be:1992) then swaps to `_SmallMapWaitSeq @ 29be:1548`
-/// (`MOV [BX+0x789f],0x1548` at 20fe:1390).
+/// as `bigMapPartnerFrameAtTick`.
 uint bigMapDetailPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
 
 /// blitAnimFrameAnchored: mask-blit at (anchorX - frame.miscflags,
-/// anchorY - frame.rowoff). Mirrors per-frame anchor math in
-/// `_UpdateAnimations @ 172b:09c1`. Both anchors are SIGNED int16
-/// (e.g. anim 0x14 BigMap walk-cycle has miscflags = -2 per cell, so
-/// the sprite translates across the screen as it cycles). Use for any
-/// animation rendered through `_NewAnimation` in the original — partner
-/// sprites, animated drops, briefing animations. Transparency comes
-/// from `flags >> 8` (NOT miscflags).
+/// anchorY - frame.rowoff).
 void blitAnimFrameAnchored(Graphics::Surface *screen, const Picture &p,
 						   int anchorX, int anchorY);
 
 /// Rotate one VGA palette range by one slot (START→END direction).
-/// Mirrors `_ColorCycle`. Used by per-site Loop-1 ColorCycle entries,
-/// hotspot marching ants (0xF9..0xFE), and the BigMap marker shine.
 void cyclePaletteRange(uint8 start, uint8 end);
 
 /// Rotate one VGA palette range by one slot in the OPPOSITE direction
-/// (END→START): save END, shift every entry up by one (END-1 → END, ...),
-/// wrap saved END to START. Mirrors `_OpenColorCycle @ 2520:04f7` (CD) /
-/// `_ReverseColorCycle_Floppy`. Used by opening-anim logos (EA Kids, etc.)
-/// where the cycle shifts END→START rather than START→END.
+/// (END→START).
 void cyclePaletteRangeReverse(uint8 start, uint8 end);
 
 /// Load the 6-step yellow marching-ants ramp into palette 0xF9..0xFE
-/// (SITEPALS ships these as uniform yellow). Shared by site hotspots and the
-/// clue puzzle so both outline clickable areas in the same original colours;
-/// `cyclePaletteRange(0xF9, 0xFE)` then pulses them.
 void applyHotspotGlowPalette();
 
 /// One hotspot (search rectangle) within a site, 14 bytes on disk.
@@ -98,8 +79,7 @@ struct Hotspot {
 	Common::Rect rect() const { return Common::Rect(x1, y1, x2, y2); }
 };
 
-/// Site / scene controller. Mirrors `_DrawSearchButtons @ 2404:0a8f` /
-/// `_SearchButtons @ 2404:0bfb` site loop.
+/// Site / scene controller. 
 class SiteScreen : public Common::EventObserver {
 public:
 	SiteScreen(EEMEngine *vm, Mystery *mystery)
@@ -126,32 +106,17 @@ private:
 	void notePartnerActivity();
 	bool playLondonTravelAnimation(uint fromSite, uint toSite);
 
-	/// Partner site-arrival sequence (when `_LastSite != _SiteNumber`).
-	/// Mirrors `_EnterSiteAnim @ 1000:9b21`: anim 6/14 (Jake/Jenny) skateboards
-	/// in from right, then anim 7/15 slides KD in from left. Returns true if skipped.
+	/// Partner site-arrival sequence
 	bool enterSiteAnim();
 
-	/// renderPartner: persistent in-site partner sprite at `_WaitAnims @ 29be:021c`.
-	/// Mirrors `_GetAnimation` + `_NewAnimation` tail of `_DoSiteLoop @ 168d:03f4`.
+	/// renderPartner: persistent in-site partner sprite 
 	void renderPartner(uint siteNum, uint32 tickMs);
 
-	/// Floppy active speaker pose shown before `_HandleHotspotClick_Floppy`.
-	/// Uses `_SpeakerInfo_Floppy + 0x28/0x2d`, replacing the idle partner.
+	/// Floppy active speaker pose 
 	bool renderFloppyHotspotPartnerPose(uint siteNum);
 
-	/// renderStaticDrops: `_AddDrop` static decorations (Loop 2).
-	/// siteData[+0x4] count, siteData[+0xc] entries (6 bytes: {picId, x, y}).
 	void renderStaticDrops(uint siteNum);
-
-	/// renderFloppyDrops: floppy variant. `*site_data`→drops; drops[1]=count;
-	/// entries at drops+2 are 5 bytes ({u16 X, u16 Y, byte picID}); PIC loaded as picID-1.
-	/// Per `_DoSiteLoop_Floppy @ 1652:0418` and `FUN_16e2_18eb`.
 	void renderFloppyDrops(uint siteNum);
-
-	/// renderAnimatedDrops: per-site animated NPCs (Loop 1) at current tick.
-	/// `_DoSiteLoop` registers each via `_NewAnimation` (siteData[+0xa] count,
-	/// siteData[+0x48] entries: {animId (-1=ColorCycle), x, y}); frames advance via
-	/// `_UpdateAnimations @ 172b:09c1`.
 	void renderAnimatedDrops(uint siteNum, uint32 tickMs);
 
 	/// Snapshot the post-BG, post-static-drops screen so the per-tick
@@ -168,11 +133,10 @@ private:
 	void syncCompositedScreen();
 
 	/// scanColorCycles: scan Loop 1 for ColorCycle entries (animId == -1),
-	/// cache (start, end) palette ranges. Mirrors `_DoSiteLoop @ 168d:03f4` init scan.
+	/// cache (start, end) palette ranges.
 	void scanColorCycles(uint siteNum);
 
-	/// applyColorCycles: rotate cached ColorCycle ranges + 0xf9..0xfe (hotspot ants)
-	/// one step. Mirrors per-tick `_ColorCycle(start, end)` calls in `_DoSiteLoop`.
+	/// applyColorCycles: rotate cached ColorCycle ranges
 	void applyColorCycles();
 
 	EEMEngine *_vm;
@@ -188,10 +152,7 @@ private:
 	uint32 _impatientDeadlineMs = 0; ///< Test-shortened impatience deadline.
 	PartnerWaitMood _partnerWaitMood = kPartnerWaitDefault;
 
-	/// Wall-clock anchor for partner wait anim phase: rendered as
-	/// `partnerFrameAtTick(animId, ..., now - _waitPhaseAnchor)`.
-	/// Bumped on site entry (`_NewAnimation` writes 0xffff at `_DoSiteLoop @ 168d:0436`)
-	/// and on return from a one-shot kdAnim (`_PlayAnimation @ 172b:1f5d` state=4).
+	/// Wall-clock anchor for partner wait anim phase
 	uint32 _waitPhaseAnchor = 0;
 
 	/// Per-site cached ColorCycle ranges (up to 5, matching original 5-slot anim table).
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6acea3e5d58..dc7c47c74a4 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -40,8 +40,6 @@
 
 namespace EEM {
 
-// Gallery slot positions @ 29be:0x116. Used by `_DrawGallery @ 158f:0046`
-// and the accuse portrait grid.
 struct GallerySlot { int x; int y; };
 const GallerySlot kGallerySlots[5] = {
 	{  83,  14 }, // 0
@@ -149,9 +147,7 @@ bool loadLondonApproachData(uint16 approachId, LondonApproachData &out) {
 	out.pages.clear();
 	uint32 pos = 16;
 	// `_DoApproach @ 1717:009b` reads the pages with `_fgets` into 255-byte
-	// slots, so each page is one NEWLINE-terminated record — NOT NUL-separated.
-	// (A*.BIN carries no NULs; splitting on '\0' lumps every page into page 0,
-	// which is why the Next/Prev arrows had nothing to page through.)
+	// slots, so each page is one NEWLINE-terminated record, NOT NUL-separated.
 	for (uint16 i = 0; i < pageCount && pos < size; i++) {
 		const uint32 start = pos;
 		while (pos < size && data[pos] != '\n')
@@ -188,16 +184,13 @@ bool decodeLondonApproachFirstFrame(uint16 videoId,
 	return true;
 }
 
-// Setup-screen highlight rects (referenced by both `doSetup` and the
-// helper `EEMEngine::setupDrawScreen`). `_SetupHighlights @ 29be:1320`.
+// Setup-screen highlight rects
 constexpr Common::Rect kSetupKid1Rect    (Common::Point( 99,  44),  49,  8);
 constexpr Common::Rect kSetupKid2Rect    (Common::Point( 99,  54),  49,  8);
 constexpr Common::Rect kSetupSoundOnRect (Common::Point(106,  86),  19,  8);
 constexpr Common::Rect kSetupSoundOffRect(Common::Point(106,  96),  19,  8);
 
-// EEM2/London setup highlights — `_SetupSettings @ 2046:0008` (_SwapColors,
-// key 0xFE). On/off label pairs for the 4 toggles (rects @ 2bca:13ec; drawn
-// in the DOS order ON-then-OFF). Partner reuses Jake/Jenny.
+// EEM2/London setup highlights
 constexpr Common::Rect kLonSetJake    (Common::Point( 99,  44), 49, 8); // 0x13ec
 constexpr Common::Rect kLonSetJenny   (Common::Point( 99,  54), 49, 8); // 0x13f4
 constexpr Common::Rect kLonSetVoiceOn (Common::Point(106,  68), 20, 8); // 0x13fc
@@ -207,9 +200,6 @@ constexpr Common::Rect kLonSetMusicOff(Common::Point(128,  85), 18, 7); // 0x142
 constexpr Common::Rect kLonSetHiOn    (Common::Point(106, 110), 29, 8); // 0x1414
 constexpr Common::Rect kLonSetHiOff   (Common::Point(106, 100), 29, 8); // 0x140c
 
-// `_SwapColors @ 172b:1d2a` — replace pixels in r where value==from
-// with to. 0xFE = BG text-key; 0x15 = active palette index, 0x00 =
-// inactive (set by `_SetupSettings @ 1f78:000d`).
 void swapColors(Graphics::ManagedSurface &dst,
 					   const Common::Rect &r, byte from, byte to) {
 	const int x1 = MAX<int>(0, r.left);
@@ -225,9 +215,7 @@ void swapColors(Graphics::ManagedSurface &dst,
 	}
 }
 
-// PDA-frame partner sprite specs. `(5, 0x50)` is the partner-at-desk anchor
-// shared by _DoNotebook (script 0x01, anim 1/0xb) and _DoGallery / _DoAccuse
-// / _MoreInfo (script 0x02, anim 2/0x10).
+// TODO: change Pda naming to TRAVIS
 struct PdaPartnerSpec {
 	uint16 scriptId;
 	uint16 animJake;
@@ -277,9 +265,6 @@ constexpr uint16 kFloppyEndingBackgroundPic = 0x8b;
 constexpr uint16 kFirstTryBadgePic = 0x205;
 constexpr Common::Point kFirstTryBadgePos(0x1e, 9);
 
-// kPdaSiteRect / kPdaPartnerFootMapRect / kPdaPartnerHeadHintRect live in
-// eem.h (shared with site.cpp). The button-row rects below are PDA-screen-
-// only (`_NoteButtons @ 29be:0147`).
 constexpr Common::Rect kPdaHelpRect(Common::Point(93, 174), 22, 16);
 constexpr Common::Rect kPdaNotebookRect(Common::Point(134, 174), 21, 16);
 constexpr Common::Rect kPdaGalleryRect(Common::Point(157, 174), 21, 16);
@@ -287,9 +272,6 @@ constexpr Common::Rect kPdaAccuseRect(Common::Point(180, 174), 21, 16);
 constexpr Common::Rect kPdaPageNextRect(Common::Point(204, 174), 20, 16);
 constexpr Common::Rect kPdaPagePrevRect(Common::Point(226, 174), 21, 16);
 constexpr Common::Rect kPdaHelp2Rect(Common::Point(267, 174), 21, 16);
-// London-only: `_NoteButtons[9]` = (0,0,66,79) → site (EEM1's slot 9 is a
-// dead 0-rect). A secondary "close the PDA" hotspot over the device's
-// top-left corner. EEM2 `_HandleNoteButton` slot 9 → screen 3.
 constexpr Common::Rect kPdaLondonCloseRect(Common::Point(0, 0), 66, 79);
 
 constexpr uint16 kProfilePickerRevealPic = 0x105;
@@ -404,8 +386,7 @@ const byte *advanceFloppyDialogRecords(const byte *rec, uint count,
 	return rec;
 }
 
-// Floppy gallery slot positions @ 2608:0x16c. Read by `_DrawGallery_Floppy
-// @ 154e:0045`'s [BX + 0x16c] (x) and [BX + 0x16e] (y) loads.
+// Floppy gallery slot positions @ 2608:0x16c.
 const GallerySlot kFloppyGallerySlots[5] = {
 	{ 0x53, 0x0e }, // 0
 	{ 0x9b, 0x0e }, // 1
@@ -414,9 +395,7 @@ const GallerySlot kFloppyGallerySlots[5] = {
 	{ 0xbf, 0x5a }  // 4
 };
 
-// `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ 29be:1064:
-//   '0'→0x15  '1'→0x16  '2'→0x17  '3'→0x18  '4'→0x19
-//   '5'→0x1a  '6'→0x20  '7'→0x21  '8'→0x22  '9'→0x1e
+// `_GetKDTextBalloon @ 1df2:0105` digit-balloon table @ 29be:1064
 const uint16 kDigitBalloons[10] = {
 	0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x20, 0x21, 0x22, 0x1e
 };
@@ -445,7 +424,6 @@ void copyToScreen(Graphics::ManagedSurface &scratch) {
 }
 
 void cycleChooserPalette() {
-	// `_DoChoose` / `_DoListPicker_Floppy` rotate 0x6f..0x73 per tick.
 	cyclePaletteRange(kChooserCycleStart, kChooserCycleEnd);
 }
 
@@ -632,12 +610,10 @@ struct ActionMenuView {
 	const char *const *pickLabel;
 	const bool *pickEnabled;
 	uint pick;
-	uint numPicks;  ///< Visible entries (London drops ScrapBook 3 → 4).
+	uint numPicks;
 };
 
-// `_DoChooseMystery @ 1a35:02b7` — opens BOOK%u.NME (CRLF strings, up
-// to 25 × 40 bytes, whitespace-line sentinel). `_DoChoose @ 1c33:0514`
-// walks until {0, 0}.
+// `_DoChooseMystery @ 1a35:02b7`
 Common::StringArray loadBookNames(uint book) {
 	Common::StringArray names;
 	const Common::String fname = Common::String::format("BOOK%u.NME", book);
@@ -672,8 +648,6 @@ void clampCaseTopRow(uint &topRow, uint listLen, uint visibleRows) {
 		topRow = maxTop;
 }
 
-// "Choose A Mystery" sub-chooser view. names = BOOK%d.NME entries
-// (mystery number = tierLo + index). solvedFlags marks greyed entries.
 struct CaseSubmenuView {
 	EEMEngine *vm;
 	const Picture *caseBg;
@@ -688,7 +662,7 @@ struct CaseSubmenuView {
 	const Common::Array<bool> *solvedFlags;
 	uint topRow;
 	uint selRow;
-	uint book;            ///< 1..3 — for the "Book N" / "Challenge Book" title
+	uint book;
 };
 
 void drawCaseGreeter(Graphics::ManagedSurface &scratch,
@@ -697,8 +671,6 @@ void drawCaseGreeter(Graphics::ManagedSurface &scratch,
 	if (!haveKdAnim || !kdAnim || kdAnim->empty())
 		return;
 
-	// `_CaseSelection` drives the partner ANI with script 0x15 regardless
-	// of partner.
 	const uint32 now = g_system->getMillis();
 	const uint frameIdx = partnerFrameAtTick(0x15, (uint)kdAnim->size(), now);
 	blitAnimFrameAnchored(scratch.surfacePtr(), (*kdAnim)[frameIdx],
@@ -755,9 +727,7 @@ bool animateCaseSelectionReveal(EEMEngine *vm, const Picture *caseBg,
 	return false;
 }
 
-// `_DoChoose`'s `DrawList @ 1c33:040d`. 12 rows × 10 px at (61, 35).
-// Original colours 0x13 sel / 0x1B greyed / 0x5C default approximated
-// here as 0xF / 0x8 / 0x7 from site palette 0.
+// `DrawList @ 1c33:040d`
 void drawCaseSubmenu(const CaseSubmenuView &v) {
 	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
@@ -792,7 +762,7 @@ void drawCaseSubmenu(const CaseSubmenuView &v) {
 			kListX, kListY0 + r * kLineH, kListW, color);
 	}
 
-	// Selection arrow (ScummVM-only — original uses colour change).
+	// Selection arrow.
 	if (v.selRow >= v.topRow && v.selRow < v.topRow + (uint)kVisible) {
 		const int r = (int)(v.selRow - v.topRow);
 		v.vm->getFont().drawString(&scratch, ">",
@@ -855,12 +825,8 @@ void drawActionMenuFrame(const ActionMenuView &v) {
 }
 
 void EEMEngine::doProfilePicker() {
-	// `screen8_handler @ 1c33:1012` — walks *.PLR (max 25), reads 12-byte
-	// player-name field, hands list to `_DoChoose`. No profiles or
-	// 0xfffe/0xffff sentinel enters `_NewPlayer`.
 	_profileCreatedThisSession = false;
 
-	// `screen8_handler` does `_FadeOut(); _GetPalette(0); _GetBackground(0x104)`.
 	setSitePalette(0);
 
 	const SaveStateList saves = listProfiles();
@@ -880,11 +846,6 @@ void EEMEngine::doProfilePicker() {
 		return;
 	}
 
-	// Existing profiles only — the original `screen8_handler @ 1c33:1012`
-	// passes the bare *.PLR list to `_DoChoose` with NO synthesized "new
-	// player" entry. _DoChoose returns 0xfffe / 0xffff for the bottom click
-	// rect @ 29be:0d08 / ESC, which routes to `_NewPlayer` (handled below
-	// via kChooserNewPlayerRect / KEYCODE_ESCAPE).
 	Common::Array<ProfilePickerEntry> entries;
 	for (const SaveStateDescriptor &s : saves) {
 		ProfilePickerEntry e;
@@ -903,8 +864,6 @@ void EEMEngine::doProfilePicker() {
 	const bool haveReveal =
 		_picsArchive.getPicture(kProfilePickerRevealPic, reveal);
 
-	// Geometry: list origin (61, 35), 10 px/row, 12 visible. PIC 0x105
-	// slides into lower strip via `screen8_handler` before `_DoChoose`.
 	ProfilePickerView view;
 	view.vm = this;
 	view.bg = &bg;
@@ -1000,7 +959,6 @@ void EEMEngine::doProfilePicker() {
 					break;
 				}
 				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `screen8_handler` sets Chelp=0 — button ignored.
 					break;
 				}
 				if (kChooserListRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -1047,7 +1005,6 @@ void EEMEngine::doProfilePicker() {
 		return;
 	}
 
-	// `_LoadPlayerRecord @ 1c33:1281`.
 	const ProfilePickerEntry &e = entries[sel];
 	if (loadProfile(e.label)) {
 		_profileCreatedThisSession = false;
@@ -1060,10 +1017,8 @@ void EEMEngine::doProfilePicker() {
 			doNewPlayer();
 	}
 }
-
+// `_NewPlayer @ 1c33:0dda`
 void EEMEngine::doNewPlayer() {
-	// `_NewPlayer @ 1c33:0dda` — BG 0x104 + peek pic 0x107, prompt for
-	// up to 12 chars.
 	_profileCreatedThisSession = false;
 	if (!_font.isLoaded()) {
 		_playerName = "Detective";
@@ -1106,8 +1061,6 @@ void EEMEngine::doNewPlayer() {
 			if (k == Common::KEYCODE_RETURN) {
 				if (name.empty())
 					name = "Detective";
-				// `_NewPlayer @ 1c33:0fa0+`: `_LoadPlayerRecord` → if
-				// missing, zero state and `_SavePlayerRecord`.
 				if (loadProfile(name)) {
 					_profileCreatedThisSession = false;
 				} else {
@@ -1115,7 +1068,6 @@ void EEMEngine::doNewPlayer() {
 					memset(_mysteriesSolved, 0, sizeof(_mysteriesSolved));
 					_mystery.clear();
 					_partner = kPartnerJake;
-					// `_NewPlayer @ 1c33:0fa3`: DAT_2d5d_3f99 = 1 (Junior).
 					_chainStage = 1;
 					saveProfile(name);
 					_profileCreatedThisSession = true;
@@ -1151,26 +1103,8 @@ void EEMEngine::doNewPlayer() {
 
 	g_system->setFeatureState(OSystem::kFeatureVirtualKeyboard, false);
 }
-
+// `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @ 1df2:044c`
 int EEMEngine::doShowEnding(uint num, bool firstPage) {
-	// `_DisplayEnding @ 1df2:0548` + `_DisplayEndingPage @ 1df2:044c`
-	// (CD); `FUN_1d40_05b7` + `FUN_1d40_031e` (floppy).
-	// CD E<num>.BIN format:
-	//   u16 pageCount
-	//   per page: u16 picNum
-	//             u16 x1, y1, x2, y2   (story rect — passed to WordWrap2)
-	//             char text[]          (null-terminated, ParseString opcodes)
-	// Floppy: small title header + shared newspaper BG (PIC 0x8b) +
-	//   per-page overlay pictures.
-	// Render: _FreeFont; _LoadFont("tiny.fnt") @ 1df2:055f; _GetPalette(0);
-	// per-page _GetBackground(picNum) + _WordWrap2(x1, y1, x2-x1, text,
-	// fontColor=0, dropColor=-1). fontColor=0 is the newspaper body-text
-	// colour (asm 1df2:04cf-04f4 — Ghidra mis-paired the trailing args).
-	// LEFT/RIGHT page nav (1df2:0689 / 06a0): boundary sets [BP-0x18] to
-	// -1 / +1 — that return value drives `_ShowScrapbook` walking forward/
-	// back through solved mysteries (1f78:0664-069c).
-	// firstPage=false opens at LAST page (used by doShowScrapbook after
-	// "previous mystery"; matches `local_8 = 0` @ 1f78:067e).
 	const Common::String fname = Common::String::format("E%u.BIN", num);
 	Common::File f;
 	if (!f.open(Common::Path(fname))) {
@@ -1189,18 +1123,16 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		return 0;
 	}
 
-	// 1df2:0558-056a: TINY.FNT; restored at 1df2:0625.
 	EEMFont tinyFont;
 	const bool haveTinyFont = tinyFont.load(Common::Path("TINY.FNT"));
 	if (!haveTinyFont)
 		warning("doShowEnding: TINY.FNT failed to load — falling back");
 
-	// 1df2:055f `_GetPalette(0)` — newspaper CLUT (body text = idx 0).
 	setSitePalette(0);
 	CursorMan.showMouse(true);
 
 	const bool floppyEnding = isFloppy();
-	uint pageOffsets[8];   // ENDING_RANGE_MAX from `_DisplayEnding`
+	uint pageOffsets[8];
 	const uint pageOffsetCap =
 		(uint)(sizeof(pageOffsets) / sizeof(pageOffsets[0]));
 	uint validPages = 0;
@@ -1264,7 +1196,7 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 		_picsArchive.getPicture(kFirstTryBadgePic, firstTryBadge);
 
 	uint pageIdx = firstPage ? 0 : (validPages - 1);
-	int direction = 0;     // -1 / 0 / +1, see header doc.
+	int direction = 0;
 	bool exitLoop = false;
 	bool dirty = true;
 	const Common::Point mousePos = g_system->getEventManager()->getMousePos();
@@ -1334,10 +1266,8 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 									  kFirstTryBadgePos, transp);
 			}
 
-			// `_ParseString` placeholders (0x80=name, 0x82=partner first).
 			const Common::String text = parseString(raw, _playerName, _partner);
 
-			// TINY.FNT + color 0 (asm 1df2:04cf — not 0xF as Ghidra shows).
 			const EEMFont &renderFont = haveTinyFont ? tinyFont : _font;
 			if (renderFont.isLoaded() && x2 > x1) {
 				const int textW = MIN<int>((int)x2 - (int)x1, kScreenWidth - (int)x1);
@@ -1430,13 +1360,9 @@ int EEMEngine::doShowEnding(uint num, bool firstPage) {
 	setInteractiveMouseCursor(false);
 	return direction;
 }
-
+// EEM1 `_ShowScrapbook(stage, 0) @ 1f78:0642`; EEM2/London scrapbook `2046::09dd`.
 void EEMEngine::doShowScrapbook(uint stage) {
-	// EEM1 `_ShowScrapbook(stage, 0) @ 1f78:0642`; EEM2/London scrapbook
-	// `FUN_2046_09dd`. Ending viewer returns -1/0/+1 for prev/close/next;
-	// current tier skips unsolved entries. Tier sizes are variant-specific
-	// (EEM1: 24/24/6 cases over three books; London: 25/25 over two books,
-	// no BOOK3.NME) — `mysteryTierRange` returns false for London stage 3.
+
 	uint tierLo = 0, tierHi = 0;
 	if (stage < 1 || !mysteryTierRange(stage, tierLo, tierHi))
 		return;
@@ -1448,11 +1374,6 @@ void EEMEngine::doShowScrapbook(uint stage) {
 		return;
 	const bool currentTier = (stage == _chainStage);
 
-	// `_ShowScrapbook @ 2046:0874` (EEM2) opens with the scrapbook/newspaper
-	// MIDI tune 0x5d (same one `_ShowOneScrap` plays after a correct accusation),
-	// gated on the voice flag + MIDI availability (DOS `DAT_3036_4c4e`/`146a` —
-	// the original gates this music on the *voice* toggle, not the music one).
-	// London only; EEM1's seg-1f78 scrapbook was not verified to play it.
 	if (isLondon() && _music && _voiceOn)
 		_music->playMus(0x5d, /* loop= */ false);
 
@@ -1493,40 +1414,16 @@ void EEMEngine::doShowScrapbook(uint stage) {
 			break;
 		}
 	}
-	// `_ShowScrapbook` / `_ShowOneScrap` stop the MIDI on the way out.
 	if (isLondon() && _music)
 		stopMusic();
 }
-
+// `_DoSetup @ 1f78:044e` (CD) / `_DoSetup_Floppy @ 1ee2:0387`.
 void EEMEngine::doSetup() {
-	// `_DoSetup @ 1f78:044e` (CD) / `_DoSetup_Floppy @ 1ee2:0387`.
-	// PIC 0x40 BG with labels baked in palette key 0xFE; `_SetupSettings
-	// @ 1f78:000d` runs `_SwapColors @ 172b:1d2a` per label rect
-	// (0x15 active, 0x00 inactive — nothing drawn as text). 13× 8-byte
-	// click rects at `_SetupButtons @ 29be:1218` (CD) / 2608:0d8c (floppy);
-	// `HandleSetupButton @ 1f78:0158` dispatches via 12-entry jumptable
-	// at 1f78:0436. Rect map (x1, y1, x2, y2 — handler):
-	//   [0]  ( 20, 44, 39, 61)   Partner toggle (1f78:017a)
-	//   [1]  ( 20, 87, 39,104)   Voice toggle   (1f78:0196 → DAT_2d5d_3f97)
-	//   [2]  ( 20,127, 39,144)   back to profile (NextScreen=8)
-	//   [3]  (281, 43,299, 60)   ScrapBook 1   (_ShowScrapbook(0,1))
-	//   [4]  (281, 62,299, 79)   ScrapBook 2   gated chainStage>=2
-	//   [5]  (281, 81,299, 98)   ScrapBook 3   gated chainStage>=3
-	//   [6]  (281,108,299,125)   Save game     (_SaveGame @ 2404:0c87)
-	//   [7]  (281,127,299,144)   New Case      (NextScreen=0xa)
-	//   [8]  ( 53,153,108,183)   Done          (SI=1, exit)
-	//   [9]  (145,163,174,187)   Help          (_InterfaceHelp(1))
-	//   [10] (212,153,266,184)   Quit          (_AreYouSure → NextScreen=0xffff)
-	//   [11] ( 81, 25,238, 37)   Credits       (PIC 0x208 fullscreen)
-	//   [12] ( 11,  1,  3,  3)   debug placeholder
-	// Highlight rects (Kid1/Kid2/SoundOn/SoundOff @ 29be:1320..1338) drive
-	// `_SwapColors` only — not click targets in the original.
 	if (!_font.isLoaded()) {
 		_nextScreen = (ScreenId)_lastScreen;
 		return;
 	}
 
-	// Original button rects (`_SetupButtons` indices wired here).
 	const Common::Rect kPartnerBtn   ( 20,  44,  39,  61); // [0]
 	const Common::Rect kVoiceBtn     ( 20,  87,  39, 104); // [1]
 	const Common::Rect kProfileBtn   ( 20, 127,  39, 144); // [2]
@@ -1539,7 +1436,7 @@ void EEMEngine::doSetup() {
 	const Common::Rect kHelpBtn      (145, 163, 174, 187); // [9]
 	const Common::Rect kQuitBtn      (212, 153, 266, 184); // [10]
 	const Common::Rect kCreditsBtn   ( 81,  25, 238,  37); // [11]
-	// Highlight / fallback-click rects (file-scope copies in `kSetup*Rect`).
+
 	const Common::Rect &kKid1Rect     = kSetupKid1Rect;
 	const Common::Rect &kKid2Rect     = kSetupKid2Rect;
 	const Common::Rect &kSoundOnRect  = kSetupSoundOnRect;
@@ -1547,7 +1444,7 @@ void EEMEngine::doSetup() {
 
 	setupDrawScreen();
 
-	_nextScreen = kScreenSetup;  // sentinel — setupLeave picks target
+	_nextScreen = kScreenSetup;
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
@@ -1569,8 +1466,6 @@ void EEMEngine::doSetup() {
 			const int mx = ev.mouse.x;
 			const int my = ev.mouse.y;
 
-			// Partner toggle [0]. Direct Jake/Jenny label clicks are a
-			// ScummVM-only fallback.
 			if (kPartnerBtn.contains(mx, my)) {
 				_partner = _partner == kPartnerJake ? kPartnerJenny : kPartnerJake;
 				dirty = true;
@@ -1591,7 +1486,6 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Voice toggle [1].
 			if (kVoiceBtn.contains(mx, my)) {
 				_voiceOn = !_voiceOn;
 				if (_audio)
@@ -1618,46 +1512,35 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// New Case [7] @ 1f78:01ad → NextScreen=0xa (CHOOSE_MYSTERY).
 			if (kNewCaseBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				_nextScreen = kScreenChooseMystery;
 				return;
 			}
 
-			// Save [6] — `_SaveGame @ 2404:0c87`.
 			if (kSaveBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				continue;
 			}
 
-			// Done [8] — MOV SI,1; JMP exit (NextScreen stays = LastScreen).
 			if (kDoneBtn.contains(mx, my)) {
 				setupLeave();
 				return;
 			}
 
-			// Quit [10] — `_AreYouSure(0)` → NextScreen=0xffff.
 			if (kQuitBtn.contains(mx, my)) {
 				if (areYouSure()) {
 					_nextScreen = kScreenInvalid;
 					return;
 				}
-				dirty = true;  // restore the BG after the prompt
+				dirty = true;
 				continue;
 			}
 
-			// Help [9] — `_InterfaceHelp(1) @ 1560:0205`. Help-pic table
-			// @ 29be:00c8: each `num` slot = 5 bytes (count + two u16
-			// PIC IDs). num=1 → count=2, pics = {0x0192, 0x01B1}. Each
-			// pic blitted via `_Rect_Move_Mask` (transparent = pic
-			// `miscflags >> 8`, so setup BG shows through). Also hides
-			// cursor (1560:0216-021c). ESC breaks (1560:02b3).
 			if (kHelpBtn.contains(mx, my)) {
 				static const uint16 kHelp1Pics[] = { 0x0192, 0x01B1 };
 				CursorMan.showMouse(false);
 				for (uint i = 0; i < ARRAYSIZE(kHelp1Pics); i++) {
-					// Restore BG between cards (1560:02e5 `_vga_fvidvid(0)`).
 					setupDrawScreen();
 					const Common::KeyCode k =
 						setupShowFullscreenPic(kHelp1Pics[i], /* transparent= */ true);
@@ -1669,7 +1552,6 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Credits [11] @ 1f78:025a — PIC 0x208 fullscreen.
 			if (kCreditsBtn.contains(mx, my)) {
 				CursorMan.showMouse(false);
 				setupShowFullscreenPic(0x208, /* transparent= */ false);
@@ -1680,14 +1562,12 @@ void EEMEngine::doSetup() {
 				continue;
 			}
 
-			// Profile [2] — NextScreen=8.
 			if (kProfileBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				_nextScreen = kScreenProfile;
 				return;
 			}
 
-			// ScrapBook [3]/[4]/[5] — `_ShowScrapbook(stage, 0)`.
 			if (kScrap1Btn.contains(mx, my)) {
 				doShowScrapbook(1);
 				setSitePalette(0);
@@ -1700,7 +1580,7 @@ void EEMEngine::doSetup() {
 				dirty = true;
 				continue;
 			}
-			// London has no third book (`mysteryTierRange` rejects stage 3).
+
 			if (kScrap3Btn.contains(mx, my) && !isLondon() &&
 				_chainStage >= 3) {
 				doShowScrapbook(3);
@@ -1741,9 +1621,6 @@ void EEMEngine::setupDrawScreen() {
 	g_system->updateScreen();
 }
 
-// Render picId and block until input. transparent=true: overlay via
-// `_Rect_Move_Mask` (`_InterfaceHelp @ 1560:0205`). false: raw fullscreen
-// blit via `_vga_fbuffvid` (credits @ 1f78:0281).
 Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent) {
 	Picture pic;
 	if (!_picsArchive.getPicture(picId, pic)) {
@@ -1759,7 +1636,6 @@ Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent
 			g_system->unlockScreen();
 		}
 		const byte transp = (byte)(pic.flags >> 8);
-		// Explicit destPos — no-destPos overload stretches to dst.
 		scratch.transBlitFrom(pic.surface, Common::Point(0, 0),
 							  (uint32)transp);
 	} else {
@@ -1786,8 +1662,6 @@ Common::KeyCode EEMEngine::setupShowFullscreenPic(uint16 picId, bool transparent
 }
 
 void EEMEngine::setupLeave() {
-	// `_DoSetup` entry: _NextScreen = _LastScreen. Fall back to it
-	// unless a handler already overrode _nextScreen.
 	if (_nextScreen == kScreenSetup) {
 		_nextScreen = (ScreenId)_lastScreen;
 		if (_nextScreen == kScreenSetup ||
@@ -1796,10 +1670,8 @@ void EEMEngine::setupLeave() {
 	}
 	saveProfile(_playerName);
 }
-
+// `_SetupSettings @ 2046:0008`
 void EEMEngine::setupDrawScreenLondon() {
-	// `_SetupSettings @ 2046:0008`: BG PIC 0x40 + _SwapColors highlights for the
-	// 4 toggles (key 0xFE, 0x15 active / 0x00 dim), drawn ON-then-OFF.
 	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
@@ -1808,16 +1680,16 @@ void EEMEngine::setupDrawScreenLondon() {
 		scratch.simpleBlitFrom(bg.surface);
 
 	const byte kKey = 0xFE, kBright = 0x15, kDim = 0x00;
-	// Partner (DAT_3036_4bd4).
+
 	swapColors(scratch, kLonSetJake,  kKey, _partner == kPartnerJake  ? kBright : kDim);
 	swapColors(scratch, kLonSetJenny, kKey, _partner == kPartnerJenny ? kBright : kDim);
-	// Voice (DAT_3036_4c4e).
+
 	swapColors(scratch, kLonSetVoiceOn,  kKey, _voiceOn ? kBright : kDim);
 	swapColors(scratch, kLonSetVoiceOff, kKey, _voiceOn ? kDim : kBright);
-	// Music (DAT_3036_4cc0).
+
 	swapColors(scratch, kLonSetMusicOn,  kKey, _musicOn ? kBright : kDim);
 	swapColors(scratch, kLonSetMusicOff, kKey, _musicOn ? kDim : kBright);
-	// Highlight boxes (DAT_3036_4c4a; hide_highlight_boxes is the inverse).
+
 	const bool hiOn = !ConfMan.getBool("hide_highlight_boxes");
 	swapColors(scratch, kLonSetHiOn,  kKey, hiOn ? kBright : kDim);
 	swapColors(scratch, kLonSetHiOff, kKey, hiOn ? kDim : kBright);
@@ -1827,16 +1699,8 @@ void EEMEngine::setupDrawScreenLondon() {
 	g_system->updateScreen();
 }
 
+// `_DoSetup @ 2046:067b`
 void EEMEngine::doSetupLondon() {
-	// `_DoSetup @ 2046:067b`. BG PIC 0x40; 13 click rects (`_SetupButtons @
-	// 2bca:12ce`); `HandleSetupButton @ 2046:01d9` dispatch (verified from the
-	// jumptable @ 2046:0661). EEM2 has FOUR toggles vs EEM1's two and a
-	// rearranged left column:
-	//   [0]( 20, 44) Partner    [1]( 20, 63) Voice    [2]( 20,101) Highlight boxes
-	//   [3]( 20,127) Profile(8) [12](20, 82) Music    [4](281, 43) ScrapBook +
-	//   [5](281, 62) ScrapBook- [6](281,108) Save     [7](281,127) New case(0xa)
-	//   [8]( 53,153) Done       [9](145,163) Quit      [10](212,153) Help
-	//   [11]( 81, 25) Credits (PIC 0x208).  _SavePlayerRecord on exit (setupLeave).
 	if (!_font.isLoaded()) {
 		_nextScreen = (ScreenId)_lastScreen;
 		return;
@@ -1856,7 +1720,7 @@ void EEMEngine::doSetupLondon() {
 	const Common::Rect kMusicBtn   ( 20,  82,  38,  99); // [12]
 
 	setupDrawScreenLondon();
-	_nextScreen = kScreenSetup;  // sentinel — setupLeave picks the target
+	_nextScreen = kScreenSetup;
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool dirty = false;
@@ -1876,7 +1740,6 @@ void EEMEngine::doSetupLondon() {
 				continue;
 			const int mx = ev.mouse.x, my = ev.mouse.y;
 
-			// --- toggles (stay on screen, re-render) ---
 			if (kPartnerBtn.contains(mx, my)) {
 				_partner = (_partner == kPartnerJake) ? kPartnerJenny
 													  : kPartnerJake;
@@ -1904,7 +1767,6 @@ void EEMEngine::doSetupLondon() {
 				continue;
 			}
 
-			// --- navigation (leave the screen) ---
 			if (kProfileBtn.contains(mx, my)) {
 				saveProfile(_playerName);
 				_nextScreen = kScreenProfile;
@@ -1924,19 +1786,16 @@ void EEMEngine::doSetupLondon() {
 					_nextScreen = kScreenInvalid;
 					return;
 				}
-				dirty = true;  // restore BG under the prompt
+				dirty = true;
 				continue;
 			}
 
-			// --- actions that stay on screen ---
 			if (kSaveBtn.contains(mx, my)) {
-				// `_SaveGame` is gated on a game being active ([0x1966]).
 				if (_mystery.isLoaded())
 					saveProfile(_playerName);
 				continue;
 			}
 			if (kHelpBtn.contains(mx, my)) {
-				// `_InterfaceHelp(0)`. Reuse the EEM1 help-card mechanism.
 				static const uint16 kHelpPics[] = { 0x0192, 0x01B1 };
 				CursorMan.showMouse(false);
 				for (uint i = 0; i < ARRAYSIZE(kHelpPics); i++) {
@@ -1957,8 +1816,7 @@ void EEMEngine::doSetupLondon() {
 				dirty = true;
 				continue;
 			}
-			// ScrapBook +/- — London has 2 books (stage 1 / 2). The DOS pages
-			// next/prev (FUN_2046_0874); map to the two available books.
+
 			if (kScrapNext.contains(mx, my)) {
 				doShowScrapbook(1);
 				setSitePalette(0);
@@ -1978,11 +1836,8 @@ void EEMEngine::doSetupLondon() {
 		g_system->delayMillis(15);
 	}
 }
-
+// `_ActionScreen @ 1c33:195b`
 void EEMEngine::doActionScreen() {
-	// `_ActionScreen @ 1c33:195b` — BG PIC 0x104 + PIC 9 @ (10, 0x87),
-	// `_DoChoose` with ActionNames @ 29be:0d6a. 5 picks alternating with
-	// separators.
 	enum MenuPick {
 		kPickChoose = 0,
 		kPickPractice,
@@ -2008,14 +1863,8 @@ void EEMEngine::doActionScreen() {
 	};
 	const char * const *kPickLabel = isSpanish() ? kPickLabelES : kPickLabelEN;
 	// London has no BOOK3.NME, so its action menu offers four entries
-	// (Choose / Practice / ScrapBook 1 / ScrapBook 2). EEM1 adds ScrapBook 3.
 	const uint numPicks = isLondon() ? (uint)kPickScrap3 : (uint)kNumPicks;
 
-	// Gating @ EEM1 1c33:19d1-1a70 by chain stage + per-tier solves:
-	//   stage 1: grey SB2/3; SB1 needs any tier-1 solve
-	//   stage 2: grey Practice + SB3; SB2 needs tier-2 solve
-	//   stage 3: grey Practice; SB3 needs tier-3 solve (EEM1 only)
-	//   stage 4: grey Choose + Practice
 	uint loT = 0, hiT = 0;
 	const bool anySolved1 = mysteryTierRange(1, loT, hiT) &&
 							anyMysterySolved(loT, hiT);
@@ -2035,7 +1884,7 @@ void EEMEngine::doActionScreen() {
 	const bool kPickEnabled[kNumPicks] = {
 		chooseOn, practiceOn, scrap1On, scrap2On, scrap3On
 	};
-	// Seed selection on first enabled entry.
+
 	uint pick = 0;
 	for (uint i = 0; i < numPicks; i++) {
 		if (kPickEnabled[i]) {
@@ -2046,7 +1895,6 @@ void EEMEngine::doActionScreen() {
 
 	const char *kSeparator = "----------------------------------";
 
-	// `_DoChoose @ 1c33:0514` click rects (4×u16 {x1,y1,x2,y2} in seg 29be):
 	const Common::Rect kOkRect      ( 12,  63,  41,  87); // 29be:0cd8 confirm
 	const Common::Rect kHelpRect    ( 12, 100,  41, 124); // 29be:0ce0 help
 	const Common::Rect kExitRect    ( 12, 137,  41, 161); // 29be:0ce8 cancel
@@ -2054,7 +1902,6 @@ void EEMEngine::doActionScreen() {
 
 	CursorMan.showMouse(true);
 
-	// Launcher-resume path can enter from `_AllBlack` palette.
 	setSitePalette(0);
 
 	Picture bg;
@@ -2087,7 +1934,6 @@ void EEMEngine::doActionScreen() {
 				return;
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
 				if (kOkRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Greyed entries ignored (`_DoChoose @ 1c33:0635`).
 					if (kPickEnabled[pick])
 						confirmed = true;
 					break;
@@ -2098,7 +1944,6 @@ void EEMEngine::doActionScreen() {
 					break;
 				}
 				if (kHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `_ActionScreen` sets Chelp=0.
 					continue;
 				}
 				if (kListRect.contains(ev.mouse.x, ev.mouse.y)) {
@@ -2129,7 +1974,6 @@ void EEMEngine::doActionScreen() {
 				break;
 			}
 			if (k == Common::KEYCODE_UP || k == Common::KEYCODE_LEFT) {
-				// `_DoChoose` arrow handlers @ 1c33:0514, bounded loop.
 				for (int i = 0; i < (int)numPicks; i++) {
 					pick = (pick == 0) ? (numPicks - 1) : pick - 1;
 					if (kPickEnabled[pick])
@@ -2179,7 +2023,6 @@ void EEMEngine::doActionScreen() {
 		return;
 	}
 
-	// "Practice Mystery" is the tutorial → mystery 0.
 	if (pick == kPickPractice) {
 		if (!_mystery.load(0, &_rng)) {
 			warning("doActionScreen: failed to load practice mystery");
@@ -2194,8 +2037,6 @@ void EEMEngine::doActionScreen() {
 	}
 
 	if (pick == kPickScrap1 || pick == kPickScrap2 || pick == kPickScrap3) {
-		// `_ActionScreen` handlers @ 1c33:1B13 / 1B26 / 1B40 →
-		// `_ShowScrapbook(0, stage)`. Returns here afterwards.
 		const uint stage = (pick == kPickScrap1) ? 1
 						 : (pick == kPickScrap2) ? 2 : 3;
 		doShowScrapbook(stage);
@@ -2207,11 +2048,9 @@ void EEMEngine::doActionScreen() {
 
 	_nextScreen = kScreenChooseMystery;
 }
-
+// EEM1: `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @ 1c33:0a87`
+// EEM2:  1abf:022a + 1cd3:0a9d)
 void EEMEngine::doCaseSelection() {
-	// `_DoChooseMystery @ 1a35:02b7` + `_CaseSelection @ 1c33:0a87`
-	// (EEM2 @ 1abf:022a / 1cd3:0a9d): load BOOK<stage>.NME, draw PIC 0x41
-	// + centered "Book N" title, then read the chosen M*.BIN.
 	const uint kMaxMystery = isLondon() ? 50 : 54;
 
 	CursorMan.showMouse(true);
@@ -2225,7 +2064,6 @@ void EEMEngine::doCaseSelection() {
 	const bool haveRevealPic =
 		_picsArchive.getPicture(kCaseSelectionRevealPic, revealPic);
 
-	// `_CaseSelection @ 1c33:0a87` greeter ANI 0x15 (Jake) / 0x16 (Jenny).
 	const uint kKdAniId = (_partner == kPartnerJake) ? 0x15 : 0x16;
 	Animation kdAnim;
 	const bool haveKdAnim = _aniArchive.loadAnimation(kKdAniId, kdAnim)
@@ -2233,12 +2071,6 @@ void EEMEngine::doCaseSelection() {
 	const int kKdAnimX = 0x112;
 	const int kKdAnimY = 0x50;
 
-	// Tier → BOOK<n>.NME. EEM1 ships three books (Junior 1..24, Senior
-	// 25..48, Master 49..54); EEM2/London ships two (1..25 / 26..50). London
-	// has no BOOK3.NME, and its stage 3 is transient (`_DisplayCorrect @
-	// 1ea1:0619` collapses it to 4), so should it ever be reached here it
-	// reuses BOOK2.NME rather than opening a missing file. `mysteryTierRange`
-	// is the single source of truth for the per-tier mystery ranges.
 	uint book;
 	switch (_chainStage) {
 	case 2:  book = 2; break;
@@ -2257,7 +2089,6 @@ void EEMEngine::doCaseSelection() {
 	}
 	const uint listLen = MIN<uint>((uint)names.size(), stageHi - stageLo + 1);
 
-	// Solved-row gating @ `_DoChoose @ 1c33:0521`.
 	Common::Array<bool> solvedFlags;
 	solvedFlags.resize(listLen);
 	for (uint i = 0; i < listLen; i++) {
@@ -2321,8 +2152,6 @@ void EEMEngine::doCaseSelection() {
 					return;
 				}
 				if (kChooserHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// Original returns 0xfffe → `_ChooseSavedGame`. ScummVM
-					// stores in-progress cases per profile.
 					saveProfile(_playerName);
 					_mystery.clear();
 					_nextScreen = kScreenProfile;
@@ -2452,39 +2281,38 @@ void EEMEngine::doCaseSelection() {
 	debugC(1, kDebugMystery, "Mystery %u loaded; %u sites, %u suspects",
 		   mn, _mystery.numSites(), _mystery.numSuspects());
 }
+// `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
+// `_HandleNoteButton @ 161e:03cb`. _NotebookRect = (78, 12, 288, 152).
+// _NoteButtons @ 29be:0147 — 11 rects × 8 bytes. Jumptable @ 161e:04ec
+// dispatches handler[i-1] (rect 0's i-1 underflows = decorative slot):
+//   [0] (134,174,155,190)  decorative — no handler
+//   [1] ( 93,174,115,190)  HELP → `_InterfaceHelp(0)`           (0x3f9)
+//   [2] (157,174,178,190)  GALLERY → `_NextScreen = 5`          (0x477)
+//   [3] (  5, 80, 44,110)  host hint → `_KDHelp`                (0x403)
+//   [4] (180,174,201,190)  SOLVE → `_SolvedCheck` → NextScreen=7 (0x436)
+//   [5] (204,174,224,190)  PAGE NEXT → `_EraseNotes` + redraw   (0x489)
+//   [6] (226,174,247,190)  PAGE PREV → CurrentPage-- + redraw   (0x4ab)
+//   [7] (  7,177, 57,200)  MAP → `_NextScreen = 2`              (0x480)
+//   [8] ( 35,111, 56,136)  SITE → `_NextScreen = 3`             (0x3ed)
+//   [9] (  0,  0,  0,  0)  same exit as [8]
+//   [10] (267,174,288,190) → `_InterfaceHelp(0)` again          (0x3f9)
+//
+// EEM2/London (`_DoNotebook @ 16a0:0517`, `_HandleNoteButton @ 16a0:03dd`,
+// jumptable @ 16a0:0503) reuses the SAME button rect table (2bca:0151) and
+// handler set, but reassigns two slots and revives slot 9 (verified by
+// disassembly — `[0x9292]` is the next-screen code):
+//   [1] ( 93,174,115,190) MAP  → screen 2  (EEM1: a 2nd InterfaceHelp).
+//                         London's dedicated map button.
+//   [7] (  7,177, 57,200) DOS EEM2 → SITE, but we keep it as MAP (→ 2) in
+//                         both variants — the EEM1 partner-foot map shortcut
+//                         (a player convenience alongside button [1]).
+//   [9] (  0,  0, 66, 79) SITE → screen 3  (EEM1: dead 0-rect)
+// Everything else (gallery, accuse, host hint, page next/prev, help [10],
+// site [8]) is identical. The note rendering, pagination, partner ANI
+// (same 1/0xb), and gizmo colour-cycle are shared as-is; only button [1]
+// (London → map) and the extra close area [9] are gated on `isLondon()`.
 
 void EEMEngine::doNotebook() {
-	// `_DoNotebook @ 161e:0500` + `_DrawNotes @ 161e:01d0` +
-	// `_HandleNoteButton @ 161e:03cb`. _NotebookRect = (78, 12, 288, 152).
-	// _NoteButtons @ 29be:0147 — 11 rects × 8 bytes. Jumptable @ 161e:04ec
-	// dispatches handler[i-1] (rect 0's i-1 underflows = decorative slot):
-	//   [0] (134,174,155,190)  decorative — no handler
-	//   [1] ( 93,174,115,190)  HELP → `_InterfaceHelp(0)`           (0x3f9)
-	//   [2] (157,174,178,190)  GALLERY → `_NextScreen = 5`          (0x477)
-	//   [3] (  5, 80, 44,110)  host hint → `_KDHelp`                (0x403)
-	//   [4] (180,174,201,190)  SOLVE → `_SolvedCheck` → NextScreen=7 (0x436)
-	//   [5] (204,174,224,190)  PAGE NEXT → `_EraseNotes` + redraw   (0x489)
-	//   [6] (226,174,247,190)  PAGE PREV → CurrentPage-- + redraw   (0x4ab)
-	//   [7] (  7,177, 57,200)  MAP → `_NextScreen = 2`              (0x480)
-	//   [8] ( 35,111, 56,136)  SITE → `_NextScreen = 3`             (0x3ed)
-	//   [9] (  0,  0,  0,  0)  same exit as [8]
-	//   [10] (267,174,288,190) → `_InterfaceHelp(0)` again          (0x3f9)
-	// BG PIC 0x3f; partner ANI 1 (Jake) / 0xb (Jenny) at (5, 80).
-	//
-	// EEM2/London (`_DoNotebook @ 16a0:0517`, `_HandleNoteButton @ 16a0:03dd`,
-	// jumptable @ 16a0:0503) reuses the SAME button rect table (2bca:0151) and
-	// handler set, but reassigns two slots and revives slot 9 (verified by
-	// disassembly — `[0x9292]` is the next-screen code):
-	//   [1] ( 93,174,115,190) MAP  → screen 2  (EEM1: a 2nd InterfaceHelp).
-	//                         London's dedicated map button.
-	//   [7] (  7,177, 57,200) DOS EEM2 → SITE, but we keep it as MAP (→ 2) in
-	//                         both variants — the EEM1 partner-foot map shortcut
-	//                         (a player convenience alongside button [1]).
-	//   [9] (  0,  0, 66, 79) SITE → screen 3  (EEM1: dead 0-rect)
-	// Everything else (gallery, accuse, host hint, page next/prev, help [10],
-	// site [8]) is identical. The note rendering, pagination, partner ANI
-	// (same 1/0xb), and gizmo colour-cycle are shared as-is; only button [1]
-	// (London → map) and the extra close area [9] are gated on `isLondon()`.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
@@ -2534,7 +2362,6 @@ void EEMEngine::doNotebook() {
 				}
 			}
 			if (ev.type == Common::EVENT_LBUTTONDOWN) {
-				// Earlier rects win on overlap (matches `_FindButton`).
 				if (kPdaSiteRect.contains(ev.mouse.x, ev.mouse.y) ||
 					(isLondon() &&
 					 kPdaLondonCloseRect.contains(ev.mouse.x, ev.mouse.y))) {
@@ -2543,19 +2370,13 @@ void EEMEngine::doNotebook() {
 					break;  // back to site
 				}
 				if (kPdaPartnerFootMapRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// EEM1 `_NoteButtons[7]` → map (screen 2). DOS EEM2 reassigns
-					// this slot to SITE, but we intentionally keep the partner-
-					// foot hotspot as a map shortcut in BOTH variants for parity
-					// with EEM1. London also has its own dedicated map button
-					// (`kPdaHelpRect` below) and can still close the PDA via the
-					// site button (35,111) or the top-left corner (0,0,66,79).
 					_nextScreen = kScreenMapAlt;
 					exitFlag = true;
 					break;
 				}
 				if (kPdaPartnerHeadHintRect.contains(ev.mouse.x, ev.mouse.y)) {
 					setInteractiveMouseCursor(false);
-					doHelp();              // _KDHelp = host hint
+					doHelp();
 					dirty = true;
 					continue;
 				}
@@ -2570,9 +2391,6 @@ void EEMEngine::doNotebook() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// `_NoteButtons[1]`. EEM1: a 2nd `_InterfaceHelp(0)` button
-					// (PICs 0x63 / 0x1ae). London reassigns this slot to MAP
-					// (`_HandleNoteButton` slot 1 → screen 2).
 					if (isLondon()) {
 						_nextScreen = kScreenMapAlt;
 						exitFlag = true;
@@ -2595,31 +2413,23 @@ void EEMEngine::doNotebook() {
 					continue;
 				}
 				if (kPdaHelp2Rect.contains(ev.mouse.x, ev.mouse.y)) {
-					// _NoteButtons[10] → handler 0x03f9 = same as [1].
 					setInteractiveMouseCursor(false);
 					doInterfaceHelp(0);
 					dirty = true;
 					continue;
 				}
-				// The notebook screen is read-only in the original:
-				// `_DoNotebook @ 161e:0500` only checks clicks against
-				// `_NoteButtons` (11 rects). Clue toggling lives in the
-				// accuse screen.
 			}
 		}
 		if (exitFlag)
 			break;
 
 		const uint32 now = g_system->getMillis();
-		// Re-render every 100 ms for partner sprite cycle.
 		if (dirty || now - lastDraw >= 100) {
 			drawNotebookFrame(page);
 			lastDraw = now;
 			mouse = g_system->getEventManager()->getMousePos();
 			setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y));
 		}
-		// _GizmoColorCycle @ 1c33:0002 — `_DoNotebook` rotates 0x6f..0x73 each
-		// _CheckFrameRate tick (the PDA gizmo / LED indicator shimmer).
 		if (now - gizmoLastTick >= kChooserCycleMillis) {
 			gizmoLastTick = now;
 			cycleChooserPalette();
@@ -2647,9 +2457,6 @@ Common::String EEMEngine::notebookNoteText(uint clueId, const byte *ni,
 		return parseString(Common::String(p, len),
 						   _playerName, _partner);
 	}
-	// EEM1 CD: 4-byte entries (textOff at +0, points at +2). EEM2/London CD:
-	// 2-byte entries (textOff only) — `_DrawNotes @ 16a0:01de` reads
-	// `noteIndex[clueId*2]`.
 	const uint stride = isLondon() ? 2 : 4;
 	const uint16 textOff = READ_LE_UINT16(ni + clueId * stride);
 	return parseString(_mystery.textAt(textOff),
@@ -2657,8 +2464,6 @@ Common::String EEMEngine::notebookNoteText(uint clueId, const byte *ni,
 }
 
 void EEMEngine::drawNotebookFrame(int &page) {
-	// `_DrawNotes @ 161e:01d0` per-page layout + partner sprite at (5,80)
-	// from `_DoNotebook @ 161e:0500`.
 	const Common::Rect kNotebookRect(78, 12, 288, 152);
 
 	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
@@ -2669,7 +2474,6 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	if (_picsArchive.getPicture(0x3f, frame))
 		scratch.simpleBlitFrom(frame.surface);
 
-	// Partner ANI 1/0xb (cells); script 0x01 (`_NewAnimation @ 161e:054c`).
 	blitPdaPartner(scratch, _aniArchive, _partner, kPdaNotebookPartner,
 				   g_system->getMillis());
 
@@ -2678,8 +2482,7 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	// EEM2/London NoteIndex entries are 2 bytes (no points field), so its real
 	// clue count is the section size / 2. `noteIndexCount()` assumes the EEM1
 	// 4-byte stride (and stays that way for the accuse-scoring path, which is a
-	// separate London concern), so derive the London-correct count here — else
-	// high-id notes get dropped.
+	// separate London concern)
 	const uint16 londonCount = isLondon()
 		? (uint16)(_mystery.noteSectionSize() / 2) : 0;
 	Common::Array<uint> found;
@@ -2699,14 +2502,10 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	const int kRectW = kNotebookRect.width();
 	const int kRectH = kNotebookRect.height();
 
-	// Walk forward to start clue of current page; fits as many as kRectH.
 	int clueCursor = 0;
 	Common::Array<int> pageStarts;
 	pageStarts.push_back(0);
-	// Floppy NoteIndex entry (7 bytes): +0 clueTextOff (absolute, what
-	// the notebook displays), +2 Jake spoken, +4 Jenny spoken, +6 score.
-	// `_DrawNotes_Floppy / FUN_15e0_01e8` reads only byte +0.
-	// CD entries are 4 bytes, offsets relative to TextBlock @ header[+0xc].
+
 	const bool floppyNb = isFloppy();
 	const byte *bufBase = _mystery.blobAt(0);
 	const uint32 mysSz  = _mystery.dataSize();
@@ -2735,10 +2534,6 @@ void EEMEngine::drawNotebookFrame(int &page) {
 			page = 0;
 	}
 
-	// `_DrawNotes @ 161e:01d0` is read-only on this screen — clue selection
-	// is driven exclusively from the accuse screen. The 0x3C / 0x5C colour
-	// choice still reflects `_NoteSelected[]` so picks made in accuse remain
-	// visible when the player flips back here.
 	const int startClue = (page < (int)pageStarts.size())
 							? pageStarts[page] : 0;
 	const int endClue   = (page + 1 < (int)pageStarts.size())
@@ -2764,8 +2559,6 @@ void EEMEngine::drawNotebookFrame(int &page) {
 		y += h + 7;
 	}
 
-	// `_DrawNotes @ 161e:01d0` appends a terminator line at the bottom of
-	// the last page once the clue list is exhausted — string @ 29be:01f4.
 	const bool isLastPage = (page + 1 >= (int)pageStarts.size());
 	if (isLastPage) {
 		const char *kEndMarker = isSpanish()
@@ -2780,11 +2573,6 @@ void EEMEngine::drawNotebookFrame(int &page) {
 }
 
 void EEMEngine::doGallery() {
-	// `_DoGallery @ 158f:065b` + `_DrawGallery @ 158f:0046`. BG PIC 0x3f.
-	// Partner ANI 2 (Jake) / 0x10 (Jenny) at (5, 0x50). 5 slot positions
-	// @ 29be:0x116. Per suspect i: picId = *(u16*)(_GalleryData + i*0x46);
-	// visible = _InGallery[_NewOrder[i]]; drawY = pos.y + (0x48 - pic.h)
-	// (bottom-aligned to baseline). Click → `_SearchSuspects` → moreInfo.
 	if (!_mystery.isLoaded())
 		return;
 
@@ -2796,16 +2584,13 @@ void EEMEngine::doGallery() {
 
 	CursorMan.showMouse(true);
 
-	// Pre-load PIC 0x3f for the MoreInfo backdrop blit further down.
-	// (`drawGalleryFrame` reloads it on its own per-call too.)
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
 	const uint8 num = _mystery.numSuspects();
 
-	// Cache slot rects for click hit-testing.
 	Common::Array<Common::Rect> slotRects;
-	Common::Array<int> slotSuspect; // logical suspect index in [0, num)
+	Common::Array<int> slotSuspect;
 	slotRects.resize(num);
 	slotSuspect.resize(num);
 	for (uint i = 0; i < num; i++) {
@@ -2877,10 +2662,6 @@ void EEMEngine::doGallery() {
 					break;
 				}
 				if (kPdaHelpRect.contains(ev.mouse.x, ev.mouse.y)) {
-					// EEM1 slot 1 → a 2nd `_InterfaceHelp(0)` (158f:0625).
-					// EEM2 gallery `_HandleGalleryButton @ 160e:05c7` slot 1 →
-					// MAP (`MOV [0x9292],2` @ 160e:05fe) — London's dedicated
-					// map button, same as the notebook/accuse PDA bar.
 					if (isLondon()) {
 						_nextScreen = kScreenMapAlt;
 						exitFlag = true;
@@ -2897,7 +2678,6 @@ void EEMEngine::doGallery() {
 					lastDraw = 0;
 					continue;
 				}
-				// `_SearchSuspects` — per-slot rect → suspect index.
 				bool clicked = false;
 				for (uint i = 0; i < slotRects.size(); i++) {
 					if (slotSuspect[i] < 0)
@@ -2934,7 +2714,6 @@ void EEMEngine::doGallery() {
 									  gallerySlotAt(slotRects, slotSuspect,
 													mouse.x, mouse.y));
 		}
-		// _GizmoColorCycle @ 1c33:0002 — `_DoGallery` rotates 0x6f..0x73 each tick.
 		if (now - gizmoLastTick >= kChooserCycleMillis) {
 			gizmoLastTick = now;
 			cycleChooserPalette();
@@ -2944,16 +2723,9 @@ void EEMEngine::doGallery() {
 	}
 	setInteractiveMouseCursor(false);
 }
-
+// `MoreInfo @ 158f:0419`
 bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 						  const Picture &galBg, bool haveBg) {
-	// `MoreInfo @ 158f:0419` — suspect detail. PIC = entry+0,
-	// _AddPicBackground at (0x94, 0xf), notes via _DrawGalleryNotes.
-	// CD layout (0x46-byte stride): +0 picId, +8 clueCount(u16), +0xa
-	// clue IDs(u16, 0xFFFF terminator, max 30).
-	// Floppy (`_MoreInfo_Floppy = 154e:042b` → `FUN_15e0_01e8`):
-	// variable stride. +0 picId, +2 alibi (0xFFFF = guilty), +4 count(u8),
-	// +5 clue IDs(u8).
 	const bool floppyMI = isFloppy();
 	const byte *suspect = floppyMI
 							  ? _mystery.floppySuspectEntry(suspectIdx)
@@ -2967,8 +2739,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 
 	setInteractiveMouseCursor(false);
 
-	// _GalleryNoteRect = (78, 93, 288, 152) @ 29be:0100.
-	// `_DrawGalleryNotes @ 158f:01f4`.
 	const int rx = 78, ry = 93;
 	const int rw = 288 - 78, rh = 152 - 93;
 	const int lineH = _font.getFontHeight();
@@ -2976,7 +2746,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 	const byte *ni = _mystery.noteIndex();
 	const uint16 niCount = _mystery.noteIndexCount();
 
-	// Pagination via `_NextClue` + `_PageBreaks[]` (case 6 @ 158f:03b8).
 	uint pageStart = 0;
 	Common::Array<uint> pageStack;
 	bool back = false;
@@ -2989,7 +2758,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 		ms.clear();
 		if (haveBg)
 			ms.simpleBlitFrom(galBg.surface);
-		// Partner sprite at (5, 0x50). Re-blitted per page.
+
 		blitPdaPartner(ms, _aniArchive, _partner, kPdaGalleryPartner,
 					   g_system->getMillis());
 		Picture detail;
@@ -3097,10 +2866,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 			}
 		}
 
-		// `_HandleMoreButton @ 158f:027d`:
-		//   [0] NOTEBOOK→4, [1] HELP→_InterfaceHelp, [2] GALLERY (close),
-		//   [3] _KDHelp, [4] ACCUSE→7, [5] PAGE_NEXT, [6] PAGE_PREV,
-		//   [7] MAP→2, [8] SITE noop, [10] HELP alt.
 		bool advance = false;
 		bool prev = false;
 		bool redraw = false;
@@ -3144,8 +2909,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 						break;
 					}
 					if (isLondon() && kPdaHelpRect.contains(mx, my)) {
-						// EEM2 gallery slot 1 → MAP (dedicated map button);
-						// only kPdaHelp2Rect (267,174) stays help in London.
 						_nextScreen = kScreenMapAlt;
 						exitGallery = true;
 						back = true;
@@ -3200,8 +2963,7 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 					break;
 				}
 			}
-			// _GizmoColorCycle @ 1c33:0002 — `MoreInfo` (158f:0480) rotates
-			// 0x6f..0x73 each tick.
+
 			const uint32 now = g_system->getMillis();
 			if (now - gizmoLastTick >= kChooserCycleMillis) {
 				gizmoLastTick = now;
@@ -3226,10 +2988,6 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 								  Common::Array<Common::Rect> &slotRects,
 								  Common::Array<int> &slotSuspect) {
-	// Gallery redraw — formerly the `drawFrame` lambda inside `doGallery`.
-	// Mirrors `_DrawGallery @ 158f:0046`: PIC 0x3f frame + partner sprite
-	// at (5, 0x50) + suspect portraits in their `_NewOrder` slots. Slot
-	// positions live in `kGallerySlots` in this file's anon namespace.
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
@@ -3240,21 +2998,9 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	if (haveBg)
 		scratch.simpleBlitFrom(galBg.surface);
 
-	// Partner sprite frame @ (5, 0x50). `_DoGallery @ 158f:065b` registers
-	// _NewAnimation(..., CONCAT22(2, ...), ...) — script key 0x02 regardless
-	// of partner. Jake's 26-frame 0x02 script (brief wave + long hold +
-	// second wave) drives BOTH partners' cells (Jenny's own 0x10 anim is
-	// only 9 frames so it wouldn't cover the full cadence).
 	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
 				   g_system->getMillis());
 
-	// Portraits — `_DrawGallery @ 158f:0046` (CD) /
-	// `_DrawGallery_Floppy @ 154e:0045` (floppy) walks suspects 0..N-1
-	// and only renders those flagged in `_InGallery[NewOrder[i]]`.
-	// Layout differs by variant:
-	//   * CD: fixed 0x46-byte stride, slot positions at `kGallerySlots`.
-	//   * Floppy: variable-stride entries (5 + entry[4] bytes per
-	//     suspect), slot positions at `kFloppyGallerySlots` (`2608:0x16c`).
 	const bool floppy = isFloppy();
 	const GallerySlot * const slots =
 		floppy ? kFloppyGallerySlots : kGallerySlots;
@@ -3317,16 +3063,16 @@ void EEMEngine::drawGalleryFrame(const byte *gd, uint8 numSuspects,
 	g_system->updateScreen();
 }
 
+// `_DoBigMap @ 20fe:09e7` two stage:
+//   Stage 1 (Overview): PIC 0x42 + site icons at MapData[+4/+6]
+//     (`_DrawBigMapButtons @ 20fe:0877`). Click in BigMapWindow
+//     returns scroll (mouseX*2 - 0x74, mouseY*2 - 0x55).
+//   Stage 2 (Detail): PIC 0x43 frame + 0xe9×0xab BIGMAP.PIC viewport
+//     at (2,2). Icons stamped at MapData[+8/+0xa] (`_StampButtons @
+//     20fe:0d2f`). Click icon = travel.
+// MapData entry (14 bytes): +0..3 ???, +4 BigMapX, +6 BigMapY,
+//   +8 SmallMapX, +0xa SmallMapY, +0xc crime-flag.
 void EEMEngine::doBigMap() {
-	// `_DoBigMap @ 20fe:09e7` two stage:
-	//   Stage 1 (Overview): PIC 0x42 + site icons at MapData[+4/+6]
-	//     (`_DrawBigMapButtons @ 20fe:0877`). Click in BigMapWindow
-	//     returns scroll (mouseX*2 - 0x74, mouseY*2 - 0x55).
-	//   Stage 2 (Detail): PIC 0x43 frame + 0xe9×0xab BIGMAP.PIC viewport
-	//     at (2,2). Icons stamped at MapData[+8/+0xa] (`_StampButtons @
-	//     20fe:0d2f`). Click icon = travel.
-	// MapData entry (14 bytes): +0..3 ???, +4 BigMapX, +6 BigMapY,
-	//   +8 SmallMapX, +0xa SmallMapY, +0xc crime-flag.
 
 	if (!_mystery.isLoaded())
 		return;
@@ -3341,18 +3087,12 @@ void EEMEngine::doBigMap() {
 
 	while (!shouldQuit()) {
 		setInteractiveMouseCursor(false);
-		// `_GetPalette(0x24)` @ EEM1 `_DoBigMap`; EEM2 `_DoBigMap`
-		// @ 2237:0a04 uses `_GetPalette(0x3b)` (shifted UI palettes).
 		setSitePalette(isLondon() ? 0x3b : 0x24);
 
-		// Stage 1: Overview. mapStartTick anchors partner timeline;
-		// `_NewAnimation` seeds frame to 0xffff so unfold plays once then
-		// loops the wait sequence.
 		const uint32 mapStartTick = g_system->getMillis();
 		drawBigMapOverview(0);
 		uint32 mapLastTick = mapStartTick;
 
-		// Rects @ CD 29be:1596 / floppy 2608:13fe (1 px diff on Setup).
 		const Common::Rect kBigMapWindow(0, 0, 247, 192);
 		const Common::Rect kSetupBtnRect = isFloppy()
 			? Common::Rect(251, 3, 315, 42)
@@ -3374,12 +3114,11 @@ void EEMEngine::doBigMap() {
 					continue;
 				}
 				if (ev.type == Common::EVENT_LBUTTONDOWN) {
-					// Setup → NextScreen=6 (`_DoBigMap @ 20fe:0c33`).
 					if (kSetupBtnRect.contains(ev.mouse.x, ev.mouse.y)) {
 						_nextScreen = kScreenSetup;
 						return;
 					}
-					// BigMapWindow click → zoom (sx=x*2-0x74, sy=y*2-0x55).
+
 					if (kBigMapWindow.contains(ev.mouse.x, ev.mouse.y)) {
 						int sx = ev.mouse.x * 2;
 						int sy = ev.mouse.y * 2;
@@ -3395,7 +3134,6 @@ void EEMEngine::doBigMap() {
 			if (wantZoom)
 				break;
 
-			// `_CheckFrameRate` cadence — 100 ms.
 			const uint32 now = g_system->getMillis();
 			if (now - mapLastTick >= 100) {
 				mapLastTick = now;
@@ -3415,7 +3153,6 @@ void EEMEngine::doBigMap() {
 		if (!wantZoom)
 			return;
 
-		// Stage 2: Detail. PIC 0x43 + BIGMAP.PIC viewport.
 		Common::File f;
 		if (!f.open(Common::Path("BIGMAP.PIC"))) {
 			warning("doBigMap: BIGMAP.PIC missing for detail view");
@@ -3439,13 +3176,11 @@ void EEMEngine::doBigMap() {
 		int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 		int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
-		// Detail partner timeline (script 0x13 unfold, then wait seq).
 		const uint32 detailStartTick = g_system->getMillis();
 		drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
 		uint32 detailLastTick = detailStartTick;
 		bool returnToOverview = false;
 
-		// `SmallMapButtons[4]` @ 20fe:156c — return to overview.
 		const Common::Rect kBigMapReturnRect(252, 43, kScreenWidth, kScreenHeight);
 		const Common::Rect kArrowYUp(237, 2, 247, 11);
 		const Common::Rect kArrowYDown(237, 163, 247, 172);
@@ -3512,7 +3247,6 @@ void EEMEngine::doBigMap() {
 						kBigMapReturnRect.contains(ev.mouse.x, ev.mouse.y) ||
 						kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y));
 					if (kDetailSetupBtn.contains(ev.mouse.x, ev.mouse.y)) {
-						// `_DoMapScreen @ 20fe:1560` — NextScreen=6.
 						_nextScreen = kScreenSetup;
 						setInteractiveMouseCursor(false);
 						return;
@@ -3572,8 +3306,6 @@ void EEMEngine::doBigMap() {
 							uint16 my;
 							uint16 buttonId;
 							if (fmap) {
-								// Floppy: rect +0/+2; BUTTON.DBD id at +4
-								// (`FUN_1fed_0c3e @ 1fed:0c3e`).
 								mx = READ_LE_UINT16(entry + 0x0);
 								my = READ_LE_UINT16(entry + 0x2);
 								buttonId = (uint16)entry[0x4];
@@ -3618,7 +3350,6 @@ void EEMEngine::doBigMap() {
 			if (returnToOverview)
 				break;
 
-			// 100 ms partner cycle.
 			const uint32 now = g_system->getMillis();
 			if (now - detailLastTick >= 100) {
 				detailLastTick = now;
@@ -3685,12 +3416,6 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 				const int lineH = _font.getFontHeight();
 				const int maxLines = MAX<int>(1, textRect.height() / lineH);
 				for (uint i = 0; i < wrapped.size() && (int)i < maxLines; i++) {
-					// `_DoApproach @ 1717:029e`:
-					// `_WordWrap(x, y, w, page, fontColor=1, dropColor=-1)`.
-					// Font colour is palette index 1 (NOT 0x0F white), drawn
-					// straight onto the restored video-frame background — the
-					// DOS `vga_fvidvid` calls just restore the clean bg, they
-					// don't paint a panel, so don't fill the rect here either.
 					_font.drawString(&scratch, wrapped[i], textRect.left,
 									 textRect.top + (int)i * lineH,
 									 textRect.width(), 1);
@@ -3776,9 +3501,6 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 	else
 		setSitePalette(0x3b);
 	bool done = false;
-	// `_DoApproach` idle loop (1717:02d4) shimmers palette 0xF3..0xF7 via
-	// `_ColorCycle(0xf3, 0xf7)` each frame-rate tick; mirror it on the video
-	// palette (the diff-animation ramp those entries belong to).
 	uint32 lastShimmer = g_system->getMillis();
 	while (!shouldQuit() && !done) {
 		Common::Event ev;
@@ -3866,9 +3588,6 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 }
 
 void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
-	// PIC 0x42 + per-site Done/Crime/Site marker (`_DrawBigMapButtons @
-	// 20fe:0877`) + partner idle at (0xfd, 0x50). Idle ANI: Jake=0x14,
-	// Jenny=0x12. elapsedMs anchors unfold→wait timeline.
 	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 		Graphics::PixelFormat::createFormatCLUT8());
 	scratch.clear();
@@ -3880,7 +3599,6 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	// Marker PICs from `_main`:
 	//   EEM1 CD @ 1a35:0f59: Done=0x20d, Site=0xc5, Crime=0xc6.
 	//   EEM2 CD @ 1abf:11a6: Done=0x006, Site=0xc5, Crime=0xc6.
-	// In EEM2, 0x20d is a normal scene/character frame, not a map marker.
 	Picture done;
 	Picture normal;
 	Picture crimeM;
@@ -3894,13 +3612,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 		const byte *entry = _mystery.mapEntry(i);
 		if (!entry)
 			continue;
-		// CD entries are 14 bytes: X at +4, Y at +6, crime at +12.
-		// Floppy entries are 11 bytes: X at +6, Y at +8, recolor at +10.
-		// Floppy layout: `FUN_1fed_07ed` (BigMap iteration):
-		//   `*(int *)(pcVar2 + i*0xb + 7)` (= entry+6, X u16)
-		//   `*(int *)(pcVar2 + i*0xb + 9)` (= entry+8, Y u16)
-		//   `pcVar2[i*0xb + 0xb]` (= entry+10, recolor flag — non-zero
-		//   selects the crime-marker PIC over the regular site marker).
+
 		const bool floppy  = _mystery.isLoaded() && isFloppy();
 		const uint16 mx    = floppy ? READ_LE_UINT16(entry + 0x6)
 									: READ_LE_UINT16(entry + 0x4);
@@ -3933,14 +3645,12 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 		}
 	}
 
-	// Partner idle at (0xfd, 0x50). `_DoBigMap @ 20fe:0a47` always passes
-	// script 0x14 (count-up) to `_NewAnimation` regardless of partner.
 	const uint kMapAniId = (_partner == kPartnerJake) ? 0x14 : 0x12;
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
 		const uint frameIdx = bigMapPartnerFrameAtTick((uint)mapAnim.size(),
 													   elapsedMs);
-		// BigMap walk-cycle miscflags = -2 (anchor shift left).
+
 		blitAnimFrameAnchored(scratch.surfacePtr(), mapAnim[frameIdx],
 							  0xfd, 0x50);
 	}
@@ -3954,9 +3664,7 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 								 const Common::Array<byte> &mapPixels,
 								 uint16 mapW, uint16 mapH,
 								 uint32 elapsedMs) {
-	// PIC 0x43 + 0xe9×0xab BIGMAP.PIC viewport at (2,2), stamped buttons,
-	// partner at (0x101, 0x50). `_DoMapScreen @ 20fe:120b` (ANI 0x13 Jake
-	// / 0x11 Jenny, seq 0x13). elapsedMs anchors unfold→wait.
+
 	const int kMapWinW = 0xe9;
 	const int kMapWinH = 0xab;
 	const int kMapWinX = 2;
@@ -3975,8 +3683,6 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	scratch.copyRectToSurface(mapPixels.data() + scrollY * mapW + scrollX,
 							  mapW, kMapWinX, kMapWinY, copyW, copyH);
 
-	// `_StampButtons @ 20fe:0d2f` (CD): button=MapData[+0], dst=+8/+0xa.
-	// Floppy `FUN_1fed_0c3e @ 1fed:0c3e`: button.dbd id at +4, rect +0/+2.
 	const bool floppyMap = _mystery.isLoaded() && isFloppy();
 	for (uint i = 0; i < _mystery.numSites(); i++) {
 		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
@@ -4015,7 +3721,6 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 		}
 	}
 
-	// Always script 0x13 (`_NewAnimation @ _DoBigMap 20fe:0a47`).
 	const uint kDetailAniId = (_partner == kPartnerJake) ? 0x13 : 0x11;
 	Animation detailAnim;
 	if (_aniArchive.loadAnimation(kDetailAniId, detailAnim) &&
@@ -4031,14 +3736,8 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 	g_system->updateScreen();
 }
 
+// `_GetKDTextBalloon @ 1df2:0105`
 uint16 EEMEngine::getKDTextBalloon(byte firstChar) const {
-	// `_GetKDTextBalloon @ 1df2:0105`:
-	//   if ((ctype[firstChar] & 2) == 0)  bub = *(u16*)29be:1068 = 0x17
-	//   else                              bub = *(u16*)(29be:0fe6+0x1e+c*2)
-	// `ctype` is Borland's `_ctype_` at 29be:2be1; bit 1 (0x02) is set
-	// only for '0'..'9'. Digit table @ 29be:1064 (see kDigitBalloons).
-	// `*(u16*)29be:1068` = entry for '2' = 0x17 — original reuses the
-	// digit-2 slot as the non-digit fallback constant.
 	if (firstChar < '0' || firstChar > '9')
 		return 0x17;
 	return kDigitBalloons[firstChar - '0'];
@@ -4095,12 +3794,9 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 	if (ctx.haveBg)
 		scratch.simpleBlitFrom(ctx.accuseBg->surface);
 
-	// Partner at (5, 0x50). `_DoAccuse @ 1df2:0c2c`: ANI 2/0x10,
-	// script 2, prior 1.
 	blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
 				   g_system->getMillis());
 
-	// Selected=0x3c, unselected=1 (`_NoteUnselectedColor` @ 1df2:0c25).
 	Common::Array<Common::Rect> &slotRects = *ctx.slotRects;
 	Common::Array<uint> &slotClues = *ctx.slotClues;
 	const Common::Array<uint> &found = *ctx.found;
@@ -4139,7 +3835,6 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 		y += h + 7;
 	}
 
-	// `_UpdateSelectionCount(remaining)` @ (0xd1, 0xb).
 	const uint remaining = (selectedCount < ctx.expected)
 		? ctx.expected - selectedCount
 		: 0;
@@ -4163,12 +3858,6 @@ void EEMEngine::accuseDrawScreen(const AccuseNotesCtx &ctx) {
 }
 
 bool EEMEngine::doAccuseNotes() {
-	// `_DoAccuse @ 1df2:0bdd` (EEM2 `@ 1ea1:0c03`) head. BG PIC 0x1A7.
-	// `_AccuseNoteRect @ 29be:1048` = (79, 27, 304, 159). Counter @ (209, 11)
-	// shows the required clue count (EEM1 `6 - chainStage` = 5/4/3 by tier;
-	// London a flat 5 — see `expected` below). Unselected color 1 (red),
-	// selected 0x3c. Click toggles selection; `_NoteButtons[4]`
-	// (180,174,201,190) SOLVE → `_HandleAccuseNoteButton` returns 2.
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return false;
 	const byte *ni = _mystery.noteIndex();
@@ -4179,18 +3868,13 @@ bool EEMEngine::doAccuseNotes() {
 	Picture accuseBg;
 	const bool haveBg = _picsArchive.getPicture(0x1a7, accuseBg);
 
-	// `FUN_1d40_0e07 @ 1d40:0e34` zeroes _NoteSelected_Floppy on entry.
 	memset(_mystery._noteSelected, 0, sizeof(_mystery._noteSelected));
 
-	// EEM1 `_DoAccuse @ 1df2:0bdd` scales the required clue count by tier
-	// (`6 - chainStage`: stage 1=5, 2=4, 3=3). London `_DoAccuse @ 1ea1:0c03`
-	// hardcodes 5 (`local_a = 5`) for both books.
 	const uint expected = isLondon()
 		? 5
 		: ((_chainStage >= 1 && _chainStage <= 3) ? (uint)(6 - _chainStage)
 												  : 5);
 
-	// `_DrawNotes(NULL, 100, ...)` walks `_CluesFound[]`.
 	Common::Array<uint> found;
 	for (uint i = 0; i < niCount && i < Mystery::kCluesFoundCap; i++) {
 		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
@@ -4202,8 +3886,6 @@ bool EEMEngine::doAccuseNotes() {
 	const int rectW = 304 - 79;
 	const int rectH = 159 - 27;
 
-	// `_NoteButtons` @ 29be:0147. `_HandleAccuseNoteButton @ 1df2:0990`
-	// returns DI=2 for i==4 (SOLVE).
 	const Common::Rect kBtnSolve   (180, 174, 201, 190); // [4]
 	const Common::Rect kBtnPageNext(204, 174, 224, 190); // [5]
 	const Common::Rect kBtnPagePrev(226, 174, 247, 190); // [6]
@@ -4218,9 +3900,6 @@ bool EEMEngine::doAccuseNotes() {
 	int numPages = 1;
 	pageBreaks[0] = 0;
 
-	// CD note: 4 bytes (u16 textOff rel TextBlock, u16 score).
-	// Floppy: 7 bytes (+0 abs textOff, +2 Jake, +4 Jenny, +6 score).
-	// Notebook always uses +0 (`FUN_15e0_01e8`).
 	const bool floppyNote = isFloppy();
 	const byte *bufBaseNotes = _mystery.blobAt(0);
 
@@ -4302,9 +3981,6 @@ bool EEMEngine::doAccuseNotes() {
 					return false;
 				}
 				if (isLondon() && kPdaHelpRect.contains(mx, my)) {
-					// EEM2 accuse `_HandleAccuseNoteButton @ 1ea1:0873` slot 1
-					// → MAP (`MOV [0x9292],2` @ 1ea1:089b) — London's dedicated
-					// map button; only kPdaHelp2Rect (267,174) stays help.
 					_nextScreen = kScreenMapAlt;
 					return false;
 				}
@@ -4315,7 +3991,6 @@ bool EEMEngine::doAccuseNotes() {
 					dirty = true;
 					continue;
 				}
-				// `_NoteButtons[5]/[6]` — page nav guards @ 1df2:09a8/099e.
 				if (kBtnPageNext.contains(mx, my)) {
 					if (page + 1 < numPages) {
 						page++;
@@ -4330,7 +4005,6 @@ bool EEMEngine::doAccuseNotes() {
 					}
 					continue;
 				}
-				// `_NoteButtons[3]` — ScummVM hint, original ignores.
 				if (kBtnPartner.contains(mx, my)) {
 					doHelp();
 					dirty = true;
@@ -4343,7 +4017,6 @@ bool EEMEngine::doAccuseNotes() {
 							selected++;
 					}
 					if (selected == expected) {
-						// `_DoAccuse` gate `uStack_8 == uStack_a`.
 						return true;
 					}
 					continue;
@@ -4371,7 +4044,6 @@ bool EEMEngine::doAccuseNotes() {
 		}
 		if (dirty)
 			accuseDrawScreen(ctx);
-		// 100 ms `_CheckFrameRate` cadence @ 1df2:0bfa.
 		static uint32 sLastTick = 0;
 		const uint32 now = g_system->getMillis();
 		if (now - sLastTick >= 100) {
@@ -4388,17 +4060,11 @@ void EEMEngine::doAccuse() {
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return;
 
-	// Floppy: variable-stride entries (5 + nameLen, not 0x46), top-5
-	// `_TextSeen` scoring (not `_NoteSelected`), header[+0x12] win-dialog.
-	// Handled by doAccuseFloppy (`FUN_1d40_11fd`, `FUN_1d40_0c79`,
-	// `_DisplayCorrect_Floppy`, `_DisplayAlibi_Floppy`).
 	if (isFloppy()) {
 		doAccuseFloppy();
 		return;
 	}
 
-	// `_AccuseEntry @ 1df2:0ff8` — gates picker on top-5 found clue
-	// score (≥0x65 required).
 	const byte *entryKdIdx = _mystery.kdTextIndex();
 	if (!entryKdIdx)
 		return;
@@ -4567,8 +4233,6 @@ void EEMEngine::doAccuse() {
 			}
 		}
 
-		// Balloon overlay (`_GetKDTextBalloon` + `_GetBalloon` +
-		// `_AddPicBackground` + `_WordWrap` @ 1df2:0c8d-0cd1).
 		Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		ms.clear();
@@ -4580,7 +4244,7 @@ void EEMEngine::doAccuse() {
 		const byte firstChar =
 			hint.empty() ? (byte)0 : (byte)hint[0];
 		uint16 bubNum = getKDTextBalloon(firstChar);
-		// Strip digit prefix (`_DisplayAlibi @ 1df2:0163` `str=pbVar7+1`).
+
 		if (firstChar >= '0' && firstChar <= '9')
 			hint.deleteChar(0);
 		bubNum = fitBalloonToText(bubNum, hint);
@@ -4609,12 +4273,11 @@ void EEMEngine::doAccuse() {
 								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
 
-		// `_SayKDDigital(3)` @ 1df2:0cd9.
+
 		if (_audio && kdIdx)
 			_audio->sayKDDigital(kdIdx, 3, _partner);
 
 		waitForInput(20000);
-		// `_DoAccuse @ 1df2:0ce5` — NextScreen = LastScreen.
 		_nextScreen = _lastScreen != kScreenInvalid
 						? (ScreenId)_lastScreen : kScreenSite;
 		return;
@@ -4632,9 +4295,6 @@ void EEMEngine::doAccuse() {
 
 	int highlighted = 0;
 
-	// KD hint balloon @ `_DoAccuseGallery @ 1df2:0a31` (0a4c-0afe).
-	// y = (h < 0x4e) ? (0x50-h)>>1 : 1. Inset table @ 29be:0875.
-	// `_SayKDDigital(4)`.
 	const byte *kdIdx = _mystery.kdTextIndex();
 	if (kdIdx) {
 		const int16 textOff = (int16)READ_LE_UINT16(kdIdx + 8);
@@ -4677,14 +4337,12 @@ void EEMEngine::doAccuse() {
 						ms.simpleBlitFrom(accuseBg.surface);
 					}
 				}
-				// `_Rect_Move_Mask` (1000:03fc) — transp = pic[0]>>8.
 				if (haveBalloon) {
 					const byte transp = (byte)(balloon.flags >> 8);
 					ms.transBlitFrom(balloon.surface,
 									 Common::Point(balloonX, balloonY),
 									 transp);
 				}
-				// Inset table @ 29be:0875.
 				uint16 tx = 5;
 				uint16 ty = 4;
 				uint16 tw = 155;
@@ -4710,20 +4368,6 @@ void EEMEngine::doAccuse() {
 
 	drawAccuseGallery(num, gd, highlighted, slotRects, slotSuspect);
 
-	// Wait-for-pick loop. Mirrors `_DoAccuseGallery` 1df2:0b26-1df2:0bc8:
-	//   * `_CheckFrameRate` + `_UpdateAnimations` per tick (1df2:0b2a-0b33)
-	//   * 5-entry input dispatch table @ 1df2:0bc9:
-	//       0x09 (TAB)   → handler 0x0b94 (cycle highlight)
-	//       0x0d (Enter) → handler 0x0b72 (pick = _SearchSuspects)
-	//       0x4b (LEFT)  → handler 0x0b94
-	//       0x4d (RIGHT) → handler 0x0b94
-	//       0xFFFF (mb)  → handler 0x0b72
-	//   * 0x0b94: `INC DI` + wraparound + `_PutMouseInRect(&Guys[DI])`,
-	//     i.e. advance highlight and warp cursor (1df2:0b94-0bb1).
-	//   * 0x0b72: `_SearchSuspects` (158f:0584) — mouse-rect hit-test;
-	//     if non-0xFFFF, pick that suspect.
-	// We don't warp the cursor (unfriendly under SDL); instead the
-	// highlight is drawn as a 1px outline and Enter picks it.
 	int picked = -1;
 	uint32 lastTick = g_system->getMillis();
 	bool dirty = false;
@@ -4745,9 +4389,6 @@ void EEMEngine::doAccuse() {
 					dirty = true;
 					break;
 				case Common::KEYCODE_LEFT:
-					// 1df2:0b94 increments DI for LEFT too — but a
-					// keyboard-driven UX is friendlier with separate
-					// directions, so we mirror Right=+1 / Left=-1.
 					highlighted = nextLiveSlot(slotRects, highlighted, -1);
 					dirty = true;
 					break;
@@ -4782,7 +4423,6 @@ void EEMEngine::doAccuse() {
 				}
 			}
 		}
-		// 100 ms `_CheckFrameRate` @ 1df2:0b33.
 		const uint32 now = g_system->getMillis();
 		if (dirty || now - lastTick >= 100) {
 			drawAccuseGallery(num, gd, highlighted, slotRects, slotSuspect);
@@ -4795,8 +4435,6 @@ void EEMEngine::doAccuse() {
 	if (picked < 0)
 		return;
 
-	// `_WITCH @ 1df2:089f` — guilty when gd[picked*0x46+0x02] == 0xFFFF;
-	// innocents store an alibi TextBlock offset there.
 	const int points          = _mystery.selectedPoints();
 	const bool pickedGuilty   = _mystery.isGuilty((uint)picked);
 	const bool guessedRight   = pickedGuilty;
@@ -4815,7 +4453,6 @@ void EEMEngine::doAccuse() {
 	//   4. Partner reaction balloon @ KDTextIndex[+10], `_SayKDDigital(5)`.
 	//   5. _FirstTry = 0; NextScreen = LastScreen (1df2:043f).
 	if (!guessedRight) {
-		// Balloon-shape table @ 29be:1050.
 		static const uint16 kAlibiBubbles[16] = {
 			0x002B, 0x002C, 0x002D, 0x002E,
 			0x00AB, 0x00AC, 0x00AD, 0x00AE,
@@ -4830,7 +4467,6 @@ void EEMEngine::doAccuse() {
 			if (raw)
 				alibi = parseString(raw, _playerName, _partner);
 		}
-		// `_DisplayAlibi @ 1df2:0163` — bindx = digit prefix, else 2.
 		uint bindx = 2;
 		const byte firstChar = alibi.empty() ? (byte)0 : (byte)alibi[0];
 		if (firstChar >= '0' && firstChar <= '9') {
@@ -4855,7 +4491,6 @@ void EEMEngine::doAccuse() {
 			_balloonArchive.size() > (bubNum & 0x7F) &&
 			_balloonArchive.loadEntry(bubNum & 0x7F, balloon);
 
-		// Position math @ 1df2:01a4-0207.
 		int balloonX = 0x21;
 		int balloonY = 1;
 		int py = 0x5a;
@@ -4875,7 +4510,6 @@ void EEMEngine::doAccuse() {
 			balloonY = (bh < 0x4f) ? (0x50 - bh) / 2 : 1;
 		}
 
-		// `base` = BG + suspect + partner. Survives both balloon phases.
 		Graphics::ManagedSurface base(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		base.clear();
@@ -4887,10 +4521,7 @@ void EEMEngine::doAccuse() {
 							   Common::Point(0x82, py),
 							   (uint32)transp);
 		}
-		// Partner at (5, 0x50). ANI 2/0x10, script 0x02 (`_DoAccuse
-		// @ 1df2:0c30`). Drawn after suspect.
 
-		// scratch = base + alibi balloon/text + partner.
 		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
 			Graphics::PixelFormat::createFormatCLUT8());
 		scratch.simpleBlitFrom(base);
@@ -4900,7 +4531,7 @@ void EEMEngine::doAccuse() {
 								  Common::Point(balloonX, balloonY),
 								  (uint32)transp);
 		}
-		// Inset table @ 29be:0875. Color 0 inside balloon (1df2:0240).
+
 		uint16 tx = 5, ty = 4, tw = 155;
 		getBalloonInsets(bubNum, tx, ty, tw);
 		if (_font.isLoaded() && !alibi.empty()) {
@@ -4911,7 +4542,6 @@ void EEMEngine::doAccuse() {
 		blitPdaPartner(scratch, _aniArchive, _partner, kPdaGalleryPartner,
 					   g_system->getMillis());
 
-		// MIDI 6 — blocks until done (or click/ESC aborts).
 		if (_music && _voiceOn) {
 			_music->playMus(6, /* loop= */ false);
 			const uint32 musStart = g_system->getMillis();
@@ -4930,7 +4560,6 @@ void EEMEngine::doAccuse() {
 						break;
 					}
 				}
-				// Hard cap if MIDI never reports finish.
 				if (g_system->getMillis() - musStart > 10000)
 					break;
 				g_system->updateScreen();
@@ -4939,8 +4568,6 @@ void EEMEngine::doAccuse() {
 			_music->stop();
 		}
 
-		// Suspect voice. talk = partner==0 ? gd[+0x6] : gd[+0x0] (1df2:0252).
-		// 1-based, so SpoolSound(talk-1).
 		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
 								   0, 0, kScreenWidth, kScreenHeight);
 		g_system->updateScreen();
@@ -4956,8 +4583,6 @@ void EEMEngine::doAccuse() {
 		}
 		waitForInput(60000);
 
-		// Partner reaction @ 1df2:026e-02b6. Rebuild from `base` so alibi
-		// balloon clears. `_SayKDDigital(5)` auto-cancels alibi voice.
 		const byte *reactIdx = _mystery.kdTextIndex();
 		if (reactIdx) {
 			const int16 reactOff = (int16)READ_LE_UINT16(reactIdx + 10);
@@ -5008,35 +4633,25 @@ void EEMEngine::doAccuse() {
 		waitForInput(60000);
 
 		_mystery._firstTry = false;
-		// `_DisplayAlibi @ 1df2:043f` — NextScreen = LastScreen.
 		_nextScreen = _lastScreen != kScreenInvalid
 						? (ScreenId)_lastScreen : kScreenSite;
 		return;
 	}
 
-	// Win — `_DisplayCorrect @ 1df2:073c`: mark solved, advance chain,
-	// MIDI 5, SCRAPBK.ANI, ending, save, NextScreen=0xc (1df2:0895).
 	{
 		const uint mn = _mystery.number();
 		if (mn < sizeof(_mysteriesSolved)) {
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 		}
 
-		// Chain advance @ 1df2:0824-0850. Skip mystery 0 (practice).
-		// stage 1: 1..0x18, stage 2: 0x19..0x30, stage 3: 0x31..0x36.
 		advanceChainStageAfterSolve(mn);
 
-		// `_DisplayCorrect @ 1df2:073c`:
-		//   _AllBlack; _BuildBackground(5, 0x42, 0x14); _FadeIn;
-		//   _MIDIPlay(5); _DisplayClue(MysteryIndex[+0x10]).
-		// `_BuildBackground @ 172b:13e2` = PIC 0x3D + SITES entry 5 at
-		// (0x42, 0x14), palette = sitenum+1 = 6.
 		Graphics::Surface *blk = g_system->lockScreen();
 		if (blk) {
 			memset(blk->getPixels(), 0, kScreenWidth * kScreenHeight);
 			g_system->unlockScreen();
 		}
-		setSitePalette(6); // sitenum + 1 (`_GetPalette`).
+		setSitePalette(6);
 		Picture frame, scene;
 		if (_picsArchive.loadEntry(0x3d, frame)) {
 			g_system->copyRectToScreen(frame.surface.getPixels(),
@@ -5054,7 +4669,6 @@ void EEMEngine::doAccuse() {
 										   sw, sh);
 		}
 
-		// Stamp partner at (5, 0x50); displayClue snapshots screen.
 		if (Graphics::Surface *screen = g_system->lockScreen()) {
 			blitPdaPartner(screen, _aniArchive, _partner,
 						   kPdaGalleryPartner, g_system->getMillis());
@@ -5065,39 +4679,29 @@ void EEMEngine::doAccuse() {
 		if (_music && _voiceOn)
 			_music->playMus(5, /* loop= */ false);
 
-		// Chain recap — partner enumerates required clues.
 		const byte *solved = _mystery.solvedClueBlock();
 		if (solved)
 			displayClue(solved);
 		if (_music && _voiceOn)
 			_music->stop();
 
-		// `_DifferenceAnimation("scrapbk.ani")` @ 1df2:0848.
 		playAnm(Common::Path("SCRAPBK.ANI"), 120,
 				/* holdLastFrame= */ false);
 
 		displayScrapbookExtra(mn);
 
-		// `_ShowOneScrap @ 1f78:0773` = `_DisplayEnding(num, 1)`.
 		doShowEnding(mn);
 
-		// `_SavePlayerRecord` @ 1df2:0857. Order: clear() before save
-		// so hasMystery=false (matches `_DeleteSavedGame` @ 1df2:0851).
 		_mystery.clear();
 		const Common::Error err = saveProfile(_playerName);
 		if (err.getCode() != Common::kNoError)
 			warning("saveProfile after solve failed: %s",
 					err.getDesc().c_str());
 
-		// `_DisplayCorrect @ 1df2:0895` — NextScreen = 0xc.
 		_nextScreen = kScreenAction;
 	}
 }
 
-// Render one KDTextIndex string as a centred KD balloon over the current
-// screen, mirroring `_DisplayHint_Floppy @ 1503:00ca` (= `FUN_1d40_11fd`'s
-// body). `kdSlot` is the index into KDTextIndex (0..N) — entries are u16
-// absolute text offsets.
 void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 							 const byte *bufBase, uint32 mysSize) {
 	if ((uint)(kdSlot * 2) + 2 > (uint)(mysSize - (kdIdx - bufBase)))
@@ -5112,8 +4716,6 @@ void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 	if (lineLen == 0)
 		return;
 	Common::String raw(p, lineLen);
-	// Digit → balloon variant (`_GetKDTextBalloon_Floppy @ 1d40:009f`
-	// + table @ 2608:0c14).
 	static const uint8 kDigitToBalloon[10] = {
 		0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1c, 0x1d, 0x1e, 0x0a
 	};
@@ -5153,7 +4755,7 @@ void EEMEngine::floppyKDHint(uint kdSlot, const byte *kdIdx,
 						  MAX<int>(8, (int)bw), text, 0);
 	g_system->copyRectToScreen(ms.getPixels(), ms.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
-	// Wait for click.
+
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool advance = false;
@@ -5347,7 +4949,6 @@ void EEMEngine::accuseDrawGallery(int highlighted,
 		if (!_picsArchive.getPicture(picId, portrait))
 			continue;
 		const GallerySlot &s = kFloppyGallerySlots[phys];
-		// Bottom-align to baseline 0x48 (`154e:00ed`).
 		const int placeX = s.x;
 		const int placeY = s.y + (0x48 - portrait.surface.h);
 		const byte transp = (byte)(portrait.flags >> 8);
@@ -5369,20 +4970,12 @@ void EEMEngine::accuseDrawGallery(int highlighted,
 }
 
 void EEMEngine::doAccuseFloppy() {
-	// Floppy accuse:
-	//   `_KDHelp_Floppy / FUN_1d40_11fd @ 1d40:11fd` — score gate.
-	//   `FUN_1d40_0e07` — clue selection (skipped; selectedPoints()
-	//     already takes top-5).
-	//   `FUN_1d40_0c79 @ 1d40:0c79` — gallery picker.
-	//   `_DisplayCorrect_Floppy @ 1d40:0894`.
-	//   `_DisplayAlibi_Floppy @ 1d40:00df`.
 	const byte *kdIdx     = _mystery.kdTextIndex();
 	const byte *bufBase   = _mystery.blobAt(0);
 	const uint32 mysSize  = _mystery.dataSize();
 	if (!kdIdx || !bufBase)
 		return;
 
-	// `FUN_1d40_11fd` — score gate, ready when score >= 100.
 	const int score = _mystery.selectedPoints();
 	uint kdSlot;
 	bool readyToSolve;
@@ -5403,9 +4996,6 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// Clue selection — `FUN_1d40_0e07 @ 1d40:0e07`. Pick `6 - chainStage`
-	// clues (1d40:0e34: `local_c = 6 - DAT_28da_3052`); London a flat 5.
-	// Count handled inside `doAccuseNotes`.
 	if (!doAccuseNotes()) {
 		if (_nextScreen == kScreenAccuse) {
 			_nextScreen = _lastScreen != kScreenInvalid
@@ -5414,8 +5004,6 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// 2nd score gate on USER-PICKED clues (1d40:0f3a, FUN_1d40_0c48 =
-	// sum of byte +6 across selected entries). <100 → slot 3 hint.
 	int userSelectedScore = 0;
 	{
 		const byte *ni2 = _mystery.noteIndex();
@@ -5436,10 +5024,7 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// `FUN_1d40_0c79` — gallery picker. KDTextIndex slot 4 prompt.
 	floppyKDHint(4, kdIdx, bufBase, mysSize);
-
-	// Floppy gallery: byte0 = numSuspects, then variable-stride entries.
 	const uint8 num = _mystery.numSuspects();
 	if (num == 0) {
 		_nextScreen = _lastScreen != kScreenInvalid
@@ -5513,16 +5098,8 @@ void EEMEngine::doAccuseFloppy() {
 	const bool guilty = _mystery.isGuilty((uint)picked);
 
 	if (guilty) {
-		// `_DisplayCorrect_Floppy @ 1d40:0894`:
-		//   _BuildBackground_Floppy(5, 0x42, 0x14); _FadeIn;
-		//   _MIDIPlayFile("travel-2.xmi");
-		//   Walk solved chain, SCRAPBK.ANI when 3 records left.
-		//   Mark solved, tier-promote, SavePlayerRecord,
-		//   ShowOneScrap, NextScreen = 0xc.
 		const uint mn = _mystery.number();
 
-		// `_BuildBackground_Floppy @ 16e2:12fd` — PIC 0x3d + SITES entry 5
-		// at (0x42, 0x14), palette 6.
 		{
 			Graphics::Surface *blk = g_system->lockScreen();
 			if (blk) {
@@ -5547,8 +5124,6 @@ void EEMEngine::doAccuseFloppy() {
 						scene.surface.pitch, sx, sy, sw, sh);
 			}
 
-			// Stamp partner at (5, 0x50); displayFloppyDialogRecords
-			// snapshots screen. ANI 2/0x10, script 0x02.
 			if (Graphics::Surface *screen = g_system->lockScreen()) {
 				blitPdaPartner(screen, _aniArchive, _partner,
 							   kPdaGalleryPartner, g_system->getMillis());
@@ -5557,12 +5132,9 @@ void EEMEngine::doAccuseFloppy() {
 			g_system->updateScreen();
 		}
 
-		// TRAVEL-2.XMI @ 2608:0c84 (`_MIDIPlayFile` @ 1d40:08c0).
 		if (_music && _voiceOn)
 			_music->playFile(Common::Path("travel-2.xmi"), false);
 
-		// Walk solved chain (header[+0x12]: count byte + dialog records).
-		// SCRAPBK.ANI fires when 3 records remain (`_PlayTitleANM_Floppy(0)`).
 		const byte *chain = _mystery.solvedClueBlock();
 		if (chain) {
 			const uint count = chain[0];
@@ -5589,15 +5161,11 @@ void EEMEngine::doAccuseFloppy() {
 		if (_music && _voiceOn)
 			_music->stop();
 
-		// 1d40:0939 — solved[mn] = _firstTry ? 2 : 1.
 		if (mn < sizeof(_mysteriesSolved))
 			_mysteriesSolved[mn] = _mystery._firstTry ? 2 : 1;
 
-		// Tier promotion @ 1d40:0941..0978. Skip mystery 0 (practice).
 		advanceChainStageAfterSolve(mn);
 
-		// `MakeSolvedSound`. `FUN_1d40_05b7` maps E<num>.BIN byte 0 (0..2)
-		// via table @ 2608:0c5e to VOC slots 0x15/0x16/0x17.
 		if (_audio && _voiceOn) {
 			Common::File ending;
 			const Common::String fname =
@@ -5615,10 +5183,8 @@ void EEMEngine::doAccuseFloppy() {
 			}
 		}
 
-		// 1d40:0991 → FUN_1ee2_06ac (= FUN_1d40_05b7 + font cleanup).
 		doShowEnding(mn);
 
-		// 1d40:0982 _SavePlayerRecord. clear() before save.
 		_mystery._solvedPuzzle = true;
 		_mystery.clear();
 		(void)saveProfile(_playerName);
@@ -5626,12 +5192,6 @@ void EEMEngine::doAccuseFloppy() {
 		return;
 	}
 
-	// `_DisplayAlibi_Floppy @ 1d40:00df`:
-	//   BG 0x3e + suspect pic at (0x82, 0x5a); MIDI fanfare2.xmi.
-	//   Balloon table @ 2608:0c0a (default 0x2c). Centered:
-	//     bx = (0x140-w)/2, by = (0x5a-h)/2.
-	//   KD reaction @ KDTextIndex[+10] = slot 5; NextScreen = LastScreen;
-	//   _firstTry = 0.
 	const byte *susp = _mystery.floppySuspectEntry((uint)picked);
 	uint16 picId = 0;
 	uint16 alibiOff = 0xFFFF;
@@ -5640,7 +5200,6 @@ void EEMEngine::doAccuseFloppy() {
 		alibiOff = _mystery.alibiTextOffset((uint)picked);
 	}
 
-	// FANFARE2.XMI alibi sting (`_MIDIPlayFile` gated on _voiceOn).
 	if (_music && _voiceOn)
 		_music->playFile(Common::Path("fanfare2.xmi"), false);
 
@@ -5660,8 +5219,6 @@ void EEMEngine::doAccuseFloppy() {
 							_playerName, _partner);
 	}
 
-	// Alibi balloon table @ 2608:0c0a (NOT 0c14). Digits @ 2608:0c3a..0c43.
-	// Default 0x2c. High bit = mirror flag.
 	static const uint8 kFloppyAlibiBalloonByDigit[10] = {
 		0x2a, 0x2b, 0x2c, 0x2d, 0xaa, 0xab, 0xac, 0xad, 0x09, 0x0a
 	};
@@ -5689,7 +5246,6 @@ void EEMEngine::doAccuseFloppy() {
 	Picture balloon;
 	const bool haveBalloon = _balloonArchive.size() > balloonIdx &&
 		_balloonArchive.loadEntry(balloonIdx, balloon);
-	// Centered @ 1d40:01a0: x=(0x140-w)/2, y=(0x5a-h)/2.
 	int balloonX = 0x21;
 	int balloonY = 1;
 	if (haveBalloon) {
@@ -5717,9 +5273,6 @@ void EEMEngine::doAccuseFloppy() {
 	g_system->copyRectToScreen(scene.getPixels(), scene.pitch, 0, 0, kScreenWidth, kScreenHeight);
 	g_system->updateScreen();
 
-	// No per-suspect VOC — alibi table @ 2608:0c5e is for post-win
-	// scrapbook (`FUN_1d40_05b7`), not alibi.
-
 	while (!shouldQuit()) {
 		Common::Event ev;
 		bool advance = false;
@@ -5738,7 +5291,6 @@ void EEMEngine::doAccuseFloppy() {
 		g_system->delayMillis(10);
 	}
 
-	// `_DisplayAlibi_Floppy @ 1d40:01ee` — KDTextIndex[+10] = slot 5.
 	floppyKDHint(5, kdIdx, bufBase, mysSize);
 	if (_music && _voiceOn)
 		_music->stop();
@@ -5747,13 +5299,11 @@ void EEMEngine::doAccuseFloppy() {
 	_nextScreen = _lastScreen != kScreenInvalid
 		? (ScreenId)_lastScreen : kScreenSite;
 }
-
+// _DoAccuseGallery @ 1df2:0a31.
 void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 								   int highlighted,
 								   Common::Array<Common::Rect> &slotRects,
 								   Common::Array<int> &slotSuspect) {
-	// `_DoAccuseGallery @ 1df2:0a31` portrait grid. PIC 0x3f + 5 slots.
-	// Partner (5, 0x50) ANI 2/0x10, script 0x02 (`_DoAccuse @ 1df2:0c30`).
 	Picture accuseBg;
 	const bool haveAccuseBg = _picsArchive.getPicture(0x3f, accuseBg);
 
@@ -5775,7 +5325,6 @@ void EEMEngine::drawAccuseGallery(uint8 numSuspects, const byte *gd,
 		const uint8 phys = _mystery._newOrder[i];
 		if (phys >= 5)
 			continue;
-		// `_DrawGallery @ 158f:00b9` skip on `_InGallery[phys]==0`.
 		if (_mystery._inGallery[phys] == 0)
 			continue;
 		const GallerySlot &s = kGallerySlots[phys];


Commit: 215d297ebd4887cc744870fa79e21345c27f063b
    https://github.com/scummvm/scummvm/commit/215d297ebd4887cc744870fa79e21345c27f063b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:32+02:00

Commit Message:
EEM: enable fit dialog ballons for London

Changed paths:
    engines/eem/detection.cpp


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index c27f37a7bfb..056915c71f6 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -76,7 +76,7 @@ const ADGameDescription gameDescriptions[] = {
 		Common::EN_ANY,
 		Common::kPlatformDOS,
 		ADGF_UNSTABLE,
-		GUIO2(GUIO_MIDIADLIB, GUIO_MIDIMT32)
+		GUIO3(GAMEOPTION_FIT_DIALOG_BALLOONS, GUIO_MIDIADLIB, GUIO_MIDIMT32)
 	},
 
 	AD_TABLE_END_MARKER


Commit: 670bb5b82079201e1871d6d46e22dbe2c6342c25
    https://github.com/scummvm/scummvm/commit/670bb5b82079201e1871d6d46e22dbe2c6342c25
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:33+02:00

Commit Message:
EEM: fixed partner animation that was one frame longer in London

Changed paths:
    engines/eem/site.cpp
    engines/eem/site.h
    engines/eem/ui.cpp


diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index ce2b9d89ab4..2c9d1561b43 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -714,7 +714,15 @@ uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
 	return (numFrames > 0) ? MIN<uint>(frame, numFrames - 1) : 0;
 }
 
-uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs) {
+uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs, bool london) {
+	if (london) {
+		static const uint8 kUnfoldL[]  = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
+		static const uint8 kWaitSeqL[] = { 10, 11, 10, 10, 10, 10, 10, 10, 10 };
+		return oneShotThenLoopFrameAtTick(kUnfoldL, ARRAYSIZE(kUnfoldL),
+										  kWaitSeqL, ARRAYSIZE(kWaitSeqL),
+										  numFrames, elapsedMs);
+	}
+	// EEM1 (11-frame anim): entrance {0..8}, idle `_BigMapWaitSeq` {9,..,10,..}.
 	static const uint8 kUnfold[]  = { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
 	static const uint8 kWaitSeq[] = { 9, 9, 9, 9, 10, 9, 9, 9, 9 };
 	return oneShotThenLoopFrameAtTick(kUnfold, ARRAYSIZE(kUnfold),
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 30567b2339d..ec7d80f370f 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -47,8 +47,10 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 /// from EEM1's, so the engine must use the EEM2 sequences for that variant.
 void setLondonAnimScripts(bool enabled);
 
-/// bigMapPartnerFrameAtTick: count-up 0..8 once, then loop `_BigMapWaitSeq`
-uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs);
+/// bigMapPartnerFrameAtTick: overview-map partner walk. EEM1 (11-frame anim):
+/// count-up 0..8 once, then idle `_BigMapWaitSeq`. London's anim 0x14/0x12 has
+/// 12 frames, so it uses a longer entrance and an idle that reaches frame 11.
+uint bigMapPartnerFrameAtTick(uint numFrames, uint32 elapsedMs, bool london);
 
 /// bigMapDetailPartnerFrameAtTick: zoomed-view partner frame. Same two-phase shape
 /// as `bigMapPartnerFrameAtTick`.
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index dc7c47c74a4..c87536e9cb2 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3649,7 +3649,7 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	Animation mapAnim;
 	if (_aniArchive.loadAnimation(kMapAniId, mapAnim) && !mapAnim.empty()) {
 		const uint frameIdx = bigMapPartnerFrameAtTick((uint)mapAnim.size(),
-													   elapsedMs);
+													   elapsedMs, isLondon());
 
 		blitAnimFrameAnchored(scratch.surfacePtr(), mapAnim[frameIdx],
 							  0xfd, 0x50);


Commit: 3b86f3eb9aaf7babc29aaf19bd54c8f1de3dfe88
    https://github.com/scummvm/scummvm/commit/3b86f3eb9aaf7babc29aaf19bd54c8f1de3dfe88
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:33+02:00

Commit Message:
EEM: do not render sublocations in London map

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index c87536e9cb2..7979152d299 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3296,8 +3296,8 @@ void EEMEngine::doBigMap() {
 						};
 						Common::Array<DetailMapHit> hits;
 						for (uint i = 0; i < _mystery.numSites(); i++) {
-							if (!_mystery._onSites[i] &&
-								i != _mystery._siteNumber)
+							// On-map flag alone, matching `_SearchMapButtons`.
+							if (!_mystery._onSites[i])
 								continue;
 							const byte *entry = _mystery.mapEntry(i);
 							if (!entry)
@@ -3607,7 +3607,9 @@ void EEMEngine::drawBigMapOverview(uint32 elapsedMs) {
 	const bool haveCrime  = _picsArchive.getPicture(0xc6,  crimeM);
 
 	for (uint i = 0; i < _mystery.numSites(); i++) {
-		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+		// `_DrawBigMapButtons` gates markers on the on-map flag alone, never the
+		// current site: a sublocation (in-site jump, never flagged) must not draw.
+		if (!_mystery._onSites[i])
 			continue;
 		const byte *entry = _mystery.mapEntry(i);
 		if (!entry)
@@ -3685,7 +3687,9 @@ void EEMEngine::drawBigMapDetail(int scrollX, int scrollY,
 
 	const bool floppyMap = _mystery.isLoaded() && isFloppy();
 	for (uint i = 0; i < _mystery.numSites(); i++) {
-		if (!_mystery._onSites[i] && i != _mystery._siteNumber)
+		// `_DrawBigMapButtons` gates markers on the on-map flag alone, never the
+		// current site: a sublocation (in-site jump, never flagged) must not draw.
+		if (!_mystery._onSites[i])
 			continue;
 		const byte *entry = _mystery.mapEntry(i);
 		if (!entry)


Commit: c021df15c99e73cdbde2906909e3e2e45e451433
    https://github.com/scummvm/scummvm/commit/c021df15c99e73cdbde2906909e3e2e45e451433
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:33+02:00

Commit Message:
EEM: added missing music in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/graphics.cpp
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 136a45ab278..f8d7db79897 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -518,6 +518,8 @@ screenLoop:
 		case kScreenChooseMystery:
 			_nextScreen = kScreenInvalid;
 			doCaseSelection();
+			if (isLondon())
+				stopMusic();
 			if (_nextScreen == kScreenInvalid && _mystery.isLoaded())
 				_nextScreen = kScreenInitClues;
 			break;
@@ -547,6 +549,8 @@ screenLoop:
 
 		case kScreenNotebook:
 			doNotebook();
+			if (isLondon())
+				stopMusic();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
@@ -555,6 +559,8 @@ screenLoop:
 
 		case kScreenGallery:
 			doGallery();
+			if (isLondon())
+				stopMusic();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
@@ -593,6 +599,8 @@ screenLoop:
 
 		case kScreenAccuse:
 			doAccuse();
+			if (isLondon())
+				stopMusic();
 			if (!_mystery.isLoaded() && _nextScreen != kScreenAction)
 				_nextScreen = kScreenAction;
 			else if (_nextScreen == current)
diff --git a/engines/eem/graphics.cpp b/engines/eem/graphics.cpp
index da96f26668c..c6c5dd08d43 100644
--- a/engines/eem/graphics.cpp
+++ b/engines/eem/graphics.cpp
@@ -34,6 +34,7 @@
 #include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/music.h"
 #include "eem/site.h"
 
 namespace EEM {
@@ -379,6 +380,8 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 		const byte *kd = _mystery.kdTextIndex();
 		const uint16 hintOff = kd ? READ_LE_UINT16(kd + 0x0c) : 0xFFFF;
 		if (hintOff != 0xFFFF) {
+			if (_music && _voiceOn)
+				_music->playMus(40, /* loop= */ false);
 			Common::String hint =
 				parseString(_mystery.textAt(hintOff), _playerName, _partner);
 			Graphics::ManagedSurface ms(kScreenWidth, kScreenHeight,
@@ -411,6 +414,7 @@ bool EEMEngine::doPuzzle(uint puzzleId) {
 			if (_audio && _voiceOn && kd)
 				_audio->sayKDDigital(kd, 6, _partner);
 			waitForInput(60000);
+			stopMusic();
 
 			g_system->copyRectToScreen(cleanBg.getPixels(), cleanBg.pitch,
 									   0, 0, kScreenWidth, kScreenHeight);
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 7979152d299..6ffa2d61c46 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1375,7 +1375,7 @@ void EEMEngine::doShowScrapbook(uint stage) {
 	const bool currentTier = (stage == _chainStage);
 
 	if (isLondon() && _music && _voiceOn)
-		_music->playMus(0x5d, /* loop= */ false);
+		_music->playMus(0x5d, /* loop= */ true);
 
 	int mystery = lo;
 	if (currentTier) {
@@ -2087,6 +2087,10 @@ void EEMEngine::doCaseSelection() {
 		warning("doCaseSelection: BOOK%u.NME failed to load", book);
 		return;
 	}
+
+	if (isLondon() && _music && _voiceOn)
+		_music->playMus(2, /* loop= */ true);
+
 	const uint listLen = MIN<uint>((uint)names.size(), stageHi - stageLo + 1);
 
 	Common::Array<bool> solvedFlags;
@@ -2322,10 +2326,24 @@ void EEMEngine::doNotebook() {
 	int hoveredNoteSlot = -1;
 	(void)hoveredNoteSlot;
 
+	const bool notebookFromSite = isLondon() && _lastScreen == kScreenSite;
+	if (_music && _voiceOn && notebookFromSite)
+		_music->playMus(30, /* loop= */ false);
+
 	drawNotebookFrame(page);
 	Common::Point mouse = g_system->getEventManager()->getMousePos();
 	setInteractiveMouseCursor(notebookButtonAt(mouse.x, mouse.y));
 
+	if (isLondon() && _music && _voiceOn) {
+		while (notebookFromSite && _music->isPlaying() && !shouldQuit()) {
+			Common::Event drain;
+			while (g_system->getEventManager()->pollEvent(drain)) {}
+			g_system->updateScreen();
+			g_system->delayMillis(10);
+		}
+		_music->playMus(5, /* loop= */ true);
+	}
+
 	uint32 lastDraw = g_system->getMillis();
 	uint32 gizmoLastTick = lastDraw;
 
@@ -2587,6 +2605,9 @@ void EEMEngine::doGallery() {
 	Picture galBg;
 	const bool haveBg = _picsArchive.getPicture(0x3f, galBg);
 
+	if (isLondon() && _music && _voiceOn)
+		_music->playMus(5, /* loop= */ true);
+
 	const uint8 num = _mystery.numSuspects();
 
 	Common::Array<Common::Rect> slotRects;
@@ -3492,7 +3513,7 @@ bool EEMEngine::doLondonApproach(uint16 approachId) {
 	CursorMan.showMouse(true);
 	setSiteHotspotCursorId(6);
 	if (_music && _voiceOn)
-		_music->playMus(0x27, /* loop= */ false);
+		_music->playMus(0x27, /* loop= */ true);
 
 	uint page = 0;
 	drawScreen(page);
@@ -4192,6 +4213,9 @@ void EEMEngine::doAccuse() {
 
 	const byte *gd = _mystery.galleryData();
 
+	if (isLondon() && _music && _voiceOn)
+		_music->playMus(4, /* loop= */ true);
+
 	// `_DoAccuse @ 1df2:0c11` outer loop; ESC → NextScreen=3.
 	if (!doAccuseNotes()) {
 		if (_nextScreen == kScreenAccuse) {
@@ -4366,6 +4390,9 @@ void EEMEngine::doAccuse() {
 		}
 	}
 
+	if (isLondon() && _music && _voiceOn)
+		_music->playMus(33, /* loop= */ true);
+
 	// Wrap past empty slots (matches original DI advance).
 	if (slotRects[highlighted].isEmpty())
 		highlighted = nextLiveSlot(slotRects, highlighted, +1);


Commit: 1234998c14ff024a18149b0922821c9c436e7062
    https://github.com/scummvm/scummvm/commit/1234998c14ff024a18149b0922821c9c436e7062
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:34+02:00

Commit Message:
EEM: corrected palette for the water in London map

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 6ffa2d61c46..d4c06d6a981 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -3160,11 +3160,11 @@ void EEMEngine::doBigMap() {
 				mapLastTick = now;
 				drawBigMapOverview(now - mapStartTick);
 				if (isLondon()) {
-					cyclePaletteRange(0xf4, 0xf9);
-					cyclePaletteRange(0xfa, 0xff);
+					cyclePaletteRangeReverse(0xf4, 0xf9);
+					cyclePaletteRangeReverse(0xfa, 0xff);
 				} else {
-					cyclePaletteRange(0xf7, 0xfa);
-					cyclePaletteRange(0xfb, 0xfe);
+					cyclePaletteRangeReverse(0xf7, 0xfa);
+					cyclePaletteRangeReverse(0xfb, 0xfe);
 				}
 			}
 			g_system->updateScreen();
@@ -3197,6 +3197,8 @@ void EEMEngine::doBigMap() {
 		int scrollX = MAX<int>(0, MIN<int>(mapW - kMapWinW, zoomX));
 		int scrollY = MAX<int>(0, MIN<int>(mapH - kMapWinH, zoomY));
 
+		setSitePalette(isLondon() ? 0x3a : 0x23);
+
 		const uint32 detailStartTick = g_system->getMillis();
 		drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH, 0);
 		uint32 detailLastTick = detailStartTick;
@@ -3381,8 +3383,8 @@ void EEMEngine::doBigMap() {
 				drawBigMapDetail(scrollX, scrollY, mapPixels, mapW, mapH,
 					now - detailStartTick);
 			if (frameTick && isLondon()) {
-				cyclePaletteRange(0xee, 0xf2);
-				cyclePaletteRange(0xea, 0xed);
+				cyclePaletteRangeReverse(0xee, 0xf2);
+				cyclePaletteRangeReverse(0xea, 0xed);
 			}
 			g_system->updateScreen();
 			g_system->delayMillis(10);


Commit: 0e72f804415c161bdcb52223a72fb28bee52fa30
    https://github.com/scummvm/scummvm/commit/0e72f804415c161bdcb52223a72fb28bee52fa30
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:34+02:00

Commit Message:
EEM: autosave every time we talk or get a new clue in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index f8d7db79897..5c245a8793f 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -1502,6 +1502,7 @@ Common::Error EEMEngine::loadGameStream(Common::SeekableReadStream *stream) {
 	s.syncAsByte(_voiceOn);
 	if (_audio)
 		_audio->setVoiceEnabled(_voiceOn);
+	s.syncAsByte(_musicOn);
 
 	byte playerFemale = 0;
 	s.syncAsByte(playerFemale);
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 2c9d1561b43..45d286e1012 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -896,12 +896,8 @@ void SiteScreen::enter(uint siteNum, bool resetPartnerMood) {
 			const uint16 clueOff = READ_LE_UINT16(idx + 2);
 			if (clueOff != 0xFFFF) {
 				const byte *clueBlock = _mystery->blobAt(clueOff);
-				if (clueBlock) {
-					// Partner-less BG so KD-anim doesn't ghost over the idle.
-					_vm->setPartnerEraseBg(&_bgSnapshot);
-					_vm->displayClue(clueBlock);
-					_vm->setPartnerEraseBg(nullptr);
-				}
+				if (clueBlock)
+					displayClueAndAutosave(clueBlock);
 			}
 		}
 		if (siteNum < Mystery::kVisitedSiteCap)
@@ -1721,6 +1717,26 @@ void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 	_vm->setHotspotMouseCursor(siteControl || idx >= 0);
 }
 
+void SiteScreen::displayClueAndAutosave(const byte *clueBlock, bool forceSave) {
+	byte before[Mystery::kCluesFoundCap];
+	memcpy(before, _mystery->_cluesFound, sizeof(before));
+
+	_vm->setPartnerEraseBg(&_bgSnapshot);
+	_vm->displayClue(clueBlock);
+	_vm->setPartnerEraseBg(nullptr);
+
+	bool save = forceSave;
+	for (uint i = 0; !save && i < Mystery::kCluesFoundCap; i++)
+		save = !before[i] && _mystery->_cluesFound[i];
+
+	if (save) {
+		const Common::Error err = _vm->saveProfile(_vm->playerName());
+		if (err.getCode() != Common::kNoError)
+			warning("auto-save after clue failed: %s",
+					err.getDesc().c_str());
+	}
+}
+
 void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	debugC(1, kDebugSite, "Site %u: hotspot %u clicked", siteNum, hotIdx);
 
@@ -1773,8 +1789,11 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 	if (spots) {
 		hotOrdinal = READ_LE_UINT16(spots + hotIdx * 14 + 0xa);
 	}
-	if (hotOrdinal < Mystery::kHotSpotsCap)
+	bool newlySeen = false;
+	if (hotOrdinal < Mystery::kHotSpotsCap) {
+		newlySeen = _mystery->_hotSpotsSeen[hotOrdinal] == 0;
 		_mystery->_hotSpotsSeen[hotOrdinal] = 1;
+	}
 	_mystery->_searchLocationNumber = (uint16)hotIdx;
 
 	// Bytes 8..9 of each 14-byte hotspot rect = byte offset to ClueBlock.
@@ -1785,29 +1804,8 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 		debugC(2, kDebugSite, "  hotspot %u -> clue offset 0x%04x",
 			   hotIdx, clueOff);
 		const byte *clueBlock = _mystery->blobAt(clueOff);
-		if (clueBlock) {
-			// Snapshot `_cluesFound` → detect new-clue 0→1 → autosave.
-			byte before[Mystery::kCluesFoundCap];
-			memcpy(before, _mystery->_cluesFound, sizeof(before));
-			_vm->setPartnerEraseBg(&_bgSnapshot);
-			_vm->displayClue(clueBlock);
-			_vm->setPartnerEraseBg(nullptr);
-			// New feature: autosave on new clue.
-			bool foundNewClue = false;
-			for (uint i = 0; i < Mystery::kCluesFoundCap; i++) {
-				if (!before[i] && _mystery->_cluesFound[i]) {
-					foundNewClue = true;
-					break;
-				}
-			}
-			if (foundNewClue) {
-				const Common::Error err =
-					_vm->saveProfile(_vm->playerName());
-				if (err.getCode() != Common::kNoError)
-					warning("auto-save after clue failed: %s",
-							err.getDesc().c_str());
-			}
-		}
+		if (clueBlock)
+			displayClueAndAutosave(clueBlock, /* forceSave= */ newlySeen);
 	}
 }
 // `_DoKDAnim(num) @ 168d:028a` + `_PlayAnimation @ 172b:1f46`:
diff --git a/engines/eem/site.h b/engines/eem/site.h
index ec7d80f370f..5301ab68eaa 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -103,6 +103,10 @@ private:
 	int  hotspotCursorId(uint siteNum, int idx) const;
 	void updateHotspotCursor(uint siteNum, int x, int y);
 	void onHotspotClicked(uint siteNum, uint hotIdx);
+	/// Show a site clue, then autosave the profile if @p forceSave is set (e.g.
+	/// a hotspot was newly marked seen) or it revealed a new notebook clue
+	/// (`_cluesFound` 0->1). Matches EEM1's per-clue persistence.
+	void displayClueAndAutosave(const byte *clueBlock, bool forceSave = false);
 	void initImpatienceCounter();
 	bool checkImpatienceCounter();
 	void notePartnerActivity();


Commit: 846a1b54522db9b23ac75c50986a457fd7497239
    https://github.com/scummvm/scummvm/commit/846a1b54522db9b23ac75c50986a457fd7497239
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:35+02:00

Commit Message:
EEM: show notes for each suspect in the gallery correctly in London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index d4c06d6a981..acb2e08f421 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2765,7 +2765,9 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 	const int lineH = _font.getFontHeight();
 	const uint clueMax = floppyMI ? clueCount : 30u;
 	const byte *ni = _mystery.noteIndex();
-	const uint16 niCount = _mystery.noteIndexCount();
+	const uint16 niCount = isLondon()
+		? (uint16)(_mystery.noteSectionSize() / 2)
+		: _mystery.noteIndexCount();
 
 	uint pageStart = 0;
 	Common::Array<uint> pageStack;
@@ -2808,26 +2810,11 @@ bool EEMEngine::moreInfo(const byte *gd, uint suspectIdx,
 				continue;
 			if (!ni || clueId >= niCount)
 				continue;
-			// Floppy: 7-byte entry (+0 text, +2 Jake, +4 Jenny, +6 score).
-			// CD: 4-byte entry (u16 textOff + u16 score).
-			Common::String txt;
-			if (floppyMI) {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 7);
-				const byte *bb = _mystery.blobAt(0);
-				const uint32 dsz = _mystery.dataSize();
-				if (bb && textOff != 0 && textOff < dsz) {
-					const char *p = (const char *)(bb + textOff);
-					uint32 len = 0;
-					while (textOff + len < dsz && p[len] != 0)
-						len++;
-					txt = parseString(Common::String(p, len),
-									  _playerName, _partner);
-				}
-			} else {
-				const uint16 textOff = READ_LE_UINT16(ni + clueId * 4);
-				txt = parseString(_mystery.textAt(textOff),
-								  _playerName, _partner);
-			}
+			// Shared notebook lookup: honours London's 2-byte NoteIndex stride
+			// (this path previously hardcoded the EEM1 4-byte stride).
+			Common::String txt = notebookNoteText(clueId, ni, niCount, floppyMI,
+												  _mystery.blobAt(0),
+												  _mystery.dataSize());
 			if (txt.empty())
 				continue;
 


Commit: 8d9720ef7419530b8321e854fd2f29798fd5c4c3
    https://github.com/scummvm/scummvm/commit/8d9720ef7419530b8321e854fd2f29798fd5c4c3
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:35+02:00

Commit Message:
EEM: deduction flow fixes for London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index acb2e08f421..de374c2bc52 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -2498,9 +2498,9 @@ void EEMEngine::drawNotebookFrame(int &page) {
 	// `_DrawNotes` walks `_NoteIndex` for current page; word-wraps each
 	// found clue in `_NotebookRect`. Selected = color 0x3c.
 	// EEM2/London NoteIndex entries are 2 bytes (no points field), so its real
-	// clue count is the section size / 2. `noteIndexCount()` assumes the EEM1
-	// 4-byte stride (and stays that way for the accuse-scoring path, which is a
-	// separate London concern)
+	// clue count is the section size / 2; `noteIndexCount()` assumes the EEM1
+	// 4-byte stride and undercounts by half. The notebook, gallery, and accuse
+	// note lists all use the London count so no found clue is dropped.
 	const uint16 londonCount = isLondon()
 		? (uint16)(_mystery.noteSectionSize() / 2) : 0;
 	Common::Array<uint> found;
@@ -3768,7 +3768,8 @@ Common::String EEMEngine::accuseNoteText(uint clueId,
 			(const char *)(ctx.bufBaseNotes + textOff),
 			_playerName, _partner);
 	}
-	const uint16 textOff = READ_LE_UINT16(ctx.ni + clueId * 4);
+	const uint stride = isLondon() ? 2 : 4;
+	const uint16 textOff = READ_LE_UINT16(ctx.ni + clueId * stride);
 	return parseString(_mystery.textAt(textOff),
 					   _playerName, _partner);
 }
@@ -3875,7 +3876,13 @@ bool EEMEngine::doAccuseNotes() {
 	if (!_mystery.isLoaded() || !_font.isLoaded())
 		return false;
 	const byte *ni = _mystery.noteIndex();
-	const uint16 niCount = _mystery.noteIndexCount();
+	// London's NoteIndex is 2-byte entries, so its real clue count is
+	// section size / 2; noteIndexCount() assumes EEM1's 4-byte stride and
+	// undercounts by half, which would drop the upper clues (e.g. answer
+	// clues 13/16 in the training case) from the selectable accuse list.
+	const uint16 niCount = isLondon()
+		? (uint16)(_mystery.noteSectionSize() / 2)
+		: _mystery.noteIndexCount();
 	if (!ni)
 		return false;
 
@@ -3891,7 +3898,10 @@ bool EEMEngine::doAccuseNotes() {
 
 	Common::Array<uint> found;
 	for (uint i = 0; i < niCount && i < Mystery::kCluesFoundCap; i++) {
-		if (_mystery._cluesFound[i] && _mystery.noteHasNotebookText(i))
+		const bool hasText = isLondon()
+			? (i < niCount)
+			: _mystery.noteHasNotebookText(i);
+		if (_mystery._cluesFound[i] && hasText)
 			found.push_back(i);
 	}
 
@@ -4671,15 +4681,19 @@ void EEMEngine::doAccuse() {
 			memset(blk->getPixels(), 0, kScreenWidth * kScreenHeight);
 			g_system->unlockScreen();
 		}
-		setSitePalette(6);
+		// `_DisplayCorrect` win background = `_BuildBackground(scene, 0x42, 0x14)`
+		// (frame PIC 0x3d + scene at 0x42,0x14, palette scene+1). EEM1 CD uses
+		// scene 5; EEM2/London uses scene 0x1b.
+		const uint winScene = isLondon() ? 0x1b : 5;
+		setSitePalette(winScene + 1);
 		Picture frame, scene;
 		if (_picsArchive.loadEntry(0x3d, frame)) {
 			g_system->copyRectToScreen(frame.surface.getPixels(),
 									   frame.surface.pitch, 0, 0,
 									   frame.surface.w, frame.surface.h);
 		}
-		if (5 < _sitesArchive.size() &&
-			_sitesArchive.loadEntry(5, scene)) {
+		if (winScene < _sitesArchive.size() &&
+			_sitesArchive.loadEntry(winScene, scene)) {
 			const int sx = 0x42, sy = 0x14;
 			const int sw = MIN<int>(scene.surface.w, kScreenWidth - sx);
 			const int sh = MIN<int>(scene.surface.h, kScreenHeight - sy);


Commit: cfed4b9586b93e7271fde79aa32d16c541ffe590
    https://github.com/scummvm/scummvm/commit/cfed4b9586b93e7271fde79aa32d16c541ffe590
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:35+02:00

Commit Message:
EEM: play the correct animation before showing the scrapbook in London

Changed paths:
    engines/eem/ui.cpp


diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index de374c2bc52..27797d24f8f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -4719,7 +4719,7 @@ void EEMEngine::doAccuse() {
 		if (_music && _voiceOn)
 			_music->stop();
 
-		playAnm(Common::Path("SCRAPBK.ANI"), 120,
+		playAnm(Common::Path(isLondon() ? "SCRAP.ANM" : "SCRAPBK.ANI"), 120,
 				/* holdLastFrame= */ false);
 
 		displayScrapbookExtra(mn);


Commit: 1161071df317d6d5776aee13ed5f8788440d9a92
    https://github.com/scummvm/scummvm/commit/1161071df317d6d5776aee13ed5f8788440d9a92
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:36+02:00

Commit Message:
EEM: concurrent partner animations during conversations

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 9fc34c93c95..6e312202ca0 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -858,10 +858,21 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 		}
 
 		const int16 kdAnimNum = (int16)READ_LE_UINT16(c + (isLondon() ? 0x4e : 0x3a));
-		if (kdAnimNum != -1) {
-			playKdAnim((uint16)kdAnimNum);
-			g_system->copyRectToScreen(bg.getPixels(), bg.pitch,
-									   0, 0, kScreenWidth, kScreenHeight);
+		// Load the partner gesture; it animates concurrently with the balloon
+		// and voice in the wait loop below.
+		Animation kdAnim;
+		int kdPx = 0;
+		int kdPy = 0;
+		uint16 kdAnimId = 0;
+		const bool haveKd = kdAnimNum != -1 &&
+			loadKdAnim((uint16)kdAnimNum, kdAnim, kdPx, kdPy, kdAnimId);
+
+		// Animate the gesture over the partner-less scene so it doesn't ghost
+		// the static partner.
+		if (haveKd && _partnerEraseBg.w == kScreenWidth &&
+			_partnerEraseBg.h == kScreenHeight) {
+			g_system->copyRectToScreen(_partnerEraseBg.getPixels(),
+				_partnerEraseBg.pitch, 0, 0, kScreenWidth, kScreenHeight);
 		}
 
 		const bool useP1 = (_partner == kPartnerJenny) &&
@@ -978,6 +989,19 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			setInteractiveMouseCursor(false);
 			bool advance = false;
 			bool skipAll = false;
+			Graphics::ManagedSurface kdBase(kScreenWidth, kScreenHeight,
+				Graphics::PixelFormat::createFormatCLUT8());
+			bool haveKdBase = false;
+			uint kdLastFrame = (uint)-1;
+			const uint32 kdStartMs = g_system->getMillis();
+			if (haveKd) {
+				Graphics::Surface *kdScr = g_system->lockScreen();
+				if (kdScr) {
+					kdBase.simpleBlitFrom(*kdScr);
+					g_system->unlockScreen();
+					haveKdBase = true;
+				}
+			}
 			while (!advance && !shouldQuit()) {
 				Common::Event ev;
 				while (g_system->getEventManager()->pollEvent(ev)) {
@@ -1009,6 +1033,20 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						break;
 					}
 				}
+				if (haveKdBase) {
+					const uint kdFrame = oneShotFrameAtTick(kdAnimId,
+						(uint)kdAnim.size(), g_system->getMillis() - kdStartMs);
+					if (kdFrame != kdLastFrame && kdFrame < kdAnim.size()) {
+						kdLastFrame = kdFrame;
+						Graphics::ManagedSurface comp(kScreenWidth, kScreenHeight,
+							Graphics::PixelFormat::createFormatCLUT8());
+						comp.simpleBlitFrom(kdBase);
+						blitAnimFrameAnchored(comp.surfacePtr(),
+							kdAnim[kdFrame], kdPx, kdPy);
+						g_system->copyRectToScreen(comp.getPixels(), comp.pitch,
+							0, 0, kScreenWidth, kScreenHeight);
+					}
+				}
 				g_system->updateScreen();
 				g_system->delayMillis(10);
 			}
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 4ea98b5aa65..c06781e49e0 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -221,8 +221,10 @@ public:
 							   const Common::String &playerName,
 							   uint partner) const;
 
-	/// `_DoKDAnim @ 168d:028a` + `_PlayAnimation @ 172b:1f46`.
-	void playKdAnim(uint16 num);
+	/// Load the partner gesture (animId + anchor) for concurrent playback with
+	/// the clue balloon/voice. false if out of range.
+	bool loadKdAnim(uint16 num, Animation &anim, int &px, int &py,
+					uint16 &animId);
 
 	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
 
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 45d286e1012..0627cdeeb65 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -702,6 +702,16 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	return frameFromScriptAtTick(s.frames, s.len, numFrames, tickMs);
 }
 
+uint oneShotFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
+	const AnimScriptRef s = findAnimScript(seqnum);
+	const uint tick = (uint)(tickMs / kFramePeriodMs);
+	if (!s.frames || s.len == 0)
+		return numFrames > 0 ? MIN<uint>(tick, numFrames - 1) : 0;
+	const uint scriptIdx = MIN<uint>(tick, (uint)s.len - 1);
+	const uint frame = s.frames[scriptIdx];
+	return numFrames > 0 ? MIN<uint>(frame, numFrames - 1) : 0;
+}
+
 // Play `unfold` once, then loop `waitSeq` forever. Mirrors the
 // original's slot-script-swap idiom
 uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
@@ -1817,76 +1827,21 @@ void SiteScreen::onHotspotClicked(uint siteNum, uint hotIdx) {
 //     -> registers a state-4 (one-shot) animation slot and lets
 //        `_UpdateAnimations` walk the script until 0x80, then
 //        frees the slot and re-activates `WaitHandle`.
-void EEMEngine::playKdAnim(uint16 num) {
+bool EEMEngine::loadKdAnim(uint16 num, Animation &anim, int &px, int &py,
+						   uint16 &animId) {
 	if (num >= ARRAYSIZE(kKdAnimTable))
-		return;
+		return false;
 
 	const uint16 (*kdTable)[6] = isLondon() ? kKdAnimTableLondon : kKdAnimTable;
 	const uint partner = (_partner == kPartnerJake) ? 0 : 1;
-	const uint16 animId = kdTable[num][partner];
-	const int    px     = (int)kdTable[num][2 + partner];
-	const int    py     = (int)kdTable[num][4 + partner];
+	animId = kdTable[num][partner];
+	px     = (int)kdTable[num][2 + partner];
+	py     = (int)kdTable[num][4 + partner];
 
-	Animation anim;
 	if (!_aniArchive.loadAnimation(animId, anim) || anim.empty()) {
-		warning("playKdAnim(%u): anim %u failed to load", num, animId);
-		return;
-	}
-
-	// State-4 one-shot walks the same (looping) script through once.
-	const AnimScriptRef s = findAnimScript(animId);
-	const uint8 *frames = s.frames;
-	uint frameCount     = s.len;
-	if (frameCount == 0) {
-		// Fallback: linear playback through anim cells.
-		frameCount = (uint)anim.size();
-	}
-
-	// Erase-source: caller-stashed partner-less BG (via `setPartnerEraseBg`)
-	// or fall back to current screen (works for full-screen contexts).
-	Graphics::ManagedSurface bg(kScreenWidth, kScreenHeight,
-		Graphics::PixelFormat::createFormatCLUT8());
-	if (_partnerEraseBg.w == kScreenWidth && _partnerEraseBg.h == kScreenHeight) {
-		bg.simpleBlitFrom(_partnerEraseBg);
-	} else {
-		Graphics::Surface *screen = g_system->lockScreen();
-		if (!screen)
-			return;
-		bg.simpleBlitFrom(*screen);
-		g_system->unlockScreen();
-	}
-
-	for (uint i = 0; i < frameCount && !shouldQuit(); i++) {
-		const uint frameIdx = frames ? (uint)frames[i] : i;
-		if (frameIdx >= anim.size())
-			continue;
-		const Picture &fr = anim[frameIdx];
-		const byte transp = (byte)(fr.flags >> 8);
-
-		Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
-			Graphics::PixelFormat::createFormatCLUT8());
-		scratch.simpleBlitFrom(bg);
-		(void)transp;  // anchored blitter recomputes from p.flags
-		blitAnimFrameAnchored(scratch.surfacePtr(), fr, px, py);
-		g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
-								   0, 0, kScreenWidth, kScreenHeight);
-		g_system->updateScreen();
-
-		const uint32 wakeup = g_system->getMillis() + 100;
-		while (g_system->getMillis() < wakeup && !shouldQuit()) {
-			Common::Event ev;
-			while (g_system->getEventManager()->pollEvent(ev)) {
-				// Drain events; don't skip mid-anim (would eat upcoming clue).
-				if (ev.type == Common::EVENT_QUIT ||
-					ev.type == Common::EVENT_RETURN_TO_LAUNCHER)
-					return;
-			}
-			g_system->updateScreen();
-			g_system->delayMillis(10);
-		}
+		warning("loadKdAnim(%u): anim %u failed to load", num, animId);
+		return false;
 	}
-
-	g_system->copyRectToScreen(bg.getPixels(), bg.pitch, 0, 0, kScreenWidth, kScreenHeight);
-	g_system->updateScreen();
+	return true;
 }
 } // End of namespace EEM
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 5301ab68eaa..106bb556a35 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -42,6 +42,9 @@ class Mystery;
 /// used both for the fallback and to clamp script values past the asset.
 uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
+/// Like partnerFrameAtTick but plays the script ONCE and holds the final frame.
+uint oneShotFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
+
 /// Select the EEM2 ("London") animation-script table inside `findAnimScript`.
 /// EEM2 ships its own `_AnimationSequences`; many partner/KD scripts differ
 /// from EEM1's, so the engine must use the EEM2 sequences for that variant.


Commit: ce99471d1431b4b5689278677587c1da538a9b40
    https://github.com/scummvm/scummvm/commit/ce99471d1431b4b5689278677587c1da538a9b40
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:36+02:00

Commit Message:
EEM: improved partner animations reducing flickering

Changed paths:
    engines/eem/clues.cpp
    engines/eem/eem.h
    engines/eem/site.cpp
    engines/eem/site.h


diff --git a/engines/eem/clues.cpp b/engines/eem/clues.cpp
index 6e312202ca0..e4f3957e725 100644
--- a/engines/eem/clues.cpp
+++ b/engines/eem/clues.cpp
@@ -968,7 +968,10 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 			if (copyRows > 0) {
 				g_system->copyRectToScreen(scratch.getBasePtr(0, copyY),
 					scratch.pitch, 0, copyY, kScreenWidth, copyRows);
-				g_system->updateScreen();
+				// Gesture entry: let the wait loop present, so the partner-less
+				// base isn't flashed before the gesture's first frame.
+				if (!haveKd)
+					g_system->updateScreen();
 			}
 		}
 
@@ -1002,6 +1005,23 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 					haveKdBase = true;
 				}
 			}
+			// Play the gesture one-shot, then loop the partner idle over the
+			// same partner-less base (the original resumes idle when it ends).
+			const uint32 kdDurationMs = haveKd
+				? oneShotDurationMs(kdAnimId, (uint)kdAnim.size()) : 0;
+			Animation idleAnim;
+			int idleX = 0;
+			int idleY = 0;
+			bool haveIdle = false;
+			if (haveKdBase && _hasPartnerIdle &&
+				getAni().loadAnimation(_partnerIdleAnimId, idleAnim) &&
+				!idleAnim.empty()) {
+				haveIdle = true;
+				idleX = _partnerIdleX;
+				idleY = _partnerIdleY;
+			}
+			bool kdInIdle = false;
+			uint kdLastIdleFrame = (uint)-1;
 			while (!advance && !shouldQuit()) {
 				Common::Event ev;
 				while (g_system->getEventManager()->pollEvent(ev)) {
@@ -1033,18 +1053,40 @@ void EEMEngine::displayClue(const byte *clueBlock) {
 						break;
 					}
 				}
+				if (skipAll)
+					break;
 				if (haveKdBase) {
-					const uint kdFrame = oneShotFrameAtTick(kdAnimId,
-						(uint)kdAnim.size(), g_system->getMillis() - kdStartMs);
-					if (kdFrame != kdLastFrame && kdFrame < kdAnim.size()) {
-						kdLastFrame = kdFrame;
-						Graphics::ManagedSurface comp(kScreenWidth, kScreenHeight,
-							Graphics::PixelFormat::createFormatCLUT8());
-						comp.simpleBlitFrom(kdBase);
-						blitAnimFrameAnchored(comp.surfacePtr(),
-							kdAnim[kdFrame], kdPx, kdPy);
-						g_system->copyRectToScreen(comp.getPixels(), comp.pitch,
-							0, 0, kScreenWidth, kScreenHeight);
+					const uint32 kdElapsed = g_system->getMillis() - kdStartMs;
+					if (haveIdle && kdElapsed >= kdDurationMs) {
+						// Resume the looping idle wait-anim.
+						const uint f = partnerFrameAtTick(_partnerIdleAnimId,
+							(uint)idleAnim.size(), kdElapsed - kdDurationMs);
+						if ((!kdInIdle || f != kdLastIdleFrame) &&
+							f < idleAnim.size()) {
+							kdInIdle = true;
+							kdLastIdleFrame = f;
+							Graphics::ManagedSurface comp(kScreenWidth, kScreenHeight,
+								Graphics::PixelFormat::createFormatCLUT8());
+							comp.simpleBlitFrom(kdBase);
+							blitAnimFrameAnchored(comp.surfacePtr(),
+								idleAnim[f], idleX, idleY);
+							g_system->copyRectToScreen(comp.getPixels(), comp.pitch,
+								0, 0, kScreenWidth, kScreenHeight);
+						}
+					} else {
+						// Gesture one-shot.
+						const uint f = oneShotFrameAtTick(kdAnimId,
+							(uint)kdAnim.size(), kdElapsed);
+						if (f != kdLastFrame && f < kdAnim.size()) {
+							kdLastFrame = f;
+							Graphics::ManagedSurface comp(kScreenWidth, kScreenHeight,
+								Graphics::PixelFormat::createFormatCLUT8());
+							comp.simpleBlitFrom(kdBase);
+							blitAnimFrameAnchored(comp.surfacePtr(),
+								kdAnim[f], kdPx, kdPy);
+							g_system->copyRectToScreen(comp.getPixels(), comp.pitch,
+								0, 0, kScreenWidth, kScreenHeight);
+						}
 					}
 				}
 				g_system->updateScreen();
diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index c06781e49e0..4fd29ea5183 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -228,6 +228,15 @@ public:
 
 	void setPartnerEraseBg(const Graphics::ManagedSurface *bg);
 
+	/// Partner idle wait-anim that `displayClue` loops once a clue gesture's
+	/// one-shot ends. Set around `displayClue` by the site loop.
+	void setPartnerIdleAnim(bool has, uint16 animId, int x, int y) {
+		_hasPartnerIdle = has;
+		_partnerIdleAnimId = animId;
+		_partnerIdleX = x;
+		_partnerIdleY = y;
+	}
+
 	/// Balloon-text-inset metadata. 52-entry table @ 29be:0875 (CD) /
 	/// 2608:05f9 (floppy), indexed by `(bubNum & 0x7F)`. 10 bytes per
 	/// entry: the first 3 fields (x inset, y inset, text width) are used
@@ -501,10 +510,15 @@ private:
 	/// ESC during intro: skip remaining opening-anim chain.
 	bool _skipIntro = false;
 
-	/// Clean BG (no partner/NPC) used by `playKdAnim` between camera-anim
-	/// cells. 
+	/// Partner-less scene that `displayClue` composites the gesture/idle over.
 	Graphics::ManagedSurface _partnerEraseBg;
 
+	/// Idle wait-anim to resume after a clue gesture (see setPartnerIdleAnim).
+	bool   _hasPartnerIdle = false;
+	uint16 _partnerIdleAnimId = 0;
+	int    _partnerIdleX = 0;
+	int    _partnerIdleY = 0;
+
 	bool _interactiveMouseCursor = false;
 	/// Active EEM2 cursor shape (index into `kLondonCursorPics`). -1 forces a
 	/// reload on the next `setSiteHotspotCursorId`.
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index 0627cdeeb65..c4d7b9f9949 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -712,6 +712,12 @@ uint oneShotFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs) {
 	return numFrames > 0 ? MIN<uint>(frame, numFrames - 1) : 0;
 }
 
+uint32 oneShotDurationMs(uint16 seqnum, uint numFrames) {
+	const AnimScriptRef s = findAnimScript(seqnum);
+	const uint count = (s.frames && s.len) ? (uint)s.len : numFrames;
+	return (uint32)count * kFramePeriodMs;
+}
+
 // Play `unfold` once, then loop `waitSeq` forever. Mirrors the
 // original's slot-script-swap idiom
 uint oneShotThenLoopFrameAtTick(const uint8 *unfold, uint unfoldLen,
@@ -1447,19 +1453,17 @@ void SiteScreen::syncCompositedScreen() {
 							   0, 0, kScreenWidth, kScreenHeight);
 }
 
-void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
+bool SiteScreen::partnerIdleAnimParams(uint siteNum, uint16 &animId,
+									   int &x, int &y) {
 	const byte *site = _mystery->siteData(siteNum);
 	if (!site)
-		return;
+		return false;
 	const uint8 partner = _vm->getPartnerIndex();
-	uint   animId;
-	int    x;
-	int    y;
 	if (_vm->isFloppy()) {
 		const uint16 spkOff = READ_LE_UINT16(site + 8);
 		const byte *spk = _mystery->blobAt(spkOff);
 		if (!spk)
-			return;
+			return false;
 		if (partner == 0) {
 			animId = READ_LE_UINT16(spk + 0);
 			x      = (int)READ_LE_UINT16(spk + 2);
@@ -1473,15 +1477,21 @@ void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
 		const uint16 speaker = READ_LE_UINT16(site + 8);
 		const uint16 (*waitTable)[6] = _vm->isLondon()
 			? kWaitAnimsLondon : kWaitAnims;
-		if (speaker >= ARRAYSIZE(kWaitAnims)) {
-			warning("renderPartner: site %u has speakerIdx=%u out of range",
-					siteNum, speaker);
-			return;
-		}
+		if (speaker >= ARRAYSIZE(kWaitAnims))
+			return false;
 		animId = waitTable[speaker][0 + partner];
 		x      = (int)(int16)waitTable[speaker][2 + partner];
 		y      = (int)(int16)waitTable[speaker][4 + partner];
 	}
+	return true;
+}
+
+void SiteScreen::renderPartner(uint siteNum, uint32 tickMs) {
+	uint16 animId;
+	int    x;
+	int    y;
+	if (!partnerIdleAnimParams(siteNum, animId, x, y))
+		return;
 
 	Animation anim;
 	if (!_vm->getAni().loadAnimation(animId, anim) || anim.empty())
@@ -1731,8 +1741,16 @@ void SiteScreen::displayClueAndAutosave(const byte *clueBlock, bool forceSave) {
 	byte before[Mystery::kCluesFoundCap];
 	memcpy(before, _mystery->_cluesFound, sizeof(before));
 
+	// Idle wait-anim for `displayClue` to resume after a gesture's one-shot.
+	uint16 idleId = 0;
+	int idleX = 0, idleY = 0;
+	const bool hasIdle =
+		partnerIdleAnimParams(_mystery->_siteNumber, idleId, idleX, idleY);
+
 	_vm->setPartnerEraseBg(&_bgSnapshot);
+	_vm->setPartnerIdleAnim(hasIdle, idleId, idleX, idleY);
 	_vm->displayClue(clueBlock);
+	_vm->setPartnerIdleAnim(false, 0, 0, 0);
 	_vm->setPartnerEraseBg(nullptr);
 
 	bool save = forceSave;
diff --git a/engines/eem/site.h b/engines/eem/site.h
index 106bb556a35..f17f8ae4e20 100644
--- a/engines/eem/site.h
+++ b/engines/eem/site.h
@@ -45,6 +45,9 @@ uint partnerFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 /// Like partnerFrameAtTick but plays the script ONCE and holds the final frame.
 uint oneShotFrameAtTick(uint16 seqnum, uint numFrames, uint32 tickMs);
 
+/// Total time for one full play of a one-shot gesture (frame count * period).
+uint32 oneShotDurationMs(uint16 seqnum, uint numFrames);
+
 /// Select the EEM2 ("London") animation-script table inside `findAnimScript`.
 /// EEM2 ships its own `_AnimationSequences`; many partner/KD scripts differ
 /// from EEM1's, so the engine must use the EEM2 sequences for that variant.
@@ -118,9 +121,14 @@ private:
 	/// Partner site-arrival sequence
 	bool enterSiteAnim();
 
-	/// renderPartner: persistent in-site partner sprite 
+	/// renderPartner: persistent in-site partner sprite
 	void renderPartner(uint siteNum, uint32 tickMs);
 
+	/// Resolve the partner idle wait-anim (animId + screen anchor) for a site.
+	/// Shared by renderPartner and the clue gesture's idle-resume. Returns
+	/// false when the site has no usable speaker/partner entry.
+	bool partnerIdleAnimParams(uint siteNum, uint16 &animId, int &x, int &y);
+
 	/// Floppy active speaker pose 
 	bool renderFloppyHotspotPartnerPose(uint siteNum);
 


Commit: 24173e32113517a95c4ce900e35eba441f7af73b
    https://github.com/scummvm/scummvm/commit/24173e32113517a95c4ce900e35eba441f7af73b
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:36+02:00

Commit Message:
EEM: properly mark interactive zones in London

Changed paths:
    engines/eem/eem.cpp
    engines/eem/site.cpp


diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index 5c245a8793f..82613a39f66 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -625,6 +625,9 @@ void EEMEngine::setInteractiveMouseCursor(bool active) {
 
 	_interactiveMouseCursor = active;
 	installMouseCursor(_picsArchive, active);
+	// The red-outline highlight replaced any London cursor shape; force the
+	// next setSiteHotspotCursorId to reinstall.
+	_siteCursorId = -1;
 }
 
 void EEMEngine::setHotspotMouseCursor(bool active) {
@@ -660,6 +663,8 @@ void EEMEngine::setSiteHotspotCursorId(int cursorId) {
 	CursorMan.replaceCursor(cursor.surface.rawSurface(), 0, 0, transparent);
 	CursorMan.replaceCursorPalette(nullptr, 0, 0);
 	_siteCursorId = cursorId;
+	// This London cursor replaced the red-outline highlight, if it was active.
+	_interactiveMouseCursor = false;
 }
 
 bool EEMEngine::openArchives() {
diff --git a/engines/eem/site.cpp b/engines/eem/site.cpp
index c4d7b9f9949..88e6c6e919e 100644
--- a/engines/eem/site.cpp
+++ b/engines/eem/site.cpp
@@ -1725,15 +1725,22 @@ void SiteScreen::updateHotspotCursor(uint siteNum, int x, int y) {
 	if (!_vm)
 		return;
 	const int idx = hotspotAtPoint(siteNum, x, y);
-	if (_vm->isLondon()) {
-		// EEM2 (`_DoSiteLoop` @ FUN_1717_07ab) swaps the cursor shape to the
-		// hovered hotspot's own cursor id; off any hotspot it is the arrow (0).
-		_vm->setSiteHotspotCursorId(idx >= 0 ? hotspotCursorId(siteNum, idx) : 0);
-		return;
-	}
 	const bool siteControl = kPdaSiteRect.contains(x, y) ||
 							 kPdaPartnerFootMapRect.contains(x, y) ||
 							 kPdaPartnerHeadHintRect.contains(x, y);
+	if (_vm->isLondon()) {
+		// EEM2 gives some hotspots their own cursor shape; every other
+		// interactive zone (the site/foot/head controls, or a hotspot with no
+		// custom cursor) falls back to the red-outline highlight.
+		const int cursorId = idx >= 0 ? hotspotCursorId(siteNum, idx) : 0;
+		if (cursorId > 0)
+			_vm->setSiteHotspotCursorId(cursorId);
+		else if (idx >= 0 || siteControl)
+			_vm->setInteractiveMouseCursor(true);
+		else
+			_vm->setSiteHotspotCursorId(0);
+		return;
+	}
 	_vm->setHotspotMouseCursor(siteControl || idx >= 0);
 }
 


Commit: 111cc673fc7edb49681f4eddd0b2c1cd26b6f3e8
    https://github.com/scummvm/scummvm/commit/111cc673fc7edb49681f4eddd0b2c1cd26b6f3e8
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-22T00:21:37+02:00

Commit Message:
EEM: completed implementation for setup screen in London

Changed paths:
    engines/eem/eem.h
    engines/eem/ui.cpp


diff --git a/engines/eem/eem.h b/engines/eem/eem.h
index 4fd29ea5183..a91adfc73e8 100644
--- a/engines/eem/eem.h
+++ b/engines/eem/eem.h
@@ -425,6 +425,9 @@ private:
 
 	void doSetupLondon();
 	void setupDrawScreenLondon();
+	/// London "profile saved" confirmation: PIC 0x203 centred over the setup
+	/// screen, dismissed by any click/key (caller redraws the setup screen).
+	void setupShowSavedConfirm();
 
 	/// `_DoInitClues @ 1a35:0411` (minus live ANI sequence playback).
 	void doInitClues();
diff --git a/engines/eem/ui.cpp b/engines/eem/ui.cpp
index 27797d24f8f..086fd98647f 100644
--- a/engines/eem/ui.cpp
+++ b/engines/eem/ui.cpp
@@ -1699,6 +1699,37 @@ void EEMEngine::setupDrawScreenLondon() {
 	g_system->updateScreen();
 }
 
+void EEMEngine::setupShowSavedConfirm() {
+	Picture pic;
+	if (!_picsArchive.getPicture(0x203, pic) || pic.surface.empty())
+		return;
+	Graphics::ManagedSurface scratch(kScreenWidth, kScreenHeight,
+		Graphics::PixelFormat::createFormatCLUT8());
+	Graphics::Surface *cur = g_system->lockScreen();
+	if (cur) {
+		scratch.simpleBlitFrom(*cur);
+		g_system->unlockScreen();
+	}
+	const int sx = MAX<int>(0, (kScreenWidth  - pic.surface.w) / 2);
+	const int sy = MAX<int>(0, (kScreenHeight - pic.surface.h) / 2);
+	scratch.transBlitFrom(pic.surface, Common::Point(sx, sy),
+						  (uint32)(byte)(pic.flags >> 8));
+	g_system->copyRectToScreen(scratch.getPixels(), scratch.pitch,
+							   0, 0, kScreenWidth, kScreenHeight);
+	g_system->updateScreen();
+	while (!shouldQuit()) {
+		Common::Event ev;
+		while (g_system->getEventManager()->pollEvent(ev)) {
+			if (ev.type == Common::EVENT_QUIT ||
+				ev.type == Common::EVENT_RETURN_TO_LAUNCHER ||
+				ev.type == Common::EVENT_KEYDOWN ||
+				ev.type == Common::EVENT_LBUTTONDOWN)
+				return;
+		}
+		g_system->delayMillis(15);
+	}
+}
+
 // `_DoSetup @ 2046:067b`
 void EEMEngine::doSetupLondon() {
 	if (!_font.isLoaded()) {
@@ -1714,8 +1745,8 @@ void EEMEngine::doSetupLondon() {
 	const Common::Rect kSaveBtn    (281, 108, 299, 125); // [6]
 	const Common::Rect kNewCaseBtn (281, 127, 299, 144); // [7]
 	const Common::Rect kDoneBtn    ( 53, 153, 108, 183); // [8]
-	const Common::Rect kQuitBtn    (145, 163, 174, 187); // [9]
-	const Common::Rect kHelpBtn    (212, 153, 266, 184); // [10]
+	const Common::Rect kHelpBtn    (145, 163, 174, 187); // [9]
+	const Common::Rect kQuitBtn    (212, 153, 266, 184); // [10]
 	const Common::Rect kCreditsBtn ( 81,  25, 238,  37); // [11]
 	const Common::Rect kMusicBtn   ( 20,  82,  38,  99); // [12]
 
@@ -1791,8 +1822,11 @@ void EEMEngine::doSetupLondon() {
 			}
 
 			if (kSaveBtn.contains(mx, my)) {
-				if (_mystery.isLoaded())
+				if (_mystery.isLoaded()) {
 					saveProfile(_playerName);
+					setupShowSavedConfirm();
+					dirty = true;
+				}
 				continue;
 			}
 			if (kHelpBtn.contains(mx, my)) {




More information about the Scummvm-git-logs mailing list